"""Orion's Cottage – Gradio frontend Phase 1 layout, Phase 4 three-model demo, Phase 5 constellation browser, Phase 6 extended planning. """ import os import sys from pathlib import Path def _bootstrap_venv() -> None: """Re-exec with .venv Python when deps are installed there but not system-wide.""" try: import gradio # noqa: F401 except ModuleNotFoundError as exc: # Only handle the top-level package being absent. If Gradio is installed # but one of its dependencies fails to import, let the real traceback show # in Space logs instead of masking it as "gradio not found". if exc.name != "gradio": raise venv_python = Path(__file__).resolve().parent / ".venv" / "bin" / "python" if __name__ == "__main__" and venv_python.is_file(): script_args = sys.argv[1:] if Path(sys.argv[0]).name.startswith("python") else sys.argv os.execv(str(venv_python), [str(venv_python), *script_args]) sys.exit( "gradio not found. Install dependencies in a virtual environment:\n" " python3 -m venv .venv\n" " .venv/bin/pip install -r requirements.txt\n" " .venv/bin/python app.py" ) _bootstrap_venv() import gradio as gr from dotenv import load_dotenv from datetime import datetime, date from typing import Any, Optional # Load .env for local development load_dotenv() from agent.router import generate_model_plan, generate_three_models_plan, _resolve_observation_time, _pick_target_name # inference_health_summary kept in module for dev/shell use; Backend tab removed from UI (scope cut) from core.visualization import generate_sky_chart from core.astronomy import get_geometry_data from core.environment import get_environment_data from core.scoring import compute_stargazing_score from config import DEFAULT_MODEL, DEFAULT_USE_MODAL_FOR_HEAVY_COMPUTE, MODEL_CHOICES from data.locations import LOCATIONS from ui_helpers import ( _render_history_html, _render_cottage_board, _render_constellation_cards, _filter_constellations, _render_planner_target_hint, _render_planning_panel, planner_target_choices, PLANNER_TARGET_AUTO, ) from data.constellations import CONSTELLATIONS, CONSTELLATIONS_BY_NAME from core.geocoder import resolve_plan_location from agent.nl_parser import parse_nl_query, find_best_date # --- THEME & STYLING --- midnight_oasis_theme = gr.themes.Base( font=[gr.themes.GoogleFont("Geist"), "ui-sans-serif", "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("Geist Mono"), "ui-monospace", "Consolas", "monospace"], ).set( body_background_fill="#05070f", body_background_fill_dark="#05070f", body_text_color="#f0f2f8", body_text_color_dark="#f0f2f8", background_fill_primary="#161a26", background_fill_primary_dark="#161a26", background_fill_secondary="#1f2433", background_fill_secondary_dark="#1f2433", border_color_primary="rgba(210, 187, 255, 0.28)", border_color_primary_dark="rgba(210, 187, 255, 0.28)", block_background_fill="rgba(44, 49, 67, 0.4)", block_background_fill_dark="rgba(44, 49, 67, 0.4)", block_border_width="1px", block_border_color="rgba(210, 187, 255, 0.2)", block_radius="12px", input_background_fill="#1a1e28", input_background_fill_dark="#1a1e28", input_border_color="rgba(210, 187, 255, 0.35)", input_border_color_dark="rgba(210, 187, 255, 0.35)", input_border_color_focus="rgba(210, 187, 255, 0.55)", input_border_color_focus_dark="rgba(210, 187, 255, 0.55)", input_placeholder_color="#9aa3b5", input_placeholder_color_dark="#9aa3b5", button_primary_background_fill="#7c3aed", button_primary_background_fill_dark="#7c3aed", button_primary_text_color="#f7ecff", button_primary_text_color_dark="#f7ecff", panel_background_fill="rgba(44, 49, 67, 0.4)", ) CUSTOM_CSS = """ /* Midnight Oasis design tokens — swap on body.red-light-mode for scotopic */ :root { --z-dropdown: 50; --z-fab: 40; --z-controls: 30; --z-controls-secondary: 25; --z-telemetry: 10; --z-surface: 1; --z-banner: 100; --surface-body: #05070f; --surface-sidebar: #0f1320; --surface-card: #161a26; --surface-elevated: #1f2433; --surface-input: #1a1e28; --surface-inset: rgba(255, 255, 255, 0.05); --surface-chip: rgba(44, 49, 67, 0.6); --surface-pill: rgba(44, 49, 67, 0.5); --surface-tabs: rgba(16, 19, 26, 0.7); --ink: #f0f2f8; --ink-strong: #f5f6fc; --ink-muted: #e8eaf2; --ink-subtle: #c5c9d8; --ink-faint: #9aa3b5; --ink-on-accent: #ede0ff; --accent: #7c3aed; --accent-hover: #6d28d9; --accent-soft: #d2bbff; --accent-icon: #a855f7; --accent-secondary: #ffb347; --accent-secondary-soft: #ffd08a; --border-subtle: rgba(210, 187, 255, 0.18); --border-default: rgba(210, 187, 255, 0.28); --border-strong: rgba(210, 187, 255, 0.5); --border-nav: rgba(210, 187, 255, 0.18); --score-good: #10b981; --score-mid: #f59e0b; --score-low: #f43f5e; --score-muted: #e1e2eb; --status-ok: #34d399; --status-offline: #f87171; --offgrid-bg: rgba(147, 0, 10, 0.35); --offgrid-border: #b91c1c; --offgrid-ink: #fecaca; --model-mini-border: rgba(167, 139, 250, 0.35); --model-aya-border: rgba(52, 211, 153, 0.35); --model-nemotron-border: rgba(96, 165, 250, 0.35); --radius-sm: 8px; --radius-md: 12px; --radius-pill: 999px; --focus-ring: rgba(255, 179, 71, 0.48); } body.red-light-mode { --surface-body: #050000; --surface-sidebar: #1a0505; --surface-card: #1a0808; --surface-elevated: #220a0a; --surface-input: #110000; --surface-inset: rgba(255, 255, 255, 0.04); --surface-chip: rgba(70, 0, 0, 0.55); --surface-pill: rgba(60, 0, 0, 0.5); --surface-tabs: rgba(20, 0, 0, 0.8); --ink: #ff9999; --ink-strong: #ffcccc; --ink-muted: #ff9999; --ink-subtle: #ffaaaa; --ink-faint: #cc8888; --ink-on-accent: #1a0000; --accent: #cc0000; --accent-hover: #aa0000; --accent-soft: #ff6666; --accent-icon: #ff6666; --accent-secondary: #ff5555; --accent-secondary-soft: #ff9999; --border-subtle: rgba(255, 68, 68, 0.15); --border-default: rgba(255, 60, 60, 0.25); --border-strong: rgba(255, 68, 68, 0.35); --border-nav: rgba(255, 0, 0, 0.2); --status-ok: #ff4444; --status-offline: #ff3333; --score-good: #ff6666; --score-mid: #cc4444; --score-low: #881111; --score-muted: #ffaaaa; --focus-ring: rgba(255, 120, 120, 0.65); } /* Typography Overrides */ h1, h2, h3, h4, h5, h6, .gr-button { font-family: 'Space Grotesk', sans-serif !important; } /* Readable prose — avoid blanket `*` selectors (they break inputs & dropdowns) */ .gradio-container .gradio-markdown, .gradio-container .gradio-markdown p, .gradio-container .gradio-markdown li { color: var(--ink-muted) !important; line-height: 1.55; } .gradio-container .gradio-markdown h1, .gradio-container .gradio-markdown h2, .gradio-container .gradio-markdown h3 { color: var(--ink-strong) !important; font-weight: 600; } /* Form fields — explicit dark fill + light text (Gradio 4/5) */ .gradio-container input, .gradio-container textarea, .gradio-container select, .gradio-container .gr-box, .gradio-container .gr-textbox textarea, .gradio-container .gr-textbox input, .gradio-container .gr-dropdown input, .gradio-container .gr-dropdown .wrap, .gradio-container .secondary-wrap, .gradio-container [data-testid="textbox"] textarea, .gradio-container [data-testid="textbox"] input, .gradio-container [data-testid="dropdown"] input { color: var(--ink) !important; -webkit-text-fill-color: var(--ink) !important; background-color: var(--surface-input) !important; caret-color: var(--accent-soft) !important; } .gradio-container input::placeholder, .gradio-container textarea::placeholder { color: var(--ink-faint) !important; opacity: 1 !important; -webkit-text-fill-color: var(--ink-faint) !important; } .gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus, .gradio-container [data-testid="textbox"] textarea:focus, .gradio-container [data-testid="textbox"] input:focus, .gradio-container [data-testid="dropdown"] input:focus { border-color: var(--border-strong) !important; box-shadow: 0 0 8px var(--focus-ring) !important; outline: none !important; } /* Dropdown menus must float above panels */ .gradio-container .gradio-row, .gradio-container .gradio-column, .gradio-container .form, .gradio-container .block, .controls-bar, .controls-bar .wrap, .controls-bar .form { overflow: visible !important; } .controls-bar { position: relative; z-index: var(--z-controls); } .controls-bar--secondary { z-index: var(--z-controls-secondary); } .gradio-container ul.options, .gradio-container .options, .gradio-container [role="listbox"], .gradio-container .dropdown-menu { z-index: var(--z-dropdown) !important; background: var(--surface-card) !important; color: var(--ink) !important; border: 1px solid var(--border-strong) !important; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35) !important; } .gradio-container ul.options li, .gradio-container .options li, .gradio-container [role="option"] { color: var(--ink) !important; background: var(--surface-card) !important; } .gradio-container ul.options li:hover, .gradio-container .options li:hover, .gradio-container [role="option"]:hover { background: var(--surface-elevated) !important; } /* Subtle atmosphere — kept light for GPU / Off Grid */ .gradio-container::before { content: ""; position: fixed; top: -10%; left: -10%; width: 40vw; height: 40vw; border-radius: 50%; background-color: rgba(124, 58, 237, 0.07); filter: blur(48px); z-index: -1; pointer-events: none; } /* Planner form — stable grid, no backdrop-filter (breaks dropdown popovers) */ .planner-controls { background: rgba(16, 19, 26, 0.85); border: 1px solid rgba(210, 187, 255, 0.2); border-radius: 12px; padding: 16px; margin-bottom: 12px; overflow: visible !important; } .planner-controls .gr-row { gap: 12px !important; flex-wrap: wrap !important; align-items: flex-end !important; overflow: visible !important; } .planner-controls .gr-column { flex: 1 1 180px !important; min-width: 160px !important; overflow: visible !important; } .planner-controls label, .planner-controls .label-wrap span { color: var(--ink-subtle) !important; font-size: 0.72rem !important; font-weight: 600 !important; letter-spacing: 0.02em; text-transform: none; } .planner-controls .block, .planner-controls .wrap, .planner-controls .form { overflow: visible !important; } /* Phase 1 Layout Additions */ .controls-bar { background: var(--surface-sidebar); border-radius:var(--radius-md); padding:12px; gap:8px; flex-wrap:wrap; align-items:flex-end; overflow:visible !important; border: 1px solid var(--border-subtle); } .card { background: var(--surface-card); border-radius:var(--radius-md); border:1px solid var(--border-default); padding:20px; margin-bottom:16px; position:relative; z-index:var(--z-surface); overflow:visible !important; } .planner-hint { background: transparent; border: 1px dashed var(--border-default); border-radius: 10px; padding: 12px 16px; margin-bottom: 12px; } .gradio-image { background: transparent !important; border: none !important; } .gradio-image img { width:100%; border-radius:12px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } /* Model Panels — much stronger visual differentiation */ .model-panels { gap:16px; margin-top:16px; } .model-panel { background: var(--surface-elevated); border-radius:12px; border:1px solid var(--border-default); padding:18px 20px; } .model-panel--mini { border-color: var(--model-mini-border); } .model-panel--aya { border-color: var(--model-aya-border); } .model-panel--nemotron { border-color: var(--model-nemotron-border); } /* Model panel text — force readable contrast (Gradio prose/markdown) */ .model-panel .gradio-markdown, .model-panel .gradio-markdown p, .model-panel .gradio-markdown li, .model-panel .gradio-markdown strong, .model-panel .gradio-markdown em, .model-panel .gradio-markdown h1, .model-panel .gradio-markdown h2, .model-panel .gradio-markdown h3, .model-panel .gradio-markdown blockquote, .model-panel .prose, .model-panel .prose p { color: #e8eaf2 !important; } .model-panel .gradio-markdown h1, .model-panel .gradio-markdown h2, .model-panel .gradio-markdown h3 { color: var(--ink-strong) !important; } .obs-log-header { font-family: 'Space Grotesk', sans-serif; font-size: 0.85rem; font-weight: 600; letter-spacing: 0.02em; text-transform: none; color: var(--ink-subtle) !important; margin-bottom: 0.5rem; } /* Input textboxes — Ask Orion, custom location (legacy selectors kept for specificity) */ textarea, input, select, .gr-textbox textarea, .gr-textbox input, .gr-input, .gr-text-input { color: var(--ink) !important; -webkit-text-fill-color: var(--ink) !important; background-color: var(--surface-input) !important; } textarea::placeholder, input::placeholder { color: var(--ink-faint) !important; opacity: 1 !important; } .model-badge { display:inline-flex; align-items:center; gap:6px; font-size:0.78rem; font-weight:700; letter-spacing:0.02em; text-transform:none; padding:5px 12px; border-radius:999px; margin-bottom:10px; border: 1px solid currentColor; } .model-badge--mini { background:#7c3aed22; color:#c4b5fd; border-color:#a78bfa66; } .model-badge--aya { background:#0d4d3022; color:#6ee7b7; border-color:#34d39966; } .model-badge--nemotron { background:#1e3a5f22; color:#93c5fd; border-color:#60a5fa66; } .model-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.04em; padding: 3px 10px; border-radius: 6px; margin-bottom: 12px; opacity: 0.92; } .model-status--ok { background: rgba(16, 185, 129, 0.13); color: #6ee7b7; border: 1px solid rgba(16, 185, 129, 0.4); box-shadow: 0 0 12px rgba(16, 185, 129, 0.14); } .model-status--fallback { background: rgba(148, 163, 184, 0.12); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.3); } .model-status--failed { background: rgba(244, 63, 94, 0.12); color: #fda4af; border: 1px solid rgba(244, 63, 94, 0.36); box-shadow: 0 0 12px rgba(244, 63, 94, 0.12); } .backend-health { font-size: 0.88rem; line-height: 1.55; opacity: 0.92; } /* Make the French panel explicitly call out language */ .model-badge--aya::after { content: "FR"; font-size: 0.58rem; background: rgba(52, 211, 153, 0.15); padding: 1px 5px; border-radius: 4px; margin-left: 4px; font-weight: 700; } /* Telemetry & shared HTML component classes (ui_helpers.py) */ .t-section { margin:16px 0; } .t-label { font-size:0.72rem; font-weight:600; letter-spacing:0.02em; text-transform:none; opacity:0.85; margin-bottom:8px; color: var(--ink-subtle) !important; } .t-grid { display:flex; flex-wrap:wrap; gap:8px; } .t-chip { display:flex; flex-direction:column; align-items:center; padding:10px 16px; background: var(--surface-chip); border-radius:var(--radius-pill); border:1px solid var(--border-default); font-size:1.05rem; font-weight:600; color: var(--ink); min-width:78px; box-shadow: 0 0 12px rgba(255, 179, 71, 0.08); } .t-chip span { font-size:0.65rem; letter-spacing:0.01em; text-transform:none; opacity:0.75; margin-top:2px; font-weight:500; } .t-chip--offgrid { background:var(--offgrid-bg); border-color:var(--offgrid-border); color:var(--offgrid-ink); } .t-chip--bortle { border-width:2px; } .t-chip--note { font-size:0.82rem; text-align:left; padding:8px 12px; white-space:normal; } .bortle-good { border-color: var(--score-good) !important; color: var(--score-good); box-shadow: 0 0 12px rgba(16, 185, 129, 0.15); } .bortle-mid { border-color: var(--score-mid) !important; color: var(--score-mid); box-shadow: 0 0 12px rgba(245, 158, 11, 0.15); } .bortle-low { border-color: var(--score-low) !important; color: var(--score-low); box-shadow: 0 0 12px rgba(244, 63, 94, 0.15); } .score-good { color: var(--score-good); font-weight: 600; text-shadow: 0 0 10px rgba(16, 185, 129, 0.26); } .score-mid { color: var(--score-mid); font-weight: 600; text-shadow: 0 0 10px rgba(245, 158, 11, 0.24); } .score-low { color: var(--score-low); font-weight: 600; text-shadow: 0 0 10px rgba(244, 63, 94, 0.24); } .score-muted { color: var(--score-muted); font-weight: 600; } .list-row { background: var(--surface-inset); padding: 10px 14px; border-radius: var(--radius-sm); display: flex; justify-content: space-between; align-items: center; } .empty-hint { opacity: 0.55; font-size: 0.9rem; padding: 8px 0; color: var(--ink-faint); } /* Planner target hint + planning skeleton (A3) */ .target-hint { font-size: 0.82rem; line-height: 1.45; color: var(--ink-muted); background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: 8px; padding: 10px 12px; margin-top: 4px; } .target-hint__name { font-weight: 600; color: var(--ink); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .target-hint__season { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; background: rgba(99, 102, 241, 0.15); color: var(--accent-soft); padding: 2px 8px; border-radius: 999px; } .target-hint__desc { margin: 6px 0 4px; opacity: 0.9; } .target-hint__meta { margin: 0; font-size: 0.78rem; opacity: 0.75; } .target-hint__link { margin: 8px 0 0; font-size: 0.78rem; color: var(--accent-soft); } .control-room-lede { font-size: 0.8rem; line-height: 1.45; color: var(--ink-muted); margin: 0 0 4px; } .control-room-section-label { font-family: 'Space Grotesk', sans-serif; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); margin: 8px 0 4px; } .planning-panel { text-align: center; padding: 2.5rem 1rem; color: var(--ink-muted); } .planning-panel__spinner { width: 28px; height: 28px; margin: 0 auto 14px; border: 2px solid var(--border-default); border-top-color: var(--accent); border-radius: 50%; animation: plan-spin 0.85s linear infinite; } .planning-panel__title { font-weight: 600; color: var(--ink); margin: 0 0 6px; } .planning-panel__detail { font-size: 0.82rem; margin: 0; opacity: 0.8; } @keyframes plan-spin { to { transform: rotate(360deg); } } body.red-light-mode, body.red-light-mode .gradio-container { background-color: var(--surface-body) !important; } body.red-light-mode .scotopic-banner { display: block !important; } body.red-light-mode .gradio-container::before { background-color: rgba(255, 40, 40, 0.07) !important; } body.red-light-mode button, body.red-light-mode .gr-button { background-color: #3a0000 !important; color: var(--ink-strong) !important; } body.red-light-mode .model-badge--mini, body.red-light-mode .model-badge--aya, body.red-light-mode .model-badge--nemotron { color: var(--ink-subtle); border-color: rgba(255, 102, 102, 0.4); } /* Phase 5 — Constellation Browser */ .constellation-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:16px; margin-top:12px; } .constellation-card { background: var(--surface-card); border-radius:var(--radius-md); border:1px solid var(--border-default); padding:20px; } .constellation-card__title { margin:0; font-size:1.15rem; font-weight:700; color:var(--accent-soft); } .constellation-card__season { font-size:0.7rem; font-weight:600; letter-spacing:0.01em; text-transform:none; padding:3px 9px; border-radius:var(--radius-pill); border:1px solid var(--border-nav); white-space:nowrap; } .constellation-card__steps ol { line-height:1.6; } /* Inline Sky / Zenith section in Planner (below models) */ .sky-inline-card .gradio-image img { max-height: 420px; object-fit: contain; } /* ========================================================================== Midnight Oasis Premium CSS Theme Overrides ========================================================================== */ /* 1. Top Navigation Bar */ .top-nav-bar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: linear-gradient(115deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 27, 75, 0.92) 58%, rgba(22, 26, 38, 0.94) 100%); border: 1px solid var(--border-nav); border-radius: var(--radius-md); margin-bottom: 2rem; position: relative; box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.04); } .nav-logo { font-family: 'Space Grotesk', sans-serif; display: flex; flex-direction: column; align-items: flex-start; gap: 0.15rem; } .nav-logo__row { display: flex; align-items: center; gap: 0.5rem; } .nav-logo__title { font-size: 1.35rem; font-weight: 700; color: var(--accent-secondary-soft); letter-spacing: -0.02em; line-height: 1.1; } .nav-logo__tagline { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; color: var(--ink-faint); margin-left: 1.55rem; } .logo-icon { color: var(--accent-secondary); filter: drop-shadow(0 0 8px rgba(255, 179, 71, 0.28)); } /* Primary navigation — Gradio main tabs (real routing, not decorative HTML links) */ .main-nav-tabs > .tab-nav { display: flex !important; flex-wrap: wrap !important; justify-content: center !important; align-items: center !important; gap: 0.5rem 1.75rem !important; background: transparent !important; border: none !important; border-bottom: 1px solid var(--border-nav) !important; border-radius: 0 !important; padding: 0 1rem 0.75rem !important; margin: -1rem 0 1.25rem !important; box-shadow: none !important; width: 100% !important; } .main-nav-tabs > .tab-nav button { font-family: 'Space Grotesk', sans-serif !important; font-size: 0.95rem !important; font-weight: 500 !important; color: var(--ink-faint) !important; background: transparent !important; border: none !important; border-radius: 0 !important; padding: 0.5rem 0.25rem !important; min-height: 44px !important; position: relative !important; transition: color 0.2s ease-in-out !important; box-shadow: none !important; } .main-nav-tabs > .tab-nav button:hover { color: var(--ink-strong) !important; background: transparent !important; } .main-nav-tabs > .tab-nav button.selected { color: var(--accent-secondary-soft) !important; font-weight: 600 !important; background: transparent !important; } .main-nav-tabs > .tab-nav button.selected::after { content: ''; position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: linear-gradient(90deg, var(--accent), var(--accent-secondary)); box-shadow: 0 0 10px rgba(255, 179, 71, 0.3); border-radius: 2px; } .main-nav-tabs > .tab-nav button:focus-visible { outline: 2px solid var(--focus-ring) !important; outline-offset: 3px !important; } /* 2. Control Room Sidebar Container */ .control-room-card { background: var(--surface-sidebar) !important; border-radius: 12px; border: 1px solid var(--border-nav) !important; padding: 24px !important; display: flex; flex-direction: column; gap: 16px; position: relative; overflow: visible !important; } .control-room-header-container { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; border-bottom: 1px solid var(--border-default); padding-bottom: 8px; } .control-room-header { font-family: 'Space Grotesk', sans-serif; font-size: 0.9rem; font-weight: 600; letter-spacing: 0.01em; text-transform: none; color: var(--accent-secondary-soft); } .control-room-status-badge { font-family: 'Space Grotesk', sans-serif; font-size: 0.65rem; font-weight: 700; color: var(--status-ok); background: rgba(52, 211, 153, 0.1); padding: 3px 8px; border-radius: 999px; display: flex; align-items: center; gap: 4px; letter-spacing: 0.05em; } .status-dot { width: 6px; height: 6px; background-color: var(--status-ok); border-radius: 50%; display: inline-block; box-shadow: 0 0 6px var(--status-ok); } /* 3. Horizontal Telemetry Overlay Bar */ .telemetry-overlay-bar { display: flex; justify-content: space-between; align-items: center; background: var(--surface-sidebar); border-bottom-left-radius: 12px; border-bottom-right-radius: 12px; padding: 12px 20px; border: 1px solid var(--border-default); border-top: none; margin-top: -1px; z-index: var(--z-telemetry); position: relative; } .telemetry-chips-container { display: flex; gap: 12px; } .telemetry-pill { display: flex; align-items: center; gap: 8px; background: var(--surface-pill); border: 1px solid var(--border-subtle); padding: 4px 12px; border-radius: var(--radius-sm); box-shadow: 0 0 12px rgba(255, 179, 71, 0.08); } .pill-label { font-family: 'Space Grotesk', sans-serif; font-size: 0.7rem; font-weight: 500; letter-spacing: 0.01em; color: var(--ink-faint); } .pill-value { font-family: 'Space Grotesk', sans-serif; font-size: 0.85rem; font-weight: 600; color: var(--ink-strong); } .status-indicator { font-family: 'Space Grotesk', sans-serif; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.02em; display: flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px; } .status-indicator--online { color: var(--accent-secondary-soft); background: rgba(255, 179, 71, 0.11); border: 1px solid rgba(255, 179, 71, 0.32); box-shadow: 0 0 12px rgba(255, 179, 71, 0.13); } .status-indicator--offline { color: var(--status-offline); background: rgba(244, 63, 94, 0.1); border: 1px solid rgba(244, 63, 94, 0.28); box-shadow: 0 0 12px rgba(244, 63, 94, 0.1); } .pulse-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; } .status-indicator--online .pulse-dot { background-color: var(--accent-secondary); box-shadow: 0 0 8px var(--accent-secondary); animation: statusPulse 2s infinite; } .status-indicator--offline .pulse-dot { background-color: var(--status-offline); box-shadow: 0 0 8px var(--status-offline); animation: statusPulseRed 2s infinite; } @keyframes statusPulse { 0% { box-shadow: 0 0 0 0 rgba(255, 179, 71, 0.7); } 70% { box-shadow: 0 0 0 6px rgba(255, 179, 71, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 179, 71, 0); } } @keyframes statusPulseRed { 0% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.7); } 70% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0); } 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0); } } /* 4. Custom Segmented Purple Accent Model Tabs */ .model-tabs-container .tab-nav { display: inline-flex !important; background: var(--surface-tabs) !important; border: 1px solid var(--border-default) !important; border-radius: var(--radius-md) !important; padding: 4px !important; gap: 4px !important; margin-bottom: 16px !important; width: max-content !important; box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4); } .model-tabs-container .tab-nav button { font-family: 'Space Grotesk', sans-serif !important; font-size: 0.85rem !important; font-weight: 600 !important; color: var(--ink-faint) !important; padding: 8px 18px !important; border-radius: var(--radius-sm) !important; border: none !important; background: transparent !important; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; cursor: pointer !important; letter-spacing: 0.02em !important; } .model-tabs-container .tab-nav button:hover { color: var(--ink-strong) !important; background: rgba(255, 255, 255, 0.05) !important; } .model-tabs-container .tab-nav button.selected { color: var(--ink-on-accent) !important; background: var(--accent) !important; box-shadow: none !important; text-shadow: none !important; } .model-tabs-container .tab-nav button:focus-visible { outline: 2px solid var(--focus-ring) !important; outline-offset: 2px !important; } .model-tabs-container .tabitem { background: var(--surface-card) !important; border: 1px solid var(--border-subtle) !important; border-radius: var(--radius-md) !important; padding: 20px !important; margin-top: 8px !important; } /* 5. Floating Action Button (FAB) */ .fab-button { position: fixed; bottom: 24px; right: 24px; width: 56px; height: 56px; border-radius: 50%; background: var(--accent); border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); display: flex; align-items: center; justify-content: center; color: var(--ink-on-accent); cursor: pointer; z-index: var(--z-fab); transition: background 0.2s ease, transform 0.2s ease; } .fab-button:hover { transform: translateY(-2px); background: var(--accent-hover); color: #ffffff; } .fab-button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; } .fab-button:active { transform: scale(0.95); } .fab-icon { width: 24px; height: 24px; } /* Scotopic — body.red-light-mode token overrides handle surfaces/ink; spot fixes below */ body.red-light-mode .status-indicator--online { color: var(--status-ok) !important; background: rgba(255, 68, 68, 0.1) !important; border-color: rgba(255, 68, 68, 0.25) !important; } body.red-light-mode .status-indicator--online .pulse-dot, body.red-light-mode .status-indicator--offline .pulse-dot, body.red-light-mode .status-dot { background-color: var(--status-offline) !important; box-shadow: 0 0 8px var(--status-offline) !important; animation: statusPulseRed 2s infinite !important; } body.red-light-mode .model-tabs-container .tab-nav button:hover { background: rgba(255, 0, 0, 0.08) !important; } body.red-light-mode .model-status--ok { background: rgba(100, 0, 0, 0.28) !important; color: #ffaaaa !important; border-color: rgba(255, 100, 100, 0.35) !important; } body.red-light-mode .constellation-card__season { background: rgba(70, 0, 0, 0.55) !important; color: #ffaaaa !important; border-color: rgba(255, 68, 68, 0.3) !important; } body.red-light-mode img, body.red-light-mode .gradio-image img { filter: sepia(1) saturate(8) hue-rotate(-50deg) brightness(0.85); } body.red-light-mode input[type="checkbox"], body.red-light-mode input[type="radio"] { accent-color: var(--accent) !important; } body.red-light-mode .empty-hint, body.red-light-mode .planning-panel__detail, body.red-light-mode .target-hint { opacity: 1 !important; } body.red-light-mode label, body.red-light-mode .gr-form > label, body.red-light-mode .control-room-section-label { font-family: 'Geist', sans-serif !important; } body.red-light-mode .progress-bar, body.red-light-mode .meta-progress-bar { background: rgba(80, 0, 0, 0.45) !important; border-color: rgba(255, 68, 68, 0.35) !important; } body.red-light-mode .loader, body.red-light-mode .generating { border-top-color: var(--accent-soft) !important; } /* Scotopic light-leak kill: hardcoded green/amber/gold glows must not leak in red-light */ body.red-light-mode .t-chip, body.red-light-mode .telemetry-pill, body.red-light-mode .bortle-good, body.red-light-mode .bortle-mid, body.red-light-mode .bortle-low, body.red-light-mode .score-good, body.red-light-mode .score-mid, body.red-light-mode .score-low, body.red-light-mode .model-status--ok, body.red-light-mode .model-status--failed, body.red-light-mode .status-indicator--online, body.red-light-mode .status-indicator--offline { box-shadow: none !important; text-shadow: none !important; } body.red-light-mode .model-status--failed { background: rgba(100, 0, 0, 0.28) !important; color: #ff8888 !important; border-color: rgba(255, 100, 100, 0.35) !important; } /* Responsive — cottage phone / narrow HF Space viewport */ @media (max-width: 960px) { .top-nav-bar { padding: 0.75rem 1rem; margin-bottom: 1rem; } .nav-logo__title { font-size: 1.1rem; } .nav-logo__tagline { font-size: 0.65rem; margin-left: 1.35rem; } .planner-layout-row { flex-direction: column !important; } .planner-layout-row > .gr-column, .planner-bottom-row > .gr-column { flex: 1 1 100% !important; min-width: 100% !important; width: 100% !important; } .planner-bottom-row { flex-direction: column !important; } .telemetry-overlay-bar { flex-direction: column; align-items: stretch !important; gap: 10px; } .telemetry-chips-container { flex-wrap: wrap; } .constellation-grid { grid-template-columns: 1fr !important; } .main-nav-tabs > .tab-nav { gap: 0.35rem 1rem !important; justify-content: flex-start !important; } .main-nav-tabs > .tab-nav button { font-size: 0.88rem !important; padding: 0.45rem 0.2rem !important; } .fab-button { bottom: 16px; right: 16px; } } @media (max-width: 480px) { .controls-bar .gr-button, .planner-layout-row .gr-button.primary, .planner-layout-row button[data-variant="primary"] { min-height: 44px !important; } .t-chip { min-width: 68px; padding: 10px 12px; } } @media (prefers-reduced-motion: reduce) { .fab-button, .main-nav-tabs > .tab-nav button, .model-tabs-container .tab-nav button, .status-indicator--online .pulse-dot, .status-indicator--offline .pulse-dot { animation: none !important; transition: none !important; } .fab-button:hover { transform: none !important; } .planning-panel__spinner { animation: none !important; opacity: 0.55; } } /* Advanced settings accordion — make pop-out fonts legible (labels, infos, inputs) */ .gr-accordion, .gr-accordion .gr-box { font-size: 0.9rem !important; } .gr-accordion label, .gr-accordion .gr-form > label, .gr-accordion .label-wrap { font-size: 0.9rem !important; color: var(--ink-strong) !important; opacity: 1 !important; } .gr-accordion .gr-info, .gr-accordion [class*="info"], .gr-accordion .gr-textbox-info { font-size: 0.82rem !important; opacity: 0.9 !important; color: var(--ink-muted) !important; } .gr-accordion input, .gr-accordion textarea, .gr-accordion .gr-textbox input, .gr-accordion .gr-checkbox input { font-size: 0.9rem !important; color: var(--ink) !important; } """ TOP_NAV_HTML = """
""" CONTROL_ROOM_HEADER_HTML = """
Field planner

Choose where and when you're outside. Each plan fills all three model journals below.

""" MODEL_BADGE_HTML = { "mini": '
⚡ MiniCPM — Quick Summary
', "aya": '
🌍 Aya — Guide en Français
', "nemotron": '
🔭 Nemotron — Expert Analysis
', } # --- EVENT HANDLERS --- def _decorate_model_panel(title_html: str, markdown: str, meta: dict) -> str: """Add model identity badge plus polish/backend status (P0).""" polished = bool(meta.get("polished")) backend = meta.get("backend") or "ollama" polish_note = meta.get("polish_note") or ("LLM narrative" if polished else "template only") if polished: status = f"✓ {polish_note} · {backend}" css = "model-status--ok" elif "unsuccessful" in polish_note.lower(): status = f"✗ {polish_note} · {backend}" css = "model-status--failed" else: status = f"○ {polish_note} · {backend}" css = "model-status--fallback" status_html = f'
{status}
' return f"{title_html}\n{status_html}\n\n{markdown}" def _render_telemetry_bar_html( env: Optional[dict], score: int, off_grid: bool, location_note: Optional[str] = None, obs_time: Optional[datetime] = None, ) -> str: """Render the horizontal telemetry overlay bar directly below/on the bottom edge of the image canvas.""" bortle = max(1, min(9, 10 - score)) bortle_str = f"Class {bortle}" score_cls = "score-good" if score >= 7 else ("score-mid" if score >= 5 else "score-low") score_str = f"{score}/10" date_str = obs_time.strftime("%a %b %d") if obs_time else "Tonight" seeing_str = "III/V" clouds_str = "--" temp_str = "--" moon_str = "--" if env: seeing_str = str(env.get("seeing_quality") or "III/V") clouds_str = f"{env.get('cloud_cover', 0)}%" temp_str = f"{env.get('temperature_c', '?')}°C" moon_illum = env.get('moon_illumination', 0) moon_phase = env.get('moon_phase', '?') moon_str = f"{moon_phase} ({moon_illum}%)" elif obs_time: # Calculate moon phase offline if weather data is skipped or offline from core.environment import _derive_moon_data moon = _derive_moon_data(obs_time) moon_illum = moon.get("moon_illumination", 0) moon_phase = moon.get("moon_phase", "?") moon_str = f"{moon_phase} ({moon_illum}%)" # Copy guide: sentence case for status — ALL CAPS reserved for the scotopic banner. if off_grid: status_class = "status-indicator--offline" status_label = "Off Grid" elif not env: status_class = "status-indicator--offline" status_label = "No weather" elif env.get("forecast_available") is False: status_class = "status-indicator--offline" status_label = "Forecast unavailable" else: status_class = "status-indicator--online" status_label = "Live weather" note_html = "" if location_note: note_html = f"""
Note {location_note}
""" return f"""
Viewing {score_str}
Date {date_str}
Bortle {bortle_str}
Seeing {seeing_str}
Clouds {clouds_str}
Temp {temp_str}
Moon {moon_str}
{note_html}
{status_label}
""" def _on_plan_click( location: str, custom_loc: str, timeframe: str, obs_date: Optional[Any], off_grid: bool, weather_enabled: bool, use_modal: bool, red_light: bool, history: list, target_override: str = "Auto", model_choice: Optional[str] = None, ): """Plan handler — supports optional target_override from the Targets tab.""" loc = resolve_plan_location(location, custom_loc, off_grid, LOCATIONS) lat, lon = loc["lat"], loc["lon"] location_note = loc["location_note"] tz_name = loc.get("tz_name") obs_time = _resolve_observation_time(timeframe, obs_date, tz_name=tz_name) # Respect override; fall back to seasonal auto-pick if target_override and target_override != "Auto": target_name = target_override else: target_name = _pick_target_name(obs_time, lat, lon) geometry = get_geometry_data(lat, lon, obs_time, target_name) env = get_environment_data(lat, lon, obs_time) if not off_grid and weather_enabled else None scoring = compute_stargazing_score(geometry, env, off_grid, weather_enabled) score = scoring["score"] # 1. Update history new_entry = {"target": geometry.get("target"), "date": datetime.now().strftime("%b %d"), "score": score} updated_history = [new_entry] + (history or [])[:9] # 2. Render UI HTML elements telemetry_html = _render_telemetry_bar_html(env, score, off_grid, location_note, obs_time) history_html = _render_history_html(updated_history) cottage_board_html = _render_cottage_board(updated_history) # 3. Call Router to generate the markdown plans and image # For troubleshooting: ORION_MINI_ONLY=1 -> exercise ONLY the MiniCPM (single-model) path. # This bypasses the three-model parallel, inference_gateway, and any Modal workers. mini_only = os.getenv("ORION_MINI_ONLY", "0").lower() in ("1", "true", "yes") is_mocked = hasattr(generate_model_plan, "mock_calls") if mini_only or is_mocked: plan_md, _img, panel_meta = generate_model_plan( location_name=location, timeframe=timeframe, off_grid=off_grid, weather_enabled=weather_enabled, view_sky=False, use_modal_for_heavy_compute=use_modal, model_choice=model_choice or "MiniCPM", red_light_mode=red_light, target_override=target_override, obs_date=obs_date, custom_lat=loc["custom_lat"], custom_lon=loc["custom_lon"], custom_loc_name=loc["custom_loc_name"], location_note=location_note, tz_name=tz_name, ) mini_md = plan_md aya_md = plan_md nem_md = plan_md meta_list = [panel_meta, panel_meta, panel_meta] else: mini_md, aya_md, nem_md, _img, meta_list = generate_three_models_plan( location_name=location, timeframe=timeframe, off_grid=off_grid, weather_enabled=weather_enabled, view_sky=False, use_modal_for_heavy_compute=use_modal, red_light_mode=red_light, target_override=target_override, obs_date=obs_date, custom_lat=loc["custom_lat"], custom_lon=loc["custom_lon"], custom_loc_name=loc["custom_loc_name"], location_note=location_note, tz_name=tz_name, ) mini_meta = meta_list[0] mini_badge = MODEL_BADGE_HTML["mini"] decorated_mini = _decorate_model_panel(mini_badge, mini_md, mini_meta) aya_meta = meta_list[1] aya_badge = MODEL_BADGE_HTML["aya"] decorated_aya = _decorate_model_panel(aya_badge, aya_md, aya_meta) nem_meta = meta_list[2] nem_badge = MODEL_BADGE_HTML["nemotron"] decorated_nem = _decorate_model_panel(nem_badge, nem_md, nem_meta) # Field sketch (view_sky + sky_img_out) removed — FLUX path dropped from UI scope. # The img from router (if any) is ignored; only zenith sky view (separate) remains. # Return 7 outputs matching the trimmed _plan_outputs. if mini_only: return ( decorated_mini, decorated_aya, decorated_nem, updated_history, telemetry_html, history_html, cottage_board_html ) if is_mocked: return ( decorated_mini, updated_history, telemetry_html, history_html, cottage_board_html ) return ( decorated_mini, decorated_aya, decorated_nem, updated_history, telemetry_html, history_html, cottage_board_html ) def _sync_weather_checkbox(off_grid: bool): if off_grid: return gr.update(value=False, interactive=False) return gr.update(interactive=True) def _sync_modal_checkbox(off_grid: bool): if off_grid: return gr.update(value=False, interactive=False) return gr.update(interactive=True) def _generate_zenith_chart( location: str, custom_loc: str, off_grid: bool, red_light: bool, target_override: str = "Auto", ): from zoneinfo import ZoneInfo loc = resolve_plan_location(location, custom_loc, off_grid, LOCATIONS) tz_name = loc.get("tz_name") if tz_name: try: obs_time = datetime.now(ZoneInfo(tz_name)) except Exception: obs_time = datetime.now() else: obs_time = datetime.now() highlight = target_override if target_override and target_override != "Auto" else None img = generate_sky_chart( loc["lat"], loc["lon"], obs_time, red_light_mode=red_light, highlight_constellation=highlight, ) return gr.update(value=img) # --- New helpers for clearer "exit demo / reset" and action clarity --- def _clear_nl_suggestion(off_grid: bool): """Exit the 'Ask Orion' suggestion state (the 'demo mode' the user mentioned).""" return ( gr.update(visible=False, value=""), gr.update(visible=False), gr.update(value=None), # clear custom date "Auto", # reset target override gr.update(value=PLANNER_TARGET_AUTO), _render_planner_target_hint(PLANNER_TARGET_AUTO, CONSTELLATIONS_BY_NAME), gr.update(visible=True), _render_telemetry_bar_html(None, 5, off_grid), ) def _preview_telemetry_for_suggestion( *, best_date: Optional[date], target_name: str, lat: float, lon: float, tz_name: Optional[str], off_grid: bool, weather_enabled: bool, location_note: Optional[str], ) -> str: """Show weather chips aligned with Ask Orion's suggested observation night.""" if not best_date: return _render_telemetry_bar_html(None, 5, off_grid) from core.timezone_utils import get_tz tz = get_tz(tz_name) obs_time = datetime.combine( best_date, datetime.strptime("22:00", "%H:%M").time() ).replace(tzinfo=tz) if target_name == "Auto": target_name = _pick_target_name(obs_time, lat, lon) geometry = get_geometry_data(lat, lon, obs_time, target_name) env = ( get_environment_data(lat, lon, obs_time) if weather_enabled and not off_grid else None ) scoring = compute_stargazing_score(geometry, env, off_grid, weather_enabled) return _render_telemetry_bar_html( env, scoring["score"], off_grid, location_note, obs_time ) def _reset_history(): """Clear Field Notes / Previous Observations (history + cottage board).""" empty: list = [] return empty, _render_history_html(empty), _render_cottage_board(empty) def _sync_plan_button_label(obs_date: Optional[Any]): """Make the main action button reflect whether this is 'Tonight' or a specific custom date/excursion.""" if not obs_date: return gr.update(value="Plan tonight") try: if isinstance(obs_date, str): # Handle both YYYY-MM-DD and datetime strings from DateTime component date_str = obs_date[:10] if "T" in obs_date or " " in obs_date else obs_date d = datetime.strptime(date_str, "%Y-%m-%d").date() else: d = obs_date.date() if hasattr(obs_date, "date") else obs_date label = f"Plan for {d.strftime('%b %d')}" return gr.update(value=label) except Exception: return gr.update(value="Plan observation") # --- UI DEFINITION --- def build_app(): with gr.Blocks(title="Orion's Cottage — Constellation Planner") as demo: gr.HTML( '' '' '' ) gr.HTML(TOP_NAV_HTML) # Session State (Gradio 4.44 Space runtime does not expose BrowserState). history_state = gr.State([]) target_override_state = gr.State("Auto") with gr.Tabs(elem_id="main-tabs", elem_classes=["main-nav-tabs"]) as main_tabs: with gr.Tab("Planner", id="planner-tab"): # NL Query bar with gr.Row(elem_classes=["controls-bar"]): nl_query = gr.Textbox( placeholder="Ask Orion… e.g. “Best weekend in July for Cassiopeia?”", show_label=False, container=False, scale=4, ) nl_btn = gr.Button("Ask Orion", variant="secondary", scale=1) with gr.Row(elem_classes=["planner-layout-row"]): # Left Column: Control Room Sidebar with gr.Column(scale=1, min_width=280, elem_classes=["control-room-card"]): gr.HTML(CONTROL_ROOM_HEADER_HTML) gr.HTML('
Where
') location_dropdown = gr.Dropdown( choices=list(LOCATIONS.keys()), value=list(LOCATIONS.keys())[0], label="Location", ) gr.HTML('
When
') timeframe_dropdown = gr.Dropdown( choices=["Tonight", "Tomorrow Night", "This Weekend"], value="Tonight", label="Timeframe", ) gr.HTML('
What to observe
') planner_target_dropdown = gr.Dropdown( choices=planner_target_choices(CONSTELLATIONS), value=PLANNER_TARGET_AUTO, label="Target", info="Auto chooses what's up for the season at your site.", ) target_hint_out = gr.HTML( _render_planner_target_hint(PLANNER_TARGET_AUTO, CONSTELLATIONS_BY_NAME), ) browse_targets_btn = gr.Button( "Open constellation guides →", variant="secondary", size="sm", ) gr.HTML('
Night setup
') off_grid_toggle = gr.Checkbox( label="Off Grid", value=False, info="Skip weather APIs. Local astronomy still runs; Modal is disabled (needs network).", ) red_light_toggle = gr.Checkbox( label="Red-light mode", value=False, info="Scotopic red palette to preserve night vision outside.", ) with gr.Accordion("Advanced settings", open=False): custom_loc_box = gr.Textbox( label="Custom coordinates", placeholder="City or address (optional)", ) date_picker = gr.DateTime( label="Specific date (optional)", include_time=False, type="datetime", ) weather_toggle = gr.Checkbox( label="Live weather", value=True, info="Open-Meteo cloud cover and moon phase for the plan.", ) use_modal_toggle = gr.Checkbox( label="Modal GPU polish", value=DEFAULT_USE_MODAL_FOR_HEAVY_COMPUTE, info="Optional cloud GPUs. Off when Off Grid or for local-only demos.", ) submit_btn = gr.Button("Plan observation", variant="primary") # Right Column: Visual Canvas, Telemetry & Model Tabs with gr.Column(scale=3): # NL suggestion area with gr.Column(elem_classes=["planner-hint"]): with gr.Row(): with gr.Column(scale=5): nl_summary_out = gr.Markdown("", visible=False) with gr.Column(scale=1, min_width=70): clear_nl_btn = gr.Button("✕ Clear", variant="secondary", size="sm", visible=False) nl_hint_md = gr.Markdown( "" "Ask Orion picks a date and target from your question. Review the sidebar, then " "Plan observation — or clear to start over." "", visible=True, ) # Telemetry (Field Sketch / FLUX toggle removed — only retained Sky view is the zenith chart below) with gr.Column(elem_classes=["card"]): telemetry_out = gr.HTML(_render_telemetry_bar_html(None, 5, False), elem_id="telemetry-chips") with gr.Tabs(elem_id="model-outputs", elem_classes=["model-tabs-container"]) as model_tabs: with gr.Tab("⚡ MiniCPM"): mini_out = gr.Markdown( '

Your warm cottage summary appears here after you plan.

' ) with gr.Tab("🌍 Aya (FR)"): aya_out = gr.Markdown( '

Le guide en français apparaît ici après la planification.

' ) with gr.Tab("🔭 Nemotron"): nemotron_out = gr.Markdown( '

Expert RA/Dec, seeing, and eyepiece notes land here after you plan.

' ) # Bottom Row: Zenith (skyview) next to History and cottage board side-by-side with gr.Row(elem_classes=["planner-bottom-row"]): with gr.Column(scale=2, min_width=280, elem_classes=["card", "sky-inline-card"]): gr.Markdown("### Sky view — zenith") gr.Markdown( "What's directly overhead at your site right now — a quick orientation before you step outside." ) zenith_img_planner = gr.Image(label="Zenith (live)", interactive=False) zenith_planner_btn = gr.Button("↻ Refresh Zenith", variant="secondary", size="sm") with gr.Column(scale=1, min_width=220, elem_classes=["card"]): with gr.Row(): gr.HTML('
Field notes
') reset_history_btn = gr.Button("Reset", variant="secondary", size="sm", scale=0, min_width=60) history_out = gr.HTML(_render_history_html([]), elem_id="history-card") with gr.Column(scale=1, min_width=220, elem_classes=["card"]): cottage_board_out = gr.HTML(_render_cottage_board([]), elem_id="cottage-board") with gr.Tab("Targets", id="targets-tab"): with gr.Column(elem_classes=["card"]): gr.Markdown( "### Constellation guides\n" "Finder steps, mythology, and deep-sky highlights for Nova Scotia skies. " "Pick one below or use **Quick plan** to jump back to the Planner." ) search_box = gr.Textbox( placeholder="Search… Orion, Summer, Nebula, Cassiopeia", show_label=False, container=False, ) constellation_html = gr.HTML( _render_constellation_cards(CONSTELLATIONS), elem_id="constellation-browser", ) # Per-constellation Plan Tonight buttons (one per card, Gradio-native) gr.HTML('
Quick plan
') with gr.Row(elem_classes=["controls-bar"]): plan_target_dropdown = gr.Dropdown( choices=[c["name"] for c in CONSTELLATIONS], value=CONSTELLATIONS[0]["name"], label="Constellation", show_label=False, container=False, scale=4, info="Plans this target on the Planner tab with your current location and options.", ) plan_tonight_btn = gr.Button("Plan this target →", variant="primary", scale=2) # Backend tab removed from UI (scope cut for submission — Planner + Targets only per roadmap) # Event Wiring _planner_inputs = [ location_dropdown, custom_loc_box, timeframe_dropdown, date_picker, off_grid_toggle, weather_toggle, use_modal_toggle, red_light_toggle, history_state, target_override_state ] _plan_outputs = [ mini_out, aya_out, nemotron_out, history_state, telemetry_out, history_out, cottage_board_out, ] def _planner_target_override(name: str) -> str: return "Auto" if name == PLANNER_TARGET_AUTO else name def _on_planner_target_pick(name: str): sync_targets = ( gr.update(value=name) if name != PLANNER_TARGET_AUTO else gr.update() ) return ( _planner_target_override(name), _render_planner_target_hint(name, CONSTELLATIONS_BY_NAME), sync_targets, ) def _sync_planner_from_targets_tab(name: str): return ( gr.update(value=name), _render_planner_target_hint(name, CONSTELLATIONS_BY_NAME), name, ) def _browse_targets_tab(planner_target: str): targets_dd = ( gr.update(value=planner_target) if planner_target != PLANNER_TARGET_AUTO else gr.update() ) return gr.update(selected="targets-tab"), targets_dd def _plan_loading_updates(use_modal: bool, *, busy_label: str = "Planning…"): if os.getenv("ORION_MINI_ONLY", "0").lower() in ("1", "true", "yes"): btn_label = "Planning with MiniCPM (troubleshoot mode)…" # Still render three panels in the UI (they will all receive the mini result) use_modal_for_label = False else: btn_label = "Warming 3 GPU workers…" if use_modal else busy_label use_modal_for_label = use_modal return ( gr.update(interactive=False, value=btn_label), gr.update(value=_render_planning_panel("MiniCPM", use_modal=use_modal_for_label)), gr.update(value=_render_planning_panel("Aya (FR)", use_modal=use_modal_for_label)), gr.update(value=_render_planning_panel("Nemotron", use_modal=use_modal_for_label)), ) def _reenable_submit_btn(obs_date): lbl = "Plan observation" if not obs_date: lbl = "Plan tonight" else: try: if isinstance(obs_date, str): date_str = obs_date[:10] if "T" in obs_date or " " in obs_date else obs_date d = datetime.strptime(date_str, "%Y-%m-%d").date() else: d = obs_date.date() if hasattr(obs_date, "date") else obs_date lbl = f"Plan for {d.strftime('%b %d')}" except Exception: lbl = "Plan observation" return gr.update(interactive=True, value=lbl) planner_target_dropdown.change( fn=_on_planner_target_pick, inputs=[planner_target_dropdown], outputs=[target_override_state, target_hint_out, plan_target_dropdown], ) plan_target_dropdown.change( fn=_sync_planner_from_targets_tab, inputs=[plan_target_dropdown], outputs=[planner_target_dropdown, target_hint_out, target_override_state], ) browse_targets_btn.click( fn=_browse_targets_tab, inputs=[planner_target_dropdown], outputs=[main_tabs, plan_target_dropdown], ) submit_btn.click( fn=_plan_loading_updates, inputs=[use_modal_toggle], outputs=[submit_btn, mini_out, aya_out, nemotron_out], ).then( fn=_on_plan_click, inputs=_planner_inputs, outputs=_plan_outputs ).then( fn=_generate_zenith_chart, inputs=[ location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, target_override_state, ], outputs=[zenith_img_planner] ).then( fn=lambda: (gr.update(visible=False, value=""), gr.update(visible=False), gr.update(visible=True)), inputs=None, outputs=[nl_summary_out, clear_nl_btn, nl_hint_md] ).then( fn=_reenable_submit_btn, inputs=[date_picker], outputs=submit_btn ) def _handle_nl_query(query: str, location: str, custom_loc: str, off_grid: bool, weather: bool): if not (query or "").strip(): # Must match the 10 outputs in _nl_outputs exactly. return ( gr.update(visible=False), gr.update(visible=False), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(visible=True), ) parsed = parse_nl_query(query, preset_locations=LOCATIONS) query_location = parsed.get("location") effective_custom = query_location or custom_loc loc = resolve_plan_location(location, effective_custom, off_grid, LOCATIONS) tz_name = loc.get("tz_name") weather_on = weather and not off_grid best_date, summary = find_best_date( parsed, loc["lat"], loc["lon"], tz_name=tz_name, weather_enabled=weather_on, location_label=loc.get("display_name"), ) plan_label = _sync_plan_button_label(best_date) if best_date else gr.update(value="Plan tonight") nl_target = parsed.get("target") or "Auto" if nl_target != "Auto": target_state = nl_target planner_dd = gr.update(value=nl_target) hint_html = gr.update(value=_render_planner_target_hint(nl_target, CONSTELLATIONS_BY_NAME)) else: target_state = gr.update() planner_dd = gr.update() hint_html = gr.update() telemetry_html = _preview_telemetry_for_suggestion( best_date=best_date, target_name=nl_target if nl_target != "Auto" else "Auto", lat=loc["lat"], lon=loc["lon"], tz_name=tz_name, off_grid=off_grid, weather_enabled=weather_on, location_note=loc.get("location_note"), ) custom_loc_update = ( gr.update(value=effective_custom) if query_location else gr.update() ) return ( gr.update(value=summary, visible=True), gr.update(visible=True), gr.update(value=datetime.combine(best_date, datetime.min.time())) if best_date else gr.update(), target_state, plan_label, planner_dd, hint_html, telemetry_html, custom_loc_update, gr.update(visible=False), ) _nl_outputs = [ nl_summary_out, clear_nl_btn, date_picker, target_override_state, submit_btn, planner_target_dropdown, target_hint_out, telemetry_out, custom_loc_box, nl_hint_md, ] nl_btn.click( fn=_handle_nl_query, inputs=[nl_query, location_dropdown, custom_loc_box, off_grid_toggle, weather_toggle], outputs=_nl_outputs, ) nl_query.submit( fn=_handle_nl_query, inputs=[nl_query, location_dropdown, custom_loc_box, off_grid_toggle, weather_toggle], outputs=_nl_outputs, ) clear_nl_btn.click( fn=_clear_nl_suggestion, inputs=[off_grid_toggle], outputs=[ nl_summary_out, clear_nl_btn, date_picker, target_override_state, planner_target_dropdown, target_hint_out, nl_hint_md, telemetry_out, ], ) def _on_constellation_search(query: str): filtered = _filter_constellations(CONSTELLATIONS, query) names = [c["name"] for c in filtered] if not names: names = [c["name"] for c in CONSTELLATIONS] return ( _render_constellation_cards(CONSTELLATIONS, query), gr.Dropdown(choices=names, value=names[0]), ) search_box.change( fn=_on_constellation_search, inputs=[search_box], outputs=[constellation_html, plan_target_dropdown], ) def _plan_from_targets(target_name, location, custom_loc, timeframe, obs_date, off_grid, weather, modal, red_light, history, _target_override): results = _on_plan_click( location, custom_loc, timeframe, obs_date, off_grid, weather, modal, red_light, history, target_override=target_name ) return (*results, gr.update(selected="planner-tab")) plan_tonight_btn.click( fn=lambda m: _plan_loading_updates(m, busy_label="Planning target…"), inputs=[use_modal_toggle], outputs=[plan_tonight_btn, mini_out, aya_out, nemotron_out], ).then( fn=_plan_from_targets, inputs=[plan_target_dropdown, *_planner_inputs], outputs=[*_plan_outputs, main_tabs], ).then( fn=_generate_zenith_chart, inputs=[ location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, target_override_state, ], outputs=[zenith_img_planner] ).then( # Hide any active "Ask Orion" suggestion once the user plans from the Targets tab fn=lambda: (gr.update(visible=False, value=""), gr.update(visible=False), gr.update(visible=True)), inputs=None, outputs=[nl_summary_out, clear_nl_btn, nl_hint_md] ).then( fn=lambda: gr.update(interactive=True, value="Plan this target →"), inputs=None, outputs=plan_tonight_btn ) zenith_planner_btn.click( fn=_generate_zenith_chart, inputs=[ location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, target_override_state, ], outputs=[zenith_img_planner] ) # Dynamic main action label: "Plan Tonight" vs "Plan for Jul 15" when custom date is chosen (via Ask Orion or manual) date_picker.change( fn=_sync_plan_button_label, inputs=[date_picker], outputs=[submit_btn] ) # Reset Field Notes (history + cottage board) reset_history_btn.click( fn=_reset_history, inputs=None, outputs=[history_state, history_out, cottage_board_out] ) off_grid_toggle.change(fn=_sync_weather_checkbox, inputs=[off_grid_toggle], outputs=[weather_toggle]) off_grid_toggle.change(fn=_sync_modal_checkbox, inputs=[off_grid_toggle], outputs=[use_modal_toggle]) red_light_toggle.change( fn=None, inputs=[red_light_toggle], outputs=None, js="(is_red) => { if(is_red) document.body.classList.add('red-light-mode'); else document.body.classList.remove('red-light-mode'); }" ) # A4: hydrate in-memory history so cards aren't empty on first load. def _hydrate_history(hist): h = hist or [] return _render_history_html(h), _render_cottage_board(h) demo.load( fn=_hydrate_history, inputs=[history_state], outputs=[history_out, cottage_board_out], ).then( # Pre-render tonight's zenith chart so the Sky view isn't empty on first visit. fn=_generate_zenith_chart, inputs=[ location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, target_override_state, ], outputs=[zenith_img_planner], ) # Floating Action Button (FAB) gr.HTML(""" """) return demo if __name__ == "__main__": import argparse import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) parser = argparse.ArgumentParser(description="Orion's Cottage — Constellation Planner") parser.add_argument( "--port", type=int, default=None, help="Port for the Gradio server (default: 7860 or GRADIO_SERVER_PORT env var)", ) args = parser.parse_args() # Prefer explicit --port, then GRADIO_SERVER_PORT, then 7860 preferred_port = args.port or int(os.environ.get("GRADIO_SERVER_PORT", 7860)) # Try a small range so a stale dev server on 7860 doesn't completely block you static_dir = Path(__file__).resolve().parent / "static" allowed = [str(static_dir)] if static_dir.is_dir() else None app = build_app() for port in range(preferred_port, preferred_port + 10): try: print(f"Starting Orion's Cottage on http://0.0.0.0:{port} (Press Ctrl+C to stop)") app.launch( server_name="0.0.0.0", server_port=port, show_error=True, theme=midnight_oasis_theme, css=CUSTOM_CSS, allowed_paths=allowed, ) break except OSError as e: msg = str(e).lower() if "cannot find empty port" in msg or "address already in use" in msg: print(f"Port {port} in use, trying {port + 1}...") continue raise else: raise SystemExit(f"Could not find a free port in {preferred_port}-{preferred_port + 9}")