orionscottage / app.py
red
Rebrand header and fix local Aya/Nemotron polish timeouts.
00d8c5c
Raw
History Blame Contribute Delete
74.8 kB
"""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 = """
<div class="top-nav-bar">
<div class="scotopic-banner" role="status" aria-live="polite" style="display: none; position: absolute; top: -10px; left: 50%; transform: translateX(-50%); background: #ff4444; color: #000; padding: 2px 10px; border-radius: 4px; font-size: 0.7rem; font-weight: bold; letter-spacing: 0.1em; z-index: 100;">SCOTOPIC MODE ACTIVE</div>
<div class="nav-logo">
<div class="nav-logo__row">
<span class="logo-icon" aria-hidden="true">✦</span>
<span class="nav-logo__title">Orion's Cottage</span>
</div>
<span class="nav-logo__tagline">Constellation Planner</span>
</div>
</div>
"""
CONTROL_ROOM_HEADER_HTML = """
<div class="control-room-header-container">
<div class="control-room-header">Field planner</div>
</div>
<p class="control-room-lede">Choose where and when you're outside. Each plan fills all three model journals below.</p>
"""
MODEL_BADGE_HTML = {
"mini": '<div class="model-badge model-badge--mini">⚡ MiniCPM — Quick Summary</div>',
"aya": '<div class="model-badge model-badge--aya">🌍 Aya — Guide en Français</div>',
"nemotron": '<div class="model-badge model-badge--nemotron">🔭 Nemotron — Expert Analysis</div>',
}
# --- 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'<div class="model-status {css}">{status}</div>'
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"""<div class="telemetry-pill">
<span class="pill-label">Note</span>
<span class="pill-value">{location_note}</span>
</div>"""
return f"""<div class="telemetry-overlay-bar">
<div class="telemetry-chips-container">
<div class="telemetry-pill">
<span class="pill-label">Viewing</span>
<span class="pill-value {score_cls}">{score_str}</span>
</div>
<div class="telemetry-pill">
<span class="pill-label">Date</span>
<span class="pill-value">{date_str}</span>
</div>
<div class="telemetry-pill">
<span class="pill-label">Bortle</span>
<span class="pill-value">{bortle_str}</span>
</div>
<div class="telemetry-pill">
<span class="pill-label">Seeing</span>
<span class="pill-value">{seeing_str}</span>
</div>
<div class="telemetry-pill">
<span class="pill-label">Clouds</span>
<span class="pill-value">{clouds_str}</span>
</div>
<div class="telemetry-pill">
<span class="pill-label">Temp</span>
<span class="pill-value">{temp_str}</span>
</div>
<div class="telemetry-pill">
<span class="pill-label">Moon</span>
<span class="pill-value">{moon_str}</span>
</div>
{note_html}
</div>
<div class="telemetry-status-container">
<div class="status-indicator {status_class}">
<span class="pulse-dot"></span> {status_label}
</div>
</div>
</div>"""
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(
'<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Geist:wght@400;500;600&display=swap" rel="stylesheet">'
'<link rel="manifest" href="/file=static/manifest.json">'
'<link rel="icon" href="/file=static/icon.svg" type="image/svg+xml">'
)
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('<div class="control-room-section-label">Where</div>')
location_dropdown = gr.Dropdown(
choices=list(LOCATIONS.keys()),
value=list(LOCATIONS.keys())[0],
label="Location",
)
gr.HTML('<div class="control-room-section-label">When</div>')
timeframe_dropdown = gr.Dropdown(
choices=["Tonight", "Tomorrow Night", "This Weekend"],
value="Tonight",
label="Timeframe",
)
gr.HTML('<div class="control-room-section-label">What to observe</div>')
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('<div class="control-room-section-label">Night setup</div>')
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(
"<span style='font-size:0.72rem; opacity:0.75;'>"
"Ask Orion picks a date and target from your question. Review the sidebar, then "
"<strong>Plan observation</strong> — or clear to start over."
"</span>",
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(
'<p class="empty-hint">Your warm cottage summary appears here after you plan.</p>'
)
with gr.Tab("🌍 Aya (FR)"):
aya_out = gr.Markdown(
'<p class="empty-hint">Le guide en français apparaît ici après la planification.</p>'
)
with gr.Tab("🔭 Nemotron"):
nemotron_out = gr.Markdown(
'<p class="empty-hint">Expert RA/Dec, seeing, and eyepiece notes land here after you plan.</p>'
)
# 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('<div class="t-label" style="flex:1">Field notes</div>')
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('<div class="t-label" style="margin:16px 0 8px;">Quick plan</div>')
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("""
<button type="button" class="fab-button" aria-label="Back to top" onclick="window.scrollTo({top: 0, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="fab-icon">
<line x1="12" y1="19" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
""")
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}")