""" app.py — FutureSelves for Build Small (Gradio Space). Two models, one Space: - MiniCPM 2.5B (~2.5B) — primary LLM for transmission generation - Nemotron-Parse (<1B) — structured note extraction (NVIDIA prize) TTS via Kokoro (82M) — fully local. Targeted prizes (8): Backyard AI, OpenBMB, NVIDIA Nemotron, Tiny Titan, Best Agent, Off Brand, Best Demo, Bonus Quest Champion. """ from __future__ import annotations import json import logging import os import shutil import tempfile import threading import uuid from dataclasses import dataclass, field, asdict from datetime import date from html import escape as html_escape from pathlib import Path from typing import Any, Optional import gradio as gr from transmission import ( CastMember, GenerationContext, GeneratedTransmission, PersonaContext, RecentChoice, RecentResponse, RecentTransmission, build_prompt, fallback_transmission, get_system_prompt, parse_transmission, ) from parse_notes import extract_note_insights, fast_insights from tts import generate_speech, get_voice_for_cast_member from demo.maya import build_maya_demo, MayaDemoBundle from modal_eval import log_agent_trace, summarize_persona_modal, write_modal_app, write_demo_trace from form_helpers import ( PLACEHOLDERS, EXAMPLES, PRIMER_HTML, BUTTON_TEXT, ARC_OPTIONS, ARC_EMOJI, VOICE_EMOJI, chip_html, _memory_cards_html, _word_chips_html, _arc_cards_html, MEMORY_TO_CHOICE_REACTION, ) logger = logging.getLogger(__name__) MODEL_NAME = os.environ.get("LLM_MODEL", "openbmb/MiniCPM-2.5-sft-bf16") CAST_MEMBER_NAMES = { "future_self": ("Your Future Self", "Always transmitting"), "future_partner": ("Future Partner", "Love arc required"), "future_mentor": ("Future Mentor", "7-day streak + toward choices"), "future_best_friend": ("Future Best Friend", "3-day streak + repair"), "shadow": ("The Shadow", "High divergence"), "alternate_self": ("Alternate Self", "14-day streak + drift"), } # ─── Model ─────────────────────────────────────────────────────────────────── _LLM = None _LLM_LOCK = threading.Lock() def _load_llm(): global _LLM if _LLM is not None: return _LLM with _LLM_LOCK: if _LLM is not None: return _LLM import torch from transformers import AutoModelForCausalLM, AutoTokenizer logger.info("Loading MiniCPM: %s", MODEL_NAME) model = AutoModelForCausalLM.from_pretrained( MODEL_NAME, trust_remote_code=True, torch_dtype=torch.float16, device_map="auto", attn_implementation="sdpa", ) model.eval() tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True) _LLM = (model, tokenizer) logger.info("MiniCPM loaded") return _LLM def _generate_with_llm(context: GenerationContext, cast_member: CastMember, local_now: str) -> tuple[GeneratedTransmission, str]: """Generate a transmission and report which path produced it. Returns (transmission, source) where source is one of: - "live" : MiniCPM produced valid JSON we could parse - "parse-fail": MiniCPM ran but its output couldn't be parsed - "llm-error": LLM call raised (OOM, network, missing model) The agent trace records this so judges can audit live vs. fallback. """ try: model, tokenizer = _load_llm() prompt = build_prompt(context, cast_member) system_prompt = get_system_prompt(context.persona.timeline_divergence_score) full = f"{system_prompt}\n\n{prompt}\n\nLocal open time: {local_now}" import torch messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": full}, ] input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tokenizer(input_text, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=700, temperature=0.8, top_p=0.9, do_sample=True, pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id, ) decoded = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip() parsed = parse_transmission(decoded) if parsed: return parsed, "live" return fallback_transmission(context, cast_member), "parse-fail" except Exception as exc: logger.warning("LLM failed: %s", exc) return fallback_transmission(context, cast_member), "llm-error" # ─── State ─────────────────────────────────────────────────────────────────── @dataclass class AppState: persona: Optional[PersonaContext] = None onboarded: bool = False onboard_step: int = 0 checked_in: bool = False check_in_word: str = "" check_in_note: str = "" today_cast: Optional[CastMember] = None today_transmission: Optional[GeneratedTransmission] = None today_audio: str = "" today_choice: str = "" generating: bool = False generation_done: bool = False choice_made: bool = False recent_transmissions: list[RecentTransmission] = field(default_factory=list) recent_choices: list[RecentChoice] = field(default_factory=list) recent_responses: list[RecentResponse] = field(default_factory=list) open_threads: list = field(default_factory=list) def apply_checkin(self, word: str, note: str): if not self.persona: self.persona = _default_persona() self.check_in_word = word.strip()[:40] self.check_in_note = note.strip() if note.strip() else "" self.checked_in = True def start_generation(self): self.generating = True self.generation_done = False self.today_audio = "" self.today_transmission = None self.today_cast = _choose_cast(self) def record_memory(self, memory_value: str, note: str): choice_val, reaction_val = MEMORY_TO_CHOICE_REACTION.get(memory_value, ("toward", "did_it")) if self.today_transmission: self.recent_transmissions.append(RecentTransmission( date_key=date.today().isoformat(), title=self.today_transmission.title or "", cliffhanger=self.today_transmission.cliffhanger or "", cast_member=self.today_cast or "future_self", )) self.recent_choices.append(RecentChoice( date_key=date.today().isoformat(), choice=choice_val, prompt=self.today_transmission.action_prompt if self.today_transmission else "", )) if self.persona: self.persona.streak += 1 setattr(self.persona, f'{choice_val}_count', getattr(self.persona, f'{choice_val}_count', 0) + 1) self.recent_responses.append(RecentResponse( reaction=reaction_val, reply_note=note.strip() if note.strip() else None, )) self.checked_in = False self.generation_done = False self.choice_made = False self.today_transmission = None self.today_audio = "" self.today_choice = "" self.check_in_word = "" self.check_in_note = "" def to_context(self) -> GenerationContext: assert self.persona ci = type("C", (), {"word": self.check_in_word, "note": self.check_in_note or None})() if self.checked_in else None return GenerationContext( persona=self.persona, check_in=ci, recent_transmissions=self.recent_transmissions, recent_choices=self.recent_choices, recent_responses=self.recent_responses, open_threads=self.open_threads, ) def streak(self) -> int: return self.persona.streak if self.persona else 0 def divergence(self) -> int: return self.persona.timeline_divergence_score if self.persona else 0 def to_dict(self) -> dict: d = asdict(self) d["persona"] = asdict(self.persona) if self.persona else None return d @staticmethod def from_dict(d: dict | None) -> AppState: if not d: return AppState() d = {k: v for k, v in d.items() if k in AppState.__dataclass_fields__} if d.get("persona"): d["persona"] = PersonaContext(**{ k: v for k, v in d["persona"].items() if k in PersonaContext.__dataclass_fields__ }) if d.get("recent_transmissions"): d["recent_transmissions"] = [ RecentTransmission(**t) for t in d["recent_transmissions"] ] if d.get("recent_choices"): d["recent_choices"] = [ RecentChoice(**c) for c in d["recent_choices"] ] if d.get("recent_responses"): d["recent_responses"] = [ RecentResponse(**r) for r in d["recent_responses"] ] return AppState(**d) def _default_persona(name: str = "you", city: str = "") -> PersonaContext: return PersonaContext( name=name.strip() or "you", city=city.strip(), current_chapter="this part of your life", primary_arc="purpose", miraculous_year="a year that feels more honest and alive", avoiding="something you keep circling", afraid_wont_happen="the future you still want", draining="carrying too much alone", selected_voice_name="Ember", selected_voice_description="warm, intimate, certain", ) # ─── Choose cast member ────────────────────────────────────────────────────── def _choose_cast(state: AppState) -> CastMember: import random if not state.recent_transmissions: return "future_self" recent = {t.cast_member for t in state.recent_transmissions[-3:]} available = [c for c in CAST_MEMBER_NAMES if c not in recent] if not available: return random.choice(["future_self", "future_partner", "future_mentor"]) return random.choices(available, weights=[3 if c == "future_self" else 2 for c in available], k=1)[0] def _constellation(state: AppState) -> list[tuple[str, str, str, str]]: """Return list of (cast_member, label, state, hint) for grid display.""" s = state.streak() d = state.divergence() p = state.persona results = [] for cm, (label, hint) in CAST_MEMBER_NAMES.items(): if cm == "future_self": results.append((cm, label, "lit", hint)) elif cm == "future_partner" and p and p.primary_arc == "love": results.append((cm, label, "lit" if d < 4 else "dim", hint)) elif cm == "future_mentor" and s >= 7: results.append((cm, label, "lit", hint)) elif cm == "future_best_friend" and s >= 3: results.append((cm, label, "lit", hint)) elif cm == "shadow" and d >= 4: results.append((cm, label, "dim", hint)) elif cm == "alternate_self" and s >= 14: results.append((cm, label, "dim", hint)) else: results.append((cm, label, "locked", hint)) return results # ─── CSS — transmission console ───────────────────────────────────────────── # Design intent: a private future-radio, not a styled Gradio dashboard. # - The container is the signal chamber; everything else orbits it. # - Typography: Fraunces (literary serif) for the chamber voice, IBM Plex Mono # for instrument labels and signal-path readouts. No Inter. # - Surfaces are atmospheric, not bordered cards: layered gradients, faint # scanlines, glow rings, no generic rounded panels. # - Constellation is a horizontal rail of voice orbs, not a card grid. # - Progress is a horizontal signal path with an active pulse, not step pills. CSS = """ /* Fonts are loaded via in create_app() head — more reliable than CSS @import in an HF Space iframe, and async/non-blocking. */ :root{ --ink:#0a0c1a; --ink-2:#0e1124; --ink-3:#141a30; --paper:#f6efdc; --paper-dim:#d8d0bd; --paper-mute:#9c9485; --amber:#e9a847; --amber-soft:#c4842d; --amber-glow:rgba(233,168,71,.45); --signal:rgba(233,168,71,.18); --violet:#7a6cc7; --violet-soft:rgba(122,108,199,.22); --live:#7adf9b; --locked:rgba(246,239,220,.16); --line:rgba(246,239,220,.10); --line-2:rgba(246,239,220,.05); } *{box-sizing:border-box} html,body{margin:0;padding:0;background:var(--ink);color:var(--paper);} body{ font-family:'IBM Plex Mono','SF Mono',ui-monospace,monospace; font-size:14px; line-height:1.55; background: radial-gradient(ellipse 80% 50% at 50% -10%,rgba(233,168,71,.08),transparent 60%), radial-gradient(ellipse 60% 50% at 50% 110%,rgba(122,108,199,.06),transparent 60%), linear-gradient(180deg,var(--ink) 0%,var(--ink-2) 60%,var(--ink) 100%); background-attachment:fixed; min-height:100vh; -webkit-font-smoothing:antialiased; letter-spacing:.01em; } body::before{ content:''; position:fixed;inset:0; background-image:repeating-linear-gradient(0deg,transparent 0,transparent 2px,rgba(246,239,220,.012) 2px,rgba(246,239,220,.012) 3px); pointer-events:none; z-index:1; } body::after{ content:''; position:fixed;inset:0; background-image:radial-gradient(circle at 20% 30%,rgba(246,239,220,.015) 0,transparent 50%), radial-gradient(circle at 80% 70%,rgba(233,168,71,.025) 0,transparent 50%); pointer-events:none; z-index:1; } ::selection{background:var(--amber-glow);color:var(--ink);} /* ─── Gradio chrome — quiet it down ─── */ .gradio-container{ max-width:780px !important; margin:0 auto; padding:0 !important; position:relative; z-index:2; font-family:'IBM Plex Mono',monospace !important; background:transparent !important; } footer, .gradio-container > .footer, .gradio-container > div > .footer {display:none !important} .app, .wrap, .panel, .container, .gap, .form, .panel-body {background:transparent !important;border:none !important;box-shadow:none !important;} /* ─── Identity strip ─── */ .identity-strip{ display:flex;align-items:center;justify-content:space-between;gap:16px; padding:18px 20px 14px; border-bottom:1px solid var(--line-2); position:relative; } .identity-strip::after{ content:'';position:absolute;left:20px;right:20px;bottom:-1px;height:1px; background:linear-gradient(90deg,transparent,var(--amber) 50%,transparent); opacity:.35; } .brand-mark{ display:flex;align-items:center;gap:10px; font-family:'IBM Plex Mono',monospace; font-size:11px;letter-spacing:.18em;text-transform:uppercase; color:var(--paper); } .brand-mark .glyph{ display:inline-block;width:14px;height:14px; border:1px solid var(--amber);border-radius:50%; position:relative; box-shadow:0 0 14px var(--amber-glow); } .brand-mark .glyph::after{ content:'';position:absolute;inset:3px;border-radius:50%; background:var(--amber); box-shadow:0 0 6px var(--amber); animation:glow 2.4s ease-in-out infinite; } .brand-mark .name{color:var(--paper);font-weight:500} .brand-mark .sub{color:var(--paper-mute);font-weight:300;margin-left:6px;letter-spacing:.22em} .instrument-cluster{display:flex;gap:14px;align-items:center} .instrument{ display:flex;flex-direction:column;align-items:flex-end;gap:1px; font-family:'IBM Plex Mono',monospace; border-left:1px solid var(--line-2); padding-left:12px; } .instrument:first-child{border-left:none;padding-left:0} .instrument .label{font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute)} .instrument .value{font-size:13px;color:var(--amber);font-variant-numeric:tabular-nums;letter-spacing:.04em} .instrument .value .unit{color:var(--paper-mute);font-size:10px;margin-left:2px} .privacy-pulse{ display:inline-flex;align-items:center;gap:6px; font-size:9px;letter-spacing:.2em;text-transform:uppercase; color:var(--live); } .privacy-pulse .dot{width:6px;height:6px;border-radius:50%;background:var(--live);box-shadow:0 0 8px var(--live);animation:glow 2s ease-in-out infinite} /* ─── Signal chamber — the centerpiece ─── */ .signal-chamber{ position:relative; margin:24px 20px 0; padding:38px 28px 30px; border-radius:4px; background: radial-gradient(ellipse 100% 70% at 50% 0%,rgba(233,168,71,.06),transparent 60%), linear-gradient(180deg,rgba(20,26,48,.65) 0%,rgba(14,17,36,.7) 100%); border:1px solid rgba(233,168,71,.18); overflow:hidden; isolation:isolate; } .signal-chamber::before{ content:'';position:absolute;inset:-1px;border-radius:4px; background:linear-gradient(180deg,rgba(233,168,71,.4),transparent 30%,transparent 70%,rgba(122,108,199,.25)); -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0); -webkit-mask-composite:xor;mask-composite:exclude; padding:1px;pointer-events:none;z-index:0; } .signal-chamber::after{ content:'';position:absolute;inset:0; background-image:repeating-linear-gradient(0deg,transparent 0,transparent 3px,rgba(246,239,220,.014) 3px,rgba(246,239,220,.014) 4px); pointer-events:none;z-index:0; } .chamber-content{position:relative;z-index:1} .chamber-eyebrow{ display:flex;align-items:center;gap:10px; font-size:10px;letter-spacing:.28em;text-transform:uppercase; color:var(--amber); margin-bottom:16px; } .chamber-eyebrow .pulse{ width:6px;height:6px;border-radius:50%;background:var(--amber); box-shadow:0 0 10px var(--amber);animation:glow 1.6s ease-in-out infinite; } .chamber-eyebrow .sep{color:var(--paper-mute);opacity:.5} .chamber-title{ font-family:'Fraunces','Times New Roman',serif; font-weight:500; font-size:34px;line-height:1.1;letter-spacing:-.015em; color:var(--paper); margin:0 0 8px; } .chamber-title em{font-style:italic;color:var(--amber);font-weight:400} .chamber-body{ font-family:'Fraunces',serif; font-size:17px;line-height:1.7; font-weight:300; color:var(--paper-dim); } .chamber-body em{color:var(--paper);font-style:italic} .chamber-body strong{color:var(--amber);font-weight:500;font-family:'IBM Plex Mono',monospace;font-size:14px;letter-spacing:.04em} .chamber-divider{ height:1px;margin:24px 0 18px; background:linear-gradient(90deg,transparent,var(--line) 20%,var(--line) 80%,transparent); } .chamber-meta-row{ display:flex;flex-wrap:wrap;gap:18px; font-size:10px;letter-spacing:.18em;text-transform:uppercase; color:var(--paper-mute); } .chamber-meta-row .meta-key{color:var(--paper-mute)} .chamber-meta-row .meta-val{color:var(--paper);font-weight:500;margin-left:6px} .chamber-callout{ margin-top:20px;padding:14px 16px; border-left:2px solid var(--amber); background:linear-gradient(90deg,rgba(233,168,71,.06),transparent); font-family:'Fraunces',serif;font-style:italic;font-size:15px;line-height:1.6; color:var(--paper); } .chamber-callout .tag{ display:block; font-family:'IBM Plex Mono',monospace;font-style:normal;font-size:9px;letter-spacing:.22em; text-transform:uppercase;color:var(--amber); margin-bottom:6px; } .chamber-actions{ display:flex;flex-direction:column;gap:14px; margin-top:24px; } .chamber-section-label{ font-size:9px;letter-spacing:.28em;text-transform:uppercase; color:var(--paper-mute); margin-bottom:8px; } /* Tuning sweep state */ .tuning-display{ display:flex;flex-direction:column;align-items:center;gap:18px; padding:14px 0 6px; } .tuning-svg{width:280px;height:90px;display:block} .tuning-svg .wave{fill:none;stroke:var(--amber);stroke-width:1.5;stroke-linecap:round;filter:drop-shadow(0 0 4px var(--amber))} .tuning-svg .wave-back{stroke:rgba(233,168,71,.18);animation:drift 3s ease-in-out infinite} .tuning-svg .wave-front{animation:drift 2.2s ease-in-out infinite reverse} .tuning-readout{ font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.18em; text-transform:uppercase;color:var(--paper-mute); display:flex;gap:18px;flex-wrap:wrap;justify-content:center; } .tuning-readout .v{color:var(--amber);font-weight:500} .tuning-readout .v.live::before{content:'●';margin-right:6px;color:var(--amber);animation:blink 1.2s ease-in-out infinite} /* ─── Constellation rail (voice orbs) ─── */ .constellation-rail{ margin:24px 20px 0; padding:14px 0 4px; border-top:1px solid var(--line-2); } .rail-head{ display:flex;align-items:baseline;justify-content:space-between; padding:0 4px 10px; } .rail-head .rail-title{ font-size:10px;letter-spacing:.28em;text-transform:uppercase; color:var(--paper); } .rail-head .rail-title em{color:var(--amber);font-style:normal;font-weight:500} .rail-head .rail-status{font-size:10px;letter-spacing:.18em;color:var(--paper-mute);text-transform:uppercase} .voice-rail{ display:flex;gap:8px;overflow-x:auto; scrollbar-width:thin;scrollbar-color:var(--line) transparent; padding:6px 4px 14px; } .voice-rail::-webkit-scrollbar{height:4px} .voice-rail::-webkit-scrollbar-thumb{background:var(--line);border-radius:2px} .voice-orb{ flex:0 0 auto; display:flex;flex-direction:column;align-items:center;gap:7px; min-width:78px;padding:10px 6px; border-radius:4px; background:rgba(20,26,48,.4); border:1px solid var(--line); transition:all .35s cubic-bezier(.4,0,.2,1); position:relative; } .voice-orb.lit{background:linear-gradient(180deg,rgba(233,168,71,.10),rgba(20,26,48,.55));border-color:rgba(233,168,71,.45)} .voice-orb.dim{background:linear-gradient(180deg,rgba(122,108,199,.08),rgba(20,26,48,.55));border-color:rgba(122,108,199,.35)} .voice-orb.locked{opacity:.42;filter:saturate(.4)} .voice-orb .orb{ width:38px;height:38px;border-radius:50%; display:flex;align-items:center;justify-content:center; background:rgba(20,26,48,.7); border:1px solid var(--line); position:relative; font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--paper-mute); letter-spacing:.04em; } .voice-orb.lit .orb{ background:radial-gradient(circle at 30% 30%,rgba(233,168,71,.4),rgba(233,168,71,.05)); border-color:rgba(233,168,71,.6); color:var(--amber); box-shadow:0 0 18px rgba(233,168,71,.35),inset 0 0 12px rgba(233,168,71,.2); } .voice-orb.lit .orb::after{ content:'';position:absolute;inset:-4px;border-radius:50%; border:1px solid rgba(233,168,71,.3); animation:ripple 2.2s ease-out infinite; } .voice-orb.dim .orb{ background:radial-gradient(circle at 30% 30%,rgba(122,108,199,.4),rgba(122,108,199,.05)); border-color:rgba(122,108,199,.55); color:var(--violet); } .voice-orb .orb-label{font-size:10px;letter-spacing:.04em;color:var(--paper-dim);text-align:center;line-height:1.2;max-width:78px} .voice-orb .orb-state{ position:absolute;top:6px;right:6px; font-size:8px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute); } .voice-orb.lit .orb-state{color:var(--amber)} .voice-orb.dim .orb-state{color:var(--violet)} .voice-orb.locked{cursor:help} /* ─── Signal path (timeline progress) ─── */ .signal-path{ margin:28px 20px 0; padding-top:18px; border-top:1px solid var(--line-2); } .path-head{ display:flex;align-items:baseline;justify-content:space-between; font-size:10px;letter-spacing:.22em;text-transform:uppercase; color:var(--paper-mute); margin-bottom:14px; } .path-head .now{color:var(--amber)} .path-track{ position:relative;height:34px;display:flex;align-items:center; } .path-line{ position:absolute;left:0;right:0;top:50%;transform:translateY(-50%); height:1px;background:var(--line); } .path-line.lit{ background:linear-gradient(90deg,var(--amber),var(--violet)); box-shadow:0 0 8px rgba(233,168,71,.5); } .path-nodes{display:flex;justify-content:space-between;width:100%;position:relative} .path-node{ display:flex;flex-direction:column;align-items:center;gap:6px; font-size:9px;letter-spacing:.18em;text-transform:uppercase; color:var(--paper-mute); } .path-node .dot{ width:9px;height:9px;border-radius:50%; background:var(--ink);border:1px solid var(--line); transition:all .3s ease; } .path-node.done .dot{background:var(--amber);border-color:var(--amber);box-shadow:0 0 8px var(--amber-glow)} .path-node.now .dot{ background:var(--paper);border-color:var(--amber); box-shadow:0 0 0 3px rgba(233,168,71,.2),0 0 14px var(--amber); animation:pulse 1.6s ease-in-out infinite; } .path-node.now{color:var(--amber);font-weight:500} .path-node.done{color:var(--paper-dim)} /* ─── Memory log (history) ─── */ .memory-log{margin:24px 20px 0;padding-top:18px;border-top:1px solid var(--line-2)} .memory-log .log-head{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--paper);margin-bottom:14px} .memory-log .log-head em{color:var(--amber);font-style:normal;font-weight:500} .memory-row{ display:grid;grid-template-columns:90px 1fr auto;gap:14px;align-items:baseline; padding:12px 0; border-bottom:1px dashed var(--line-2); } .memory-row:last-child{border-bottom:none} .memory-row .ts{font-size:10px;letter-spacing:.12em;color:var(--paper-mute);font-variant-numeric:tabular-nums} .memory-row .body{font-family:'Fraunces',serif;font-size:14px;line-height:1.5;color:var(--paper-dim)} .memory-row .body em{color:var(--paper);font-style:italic} .memory-row .tag{ font-size:9px;letter-spacing:.2em;text-transform:uppercase; color:var(--amber);padding:3px 8px;border:1px solid var(--line);border-radius:2px; white-space:nowrap; } .memory-row .tag.choice-toward{color:var(--live);border-color:rgba(122,223,155,.4)} .memory-row .tag.choice-steady{color:var(--amber);border-color:rgba(233,168,71,.4)} .memory-row .tag.choice-release{color:var(--violet);border-color:rgba(122,108,199,.4)} .memory-row .tag.choice-repair{color:#c98ad1;border-color:rgba(201,138,209,.4)} /* Audio play chip beside memory-log tags — only renders when the transmission has a pre-rendered voice sample. Hover for play hint. */ .audio-chip{ display:inline-flex;align-items:center;justify-content:center; width:18px;height:18px;margin-left:8px; border-radius:50%; background:rgba(233,168,71,.12); color:var(--amber); font-size:9px; text-decoration:none; border:1px solid rgba(233,168,71,.4); transition:all .2s ease; vertical-align:middle; cursor:pointer; } .audio-chip:hover{ background:rgba(233,168,71,.25); color:var(--paper); border-color:var(--amber); transform:scale(1.1); text-decoration:none; } /* Demo button — softer than the primary, lives below the onboarding step 1 inputs. The amber dot in the label is the only visual signal that this is a curated showcase, not a regular action. */ button.demo-btn, button.demo-btn.lg, .gr-button.demo-btn{ background:transparent !important; border:1px solid var(--line) !important; color:var(--paper-dim) !important; font-size:10px !important; letter-spacing:.18em !important; padding:8px 14px !important; margin-top:6px !important; } button.demo-btn:hover, .gr-button.demo-btn:hover{ border-color:var(--amber) !important; color:var(--amber) !important; background:rgba(233,168,71,.06) !important; } /* Share button — copy-to-clipboard for the transmission. Lives beneath the audio module. Smaller, quieter than the chamber action buttons, but visibly interactive. */ .share-btn{ background:transparent; border:1px solid var(--line); color:var(--amber); font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.18em; text-transform:uppercase; padding:8px 14px; border-radius:2px; cursor:pointer; transition:all .2s ease; } .share-btn:hover{ border-color:var(--amber); background:rgba(233,168,71,.08); transform:translateY(-1px); } .share-btn:active{ transform:translateY(0); background:rgba(233,168,71,.15); } /* ─── Footer status line ─── */ .signal-footer{ margin-top:32px;padding:18px 20px 28px; border-top:1px solid var(--line-2); display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap; font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--paper-mute); } .signal-footer .freq{color:var(--amber);font-variant-numeric:tabular-nums} .signal-footer .live-line{display:flex;gap:14px;align-items:center} .signal-footer .live-line .dot{width:6px;height:6px;border-radius:50%;background:var(--live);box-shadow:0 0 8px var(--live);animation:glow 1.6s ease-in-out infinite} /* ─── Animations ─── */ @keyframes glow{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.25)}} @keyframes pulse{0%,100%{opacity:.7}50%{opacity:1}} @keyframes ripple{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.6);opacity:0}} @keyframes drift{0%,100%{transform:translateX(0)}50%{transform:translateX(6px)}} @keyframes blink{0%,49%{opacity:1}50%,100%{opacity:.25}} @keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} .fade-in{animation:fadeIn .55s ease both} /* ─── Gradio form controls — instrument them, don't card them ─── */ .gr-button{ border-radius:2px !important; font-family:'IBM Plex Mono',monospace !important; font-weight:500 !important;letter-spacing:.14em !important; text-transform:uppercase !important; font-size:11px !important; padding:10px 18px !important; transition:all .25s cubic-bezier(.4,0,.2,1) !important; border:1px solid var(--line) !important; background:rgba(20,26,48,.5) !important; color:var(--paper) !important; } .gr-button:hover{ border-color:var(--amber) !important; background:rgba(233,168,71,.08) !important; color:var(--amber) !important; transform:translateY(-1px); } .gr-button-primary{ background:linear-gradient(180deg,var(--amber),var(--amber-soft)) !important; border:1px solid var(--amber) !important; color:var(--ink) !important; box-shadow:0 0 0 1px rgba(233,168,71,.25),0 6px 24px -8px var(--amber-glow) !important; position:relative;overflow:hidden; } .gr-button-primary:hover{ background:linear-gradient(180deg,#f3b357,var(--amber)) !important; color:var(--ink) !important; border-color:var(--amber) !important; box-shadow:0 0 0 1px rgba(233,168,71,.4),0 8px 30px -6px var(--amber-glow) !important; transform:translateY(-1px); } .gr-button-primary::after{ content:'';position:absolute;inset:0; background:linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent); transform:translateX(-100%);transition:transform .8s; } .gr-button-primary:hover::after{transform:translateX(100%)} .gr-input, .gr-textarea, .gr-box, .gr-form, .gr-input-wrap, .gr-textarea-wrap, .gr-panel, .gr-block, .gr-component{ background:rgba(20,26,48,.55) !important; border:1px solid var(--line) !important; border-radius:2px !important; color:var(--paper) !important; font-family:'IBM Plex Mono',monospace !important; font-size:13px !important; box-shadow:none !important; } .gr-input:focus, .gr-textarea:focus{ border-color:var(--amber) !important; box-shadow:0 0 0 1px var(--amber-glow) !important; outline:none !important; } .gr-input::placeholder, .gr-textarea::placeholder{color:var(--paper-mute) !important;font-style:italic} label, .gr-label, .gr-form-label{ font-family:'IBM Plex Mono',monospace !important; font-size:9px !important;letter-spacing:.22em !important;text-transform:uppercase !important; color:var(--paper-mute) !important; margin-bottom:4px !important; } .radio-group, .gr-radio, .gr-radio-wrap{ background:transparent !important;border:none !important;padding:0 !important; } .gr-radio-label, .gr-radio .label{ font-family:'IBM Plex Mono',monospace !important;font-size:12px !important; color:var(--paper-dim) !important; } /* Tabs are nearly invisible — this is one signal chamber, not a dashboard */ .tab-nav, .tabitem, .tabs, .tab-wrapper{ background:transparent !important; border:none !important; border-bottom:1px solid var(--line-2) !important; border-radius:0 !important; margin:0 20px !important; padding:0 !important; } .tab-nav button, .tabitem button{ background:transparent !important;border:none !important; font-family:'IBM Plex Mono',monospace !important; font-size:10px !important;letter-spacing:.22em !important;text-transform:uppercase !important; color:var(--paper-mute) !important; padding:14px 4px !important;margin-right:24px !important; border-bottom:1px solid transparent !important; transition:color .3s,border-color .3s !important; } .tab-nav button:hover, .tabitem button:hover{color:var(--paper) !important} .tab-nav button.selected, .tabitem button.selected{ color:var(--amber) !important; border-bottom-color:var(--amber) !important; font-weight:500 !important; } h1,h2,h3,h4{ font-family:'Fraunces',serif !important; font-weight:500 !important;letter-spacing:-.01em !important; color:var(--paper) !important; } h3{font-size:18px !important;margin:0 0 6px !important;} audio{width:100%;margin:6px 0;border-radius:2px;filter:invert(.92) hue-rotate(180deg) saturate(.6)} audio::-webkit-media-controls-panel{background:var(--ink-3);} .column, .row, .group, .form{background:transparent !important;border:none !important;box-shadow:none !important;gap:10px !important;} /* Status text on the awaiting/choice-result screens */ .choice-outcome{ margin-top:18px;padding:14px 18px; border-left:2px solid var(--live); background:linear-gradient(90deg,rgba(122,223,155,.08),transparent); font-family:'Fraunces',serif;font-style:italic;font-size:16px;line-height:1.6; color:var(--paper); } .choice-outcome .tag{ display:block;font-family:'IBM Plex Mono',monospace;font-style:normal; font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--live); margin-bottom:6px; } /* Unlock toast — appears after voice unlock */ .unlock-toast{ display:block; margin-top:10px; padding:8px 10px; background:linear-gradient(90deg,rgba(233,168,71,.12),rgba(122,108,199,.08)); border:1px solid var(--amber); border-radius:2px; font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--paper); font-family:'IBM Plex Mono',monospace; animation:fadeIn .5s ease both; } .unlock-toast .tag{ display:inline; color:var(--amber); font-size:9px; letter-spacing:.18em; text-transform:uppercase; margin-right:6px; } .timeline-shift{ display:flex;gap:10px;align-items:stretch;margin-top:18px; } .timeline-shift .stem{ flex:1;display:flex;flex-direction:column;align-items:center;gap:6px; padding:14px 8px; border:1px dashed var(--line);border-radius:2px; font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute); } .timeline-shift .stem .v{font-size:18px;color:var(--paper);font-family:'Fraunces',serif;font-weight:500} .timeline-shift .stem .delta{color:var(--amber)} /* Architecture display */ .arch-pane{ margin:18px 20px 0;padding:20px; background:rgba(14,17,36,.55);border:1px solid var(--line-2);border-radius:2px; font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:1.7;color:var(--paper-dim); white-space:pre-wrap;overflow-x:auto; } .arch-pane .arch-h{ color:var(--amber);font-size:9px;letter-spacing:.24em;text-transform:uppercase; border-bottom:1px solid var(--line-2);padding-bottom:8px;margin-bottom:12px; } .arch-pane .k{color:var(--paper-mute)} .arch-pane .v{color:var(--amber)} .arch-pane a{color:var(--amber);text-decoration:none;border-bottom:1px dotted rgba(233,168,71,.4)} .arch-pane a:hover{border-bottom-style:solid} /* ─── Mobile responsive ─── */ @media (max-width: 720px){ .identity-strip{flex-direction:column;align-items:flex-start;gap:12px;padding:14px 16px 12px} .instrument-cluster{flex-wrap:wrap;gap:10px 14px;width:100%;justify-content:space-between} .instrument{padding-left:0;border-left:none} .instrument:not(:last-child){padding-right:14px;border-right:1px solid var(--line-2)} .signal-chamber{margin:18px 14px 0;padding:28px 18px 22px} .chamber-title{font-size:26px;line-height:1.12} .chamber-body{font-size:16px;line-height:1.65} .constellation-rail{margin:18px 14px 0} .signal-path{margin:22px 14px 0} .memory-log{margin:18px 14px 0} .signal-footer{padding:14px 16px 22px;flex-direction:column;align-items:flex-start;gap:6px} .gradio-container{max-width:none !important} .tab-nav, .tabitem, .tabs, .tab-wrapper{margin:0 14px !important} .tab-nav button, .tabitem button{margin-right:14px !important;padding:12px 2px !important} .path-node{font-size:8px;letter-spacing:.12em} .path-node .dot{width:8px;height:8px} .voice-orb{min-width:70px;padding:8px 4px} .voice-orb .orb{width:34px;height:34px;font-size:10px} .tuning-svg{width:100%;max-width:280px;height:70px} .chamber-callout{font-size:14px} .chamber-eyebrow{font-size:9px;letter-spacing:.22em} } @media (max-width: 480px){ .chamber-title{font-size:22px} .chamber-body{font-size:15px} .identity-strip{padding:12px 14px 10px} .brand-mark{font-size:10px} .instrument .value{font-size:12px} .instrument .label{font-size:8px} .privacy-pulse{font-size:8px} } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important} } /* ─── Example chips — inline phrase buttons ─── */ .ex-chip-row{ display:flex;flex-wrap:wrap;gap:5px; margin:3px 0 8px; } .ex-chip{ background:transparent; border:1px solid var(--line); border-radius:10px; color:var(--paper-mute); font-family:'IBM Plex Mono',monospace; font-size:10px; font-style:italic; padding:3px 10px; cursor:pointer; transition:all .2s ease; white-space:nowrap; } .ex-chip:hover{ border-color:var(--amber); color:var(--amber); background:rgba(233,168,71,.08); } .ex-chip.used{ color:var(--live); border-color:rgba(122,223,155,.5); font-style:normal; } /* ─── Choice/reaction cards ─── */ .card-grid{ display:grid; gap:8px; margin:6px 0; } .choice-grid{grid-template-columns:repeat(2,1fr)} .reaction-grid{grid-template-columns:repeat(2,1fr)} .arc-grid{grid-template-columns:repeat(2,1fr)} .memory-grid{grid-template-columns:repeat(2,1fr)} .choice-card, .reaction-card, .arc-card, .memory-card{ display:flex;flex-direction:column;align-items:flex-start;gap:3px; padding:12px 14px; border:1px solid var(--line); border-radius:3px; background:rgba(20,26,48,.4); cursor:pointer; transition:all .25s cubic-bezier(.4,0,.2,1); text-align:left; font-family:'IBM Plex Mono',monospace; animation:fadeSlideIn .35s ease-out backwards; } .arc-card:nth-child(1){animation-delay:.05s} .arc-card:nth-child(2){animation-delay:.1s} .arc-card:nth-child(3){animation-delay:.15s} .arc-card:nth-child(4){animation-delay:.2s} @keyframes fadeSlideIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} /* Transmission entrance animation */ .transmission-card{animation:fadeSlideIn .5s ease-out backwards} /* Audio player glow when playing */ audio.playing{animation:pulseGlow 1.5s ease-in-out infinite} @keyframes pulseGlow{0%,100%{box-shadow:0 0 0 0 rgba(233,168,71,0)}50%{box-shadow:0 0 12px 2px rgba(233,168,71,.4)}} /* Memory log staggered entrance */ .memory-entry{animation:fadeSlideIn .35s ease-out backwards} .memory-entry:nth-child(1){animation-delay:.05s} .memory-entry:nth-child(2){animation-delay:.1s} .memory-entry:nth-child(3){animation-delay:.15s} .memory-entry:nth-child(4){animation-delay:.2s} /* Tonight's move callout glow */ .tonight-card{transition:all .3s ease} .tonight-card:hover{box-shadow:0 0 0 1px var(--amber),0 0 16px -4px var(--amber-glow)} /* Constellation rail hover sparkle */ .voice-orb{transition:all .25s ease} .voice-orb:hover{transform:scale(1.15);filter:brightness(1.3)} .voice-orb:hover::after{content:'';position:absolute;width:100%;height:100%;top:0;left:0;background:radial-gradient(circle,rgba(255,255,255,.4) 0%,transparent 70%);animation:sparkle .6s ease-out} /* Loading skeleton shimmer */ .skeleton{background:linear-gradient(90deg,rgba(30,40,60,.3) 25%,rgba(50,65,85,.5) 50%,rgba(30,40,60,.3) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite} @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} /* Share toast */ .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--amber);color:#000;padding:8px 16px;border-radius:4px;font-size:12px;opacity:0;transition:opacity .3s;z-index:9999} .toast.show{opacity:1} .choice-card:hover, .reaction-card:hover, .arc-card:hover, .memory-card:hover{ border-color:var(--amber); background:linear-gradient(135deg,rgba(233,168,71,.08),rgba(20,26,48,.55)); } .choice-card.active, .reaction-card.active, .arc-card.active, .memory-card.active{ border-color:var(--amber); background:linear-gradient(135deg,rgba(233,168,71,.12),rgba(20,26,48,.55)); box-shadow:0 0 0 1px rgba(233,168,71,.3),0 0 14px -4px var(--amber-glow); } .card-emoji{ font-size:16px; line-height:1; margin-bottom:2px; } .card-label{ font-size:12px; font-weight:500; letter-spacing:.06em; color:var(--paper); } .card-sub{ font-size:9px; color:var(--paper-mute); letter-spacing:.04em; font-style:italic; } /* ─── Hidden radio — visually hidden but DOM-present for JS interactions ─── */ .hidden-radio{ position:absolute !important; width:1px !important; height:1px !important; padding:0 !important; margin:-1px !important; overflow:hidden !important; clip:rect(0,0,0,0) !important; white-space:nowrap !important; border:0 !important; opacity:0 !important; pointer-events:none !important; } .hidden-radio .gr-form-label, .hidden-radio label{display:none !important} /* ─── Section form — seamlessly floats with the chamber ─── */ .section-form{ margin:8px 20px 0; padding:2px 0; border:none; background:transparent; } .section-form .gr-box, .section-form .gr-form, .section-form .gr-panel{ background:transparent !important; border:none !important; padding:0 !important; margin:0 !important; } /* ─── Accordion primer — permission/guidance text ─── */ .acc-primer{ font-size:10px; line-height:1.5; color:var(--paper-mute); font-style:italic; margin-bottom:8px; } /* ─── Onboarding primer — warmup before the form ─── */ .onboard-primer{ margin-bottom:18px; } .primer-eyebrow{ font-size:9px; letter-spacing:.28em; text-transform:uppercase; color:var(--paper-mute); margin-bottom:8px; } .primer-line{ font-family:'Fraunces',serif; font-size:15px; line-height:1.7; color:var(--paper-dim); font-style:italic; border-left:2px solid var(--line); padding-left:14px; } @media (max-width: 720px){ .choice-grid, .reaction-grid, .arc-grid{grid-template-columns:1fr} }""" # ─── Render helpers — transmission console primitives ─────────────────────── # Every renderer composes from these. No generic bordered card helper. def _identity_strip(state: AppState) -> str: """Compact top bar — brand + privacy + instrument readouts (streak, divergence, choices).""" return f"""
FutureSelves/ private future-radio
streak {state.streak():02d}d
divergence {state.divergence():02d}Δ
choices {len(state.recent_choices):02d}
on-device
""" def _signal_chamber(eyebrow_html: str, title_html: str, body_html: str, *, meta_html: str = "", callout_html: str = "", actions_html: str = "") -> str: """The signal chamber — the single centerpiece surface. No borders, atmospheric treatment.""" meta = f'
{meta_html}
' if meta_html else "" callout = f'{callout_html}' if callout_html else "" actions = f'
{actions_html}
' if actions_html else "" return f"""
{eyebrow_html} {title_html} {body_html} {callout} {meta} {actions}
""" def _chamber_eyebrow(left: str, right: str = "") -> str: r = f'·{right}' if right else "" return f'
{left}{r}
' def _chamber_title(text: str) -> str: return f'

{text}

' def _chamber_body(html: str) -> str: return f'
{html}
' def _chamber_callout(tag: str, html: str) -> str: extra_class = " tonight-card" if tag == "tonight's move" else "" return f'
{tag}{html}
' def _chamber_meta(pairs: list[tuple[str, str]]) -> str: items = "".join(f'{k}{v}' for k, v in pairs) return items def _constellation_rail(state: AppState) -> str: """Horizontal rail of voice orbs — matches the Expo app's voiceGrid language.""" stars = _constellation(state) items = [] for cm, label, st, hint in stars: initials = "".join(w[0] for w in label.split()[:2]).upper() or "??" state_label = {"lit": "live", "dim": "faint", "locked": "off-air"}[st] tooltip = f' title="{_unlock_criteria(cm)}"' if st == "locked" else "" items.append( f'
' f'{state_label}' f'
{initials}
' f'
{label}
' f'
' ) return f"""
constellation · {sum(1 for _, _, s, _ in stars if s == "lit")} of {len(stars)} live
streak {state.streak():02d}d · divergence {state.divergence():02d}Δ
{''.join(items)}
""" _STEPS = [ ("onboard", "Onboard"), ("check-in", "Check-in"), ("generate", "Generate"), ("choose", "Choose"), ] def _signal_path(current: str) -> str: """Horizontal signal-path progress, not step pills.""" if current not in {k for k, _ in _STEPS}: current = "onboard" idx = next(i for i, (k, _) in enumerate(_STEPS) if k == current) nodes = [] for i, (k, label) in enumerate(_STEPS): cls = "done" if i < idx else ("now" if i == idx else "") nodes.append(f'
{label}
') lit_pct = (idx / max(len(_STEPS) - 1, 1)) * 100 return f"""
signal path{_STEPS[idx][1]}
{''.join(nodes)}
""" def _tuning_display(cast_label: str) -> str: """Atmospheric tuning-state visual: animated SVG waves + readouts, replaces the spinner.""" return f"""
status · building your message channel · your future self is listening
""" def _audio_module(label: str, audio_path: str) -> str: """Audio player styled as an instrument readout, not a card. audio_path is a Gradio-served path (either a Kokoro tempfile or a staged static file in the temp dir). The /file= prefix is Gradio's file-serving endpoint; the bare path doesn't work for arbitrary repo files on HF Spaces. """ if not audio_path: return "" if audio_path.startswith("/file="): src = audio_path else: src = f"/file={audio_path}" return f"""
voice transmission · from {label}
""" def _audio_dropped_module(label: str) -> str: """Visible callout when both Kokoro and the pre-rendered fallback audio failed. Without this, the chamber silently renders text-only and the user assumes the demo is broken. Make the failure mode part of the product voice. """ label_safe = html_escape(label) return f"""
voice line dropped · {label_safe} could not be tuned
The text arrived but the voice did not. The transmission is still legible above.
""" def _share_module(title: str, body: str, action: str, cliff: str) -> str: """A copy-to-clipboard button for the transmission, styled as an instrument. Judges (and demo viewers) often want to share the transmission text without having to retype it. The button copies the full transmission (title, body, action, cliffhanger) to the clipboard via a tiny vanilla-JS handler. The visible label is a short label; the copied text is the full transmission. """ safe = ( body.replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$") if body else "" ) payload = ( f"{title}\\n\\n{body}\\n\\n— Tonight: {action}\\n— Tomorrow: {cliff}\\n\\n— from FutureSelves" ).replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$").replace("\n", "\\n") return f"""
share with someone you trust
""" def _memory_log(state: AppState, *, limit: int = 8) -> str: """Memory log — grid of timestamped rows, not stacked cards. When the state carries demo-audio mapping (e.g. Maya's loaded state), past transmissions render a tiny play chip that links to the pre-rendered audio. The mapping is a hard-coded lookup keyed on transmission title so it works for the demo without polluting AppState's schema. """ demo_audio = _demo_audio_lookup() if not state.recent_choices and not state.recent_transmissions: return "" rows = [] for t in reversed(state.recent_transmissions[-limit:]): audio_chip = "" if t.title in demo_audio: rel = demo_audio[t.title] staged = _stage_static_audio(rel) if staged: audio_chip = ( f'' ) cast_label = CAST_MEMBER_NAMES.get(t.cast_member, ("", ""))[0] or t.cast_member rows.append( f'
' f'{html_escape(t.date_key)}' f'“{html_escape(t.title)}”' f'{html_escape(cast_label)}{audio_chip}' f'
' ) for c in reversed(state.recent_choices[-limit:]): # c.choice is enum-like (toward/steady/release/repair) but escape for safety. choice_safe = html_escape(c.choice) rows.append( f'
' f'{html_escape(c.date_key)}' f'{html_escape(c.prompt)}' f'{choice_safe}' f'
' ) return f"""
memory log · last {min(limit, len(rows))} signals
{''.join(rows)}
""" def _demo_audio_lookup() -> dict[str, str]: """Map demo transmission titles to their pre-rendered audio file paths. These are the Maya demo transmissions — pre-written for the "Try Maya's example" button. When a judge's state has these titles in its memory log, we render a play chip beside the title. Audio files are committed to the repo at audio/voices/. They're served via _stage_static_audio (Gradio temp dir) at the time the chip is rendered, not via the repo root. """ return { "You know which conversation you keep rescheduling": "audio/voices/day-04-shadow.wav", "You are not behind. You are building.": "audio/voices/day-03-mentor.wav", "You are softer than the world taught you to be": "audio/voices/day-02-partner.wav", "The weight you are carrying is not all yours to carry": "audio/voices/day-01-self.wav", "The threshold is a door, not a wall": "audio/voices/today-threshold.wav", } def _render_persona_card() -> str: """Render the demo persona summary in the architecture tab. Reads from traces/persona-summaries.json. If the file is empty or missing, returns a placeholder. The summary is a 1-paragraph narrative description of who Maya is, generated by the Modal function (or the local heuristic fallback). Showing it in the architecture tab is how judges verify the Modal integration. """ summary_file = Path(__file__).parent / "traces" / "persona-summaries.json" if not summary_file.exists(): return 'no persona summary yet — generate one via the demo button' try: summaries = json.loads(summary_file.read_text()) except json.JSONDecodeError: return 'persona summary file corrupted' if not summaries: return 'no persona summary yet — generate one via the demo button' last = summaries[-1] src = html_escape(last.get("source", "?")) summary = html_escape(last.get("summary", "")) name = html_escape(last.get("persona_name", "?")) return ( f'' f'“{summary}”' f'
' f'— {name} · via {src}
' ) def _signal_footer() -> str: return """""" def _context_note(html: str) -> str: """Single small fallback surface for messages that don't fit the chamber. Used sparingly.""" return f'
{html}
' # ─── App logic (async gen helper) ───────────────────────────────────────────── def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_str: str): import time as _time start = _time.time() result, source = _generate_with_llm(context, cm, now_str) state.today_transmission = result # Try local Kokoro first. If it fails (Space, missing deps, etc.) and we # have a matching pre-rendered voice sample for the cast member, fall # back to that — the demo stays alive even when TTS is unavailable. audio = generate_speech(result.text, voice=get_voice_for_cast_member(cm)) if not audio: audio = _fallback_demo_audio(cm) state.today_audio = audio or "" state.generating = False state.generation_done = True # Log the full agent trace (system prompt + user prompt + raw output + # parsed JSON) for the Sharing is Caring bonus quest. This runs on # every live generation, so judges can audit the agent's decisions. duration_ms = int((_time.time() - start) * 1000) try: from transmission import build_prompt, get_system_prompt sys_p = get_system_prompt(context.persona.timeline_divergence_score) usr_p = build_prompt(context, cm) parsed = { "title": result.title, "text": result.text, "actionPrompt": result.action_prompt, "cliffhanger": result.cliffhanger, } if result else None # Run note extraction (Nemotron-Parse or keyword fallback) for the trace insights = None if context.check_in and context.check_in.note: try: ins = extract_note_insights(context.check_in.note) or fast_insights(context.check_in.note) if ins: insights = { "sentiment": ins.sentiment, "emotions": ins.emotions, "themes": ins.themes, "intensity": ins.intensity, } except Exception: pass log_agent_trace( persona_name=context.persona.name, cast_member=cm, system_prompt=sys_p, user_prompt=usr_p, raw_output=result.text if result else "", parsed_output=parsed, insights=insights, duration_ms=duration_ms, source=source, ) except Exception as exc: logger.warning("Failed to log agent trace: %s", exc) def _fallback_demo_audio(cm: CastMember) -> Optional[str]: """Map cast members to their pre-rendered voice samples. Used when Kokoro is unavailable (HF Space, missing dep chain) so the transmission still arrives with a voice. Each cast member has at most one canned voice sample that loops the canonical phrase for that voice; in a real product this would be replaced by Kokoro output, but for the demo it gives judges a real voice to hear even when MiniCPM and Kokoro aren't running. The file is copied to a Gradio temp dir so it's served via the /file= endpoint (Gradio does NOT serve arbitrary files from the repo root on a Space — it only serves from its temp dir). """ sample_map = { "future_self": "audio/voices/today-threshold.wav", "future_partner": "audio/voices/day-02-partner.wav", "future_mentor": "audio/voices/day-03-mentor.wav", "shadow": "audio/voices/day-04-shadow.wav", } rel = sample_map.get(cm) if not rel: return None return _stage_static_audio(rel) def _stage_static_audio(rel_path: str) -> Optional[str]: """Copy a static audio file to Gradio's temp dir so it's served at /file=. Gradio Spaces serve user files at /file= only for files in the temp dir. Files in the repo root (audio/voices/...) are NOT served at the root URL. We copy the file once and cache the destination path, so the cost is one shutil.copy per file per session, not per request. """ if not hasattr(_stage_static_audio, "_cache"): _stage_static_audio._cache = {} if rel_path in _stage_static_audio._cache: cached = _stage_static_audio._cache[rel_path] if os.path.exists(cached): return cached src = Path(__file__).parent / rel_path if not src.exists(): logger.warning("Static audio not found: %s", src) return None # Use a stable filename in the temp dir so caching works dest_dir = Path(tempfile.gettempdir()) / "futureselves_audio" dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / src.name if not dest.exists(): shutil.copy(str(src), str(dest)) _stage_static_audio._cache[rel_path] = str(dest) return str(dest) # ─── Renderers — each returns the same shell, swaps only the chamber content ─ def _state_for_path(state: AppState) -> str: """Map app state to a signal-path key.""" if state.generating and not state.generation_done: return "generate" if state.today_transmission and not state.choice_made: return "choose" if state.checked_in else "generate" if state.checked_in and not state.today_transmission: return "generate" if state.checked_in: return "generate" return "check-in" def _render_home(state: AppState, ack: str = "") -> str: p = state.persona or _default_persona() name_safe = html_escape(p.name or "you") city_safe = html_escape(p.city or "everywhere") chapter_safe = html_escape(p.current_chapter or "a chapter still forming") avoiding_safe = html_escape(p.avoiding or "something you keep circling") eyebrow = _chamber_eyebrow("line idle", f"{name_safe} · {city_safe}") title = _chamber_title(f"Ready when you are, {name_safe}.") continuity_hint = "" if state.recent_choices: last_choice = state.recent_choices[-1] choice_labels = { "toward": "moved toward what matters", "steady": "held your ground", "release": "let something go", "repair": "mended a frayed thread", } continuity_hint = f" The line has been listening since you last chose to {choice_labels.get(last_choice.choice, 'continue')}." if not state.onboarded: body_html = _chamber_body( "Give the line one word. I'll turn it into something " "your future self needs you to hear tonight." ) else: body_html = _chamber_body( f"Check in with one word to open the line. " f"You are in {chapter_safe}, " f"and avoiding {avoiding_safe}." f"{continuity_hint}" ) callout = "" if ack == "memory": callout = _chamber_callout( "remembered", "I’ll remember that for tomorrow. The line is ready when you are." ) elif not state.onboarded and state.recent_transmissions: callout = _chamber_callout( "make it yours", f'Want tomorrow’s signal to know you better? ' f'Personalize your line →' ) elif state.recent_transmissions: last = state.recent_transmissions[-1] callout = _chamber_callout( "last transmission", f'“{html_escape(last.title)}” · {html_escape(last.date_key)}' ) chapter_truncated = p.current_chapter[:40] + "…" if p.current_chapter and len(p.current_chapter) > 40 else (p.current_chapter or "—") meta = _chamber_meta([ ("arc", html_escape(p.primary_arc or "—")), ("chapter", html_escape(chapter_truncated)), ]) out = _identity_strip(state) out += _signal_chamber(eyebrow, title, body_html, meta_html=meta, callout_html=callout) out += _constellation_rail(state) out += _signal_path(_state_for_path(state)) out += _memory_log(state) out += _signal_footer() return out def _render_generating(cast: CastMember, word: str = "") -> str: label = CAST_MEMBER_NAMES.get(cast, ("", ""))[0] or cast word_safe = html_escape(word.strip()) if word.strip() else "" eyebrow = _chamber_eyebrow("tuning the line", label) title = _chamber_title(f'{label} is reaching across time.') if word_safe: body_html = _chamber_body(f'I’m turning “{word_safe}” into something you can use tonight.') else: body_html = _chamber_body("Stand by. The signal is being assembled from your last few days.") body_html += _tuning_display(label) meta = _chamber_meta([ ("channel", "α-04"), ("depth", "340ms"), ("cast", label), ]) out = _identity_strip(AppState()) # state not threaded here; identity is enough out += _signal_chamber(eyebrow, title, body_html, meta_html=meta) out += _signal_path("generate") out += _signal_footer() return out def _render_transmission(state: AppState) -> str: t = state.today_transmission if not t: return _render_home(state) label = CAST_MEMBER_NAMES.get(state.today_cast or "future_self", ("", ""))[0] or "Future Self" title_safe = html_escape(t.title or "") text_safe = html_escape(t.text or "") action_safe = html_escape(t.action_prompt or "") cliff_safe = html_escape(t.cliffhanger or "") eyebrow = _chamber_eyebrow("transmission received", label) title = _chamber_title(f'{title_safe}') body_html = _chamber_body(text_safe) actions = "" if state.today_audio: actions = _audio_module(label, state.today_audio) elif state.generation_done: actions = _audio_dropped_module(label) # Share module gets raw text — it's payload-encoded for clipboard, not HTML-interpolated. actions += _share_module(t.title, t.text, t.action_prompt, t.cliffhanger) # The chamber holds the audio module + tonight's move. Tomorrow's cliffhanger # is shown as a second anchor line below the chamber so the page has a clear # rhythm: text → audio + tonight → tomorrow → constellation → memory log. tonight = _chamber_callout("tonight's move", action_safe) tomorrow = _chamber_callout("tomorrow", cliff_safe) out = _identity_strip(state) out += _signal_chamber(eyebrow, title, body_html, actions_html=actions, callout_html=tonight) out += tomorrow out += _constellation_rail(state) out += _signal_path("choose") out += _memory_log(state, limit=5) out += _signal_footer() return out def _unlock_criteria(cm: str) -> str: """Return unlock criteria tooltip for locked voice orbs.""" criteria = { "future_partner": "Unlock: Choose love arc · divergence < 4", "future_mentor": "Unlock: 7-day streak · choose steady/toward 7×", "future_best_friend": "Unlock: 3-day streak · choose repair once", "shadow": "Unlock: divergence ≥ 4 · timeline fracture", "alternate_self": "Unlock: 14-day streak · timeline drift", } return criteria.get(cm, "locked") def _render_history(state: AppState) -> str: out = _identity_strip(state) eyebrow = _chamber_eyebrow("memory log", "transmissions and choices") title = _chamber_title("Every signal so far.") if not state.recent_choices and not state.recent_transmissions: body = _chamber_body( "The line is idle. One word on the Today tab starts the transmission." ) out += _signal_chamber(eyebrow, title, body, actions_html='
open today tab
') else: body = _chamber_body("Scroll for the full record of what was said and what you chose.") out += _signal_chamber(eyebrow, title, body) out += _constellation_rail(state) out += _memory_log(state, limit=20) out += _signal_path("choose" if state.choice_made else "check-in") out += _signal_footer() return out # ─── Build Gradio UI ───────────────────────────────────────────────────────── def create_app(): # Bootstrap: write the Modal app source and the demo trace on first # boot. These are committed to the repo but regenerating them at # startup means judges always see a current trace + a current # Modal function definition. try: write_modal_app() write_demo_trace() except Exception as exc: logger.warning("Startup bootstrap failed: %s", exc) # Off Brand: typewriter effect on transmission text js_code = """ function startTypewriter() { const el = document.querySelector('.typewriter'); if (!el || el.dataset.typed) return; el.dataset.typed = '1'; const text = el.textContent; el.textContent = ''; el.style.visibility = 'visible'; let i = 0; function type() { if (i < text.length) { el.textContent += text.charAt(i); i++; setTimeout(type, 6 + Math.random() * 12); } } type(); } setInterval(startTypewriter, 500); startTypewriter(); // ─── Example chip handler — click to populate textbox ─── function setupExampleChips() { document.querySelectorAll('.ex-chip').forEach(chip => { if (chip.dataset.wired) return; chip.dataset.wired = '1'; chip.addEventListener('click', function(e) { e.preventDefault(); const fieldId = this.dataset.field; const value = this.dataset.value; // Find the textarea/input inside the field container const container = document.getElementById(fieldId); if (!container) return; const input = container.querySelector('textarea, input'); if (!input) return; // Set value natively so Gradio picks it up const nativeSetter = Object.getOwnPropertyDescriptor( input.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, 'value' ).set; nativeSetter.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); // Visual feedback const orig = this.textContent; this.textContent = '\u2713'; this.classList.add('used'); setTimeout(() => { this.textContent = orig; this.classList.remove('used'); }, 1200); }); }); } // ─── Choice/reaction card handler — click to select ─── function setupCardSelection() { document.querySelectorAll('.choice-card, .reaction-card, .arc-card, .memory-card').forEach(card => { if (card.dataset.wired) return; card.dataset.wired = '1'; card.addEventListener('click', function(e) { e.preventDefault(); const grid = this.closest('.card-grid'); if (!grid) return; // Deselect all cards in this grid grid.querySelectorAll('.choice-card, .reaction-card, .arc-card, .memory-card').forEach(c => c.classList.remove('active')); // Select this card this.classList.add('active'); // Find the hidden radio widget by elem_id and click the matching option let gridClass = 'field-memory'; if (grid.classList.contains('choice-grid')) gridClass = 'field-choice'; else if (grid.classList.contains('reaction-grid')) gridClass = 'field-reaction'; else if (grid.classList.contains('memory-grid')) gridClass = 'field-memory'; else if (grid.classList.contains('arc-grid')) gridClass = 'field-oarc'; const radioWidget = document.getElementById(gridClass); if (!radioWidget) return; const val = this.dataset.value; // Find the radio input with matching value attribute const radio = radioWidget.querySelector('input[value="' + val + '"]'); if (radio) { radio.click(); } else { // Fallback: try clicking the label const labels = radioWidget.querySelectorAll('.gr-radio-label'); labels.forEach(label => { if (label.textContent.trim().toLowerCase() === val) { label.click(); } }); } }); }); } // Re-run after Gradio re-renders function setupInteractions() { setupExampleChips(); setupCardSelection(); } setInterval(setupInteractions, 800); setupInteractions(); """ with gr.Blocks( css=CSS, # No Inter. Fraunces for the chamber voice, IBM Plex Mono for instrument labels. # Pass an empty font list to gr.themes.Base so the @import in CSS wins. # Color() takes 11 shades (c50..c950) — built around our chamber palette. theme=gr.themes.Base( primary_hue=gr.themes.Color( c50="#fbf3e3", c100="#f5e3bd", c200="#eed29a", c300="#e7c277", c400="#e0b15a", c500="#e9a847", c600="#c4842d", c700="#a06820", c800="#7a4a14", c900="#5a3608", c950="#3a2205", ), secondary_hue=gr.themes.Color( c50="#ece9f8", c100="#d6cef0", c200="#bcb0e7", c300="#a192de", c400="#8b7cd3", c500="#7a6cc7", c600="#5b4ea6", c700="#3f3470", c800="#2c234d", c900="#1a1530", c950="#0d0a1c", ), neutral_hue=gr.themes.Color( c50="#f6efdc", c100="#d8d0bd", c200="#b8b09c", c300="#9c9485", c400="#7e766a", c500="#5a5f75", c600="#3a3f55", c700="#2a2e42", c800="#1c2032", c900="#141a30", c950="#0a0c1a", ), font=["IBM Plex Mono", "ui-monospace", "monospace"], font_mono=["IBM Plex Mono", "ui-monospace", "monospace"], ), title="FutureSelves", head=( # Google Fonts via , not CSS @import — async, not blocked by # CSP, works in HF Space iframes. Two faces only: Fraunces for the # chamber voice, IBM Plex Mono for instrument labels and signal path. "" "" "" f"" ), ) as demo: browser_state = gr.BrowserState(None) state = gr.State(init_state()) demo.load(fn=lambda d: AppState.from_dict(d), inputs=[browser_state], outputs=[state]) with gr.Tabs(elem_classes="tab-nav"): # ── Today tab ────────────────────────────────────────────── with gr.Tab("Today"): # Initial chamber content shown before the page hydrates. Mirrors # _render_home() for the unonboarded state so there's no flash. content = gr.HTML( _signal_chamber( _chamber_eyebrow("line idle", "you · everywhere"), _chamber_title("Ready when you are, you."), _chamber_body("Give the line one word. I'll turn it into something your future self needs you to hear tonight."), ) ) with gr.Column(visible=False) as onboard_col: # Primer line — sets emotional tone before the form gr.HTML(PRIMER_HTML) with gr.Column(visible=True) as step1_col: step1_heading = gr.HTML("### 👤 Make it yours") oname = gr.Textbox( label="Your name", placeholder=PLACEHOLDERS["name"], elem_id="field-oname", ) octiy = gr.Textbox( label="Your city", placeholder=PLACEHOLDERS["city"], elem_id="field-octiy", ) step1_btn = gr.Button(BUTTON_TEXT["step1_next"], variant="primary") with gr.Column(visible=False) as step2_col: step2_heading = gr.HTML("### 🧭 Your chapter") ochapter = gr.Textbox( label="Current life chapter", lines=2, placeholder=PLACEHOLDERS["chapter"], elem_id="field-ochapter", ) # Arc selection rendered as 4 cards, not a radio. # Click a card to select. The hidden radio holds the # state value for the step2_btn click handler. step2_arc_html = gr.HTML(_arc_cards_html("purpose")) oarc = gr.Radio( ["money", "love", "purpose", "health"], label="", value="purpose", elem_classes="hidden-radio", elem_id="field-oarc", ) step2_btn = gr.Button(BUTTON_TEXT["step2_next"], variant="primary") with gr.Column(visible=False) as step3_col: gr.Markdown("### 🌱 What's alive in you?") with gr.Row(): with gr.Column(scale=1): oavoid = gr.Textbox( label="Avoiding", lines=2, placeholder=PLACEHOLDERS["avoiding"], elem_id="field-oavoid", ) gr.HTML(chip_html(EXAMPLES["avoiding"], "field-oavoid")) with gr.Column(scale=1): ofraid = gr.Textbox( label="Afraid won't happen", lines=2, placeholder=PLACEHOLDERS["afraid"], elem_id="field-ofraid", ) gr.HTML(chip_html(EXAMPLES["afraid"], "field-ofraid")) with gr.Row(): with gr.Column(scale=1): odrain = gr.Textbox( label="Draining you", lines=2, placeholder=PLACEHOLDERS["draining"], elem_id="field-odrain", ) gr.HTML(chip_html(EXAMPLES["draining"], "field-odrain")) with gr.Column(scale=1): omira = gr.Textbox( label="Miraculous year", lines=2, placeholder=PLACEHOLDERS["miraculous"], elem_id="field-omira", ) gr.HTML(chip_html(EXAMPLES["miraculous"], "field-omira")) gr.HTML( '
a sentence is enough. you can come back and edit anytime.
' ) step3_btn = gr.Button(BUTTON_TEXT["step3_begin"], variant="primary") def _onboard_step1(n: str, c: str, s: AppState) -> AppState: s.persona = PersonaContext( name=n.strip(), city=c.strip(), selected_voice_name="Ember", selected_voice_description="warm, intimate, certain", ) s.onboard_step = 1 return s def _onboard_step2(ch: str, a: str, s: AppState) -> AppState: if s.persona: s.persona.current_chapter = ch.strip() s.persona.primary_arc = a s.onboard_step = 2 return s def _onboard_step3(av: str, af: str, dr: str, mi: str, s: AppState): if s.persona: s.persona.avoiding = av.strip() s.persona.afraid_wont_happen = af.strip() s.persona.draining = dr.strip() s.persona.miraculous_year = mi.strip() s.onboarded = True s.onboard_step = 3 return _render_home(s), s.to_dict(), s def _load_maya_demo(s: AppState): """Populate state with Maya's 4-day history + today's transmission.""" bundle = build_maya_demo() s.persona = bundle.persona s.onboarded = True s.onboard_step = 3 s.recent_transmissions = list(bundle.past_transmissions) s.recent_choices = list(bundle.past_choices) s.recent_responses = list(bundle.past_responses) s.checked_in = True s.check_in_word = bundle.check_in_word s.check_in_note = bundle.check_in_note s.today_cast = bundle.today_cast s.today_transmission = bundle.today_transmission # Stage the static audio into a Gradio-served temp # path so the