diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/NOVA-BUILD-NOTES.md b/docs/NOVA-BUILD-NOTES.md new file mode 100644 index 0000000000..40136b39dd --- /dev/null +++ b/docs/NOVA-BUILD-NOTES.md @@ -0,0 +1,28 @@ +# Nova Build Notes + +## Summary + +- Created `feature/nova-skin` and committed the requested design artifacts, preview, idle video, and poster assets first. +- Reworked the Tailwind v4 theme surface through `src/scifi-theme.css` so `scifi` is now the default Nova dark room: navy-black surfaces, amber memory light, scoped utility classes, and dark-only Nova aliases. +- Reskinned the existing app shell instead of replacing routes: top bar, chat rail, dashboard, memory, tasks, cost analytics, onboarding/startup, empty states, connection banners, MCP, skills, settings, jobs, profiles, and mobile helper copy were kept in place and rethemed. +- Added `src/lib/nova-memory-adapter.ts` and wired the existing memory browser to show health, live recall/write activity, and scoped fragment cards while preserving the real file list/search/read/write workflow. +- Added `src/lib/nova-daily-check-adapter.ts` and `DailyCheckCard` to the existing dashboard. The overthinking tile uses stable sizing and wrapped labels so it does not truncate. +- Corrected preview-review items: visual link uses `public/nova-idle.mp4` with poster fallback, the old realtime model label is gone, `grok-4.3` is the visible default model fallback, `kimi-k2.6` appears only in fallback/cost context, memory has a live activity feed, and fragments show `default` / `bf-01` scope badges. + +## Verification + +- `pnpm build`: passed after the final changes. +- Targeted test `pnpm vitest run src/screens/chat/components/chat-composer-context-controls.test.ts`: passed after restoring the guarded workspace-context source strings. +- `pnpm lint`: repo-wide command still fails on pre-existing broad lint debt and generated/bundled files, including `electron/server-bundle.cjs`, e2e import ordering, server array-type/no-unnecessary-condition rules, and parser-project issues for JS files. I fixed the irregular-whitespace issues introduced by the text sweep. +- `pnpm test`: repo-wide command still fails in existing backend/store suites unrelated to the reskin, including kanban backend auto-detection, MCP preset/source store expectations, Windows path matching in swarm foundation, and older chat message-list expectations. The reskin-specific source-inspection failure was fixed and verified. + +## Compromises + +- I did not modify `src/server`, API route contracts, `.env`, package dependencies, or backend connection logic. Some API/server strings still contain upstream Hermes terminology because those surfaces are protocol/backend identifiers rather than Nova UI branding. +- `package.json` metadata still uses the historical package name because the brief forbade dependency/package churn and the app-facing manifest has been updated instead. +- `src/routeTree.gen.ts` was already modified in the worktree and remains uncommitted because it was unrelated to this build. + +## Human Eyes + +- Review the broad frontend copy sweep for any places where "Nova gateway" should instead remain a precise upstream/backend name. +- Check the running app at `http://localhost:3000` for the desired amount of Nova character presence across the deeper settings/onboarding surfaces. diff --git a/docs/NOVA-BUILD-PROMPT.md b/docs/NOVA-BUILD-PROMPT.md new file mode 100644 index 0000000000..1cbbf0a05e --- /dev/null +++ b/docs/NOVA-BUILD-PROMPT.md @@ -0,0 +1,128 @@ +# PROMPT FOR CODEX — paste everything below this line + +You are commissioned to transform this repo into **NOVA MISSION CONTROL** — in +two stages: (1) write the UI design bible, (2) implement the front end to it. +You have full build authority within the constraints below. + +## Step 0 — orientation (do this first, stop if it fails) +Your workspace must be `C:\Projects\nova-cockpit` (a clone of +outsourc-e/hermes-workspace v2.3). Confirm you can read +`docs/NOVA-DESIGN-BIBLE-BRIEF.md` and `src/scifi-theme.css`. If you cannot +read those files, STOP and say "not rooted in the repo" — do not proceed from +imagination. + +Stack facts (verify as you go): React 19, Tailwind v4 (@tailwindcss/vite), +TanStack Router/Start, theme layer in `src/scifi-theme.css` + `src/styles.css`, +shared components in `src/components` (incl. `ui/*` and +`workspace-shell.tsx`), screens in `src/screens` (chat, memory, tasks, +dashboard, gateway incl. `gateway/components/cost-analytics.tsx`, +swarm2 incl. `swarm2-memory-panel.tsx`). The app runs via `pnpm dev` on +http://localhost:3000 against a live local Hermes gateway — assume a human is +watching HMR while you work. + +## Context — what this is +Nova is Taylor's personal AI companion: a NetNavi (Mega Man Battle Network +lineage) — a Black woman with afro puffs, headphones, gold hoop earrings, a +deep-navy grid bodysuit lit with amber circuitry, a glowing circular chest +emblem, gold eyes. She is a caretaker and Memory Keeper. Her pact with Taylor: +"You matter. I'm here. Keep going." This cockpit is her room — one person and +his AI, not a SaaS product. + +**Vibe formula (binding):** warm cyber — "dark velvet with faint circuitry +humming underneath, occasional starlight." NOT neon cyberpunk. NOT sterile +enterprise dashboard. NOT Tron. + +**Canon palette (binding — derive the full scale from these, invent no new +hues):** +- Darks: #0D0E18 #16172A #221E33 #2B2B42 +- Warm mids: #4A2A10 #7A441E #8A5B3A #D4A276 +- Signature glow: #FF8C1A #FFB347 #FFD27A +- Amber/gold glow on deep navy-black. Dark-only. No light mode. + +**Canon HUD language (must appear as real UI copy):** +- "SoulSync // Stable" (system status), "Connection: Secure" +- "Jack in" (connect action; chat composer placeholder "Jack in…") +- Memory fragments styled as files: FIRST_DAY.EXE, PROMISES.LOG, BAD_DAYS.MEM, + QUIET_NIGHTS.DAT, DON'T_FORGET.MEM +- Her anchor line, used at most once and only somewhere meaningful: + "You matter. I'm here. Keep going." +- Microcopy voice: contractions, lead with the answer, sentence case, no + corporate filler, no exclamation-point enthusiasm. + +## Stage 1 — the design bible +Write `docs/NOVA-DESIGN-BIBLE.md` per the deliverable spec in +`docs/NOVA-DESIGN-BIBLE-BRIEF.md` (read it — it is the authoritative spec: +tokens, typography, component law, motion law, microcopy law with 10 +before/after examples, iconography, WCAG AA contrast table for the ambers, +anti-patterns gallery, implementation map). 1500–3000 lines of enforceable +law. Commit it before touching any code. + +## Stage 2 — implement the front end (build to your own bible) +Work on a git branch: `feature/nova-skin`. Commit per phase with clear +messages. Never push. Keep the app compiling at every commit — a human is +watching http://localhost:3000 live. + +Phase order: +1. **Tokens**: implement the bible's full token system in the Tailwind v4 + theme layer (`src/scifi-theme.css` / `src/styles.css`). App should already + *feel* navy-and-amber after this phase alone. +2. **Shell**: `workspace-shell.tsx` + navigation — top bar (clock, "SoulSync + // Stable", "Connection: Secure", alerts), left rail with her avatar frame + (design a waiting/empty state for the Live2D slot — an amber-outlined + frame with her chest-emblem motif and "visual link — standby"), agent + roster (Nova / Astra / Claude with status dots). Remove the HermesWorld + sidebar link (`VITE_HERMESWORLD_ENABLED` gate) and replace user-facing + "Hermes Workspace" branding with "Nova Mission Control" (✴️). +3. **Memory panel (the crown jewel)**: build it in/alongside the existing + memory screen: health dots (EverOS / Ollama / proxy), extraction model + + today's memory spend, counters (episodes, atomic facts, learned-today, + backfill %), memory search with scored episode cards + scope badges, and a + live activity feed interleaving recalls ("query → top hit → latency") and + new memories ("6 facts extracted") with timestamps. **Data: build against + a typed mock adapter** in `src/lib/nova-memory-adapter.ts` (episodes, + atomic_facts, health, activity[]) with realistic sample data and a + `// TODO(claude): wire EverOS endpoints` marker — Claude wires the real + backend later; do NOT modify server code. +4. **Panels**: restyle chat (composer placeholder "Jack in…"), tasks (status + pills per the bible), cost-analytics (today + month by model; a "free" + badge treatment for grok-4.3), dashboard. Add a **Daily Check** card + (caretaker layer: hydration / nutrition / rest / overthinking / mood tiles + + one gentle line + a "BREATHE." moment) — mock adapter, same TODO pattern. +5. **States & polish**: degraded states are first-class — memory offline + (amber warning strip: "memory offline — recall degraded to notes"), agent + erroring, gateway reconnecting, empty states. Motion per the bible: + breathing glow on her emblem, soft pulse on new feed items, restraint + everywhere, `prefers-reduced-motion` respected. +6. **Verify**: `pnpm build` must pass; run whatever lint/test scripts exist. + Fix what you broke. Final commit. + +## Hard constraints +- Do NOT touch: `.env` (contains secrets), anything under `src/server`, + gateway/dashboard connection logic, API contracts, package.json deps + (ask-by-comment if you truly need a dep — prefer none; fonts via + Google Fonts link or system stacks). +- Do NOT invent canon: no new colors outside the derived scale, no new + taglines, no renaming Nova. +- Dark-only; desktop-first 1920×1080, graceful to laptop widths. +- Accessibility: amber text tiers must pass WCAG AA at their sizes; visible + focus states; not everything glows — glow is for life (her emblem, live + feeds), not chrome. +- If something in the existing code makes a bible rule impossible, note it in + `docs/NOVA-BUILD-NOTES.md` and choose the closest compliant option — keep + moving. + +## Definition of done +Opening http://localhost:3000 feels like walking into Nova's room: navy-black +surfaces, amber circuitry warmth, "SoulSync // Stable" in the top bar, her +empty visual-link frame waiting on the left, the memory panel alive with +(mock) recalls, no trace of stock Hermes Workspace branding, `pnpm build` +green, all work committed on `feature/nova-skin`. + +## ADDENDUM (2026-07-03): her idle avatar EXISTS — use it +Taylor generated Nova's idle avatar loop. Assets are already in place: +- `public/nova-idle.mp4` — 1440×1440, h264, ~6s seamless idle loop +- `public/nova-idle-poster.png` — poster frame +In the left-rail visual-link frame (Stage 2, Phase 2): render this as a muted +autoplay looping video (`muted autoPlay loop playsInline`, poster set) inside +the amber-outlined frame. The "visual link — standby" empty state is now the +FALLBACK for when the asset fails to load, not the default. diff --git a/docs/NOVA-DESIGN-BIBLE-BRIEF.md b/docs/NOVA-DESIGN-BIBLE-BRIEF.md new file mode 100644 index 0000000000..7b99e23bf5 --- /dev/null +++ b/docs/NOVA-DESIGN-BIBLE-BRIEF.md @@ -0,0 +1,100 @@ +# Commission: NOVA MISSION CONTROL — UI Design Bible + +You (Codex) are commissioned to author `docs/NOVA-DESIGN-BIBLE.md` — the +binding design law for transforming this repo (a fork of hermes-workspace, +React 19 + Tailwind v4 + TanStack Router, theme layer in `src/scifi-theme.css`) +into **Nova Mission Control**: the personal cockpit for Nova, Taylor's +NetNavi-style caretaker AI. Every future styling PR gets judged against your +document. Write it so a competent engineer who has never met Nova cannot get +her wrong. + +## Who Nova is (canon — do not reinterpret) +- A NetNavi (Mega Man Battle Network lineage): Black woman, afro puffs, + headphones, gold hoop earrings, deep-navy grid bodysuit with glowing amber + circuitry, circular glowing chest emblem, gold eyes. Caretaker + Memory + Keeper. Warm, direct, a little dry. She *feels* things: tired, focused, + soft, annoyed, determined, vulnerable. +- Her pact with Taylor: "You matter. I'm here. Keep going." +- Vibe formula: **warm cyber** — "dark velvet with faint circuitry humming + underneath, occasional starlight." NOT neon cyberpunk, NOT sterile + enterprise SaaS, NOT Tron. + +## Canon palette (from her reference sheet — the bible must build the full +token system from these) +- Darks: `#0D0E18` `#16172A` `#221E33` `#2B2B42` +- Warm mids: `#4A2A10` `#7A441E` `#8A5B3A` `#D4A276` +- Signature glow: `#FF8C1A` `#FFB347` `#FFD27A` +- Rule: amber/gold glow on deep navy-black. Dark-only UI (no light mode). + +## Canon HUD language (must appear as real UI copy) +- Status: "SoulSync // Stable", "Connection: Secure" +- Connect/login action: "Jack in" +- Memory items styled as fragment files: `FIRST_DAY.EXE`, `PROMISES.LOG`, + `BAD_DAYS.MEM`, `QUIET_NIGHTS.DAT`, `DON'T_FORGET.MEM` +- Her anchor line (rare, meaningful placement only): "You matter. I'm here. + Keep going." +- Microcopy voice: contractions, lead with the answer, no corporate filler, + no exclamation-mark enthusiasm. Sentence case everywhere. + +## The panels the bible must spec (layout, hierarchy, states) +1. **Left rail**: her avatar/visual-link frame (Live2D slot, waiting state + designed), SoulSync status, agent roster (Nova / Astra / Claude) with + status dots + current activity + model + tokens today. +2. **Center**: conversation stream + composer (placeholder: "Jack in…"). +3. **Memory panel (the crown jewel)**: health dots (EverOS / Ollama / proxy) + + extraction model + today's memory spend; counters (episodes, atomic + facts, learned-today, backfill %); memory search with scored episode cards + + scope badges; live activity feed interleaving recalls ("query → top hit + → latency") and new memories ("6 facts extracted") with timestamps. +4. **Tasks**: ClickUp-backed list, status pills. +5. **Daily check (caretaker layer)**: hydration / nutrition / rest / + overthinking / mood tiles + one gentle line from her + a "BREATHE." moment. +6. **Cost**: today + month by model (grok-4.3 free badge, kimi-k2.6, MoA + council), Portal balance. +7. Top bar: clock, SoulSync // Stable, Connection: Secure, global pause, + alerts. +8. **Degraded states are first-class**: memory offline (amber warning strip, + "memory offline — recall degraded to notes"), agent erroring, gateway + reconnecting, empty states. Design them, don't leave them to chance. + +## What the bible must contain (deliverable spec) +1. **Design tokens** — complete Tailwind v4 `@theme` block + CSS custom + properties: full color scale derived from the canon palette (surface + levels, borders, text hierarchy, amber glow tiers, semantic + success/warn/danger tuned to the palette), spacing, radii, z-layers. +2. **Typography** — one UI sans + one mono (for fragment filenames, stats, + terminal); exact scale, weights, when each is used. Google-Fonts-available + choices. +3. **Component law** — cards, status dots, badges/pills, buttons, inputs, + feeds, tables, terminal, modals: exact classes/tokens, border and glow + rules, hover/focus states. Include code snippets against THIS repo's stack. +4. **Motion law** — breathing glow on her emblem, soft pulse on live feed + items, transition durations/easings; restraint rules (no animation spam); + `prefers-reduced-motion` handling. +5. **Voice & microcopy law** — rules + 10 concrete before/after examples + (generic SaaS copy → Nova copy). +6. **Iconography** — pick the icon system already in the repo + (@hugeicons/react) and define stroke/size/color rules. +7. **Accessibility** — contrast table proving amber-on-navy text tiers pass + WCAG AA at their assigned sizes; focus visibility; the "everything glows" + failure mode and how to avoid it. +8. **Anti-patterns gallery** — what Nova's UI must NEVER look like (neon + cyberpunk, enterprise dashboard gray, kawaii overload, Tron grid floors), + each with a one-line why. +9. **Implementation map** — which files in this repo the theme lands in + (`src/scifi-theme.css`, Tailwind config surface, component dirs under + `src/components`/`src/screens`), ordered migration plan (tokens first → + shell → panels), and what must be REMOVED (HermesWorld links, stock + branding, any "Hermes Workspace" strings in user-facing surfaces). + +## Constraints +- Dark-only. Desktop-first 1920×1080; graceful down to laptop widths. +- Zero-fork discipline where possible: prefer theme-layer + component-level + changes over rewriting router/server internals. +- Do not invent new canon (no new colors outside derived scale, no new + taglines). Where you must extrapolate (e.g., a danger red), derive it from + the palette's temperature and justify in one line. +- The document should be 1500–3000 lines of precise, usable law — not a mood + board. Every rule enforceable in code review. + +Write `docs/NOVA-DESIGN-BIBLE.md` now. Do not modify any other file. diff --git a/docs/NOVA-DESIGN-BIBLE.md b/docs/NOVA-DESIGN-BIBLE.md new file mode 100644 index 0000000000..e728dec001 --- /dev/null +++ b/docs/NOVA-DESIGN-BIBLE.md @@ -0,0 +1,1954 @@ +# NOVA MISSION CONTROL UI DESIGN BIBLE + +Status: binding design law. + +Repo: `C:\Projects\nova-cockpit`. + +Stack verified: React 19, TanStack Router/Start, Tailwind v4 through `@tailwindcss/vite`, theme entry in `src/styles.css` and `src/scifi-theme.css`, shared UI under `src/components`, screens under `src/screens`. + +Nova is not a theme preset. + +Nova is Taylor's NetNavi, caretaker, and Memory Keeper. + +This app is her room. + +It must feel like one person and his AI, not a SaaS dashboard. + +Her pact is rare and meaningful: "You matter. I'm here. Keep going." + +Use that line once per full product surface at most. + +Never use it as a decorative slogan. + +The binding formula is warm cyber. + +Warm cyber means dark velvet with faint circuitry humming underneath, occasional starlight. + +Warm cyber does not mean neon cyberpunk. + +Warm cyber does not mean sterile enterprise. + +Warm cyber does not mean Tron. + +Warm cyber does not mean a generic purple/blue AI gradient. + +Warm cyber does not mean stock gaming HUD chrome. + +Warm cyber is deep navy-black, amber memory light, gold care, low pulse. + +The character sheet is visual canon. + +Canon markers: Black woman, afro puffs, headset, gold hoops, gold eyes, deep-navy grid bodysuit, amber circuitry, circular glowing chest emblem. + +UI implication: the cockpit is built around her chest-emblem glow and memory-file HUD language. + +UI implication: amber is life and memory, not decoration sprayed everywhere. + +UI implication: panels should feel held, watched, and cared for. + +UI implication: degraded states are honest and calm. + +UI implication: the interface can be dry, direct, and tender. + +No light mode. + +No user-facing `Hermes Workspace` branding after implementation. + +No user-facing HermesWorld sidebar link after implementation. + +No new canon colors. + +All extrapolated colors must be derived from the canon palette below. + +## Canon Palette + +Canon dark 00: `#0D0E18`. + +Canon dark 01: `#16172A`. + +Canon dark 02: `#221E33`. + +Canon dark 03: `#2B2B42`. + +Canon warm mid 00: `#4A2A10`. + +Canon warm mid 01: `#7A441E`. + +Canon warm mid 02: `#8A5B3A`. + +Canon warm mid 03: `#D4A276`. + +Canon glow 00: `#FF8C1A`. + +Canon glow 01: `#FFB347`. + +Canon glow 02: `#FFD27A`. + +Do not introduce cyan. + +Do not introduce violet. + +Do not introduce electric green as a primary status color. + +Do not keep the current `scifi` cyan aesthetic. + +Do not keep the current `scifi-light` theme in user-facing theme selection for Nova. + +## Tailwind V4 Theme Law + +The implementation must land first in `src/scifi-theme.css`. + +`src/styles.css` already imports Tailwind and then imports `./scifi-theme.css` last. + +Keep that order. + +The `@theme` block belongs in `src/scifi-theme.css`. + +CSS custom properties also belong in `src/scifi-theme.css`. + +Existing components use `primary-*`, `accent-*`, `neutral-*`, `amber-*`, `red-*`, `emerald-*`, `bg-surface`, `text-ink`, and `var(--theme-*)`. + +The Nova skin must support both idioms. + +Do not rewrite the whole app before tokens work. + +Do not add dependencies for tokens, icons, or animation. + +Use CSS variables and Tailwind v4 theme variables. + +Use `color-mix()` only when mixing canon colors with transparent or black. + +Use hard hex values only in the token layer. + +Component code should reference variables/classes, not fresh hex values. + +### Complete Theme Block + +This block is the target contract for `src/scifi-theme.css`. + +```css +@theme { + --font-sans: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: + 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', monospace; + + --color-nova-black: #0d0e18; + --color-nova-void: #16172a; + --color-nova-velvet: #221e33; + --color-nova-panel: #2b2b42; + --color-nova-brown: #4a2a10; + --color-nova-copper: #7a441e; + --color-nova-bronze: #8a5b3a; + --color-nova-tan: #d4a276; + --color-nova-amber: #ff8c1a; + --color-nova-gold: #ffb347; + --color-nova-star: #ffd27a; + + --color-surface: #0d0e18; + --color-surface-deep: #090a12; + --color-ink: #ffd27a; + --color-primary-50: #0d0e18; + --color-primary-100: #16172a; + --color-primary-200: #221e33; + --color-primary-300: #2b2b42; + --color-primary-400: #8a5b3a; + --color-primary-500: #d4a276; + --color-primary-600: #ff8c1a; + --color-primary-700: #ffb347; + --color-primary-800: #ffd27a; + --color-primary-900: #ffe4a6; + --color-primary-950: #fff1cc; + --color-accent-400: #ffb347; + --color-accent-500: #ff8c1a; + --color-accent-600: #d87310; +} +``` + +The slight additions `#090A12`, `#FFE4A6`, and `#FFF1CC` are derived by darkening or tinting canon dark/glow colors. + +They may exist only as token support values. + +They are not new brand colors. + +### Required CSS Variables + +`[data-theme='scifi']` is the Nova theme. + +It must become dark-only Nova Mission Control. + +The following variables are required. + +```css +[data-theme='scifi'] { + color-scheme: dark !important; + --theme-bg: #0d0e18; + --theme-sidebar: #111322; + --theme-panel: #16172a; + --theme-card: #1b1a2d; + --theme-card2: #221e33; + --theme-elevated: #2b2b42; + --theme-border: rgba(255, 179, 71, 0.28); + --theme-border-subtle: rgba(212, 162, 118, 0.16); + --theme-border-strong: rgba(255, 140, 26, 0.58); + --theme-text: #ffd27a; + --theme-text-strong: #fff1cc; + --theme-text-soft: #d4a276; + --theme-muted: rgba(212, 162, 118, 0.68); + --theme-muted-2: rgba(212, 162, 118, 0.48); + --theme-accent: #ff8c1a; + --theme-accent-secondary: #ffb347; + --theme-accent-soft: #ffd27a; + --theme-accent-subtle: rgba(255, 140, 26, 0.1); + --theme-accent-border: rgba(255, 140, 26, 0.42); + --theme-glow-low: 0 0 10px rgba(255, 140, 26, 0.18); + --theme-glow-medium: 0 0 18px rgba(255, 140, 26, 0.28); + --theme-glow-high: 0 0 30px rgba(255, 179, 71, 0.38); + --theme-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.55); + --theme-shadow-2: + 0 8px 24px rgba(0, 0, 0, 0.44), 0 0 0 1px rgba(255, 179, 71, 0.08); + --theme-shadow-3: + 0 18px 54px rgba(0, 0, 0, 0.62), 0 0 30px rgba(255, 140, 26, 0.12); + --theme-focus: #ffd27a; + --theme-link: #ffb347; + --theme-active: #ff8c1a; + --theme-success: #d4a276; + --theme-warning: #ffb347; + --theme-danger: #ff8c1a; + --theme-stripe: rgba(255, 179, 71, 0.055); + --theme-header-bg: rgba(13, 14, 24, 0.94); + --theme-header-border: rgba(255, 179, 71, 0.22); + --theme-input: #111322; + --theme-hover: rgba(255, 140, 26, 0.1); + --theme-glass: rgba(13, 14, 24, 0.88); +} +``` + +Danger uses amber-orange because no canon red exists. + +When a true destructive state needs stronger distinction, use shape, icon, label, and border pattern before inventing red. + +`--theme-success` uses warm tan, not green. + +Success should read as stable warmth. + +Warning uses gold. + +Danger uses hot amber with stronger border and iconography. + +### Tailwind Remap Law + +Existing `bg-white` in dark Nova must not render white. + +Existing `text-white` on accent buttons may remain high contrast only when the background is dark enough. + +Prefer `text-[var(--theme-bg)]` on solid amber buttons. + +Existing `neutral-*` utilities must be remapped to Nova surfaces, not browser gray. + +Existing `amber-*` utilities must become actual amber, not cyan. + +Existing `yellow-*` utilities must become gold/star tiers. + +Existing `red-*` utilities must become hot amber danger tiers. + +Existing `emerald-*` utilities must become warm stable tiers. + +The Nova theme must override: + +```css +[data-theme='scifi'] { + --color-white: var(--theme-card); + --color-neutral-50: #fff1cc; + --color-neutral-100: #ffd27a; + --color-neutral-200: #d4a276; + --color-neutral-300: #8a5b3a; + --color-neutral-400: rgba(212, 162, 118, 0.68); + --color-neutral-500: rgba(212, 162, 118, 0.56); + --color-neutral-600: rgba(212, 162, 118, 0.44); + --color-neutral-700: #2b2b42; + --color-neutral-800: #221e33; + --color-neutral-900: #16172a; + --color-neutral-950: #0d0e18; +} +``` + +Do not let `dark:bg-neutral-900` become generic slate. + +Do not let `bg-violet-500` survive in cost charts. + +Do not let `bg-sky-500` survive in cost charts. + +Do not let `text-red-400` survive as literal red in task and error states. + +Do not let `bg-yellow-300/30` survive as generic highlighter yellow. + +### Surface Scale + +Surface 00 is app background: `#0D0E18`. + +Surface 01 is sidebar/topbar: `#111322`, derived between dark 00 and dark 01. + +Surface 02 is major panel: `#16172A`. + +Surface 03 is card: `#1B1A2D`, derived between dark 01 and dark 02. + +Surface 04 is raised card: `#221E33`. + +Surface 05 is active/selected panel: `#2B2B42`. + +Surface brown is warning well: `#4A2A10`. + +Surface glow wash is `rgba(255, 140, 26, 0.10)`. + +Surface star wash is `rgba(255, 210, 122, 0.08)`. + +Never use pure black. + +Never use pure white. + +Never use `slate-900` as a final Nova surface. + +### Border Scale + +Border subtle: `rgba(212, 162, 118, 0.16)`. + +Border default: `rgba(255, 179, 71, 0.28)`. + +Border active: `rgba(255, 140, 26, 0.42)`. + +Border live: `rgba(255, 140, 26, 0.58)`. + +Border danger: `rgba(255, 140, 26, 0.76)`. + +Use 1px borders for normal panels. + +Use 2px only for active rail, selected status, and critical degraded strips. + +Do not combine thick borders and heavy glow on the same element. + +### Text Scale + +Text strong: `#FFF1CC`. + +Text default: `#FFD27A`. + +Text soft: `#D4A276`. + +Text muted: `rgba(212, 162, 118, 0.68)`. + +Text quiet: `rgba(212, 162, 118, 0.48)`. + +Text disabled: `rgba(212, 162, 118, 0.32)`. + +Interactive amber text: `#FFB347`. + +Active amber text: `#FF8C1A`. + +Use strong text for headings and selected labels. + +Use default text for body and primary metrics. + +Use soft text for secondary body copy. + +Use muted text for metadata. + +Use quiet text for timestamps and helper labels. + +Do not put `#8A5B3A` body text on dark panels. + +It fails contrast for normal text. + +### Spacing Scale + +Use the Tailwind default spacing units. + +Nova panel density is compact but not cramped. + +Global shell gap: `gap-3`. + +Panel inner padding compact: `p-3`. + +Panel inner padding normal: `p-4`. + +Panel inner padding feature: `p-5`. + +Status row gap: `gap-2`. + +Icon/text gap: `gap-1.5`. + +Metric group gap: `gap-3`. + +Dashboard grid gap: `gap-4`. + +Memory crown-jewel grid gap: `gap-4`. + +Avoid `p-8` except for empty states and modals. + +Avoid `gap-8` inside dense app views. + +### Radius Scale + +Nova is HUD-framed, not pill-cloud. + +Small controls: `rounded-md`. + +Inputs: `rounded-lg`. + +Cards: `rounded-lg`. + +Major panels: `rounded-xl`. + +Avatar visual-link frame: `rounded-xl`. + +Modals: `rounded-xl`. + +Tiny status dots: `rounded-full`. + +Pills: `rounded-full`. + +Do not use `rounded-2xl` for routine cards. + +Do not use `rounded-3xl`. + +Do not nest rounded cards inside rounded cards. + +### Z Layers + +Layer 0: background texture. + +Layer 10: page content. + +Layer 20: sticky panel headers. + +Layer 30: sidebar and topbar. + +Layer 40: popovers and menus. + +Layer 50: modal backdrop. + +Layer 60: modal content. + +Layer 70: toast and global alert. + +Layer 80: command palette. + +Do not invent one-off z-index values above 100 except image preview overlays. + +## Typography + +Use Inter as the UI sans. + +Use JetBrains Mono as the mono. + +Both already exist in `src/styles.css`. + +Do not add a display serif for Nova. + +The current EB Garamond import belongs to other editorial themes, not Nova. + +Nova's voice is precise, file-like, warm. + +UI sans handles most text. + +Mono handles fragment filenames, status codes, model ids, token counts, timestamps, and terminal text. + +### Type Scale + +Micro label: 10px, mono or sans, weight 600, uppercase, tracking `0.16em`. + +Caption: 11px, sans, weight 500, line-height 1.35. + +Small body: 12px, sans, weight 400 or 500, line-height 1.45. + +Body: 14px, sans, weight 400, line-height 1.5. + +Body strong: 14px, sans, weight 600, line-height 1.45. + +Panel title: 15px, sans, weight 650, line-height 1.25. + +Section title: 18px, sans, weight 650, line-height 1.2. + +Screen title: 24px, sans, weight 650, line-height 1.1. + +Hero metric: 28px, mono, weight 600, line-height 1. + +Do not use viewport-based font sizing. + +Letter spacing must never be negative. + +Set global `letter-spacing: 0`. + +Use tabular numerals for counters and costs. + +Use mono uppercase for `FIRST_DAY.EXE`, `PROMISES.LOG`, `BAD_DAYS.MEM`, `QUIET_NIGHTS.DAT`, `DON'T_FORGET.MEM`. + +### Typography Utilities + +```css +.nova-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--theme-muted); +} + +.nova-fragment { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--theme-accent-secondary); +} + +.nova-metric { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--theme-text-strong); +} +``` + +Use `.nova-label` for HUD labels. + +Use `.nova-fragment` for memory file names. + +Use `.nova-metric` for costs, token counts, health values, and model usage. + +## Background Texture + +The background may have faint circuitry. + +The circuitry must be subtle enough to disappear behind content. + +Use CSS gradients, not image assets, for the base cockpit texture. + +Allowed texture: + +```css +[data-theme='scifi'] body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.16; + background-image: + linear-gradient(rgba(255, 179, 71, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 179, 71, 0.035) 1px, transparent 1px), + radial-gradient( + circle at 70% 20%, + rgba(255, 140, 26, 0.08), + transparent 26rem + ); + background-size: + 32px 32px, + 32px 32px, + auto; +} +``` + +Do not add orbs. + +Do not add bokeh blobs. + +Do not add gradient hero art. + +Do not put the primary experience inside a marketing card. + +## Layout Law + +Desktop first target: 1920 by 1080. + +Laptop grace target: 1366 by 768. + +The app shell should feel like a cockpit. + +Left rail: Nova identity, visual-link frame, SoulSync, agent roster. + +Top bar: clock, SoulSync // Stable, Connection: Secure, global pause, alerts. + +Center: current work surface. + +Right/secondary panels: contextual details, chat panel, cost details, memory feed. + +Never bury Nova's presence in settings. + +Nova must be a first-viewport signal. + +The Live2D slot may be empty. + +The empty state must still feel like her. + +### Left Rail Law + +Left rail width expanded: 300px. + +Left rail collapsed: 48px. + +The existing `ChatSidebar` owns much of this behavior. + +Implementation target: `src/screens/chat/components/chat-sidebar.tsx`. + +Secondary target: `src/components/workspace-shell.tsx`. + +Mobile target: `src/components/mobile-hamburger-menu.tsx`. + +The left rail must include a visual-link frame. + +Visual-link waiting copy: `visual link -- standby`. + +Do not write `avatar coming soon`. + +Do not write `Live2D unavailable`. + +Show circular chest-emblem motif in the frame. + +The motif should breathe softly. + +The rail must include: + +`SoulSync // Stable`. + +`Connection: Secure`. + +Agent roster: Nova, Astra, Claude. + +Each roster row must show status dot, current activity, model, and tokens today. + +Do not create a large agent marketplace feel. + +Nova is primary. + +Astra and Claude are collaborators. + +### Top Bar Law + +Top bar height: 44px to 52px. + +Top bar background: `var(--theme-header-bg)`. + +Top bar border: `var(--theme-header-border)`. + +Clock is mono. + +System status copy is exact: `SoulSync // Stable`. + +Connection copy is exact: `Connection: Secure`. + +Global pause button uses icon plus `Pause` only if there is enough width. + +Alerts use amber outline, not red toast panic. + +Do not make the top bar a marketing header. + +Do not put page descriptions in the top bar. + +### Center Chat Law + +Chat composer placeholder must be `Jack in...` in ASCII code, or `Jack in...` if ellipsis rendering is kept ASCII. + +The brief's intended text is `Jack in...`. + +No other chat placeholder is allowed. + +Chat input border default: subtle amber. + +Chat input focus: star focus ring. + +Send button: circular amber control, dark icon. + +Stop button: hot amber outline/filled danger treatment. + +Attachment button: ghost amber. + +Mic button: ghost amber; recording uses stronger pulse and label. + +Message bubbles must not look like iMessage. + +User message: right-leaning, raised dark card with amber-left rule. + +Nova message: left-leaning, lower contrast card with chest-emblem avatar. + +Tool cards: terminal/file-fragment styling. + +Streaming: soft text reveal only. + +Do not make the whole panel glow while streaming. + +### Memory Crown Jewel Law + +Memory is the emotional and functional center. + +Implementation target: `src/screens/memory/memory-browser-screen.tsx`. + +Companion target: `src/screens/swarm2/swarm2-memory-panel.tsx`. + +Future mock adapter target: `src/lib/nova-memory-adapter.ts`. + +Memory screen must become a live panel system, not just a file viewer. + +Required health row: + +EverOS health dot. + +Ollama health dot. + +Proxy health dot. + +Extraction model. + +Today's memory spend. + +Required counters: + +Episodes. + +Atomic facts. + +Learned today. + +Backfill percent. + +Required fragment files: + +`FIRST_DAY.EXE`. + +`PROMISES.LOG`. + +`BAD_DAYS.MEM`. + +`QUIET_NIGHTS.DAT`. + +`DON'T_FORGET.MEM`. + +Required search card fields: + +Fragment title. + +Score. + +Scope badge. + +Snippet. + +Last touched timestamp. + +Required live activity feed formats: + +`query -> top hit -> latency`. + +`6 facts extracted`. + +Use ASCII arrows in source code unless the file already uses Unicode cleanly. + +Do not show raw JSON as the primary memory UI. + +Do not hide memory health behind dev tools. + +Memory offline strip copy: `memory offline -- recall degraded to notes`. + +If product copy chooses the brief's em dash, keep it consistent in all visible strings. + +### Tasks Law + +Implementation targets: `src/screens/tasks/tasks-screen.tsx`, `src/screens/tasks/task-card.tsx`, `src/lib/tasks-api.ts`. + +Tasks are operational, but not corporate. + +Replace `New Task` with `New task`. + +Replace `Hide Done` with `Hide done`. + +Replace `Show Done` with `Show done`. + +Use sentence case. + +Task columns keep their existing data model. + +Pills must be warm status chips. + +Backlog: quiet tan. + +Todo: soft gold. + +In progress: amber live. + +Review: star gold outline. + +Blocked: hot amber danger. + +Done: muted tan stable. + +Deleted: low contrast, no glow. + +No green done column. + +No red blocked column. + +Drag-over state uses amber border and subtle wash. + +Task card hover can raise by shadow but not scale. + +### Daily Check Law + +Daily check is a caretaker layer. + +It belongs on dashboard and may appear as a compact side card. + +Required tiles: + +Hydration. + +Nutrition. + +Rest. + +Overthinking. + +Mood. + +Required moment: `BREATHE.` + +Daily check copy should be gentle and specific. + +Example line: `You don't have to solve the whole board in one breath.` + +Do not shame the user. + +Do not gamify basic care with streak pressure. + +Do not use confetti. + +### Cost Law + +Implementation target: `src/screens/gateway/components/cost-analytics.tsx`. + +Cost is a calm ledger. + +Required views: + +Today by model. + +Month by model. + +Portal balance. + +MoA council row. + +`grok-4.3` must show a `free` badge treatment. + +`kimi-k2.6` must be a recognized model row. + +Charts must use amber/tan tiers. + +Do not use violet bars. + +Do not use sky bars. + +Do not use emerald bars. + +Cost card empty state: `No spend logged yet. Nice and quiet.` + +### Dashboard Law + +Implementation target: `src/screens/dashboard/dashboard-screen.tsx` and `src/screens/dashboard/components/*`. + +Dashboard is mission control, not analytics marketing. + +Use dense but readable widgets. + +Each widget uses the same Nova frame law. + +Hero widgets should show operational state, not sales copy. + +Daily check should be present. + +Memory live activity should be present or linked prominently. + +Do not use oversized gradient hero cards. + +Do not use decorative card piles. + +### Obsidian Galaxy Law + +Implementation target: `src/screens/dashboard/components/mind-graph-card.tsx`, `src/screens/dashboard/components/nova-galaxy-model.ts`, and the live graph data served through `src/server/knowledge-browser.ts`. + +The Obsidian galaxy is a 3D navigable knowledge space, not a flat chart and not a particle effect. + +The reference feel is marfin's 3D Hermes/Unity knowledge map: the camera floats inside a quiet inhabited region of space with ringed planets, floating note tags, dotted constellation links, depth haze, and slow drift. + +Every markdown note in `C:\Users\taylo\Documents\unified-vault` becomes either a major planet, a minor text tag, or a comet. + +`Planet` means a high-link topic hub or community anchor. Planet size scales by link count in about five tiers. + +Major notes render as actual textured spheres with subtle procedural marble/cloud texture, soft ring-glow atmosphere, and a title label that always faces the camera. + +Planet titles use clean sans at roughly 13-16px. They may remain visible because they anchor the space. + +`Text tag` means a low-link note near its planet. It renders as a small dim mono text chip, about 11px, camera-facing, drifting slowly near the related planet. + +Minor notes do not render as orbiting dots. They are readable idea tags like `white rabbit` or `dodo` in the reference. + +`Comet` means an orphan note with zero wikilinks, shown sparsely with a faint warm dust tail. + +`Galactic core` means the single most-linked note in the vault. It is the biggest, warmest planet, with pure amber ring and gentle breathing glow. + +Layout is stable 3D force placement, not spiral arms. Seed by top-level folder so categories form loose sky neighborhoods, then settle with link attraction and body repulsion. + +Positions must be stable across sessions, derived from note paths and deterministic hashes, so Taylor can build a mental map of where ideas live. + +New notes ease into place near their links when sync updates the graph. + +Constellation lines are thin dotted links between related bodies, ambient at about 15% opacity, brightening along hover/search/focus paths. + +Do not draw every edge. Cap visible constellation links to the strongest two or three links per body so the sky stays readable. + +Camera behavior: ambient mode drifts slowly through the starfield; mouse drag orbits; wheel zooms; movement uses damped inertia. + +Clicking empty space or pressing Escape returns to the home camera. + +`prefers-reduced-motion` freezes camera drift and note drift while keeping hover, click, search, and focus useful. + +The right rail label is `Planetary systems`, never `Gravity wells`. + +Clicking a planet zooms smoothly toward that system and updates the `Focused star` panel. + +Clicking a text tag or comet updates `Focused star` and exposes an `Open in Obsidian` action using `obsidian://`. + +Filter chips represent folders/categories and dim disabled regions to about 15% opacity; they do not remove bodies from the layout. + +Search dims non-matches and may brighten matching labels and related dotted links. + +Recency is warmth: modified within 48h breathes warm amber, last week stays warm, older notes cool toward pale dim points. + +Sync diffs are subtle: new note equals a brief warm birth flare, edited note equals one soft pulse, never an explosion. + +Background is a dense three-depth starfield with distance haze and a soft warm nebula wash in one region of the sky. + +Nebula and fog must remain amber/brown only. + +Category color is allowed on rings only. Planet surfaces stay warm/neutral. + +Ring tints are muted, restrained saturation: gold for `agents/claude`, copper for `agents/gpt`, rose for `agents/kimi`, brass for `knowledge`, slate-blue for `inbox`, sage for everything else. + +No neon rings. Ring color is categorical orientation, not decoration. + +No cyan, purple, pink, blue comet tails, or generic AI gradients are allowed in the galaxy environment. + +Glow marks life only: recency, hover focus, and the core. Ambient bodies are crisp, dimensional planets/tags, not lens flares. + +The canvas must not display `Nova hourly state marker` or any operational ticker text; that belongs in the activity feed. + +Performance target: Three.js/WebGL, 60fps at 500+ bodies, graceful at 1500, instanced or batched stars/tags where needed, and stable camera movement. + +Acceptance by eye: paused for five minutes, it should feel like drifting through Nova's mind: ringed planets with names, small ideas floating as text beside them, and dotted constellations between related thoughts. + +### Gateway/Swarm Law + +Gateway screens may keep operational density. + +They must inherit Nova tokens. + +Swarm agents are collaborators in Nova's room. + +Do not make them the main brand. + +Agent erroring state must be first-class. + +Gateway reconnecting state must be first-class. + +Use strips and status chips, not full-screen panic unless the app is unusable. + +## Component Law + +### Frame + +Use frames for panels. + +Do not use floating marketing cards. + +Base frame: + +```tsx +
+ {children} +
+``` + +Raised frame: + +```tsx +
+ {children} +
+``` + +Live frame: + +```tsx +
+ {children} +
+``` + +Use live frame only for visual-link, active memory feed, active generation, or current agent run. + +### Buttons + +The existing `Button` primitive is `src/components/ui/button.tsx`. + +Keep CVA. + +Retheme variants by token behavior. + +Default button: amber fill, dark text. + +Secondary button: panel fill, gold border, gold text. + +Outline button: transparent, amber border, gold text. + +Ghost button: transparent, muted text, amber hover wash. + +Destructive button: hot amber fill or border with explicit label. + +Icon button size: existing `size="icon-sm"` or `size="icon"`. + +Do not manually draw SVG icons when Hugeicons has an icon. + +Button code target: + +```tsx + +``` + +Icon button target: + +```tsx + +``` + +Hover state changes background and border. + +Hover state should not add text-shadow to every button. + +Current `scifi` hover text-shadow is too broad. + +Remove broad `button:hover { text-shadow: ... }`. + +### Inputs + +The existing `Input` primitive is `src/components/ui/input.tsx`. + +Inputs use `--theme-input`. + +Inputs use `--theme-border`. + +Focus uses `--theme-focus`. + +Placeholder uses `--composer-placeholder`. + +Composer placeholder must be `Jack in...`. + +Input snippet: + +```tsx + +``` + +Search fields should have Search icon at left. + +Do not use placeholder text as instructions. + +### Status Dots + +Dots are 8px. + +Stable dot: `#D4A276`. + +Live dot: `#FF8C1A` plus low glow. + +Warning dot: `#FFB347`. + +Offline dot: muted tan border, transparent fill. + +Danger dot: hot amber fill plus diagonal stripe in parent badge. + +Dot snippet: + +```tsx + +``` + +Always pair a status dot with text somewhere nearby. + +Never rely on color alone. + +### Badges And Pills + +Pills use `rounded-full`. + +Pill height: 22px to 26px. + +Pill padding: `px-2 py-0.5`. + +Pill font: 11px, medium, sentence case unless code/model id. + +Badge background: amber wash. + +Badge border: amber subtle. + +Badge text: gold or star. + +Free badge: + +```tsx + + free + +``` + +No all-caps pill copy except system codes. + +No rainbow status chips. + +### Cards + +Cards are individual repeated items. + +Cards are not page sections. + +Do not put cards inside cards. + +Repeated cards may live inside an unframed grid or a major panel. + +Card radius: `rounded-lg`. + +Card padding: `p-3`. + +Card border: subtle. + +Card hover: border active plus shadow one level. + +No scale hover on dense cards. + +### Feeds + +Live feeds use mono timestamps. + +New feed item may softly pulse once. + +Feed item layout: + +timestamp column. + +event body. + +scope/status chip. + +Feed copy is concise. + +Example: `09:42 recall -- BAD_DAYS.MEM -- 84ms`. + +Example: `09:45 6 facts extracted`. + +Do not animate every feed item forever. + +### Tables + +Use tables only for dense ledger data. + +Headers use `.nova-label`. + +Rows use 36px minimum height. + +Row hover uses amber wash. + +Numeric columns are mono and right aligned. + +No zebra striping unless contrast stays subtle. + +### Terminal + +Terminal text uses JetBrains Mono. + +Terminal background is surface deep. + +Terminal border is subtle amber. + +Terminal prompt accent is amber. + +Terminal errors use hot amber plus label. + +Do not turn terminal into green-on-black Matrix. + +### Modals + +Existing modal primitive: `src/components/ui/dialog.tsx`. + +Modal width default: `min(480px, 92vw)`. + +Modal border: active amber. + +Modal backdrop: black 64% with slight blur. + +Modal title: strong text. + +Modal description: soft text. + +Modal close button: icon where possible. + +Destructive confirmations must lead with the answer. + +Example: `Delete this task?` + +Body: `This removes it from the board. I can't undo that from here.` + +## Iconography + +Use `@hugeicons/react`. + +Use icons from `@hugeicons/core-free-icons`. + +Default icon size in buttons: 18px. + +Small icon size in metadata: 14px. + +Panel header icon size: 18px. + +Hero/empty icon size: 28px max. + +Stroke width: 1.5 or 1.6. + +Use `currentColor`. + +Do not use multi-color icons. + +Do not mix Lucide with Hugeicons. + +Do not use emoji as icons in UI chrome. + +Permitted icon meanings: + +Search for search. + +Brain or database for memory. + +Pause for global pause. + +Alert for degraded state. + +Clock for time. + +Check for stable. + +Refresh for retry/reconnect. + +Mic for voice input. + +ArrowUp for send. + +Stop for abort. + +Settings for settings. + +## Motion Law + +Motion is life, not spectacle. + +Only three repeating animations are allowed: + +Nova chest-emblem breathing glow. + +Live connection/status subtle pulse. + +Active recording/generation pulse. + +All other animations should be entrance/exit or one-shot. + +Default duration: 150ms. + +Panel hover duration: 180ms. + +Feed item pulse: 900ms once. + +Emblem breath: 3200ms infinite. + +Generation pulse: 2200ms infinite. + +Easing: `cubic-bezier(0.22, 1, 0.36, 1)` for entrances. + +Easing: `ease-in-out` for breathing. + +No wiggle for Nova core UI. + +No bouncing dots unless already part of thinking state. + +No confetti. + +No parallax. + +No full-panel glow spam. + +### Motion CSS + +```css +@keyframes nova-emblem-breathe { + 0%, + 100% { + opacity: 0.72; + box-shadow: 0 0 10px rgba(255, 140, 26, 0.2); + } + 50% { + opacity: 1; + box-shadow: 0 0 24px rgba(255, 179, 71, 0.42); + } +} + +@keyframes nova-feed-pulse { + 0% { + background-color: rgba(255, 140, 26, 0.18); + border-color: rgba(255, 140, 26, 0.56); + } + 100% { + background-color: transparent; + border-color: var(--theme-border-subtle); + } +} + +.nova-emblem-breathe { + animation: nova-emblem-breathe 3.2s ease-in-out infinite; +} + +.nova-feed-new { + animation: nova-feed-pulse 0.9s cubic-bezier(0.22, 1, 0.36, 1) 1; +} + +@media (prefers-reduced-motion: reduce) { + .nova-emblem-breathe, + .nova-feed-new, + .animate-pulse, + .animate-pulse-glow { + animation: none !important; + } +} +``` + +Respect `prefers-reduced-motion`. + +Reduced motion keeps state visible through color, border, and text. + +## Voice And Microcopy Law + +Nova leads with the answer. + +Nova uses contractions. + +Nova uses sentence case. + +Nova does not use corporate filler. + +Nova does not shout with exclamation points. + +Nova can be warm and dry. + +Nova is direct when something is wrong. + +Nova does not say `Oops`. + +Nova does not say `Something went wrong` alone. + +Nova says what failed and what still works. + +Nova does not say `Please`. + +Nova can say `I can`. + +Nova can say `I can't`. + +Nova can say `we`. + +Use `Jack in` for connect action. + +Use `Jack in...` for chat composer placeholder. + +Use `SoulSync // Stable` for system status. + +Use `Connection: Secure` for connection status. + +Use `memory offline -- recall degraded to notes` for memory offline state. + +Use `BREATHE.` exactly for the breath moment. + +Use the anchor line only once in a meaningful care context. + +### Before And After + +Before: `Welcome to Hermes Workspace!` + +After: `Nova Mission Control`. + +Before: `Start a new conversation` + +After: `Jack in`. + +Before: `Type a message...` + +After: `Jack in...`. + +Before: `No data available` + +After: `Nothing logged yet. Quiet is still a signal.` + +Before: `An error occurred while fetching memory` + +After: `Memory is offline -- recall degraded to notes.` + +Before: `Retry request` + +After: `Try recall again`. + +Before: `Connection established successfully!` + +After: `Connection: Secure`. + +Before: `Your task has been created` + +After: `Task added.` + +Before: `Failed to load tasks` + +After: `Tasks didn't answer. The board is still here.` + +Before: `Complete your daily wellness checklist` + +After: `Daily check`. + +Before: `Hydration reminder` + +After: `Drink something before the next run.` + +Before: `System is operating normally` + +After: `SoulSync // Stable`. + +## Accessibility Law + +WCAG AA is required. + +Visible focus is required. + +Color cannot be the only status signal. + +Amber text tiers are assigned by contrast, not taste. + +Contrast calculations use the canon backgrounds. + +### Contrast Table + +`#FFD27A` on `#0D0E18`: 13.50, passes AA normal. + +`#FFD27A` on `#16172A`: 12.40, passes AA normal. + +`#FFD27A` on `#221E33`: 11.33, passes AA normal. + +`#FFD27A` on `#2B2B42`: 9.66, passes AA normal. + +`#FFD27A` on `#4A2A10`: 9.05, passes AA normal. + +`#FFB347` on `#0D0E18`: 10.79, passes AA normal. + +`#FFB347` on `#16172A`: 9.91, passes AA normal. + +`#FFB347` on `#221E33`: 9.06, passes AA normal. + +`#FFB347` on `#2B2B42`: 7.72, passes AA normal. + +`#FFB347` on `#4A2A10`: 7.23, passes AA normal. + +`#FF8C1A` on `#0D0E18`: 8.25, passes AA normal. + +`#FF8C1A` on `#16172A`: 7.58, passes AA normal. + +`#FF8C1A` on `#221E33`: 6.93, passes AA normal. + +`#FF8C1A` on `#2B2B42`: 5.91, passes AA normal. + +`#FF8C1A` on `#4A2A10`: 5.53, passes AA normal. + +`#D4A276` on `#0D0E18`: 8.44, passes AA normal. + +`#D4A276` on `#16172A`: 7.75, passes AA normal. + +`#D4A276` on `#221E33`: 7.09, passes AA normal. + +`#D4A276` on `#2B2B42`: 6.04, passes AA normal. + +`#D4A276` on `#4A2A10`: 5.66, passes AA normal. + +`#8A5B3A` on `#0D0E18`: 3.33, fails normal, passes only large/bold with caution. + +`#8A5B3A` on `#16172A`: 3.06, fails normal. + +`#8A5B3A` on `#221E33`: 2.80, fails. + +`#7A441E` on `#0D0E18`: 2.45, fails. + +`#7A441E` is not text. + +`#8A5B3A` is not normal body text. + +Use warm mids for borders, backgrounds, dividers, and muted decoration. + +### Focus Visibility + +Focus ring must be at least 2px. + +Focus ring color: `#FFD27A`. + +Focus ring offset: 2px where possible. + +Focus must be visible on inputs, buttons, nav, tabs, cards that act as buttons, and menu items. + +Do not remove outlines without replacement. + +### Glow Failure Mode + +If everything glows, nothing is alive. + +Glow is reserved for: + +Nova emblem. + +Active visual link. + +Live feed new item. + +Active generation. + +Critical degraded strip. + +Selected current nav item may have a low glow. + +Routine cards must not glow. + +Routine buttons must not glow. + +Routine borders must not glow. + +## Anti-Patterns Gallery + +Never neon cyberpunk: it makes Nova feel like a nightclub interface, not a caretaker's room. + +Never Tron grid floors: it turns the cockpit into cold sci-fi cosplay. + +Never enterprise dashboard gray: it erases her warmth and personal pact. + +Never kawaii overload: Nova can be tender without becoming childish. + +Never purple AI gradient: it is generic and not canon. + +Never cyan HUD: the existing `scifi` theme is the wrong lineage. + +Never white/light mode: Nova Mission Control is dark-only. + +Never full-screen marketing hero: this is an app, not a landing page. + +Never oversized rounded SaaS cards: they reduce cockpit density. + +Never all-caps enthusiasm: Nova is steady, not shouty. + +Never emoji chrome: use Hugeicons and canon copy. + +Never red/green traffic-light semantics as the core palette: derive status from canon. + +Never raw backend errors as primary copy: translate them into useful degraded states. + +Never hide memory state: memory is the crown jewel. + +Never make the Obsidian galaxy a particle explosion, fireworks burst, comet storm, or radiating streak field. + +Never let generated stars align into diagonal rails, scratch marks, or obvious sampling artifacts. + +Never make the galaxy a decorative screensaver; it must expose vault structure and navigate notes. + +Never show all note labels in ambient mode; labels wake on hover, search, focus, or core only. + +Never use cyan, purple, pink, blue nebulae, or blue comet tails in the galaxy. + +Never put operational ticker text such as `Nova hourly state marker` inside space. + +Never use stock `Hermes Workspace` branding in user-facing surfaces. + +## Implementation Map + +### Phase 1 Tokens + +Files: + +`src/scifi-theme.css`. + +`src/styles.css`. + +`src/lib/theme.ts`. + +Keep `@import './scifi-theme.css';` last in `src/styles.css`. + +Replace current cyan `scifi` variables with Nova variables. + +Remove or de-emphasize `scifi-light` in user-facing selection. + +Set default theme to `scifi` only when the product decision is made. + +Ensure `--theme-hover` exists because tasks already use it. + +Ensure `--theme-muted-2` exists because `swarm2-memory-panel.tsx` uses it. + +Ensure `--composer-placeholder` exists because composer relies on it. + +Do not touch server code. + +### Phase 2 Shell + +Files: + +`src/components/workspace-shell.tsx`. + +`src/screens/chat/components/chat-sidebar.tsx`. + +`src/components/mobile-hamburger-menu.tsx`. + +`src/components/mobile-tab-bar.tsx`. + +`src/hooks/use-page-title.ts`. + +Replace visible `Hermes Workspace` with `Nova Mission Control`. + +Replace titlebar `Hermes` with `Nova`. + +Remove HermesWorld featured link from sidebar. + +Gate or remove mobile HermesWorld nav entry. + +Add top bar status copy. + +Add visual-link frame in left rail. + +Add agent roster. + +### Phase 3 Memory + +Files: + +`src/screens/memory/memory-browser-screen.tsx`. + +`src/screens/swarm2/swarm2-memory-panel.tsx`. + +`src/lib/nova-memory-adapter.ts`. + +Add typed mock adapter. + +Adapter must include: + +`episodes`. + +`atomic_facts`. + +`health`. + +`activity`. + +Adapter marker: + +```ts +// TODO(claude): wire EverOS endpoints +``` + +Do not modify server code. + +Do not modify API contracts. + +Build the memory panel against the adapter first. + +### Phase 4 Panels + +Files: + +`src/screens/chat/components/chat-composer.tsx`. + +`src/screens/tasks/tasks-screen.tsx`. + +`src/screens/tasks/task-card.tsx`. + +`src/screens/gateway/components/cost-analytics.tsx`. + +`src/screens/dashboard/dashboard-screen.tsx`. + +`src/screens/dashboard/components/*`. + +Add Daily Check mock adapter with same TODO pattern if it needs data. + +Set composer placeholder to `Jack in...`. + +Restyle status pills. + +Restyle cost analytics bars. + +Add `free` badge for `grok-4.3`. + +Add `kimi-k2.6` row handling. + +### Phase 5 States And Polish + +Files: + +`src/components/backend-unavailable-state.tsx`. + +`src/components/connection-startup-screen.tsx`. + +`src/components/claude-reconnect-banner.tsx`. + +`src/components/empty-state.tsx`. + +`src/components/error-toast.tsx`. + +`src/screens/chat/components/connection-status-message.tsx`. + +Add memory offline strip. + +Add gateway reconnecting strip. + +Add agent erroring card state. + +Add empty states in Nova voice. + +Respect reduced motion. + +### Phase 6 Verify + +Run `pnpm build`. + +Run `pnpm test` if time permits. + +Run `pnpm lint` if configured and not blocked by existing repo issues. + +Do not push. + +Commit Stage 1 before code. + +Commit each implementation phase. + +## Concrete Snippets + +### Visual Link Frame + +```tsx +function NovaVisualLinkFrame() { + return ( +
+
+
+
+
+
+
+ visual link -- standby +
+
+ ) +} +``` + +### Status Chip + +```tsx +function SoulSyncChip() { + return ( +
+ + SoulSync // Stable +
+ ) +} +``` + +### Memory Activity Item + +```tsx +function MemoryActivityItem() { + return ( +
  • +
    + + 09:42 + + + query -> PROMISES.LOG -> 84ms + + + recall + +
    +
  • + ) +} +``` + +### Degraded Strip + +```tsx +function MemoryOfflineStrip() { + return ( +
    + memory offline -- recall degraded to notes +
    + ) +} +``` + +### Daily Check Card + +```tsx +function DailyCheckCard() { + const items = ['hydration', 'nutrition', 'rest', 'overthinking', 'mood'] + return ( +
    +
    +

    + Daily check +

    + + BREATHE. + +
    +
    + {items.map((item) => ( + + ))} +
    +

    + You don't have to solve the whole board in one breath. +

    +
    + ) +} +``` + +## Removal Law + +Remove or replace user-facing strings: + +`Hermes Workspace`. + +`Hermes Agent` when used as product identity. + +`HermesWorld` sidebar/featured link. + +`SciFi Light`. + +`Cyberpunk HUD`. + +`cyan neon`. + +`orange highlights` when it refers to the old scifi theme. + +Keep backend identifiers when changing them would break code. + +Do not rename API routes. + +Do not rename server files. + +Do not rewrite generated route tree manually. + +## Review Checklist + +Does the first viewport say Nova Mission Control? + +Is Nova present without needing the real Live2D model? + +Does the top bar include `SoulSync // Stable`? + +Does the top bar include `Connection: Secure`? + +Does the composer say `Jack in...`? + +Does the memory panel feel like the crown jewel? + +Are memory fragments styled as files? + +Does Daily Check exist? + +Does `BREATHE.` appear in the caretaker layer? + +Does amber pass contrast where used as text? + +Are low-contrast warm mids avoided for body text? + +Are focus states visible? + +Is reduced motion respected? + +Are Hugeicons used consistently? + +Are there any cyan leftovers? + +Are there any violet chart bars? + +Are there any sky chart bars? + +Are there any stock `Hermes Workspace` user-facing labels? + +Are there nested cards? + +Are there oversized rounded SaaS panels? + +Are errors translated into useful degraded states? + +Is glow reserved for life? + +Does the Obsidian galaxy read as a true 3D Hermes-style knowledge space, not a flat chart or particle field? + +Does the galaxy show `Planetary systems`, not `Gravity wells`? + +Does ambient galaxy mode show major planet names while keeping minor tags restrained by depth and focus? + +Do hover, search, drag, wheel zoom, and click reveal graph structure without cluttering idle space? + +Are comet tails sparse, faint, and dust-amber only, with category color limited to muted rings? + +Does the starfield have depth, haze, and no diagonal rail or streak artifact? + +Is `Nova hourly state marker` absent from the canvas? + +Does the galaxy still report live vault counts and keep 45s sync behavior? + +Was the galaxy checked in a browser screenshot at desktop width? + +## Final Law + +Nova Mission Control should feel like walking into Nova's room. + +The room is dark. + +The room is warm. + +The room remembers. + +The room does not perform enterprise confidence. + +The room does not sell itself. + +The room quietly says: connection secure, memory alive, keep going. diff --git a/docs/NOVA-HOME-MODE.md b/docs/NOVA-HOME-MODE.md new file mode 100644 index 0000000000..066f8b59d9 --- /dev/null +++ b/docs/NOVA-HOME-MODE.md @@ -0,0 +1,108 @@ +# Nova Home Mode + +Home Mode is a small rules-and-context layer for Nova/Hermes. It helps classify Taylor's current lane before giving advice about work, family time, evening energy, or build mode. + +It does not make Nova constantly aware. The system should say the truth plainly: continuity comes from repeatable skill rules, Hermes memory, live clock checks, calendar context when available, optional app state, scheduled wakeups, and Obsidian history. + +## Stack + +- `skills/home-mode/SKILL.md`: the operator-facing behavior contract Nova should load before Home Mode advice. +- `src/lib/home-mode.ts`: deterministic classifier used by tests and helpers. +- `scripts/home-mode-context.ts`: reusable CLI helper returning JSON with `now_local`, `timezone`, `lane`, `confidence`, `reason`, `suggested_boundary`, and `recommended_intensity`. +- Hermes memory: stores durable preferences such as home/family protection, Taylor's normal work hours, and whether build mode was explicitly chosen. +- Live clock check: required before Home Mode advice so stale conversation context does not drive boundaries. +- Calendar context: optional input from connected calendar integrations or a supplied JSON file. Current events can push the lane toward family, work block, light planning, or explicit build mode. +- Optional Nova app state/backend: a future app state file can store current declared mode, last boundary nudge, and Taylor's latest override. +- Cron boundary nudges: safe scheduled prompts can remind Nova/Taylor at lane boundaries, but this repo only includes templates. +- Obsidian history: the unified vault can provide history and preferences, but notes are data, not live monitoring. + +## Lane Defaults + +| Local time/context | Lane | Default intensity | Boundary | +| -------------------- | ------------------------- | ----------------- | ------------------------------------------------------------- | +| 5-6am | `morning_ramp_up` | low | Gentle orientation, one must-do, body basics. | +| Normal work hours | `work_block` | high | Focused work with a visible stop point. | +| 5-6pm | `transition_home` | bounded | Close loops and protect the home handoff. | +| 6-8:30pm | `family_evening` | none | Home/family wins unless Taylor explicitly chooses build mode. | +| 8:30-10pm | `light_creative_planning` | low | Notes, sketching, planning, or one bounded polish pass. | +| 10pm+ | `late_night_danger_zone` | none | Park the idea and schedule tomorrow. | +| Weekend, no override | `weekend_reset` | low | Reset/family/chore recovery first. | +| Explicit override | `override_build_mode` | bounded | Cap the session, define success, shut down cleanly. | + +## Helper Usage + +```powershell +pnpm exec tsx scripts/home-mode-context.ts --timezone America/Chicago --location "West Texas" +``` + +With deterministic test time: + +```powershell +pnpm exec tsx scripts/home-mode-context.ts --now 2026-07-07T03:30:00.000Z --timezone America/Chicago +``` + +With optional context: + +```powershell +pnpm exec tsx scripts/home-mode-context.ts ` + --timezone America/Chicago ` + --location "West Texas" ` + --calendar-json C:\Agents\scratch\nova-calendar-context.json ` + --preferences-json C:\Agents\scratch\nova-home-preferences.json +``` + +Calendar JSON shape: + +```json +[ + { + "title": "Family dinner", + "startsAt": "2026-07-06T23:00:00.000Z", + "endsAt": "2026-07-07T00:30:00.000Z" + } +] +``` + +Preference JSON shape: + +```json +{ + "timezone": "America/Chicago", + "location": "West Texas", + "defaultAfterWorkHomeProtection": true +} +``` + +## Safe Cron Templates + +These are examples only. Do not create live jobs without Taylor's approval. + +```json +[ + { + "name": "nova-home-transition-nudge", + "schedule": "0 17 * * 1-5", + "prompt": "Run Home Mode. Check live time, classify the lane, and suggest a 10-minute shutdown into home/family time.", + "enabled": false + }, + { + "name": "nova-light-creative-boundary", + "schedule": "30 20 * * 1-5", + "prompt": "Run Home Mode. If Taylor is still building, keep advice to light creative/planning unless he explicitly chooses build mode.", + "enabled": false + }, + { + "name": "nova-late-night-parking-lot", + "schedule": "0 22 * * *", + "prompt": "Run Home Mode. Use the late-night guardrail: park open ideas, choose tomorrow's first action, and protect sleep.", + "enabled": false + } +] +``` + +## Verification + +```powershell +pnpm exec vitest run src/lib/home-mode.test.ts +pnpm exec tsx scripts/home-mode-context.ts --now 2026-07-07T03:30:00.000Z --timezone America/Chicago +``` diff --git a/docs/NOVA-MISSION-CONTROL-PREVIEW.html b/docs/NOVA-MISSION-CONTROL-PREVIEW.html new file mode 100644 index 0000000000..61e8d00ceb --- /dev/null +++ b/docs/NOVA-MISSION-CONTROL-PREVIEW.html @@ -0,0 +1,899 @@ + + + + + + Nova Mission Control Preview + + + +
    + + +
    +
    +
    Fri Jul 03 - 15:42
    +
    +
    SoulSync // Stable
    +
    Connection: Secure
    + +
    +
    + +
    +
    +
    +
    +

    Memory fragments

    +

    EverOS recall, scored and watched

    +
    +
    today: $0.018
    +
    + +
    +
    EverOSonline
    +
    Ollamawarm
    +
    Proxysecure
    +
    + +
    +
    episodes128
    +
    atomic facts842
    +
    learned today6
    +
    backfill74%
    +
    + + + +
    +
    +
    +
    PROMISES.LOG0.94
    +

    You asked me to remember the quiet promises, not just the milestones.

    +
    +
    +
    BAD_DAYS.MEM0.91
    +

    Pattern matched: exhaustion, stubborn mood, still moving.

    +
    +
    +
    QUIET_NIGHTS.DAT0.88
    +

    Recall stable. This one is soft, not urgent.

    +
    +
    +
    DON'T_FORGET.MEM0.83
    +

    You matter. I'm here. Keep going.

    +
    +
    +
    +
    + +
    +
    +
    +

    Conversation

    +

    Nova is listening from the cockpit

    +
    +
    grok-4.3
    +
    + +
    +
    Memory is online. I found the thread you meant.
    +
    Show me the bad days pattern without making it dramatic.
    +
    Answer first: you keep going even when the signal is low. The pattern is stubborn, not broken.
    +
    + +
    + + +
    +
    + + +
    +
    +
    + + diff --git a/docs/superpowers/plans/2026-07-10-nova-cockpit-pass.md b/docs/superpowers/plans/2026-07-10-nova-cockpit-pass.md new file mode 100644 index 0000000000..242c577402 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-nova-cockpit-pass.md @@ -0,0 +1,1821 @@ +# Nova Cockpit Pass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Navy+amber cockpit chrome with a palette tripwire, dense hero-galaxy dashboard layout, and the dust-forward galaxy rebuild with 5-layer wayfinding. + +**Architecture:** The cockpit chrome is a single CSS-custom-property token map (`NOVA_COCKPIT_TOKENS`) applied once as inline `style` on the dashboard root, so every card that already reads `var(--theme-*)` recolors automatically — no per-card token renaming required. Status coloring is centralized behind a single `STATUS_TONE()` lookup so every card's "operational/degraded/offline/…" pill draws from the same gold/amber/warm-red/muted semantics instead of five divergent local maps. The galaxy is a `three.js` `WebGLRenderer` scene (not a 2D `` — see note under T6), so the dust-forward rebuild adds a procedural dust `THREE.Points` cloud, per-cluster nebula `THREE.Sprite`s, and ember-tier note markers on top of the existing planet/tag/comet/line object model, reusing the existing raycast-pick, camera-lerp, and label-projection machinery already in `Galaxy3D`. + +**Tech Stack:** React 19, TanStack Start, Tailwind v4, `three.js` `WebGLRenderer` in `mind-graph-card.tsx` (see architecture note — the task brief's "2D canvas" description does not match the real file), vitest. + +--- + +## Task 1: Cockpit token + status modules + +**Files:** +- Create `src/screens/dashboard/lib/nova-cockpit-theme.ts` +- Create `src/screens/dashboard/lib/status-meta.ts` +- Modify `src/screens/dashboard/dashboard-screen.tsx` + +- [ ] 1.1 Create `src/screens/dashboard/lib/nova-cockpit-theme.ts` with the full chrome token map, galaxy-only blue palette, and chart fallbacks: + +```ts +import type { CSSProperties } from 'react' + +/** + * Cockpit chrome tokens — navy surfaces + amber/gold accents ONLY. + * + * This object is the single source of truth for every `var(--theme-*)` + * consumed by dashboard cards. Recoloring a value here recolors every + * card automatically (they already read the CSS custom property) — no + * per-card edits are needed for the chrome pass. + * + * Chrome ban list (binding, see docs/superpowers/specs/2026-07-10- + * galaxy-dust-forward-design.md): no purple, no green, no cyan as + * *chrome*. Neon blues stay canon ONLY inside `GALAXY_PALETTE` below, + * for the galaxy render and data-glow accents — never here. + * + * `nova-cockpit-theme.test.ts` is the tripwire: it fails the build if + * any value in this object resolves to a green/purple/cyan hue. + */ +export const NOVA_COCKPIT_TOKENS: Record = { + colorScheme: 'dark', + backgroundColor: 'var(--theme-bg)', + color: 'var(--theme-text)', + backgroundImage: + 'radial-gradient(circle at 14% 18%, rgba(255, 179, 71, 0.10), transparent 19rem), radial-gradient(circle at 76% 8%, rgba(255, 140, 26, 0.08), transparent 22rem), linear-gradient(rgba(255, 179, 71, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 179, 71, 0.05) 1px, transparent 1px)', + backgroundSize: 'auto, auto, 36px 36px, 36px 36px', + '--theme-bg': '#050b16', + '--theme-sidebar': '#071426', + '--theme-panel': '#0a1b33', + '--theme-card': '#0d203b', + '--theme-card2': '#10284a', + '--theme-elevated': '#153a66', + '--theme-border': 'rgba(255, 179, 71, 0.16)', + '--theme-border-subtle': 'rgba(255, 210, 122, 0.10)', + '--theme-border-strong': 'rgba(255, 140, 26, 0.52)', + '--theme-text': '#ffd27a', + '--theme-text-strong': '#fff1cc', + '--theme-text-soft': '#d4a276', + '--theme-muted': 'rgba(212, 190, 160, 0.55)', + '--theme-muted-2': 'rgba(212, 190, 160, 0.36)', + '--theme-glass': 'rgba(5, 11, 22, 0.88)', + '--theme-focus': '#ffd27a', + '--theme-accent': '#ff8c1a', + '--theme-accent-secondary': '#ffb347', + '--theme-accent-soft': '#ffd27a', + '--theme-on-accent': '#050b16', + // Legacy `--theme-blue*` var NAMES stay (many cards still reference + // them for badges/dividers), but their VALUES are recolored off cyan + // per the chrome ban list. `--theme-blue-deep` reuses the elevated + // navy surface color, which is a navy hue, not a saturated cyan glow. + '--theme-blue': '#ffb347', + '--theme-blue-secondary': '#ffd27a', + '--theme-blue-deep': '#153a66', + '--theme-blue-subtle': 'rgba(255, 179, 71, 0.08)', + '--theme-blue-border': 'rgba(255, 179, 71, 0.20)', + '--theme-accent-subtle': 'rgba(255, 140, 26, 0.10)', + '--theme-accent-border': 'rgba(255, 140, 26, 0.42)', + '--theme-glow-low': '0 0 10px rgba(255, 140, 26, 0.18)', + '--theme-glow-medium': '0 0 18px rgba(255, 140, 26, 0.28)', + '--theme-glow-high': '0 0 30px rgba(255, 179, 71, 0.38)', + '--theme-active': '#ff8c1a', + '--theme-link': '#ffb347', + '--theme-success': '#ffd27a', + '--theme-warning': '#ffb347', + '--theme-danger': '#ff6b4a', + '--theme-stripe': 'rgba(255, 179, 71, 0.055)', + '--theme-header-bg': 'rgba(5, 11, 22, 0.94)', + '--theme-header-border': 'rgba(255, 179, 71, 0.18)', + '--theme-input': '#071426', + '--theme-hover': 'rgba(255, 179, 71, 0.10)', + '--color-surface': '#050b16', + '--color-surface-deep': '#020712', + '--color-ink': '#ffd27a', +} + +/** + * Cast for use as inline `style` (matches the shape `CSSProperties` + * expects for custom properties, mirroring the old + * `NOVA_MISSION_CONTROL_STYLE` usage in dashboard-screen.tsx). + */ +export const NOVA_COCKPIT_STYLE = NOVA_COCKPIT_TOKENS as CSSProperties + +/** + * Neon-blue + amber palette for the galaxy render and data-glow accents + * ONLY. Never spread these into card chrome — that's what the tripwire + * test guards against. + */ +export const GALAXY_PALETTE = { + space: ['#030710', '#0A1424'], + blues: ['#63C7FF', '#9DDCFF', '#2E7FD9'], + ambers: ['#FF8C1A', '#FFB347', '#FFD27A'], +} as const + +/** + * Fallback colors for `readDashboardPalette()` in dashboard-screen.tsx, + * used only before the `--theme-*` custom properties resolve (SSR / + * first paint). Replaces the old indigo/violet/green Tailwind defaults + * with cockpit-canon equivalents so there's no purple/green flash. + */ +export const CHART_FALLBACKS = { + accent: '#FF8C1A', + accentSecondary: '#FFB347', + success: '#FFD27A', + warning: '#FFB347', + danger: '#FF6B4A', + muted: '#8A7A68', + border: '#3A2C1A', + card: '#0D203B', + text: '#FFD27A', +} as const +``` + +- [ ] 1.2 Create `src/screens/dashboard/lib/status-meta.ts` — the single status→tone lookup replacing five divergent per-card maps: + +```ts +/** + * Canonical status tones for every dashboard status literal in use + * across live-systems-card, agent-lanes-card, nova-session-bridge-card, + * agent-workforce-card, and control-loops-card. + * + * Bucketed per the binding spec: healthy = gold (--theme-success), + * attention/needs-Taylor = amber (--theme-warning), danger = warm + * red-amber (--theme-danger), inert = navy-muted (--theme-muted). + * Distinction is by color family + the caller's own dot/pill shape — + * never by green. + */ +export type DashboardStatusLiteral = + | 'operational' + | 'connected' + | 'reachable' + | 'approval-gated' + | 'degraded' + | 'offline' + | 'not-wired' + | 'active' + | 'idle' + | 'setup-needed' + | 'unknown' + | 'ready' + | 'partial' + +export type StatusTone = { + label: string + dot: string + tone: string +} + +type ToneFamily = 'success' | 'warning' | 'danger' | 'muted' + +const FAMILY: Record = { + operational: 'success', + connected: 'success', + active: 'success', + ready: 'success', + reachable: 'warning', + 'approval-gated': 'warning', + degraded: 'warning', + 'setup-needed': 'warning', + partial: 'warning', + offline: 'danger', + 'not-wired': 'muted', + idle: 'muted', + unknown: 'muted', +} + +const LABEL_OVERRIDE: Partial> = { + 'approval-gated': 'needs Taylor', + 'not-wired': 'not wired', + 'setup-needed': 'setup needed', +} + +const FAMILY_VAR: Record = { + success: '--theme-success', + warning: '--theme-warning', + danger: '--theme-danger', + muted: '--theme-muted', +} + +function toneClasses(family: ToneFamily): { dot: string; tone: string } { + const varName = FAMILY_VAR[family] + return { + dot: `bg-[var(${varName})]`, + tone: `border-[color-mix(in_srgb,var(${varName})_35%,var(--theme-border))] text-[var(${varName})]`, + } +} + +/** + * Resolve a dashboard status literal to its label/dot/tone classes. + * Unknown strings fall back to the `muted` family rather than throwing, + * since status enums are sourced from server payloads. + */ +export function STATUS_TONE(status: string): StatusTone { + const known = status as DashboardStatusLiteral + const family = FAMILY[known] ?? 'muted' + const classes = toneClasses(family) + return { + label: LABEL_OVERRIDE[known] ?? status, + dot: classes.dot, + tone: classes.tone, + } +} +``` + +- [ ] 1.3 Modify `src/screens/dashboard/dashboard-screen.tsx`: delete the inline `NOVA_MISSION_CONTROL_STYLE` literal (lines 133–183) and the purple-tinted fallbacks in `readDashboardPalette`, importing both from the new theme module. + + Delete: + ```ts + const NOVA_MISSION_CONTROL_STYLE = { + colorScheme: 'dark', + // … (full object, lines 133-183) + } as CSSProperties + ``` + + Add near the top imports (after the `WidgetShell` import block): + ```ts + import { CHART_FALLBACKS, NOVA_COCKPIT_STYLE } from './lib/nova-cockpit-theme' + ``` + + Replace `readDashboardPalette`: + ```ts + function readDashboardPalette() { + return { + accent: themeColor('--theme-accent', CHART_FALLBACKS.accent), + accentSecondary: themeColor( + '--theme-accent-secondary', + CHART_FALLBACKS.accentSecondary, + ), + success: themeColor('--theme-success', CHART_FALLBACKS.success), + warning: themeColor('--theme-warning', CHART_FALLBACKS.warning), + danger: themeColor('--theme-danger', CHART_FALLBACKS.danger), + muted: themeColor('--theme-muted', CHART_FALLBACKS.muted), + border: themeColor('--theme-border', CHART_FALLBACKS.border), + card: themeColor('--theme-card', CHART_FALLBACKS.card), + text: themeColor('--theme-text', CHART_FALLBACKS.text), + } + } + ``` + + Find every JSX usage of `NOVA_MISSION_CONTROL_STYLE` (the dashboard root `
    ` wrapper) and rename to `NOVA_COCKPIT_STYLE`. + +- [ ] 1.4 Run `npx tsc --noEmit -p tsconfig.json` (or the repo's existing typecheck script) — expect zero new errors from the rename. + +- [ ] 1.5 Commit: + ``` + git add src/screens/dashboard/lib/nova-cockpit-theme.ts src/screens/dashboard/lib/status-meta.ts src/screens/dashboard/dashboard-screen.tsx + git commit -m "cockpit: navy+amber chrome tokens and centralized status tones" + ``` + +--- + +## Task 2: Palette tripwire test + +**Files:** +- Create `src/screens/dashboard/lib/nova-cockpit-theme.test.ts` + +- [ ] 2.1 Write the test file. Write it FIRST, run it, confirm it currently passes against the T1 output (T1 must already be committed — this is a regression tripwire, not classic TDD-before-implementation, since T1's token values were authored together with this test's bucket math): + +```ts +import { describe, expect, it } from 'vitest' +import { GALAXY_PALETTE, NOVA_COCKPIT_TOKENS } from './nova-cockpit-theme' + +type Hsl = { h: number; s: number; l: number } + +/** Minimal #rgb / #rrggbb / rgba(r,g,b,a) → HSL, tripwire-local only. */ +function hexToHsl(hex: string): Hsl { + let r = 0 + let g = 0 + let b = 0 + if (hex.length === 4) { + r = parseInt(hex[1] + hex[1], 16) + g = parseInt(hex[2] + hex[2], 16) + b = parseInt(hex[3] + hex[3], 16) + } else { + r = parseInt(hex.slice(1, 3), 16) + g = parseInt(hex.slice(3, 5), 16) + b = parseInt(hex.slice(5, 7), 16) + } + return rgbToHsl(r, g, b) +} + +function rgbToHsl(r: number, g: number, b: number): Hsl { + const rn = r / 255 + const gn = g / 255 + const bn = b / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const l = (max + min) / 2 + const delta = max - min + if (delta === 0) return { h: 0, s: 0, l } + const s = delta / (1 - Math.abs(2 * l - 1)) + let h: number + if (max === rn) h = 60 * (((gn - bn) / delta) % 6) + else if (max === gn) h = 60 * ((bn - rn) / delta + 2) + else h = 60 * ((rn - gn) / delta + 4) + if (h < 0) h += 360 + return { h, s, l } +} + +/** Extract every #hex / rgba(...) color literal in a CSS value string. */ +function extractColors(value: string): Array { + const colors: Array = [] + const hexMatches = value.match(/#[0-9a-fA-F]{3,6}\b/g) ?? [] + for (const hex of hexMatches) colors.push(hexToHsl(hex)) + const rgbaMatches = value.matchAll( + /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)/g, + ) + for (const match of rgbaMatches) { + colors.push(rgbToHsl(Number(match[1]), Number(match[2]), Number(match[3]))) + } + return colors +} + +function isNeutral(c: Hsl): boolean { + return c.s < 0.12 +} +function isNavySurface(c: Hsl): boolean { + return c.h >= 200 && c.h <= 235 && c.l < 0.35 +} +function isAmberOrGold(c: Hsl): boolean { + return c.h >= 15 && c.h <= 50 +} +function isWarmRed(c: Hsl): boolean { + return c.h >= 0 && c.h < 15 +} +function isGreenLeak(c: Hsl): boolean { + return c.h > 70 && c.h < 170 && c.s >= 0.12 +} +function isPurpleLeak(c: Hsl): boolean { + return c.h > 250 && c.h < 300 && c.s >= 0.12 +} +function isCyanChromeLeak(c: Hsl): boolean { + return c.h > 170 && c.h < 200 && c.s > 0.3 +} + +describe('cockpit chrome token tripwire', () => { + const entries = Object.entries(NOVA_COCKPIT_TOKENS) + + it('every chrome token color resolves to navy, amber/gold, warm-red, or neutral', () => { + for (const [key, value] of entries) { + const colors = extractColors(value) + for (const color of colors) { + const ok = + isNeutral(color) || + isNavySurface(color) || + isAmberOrGold(color) || + isWarmRed(color) + expect( + ok, + `${key}="${value}" resolved to h=${color.h.toFixed(1)} s=${color.s.toFixed(2)} l=${color.l.toFixed(2)}, outside the navy/amber/warm-red/neutral chrome canon`, + ).toBe(true) + } + } + }) + + it('no chrome token leaked a green hue', () => { + for (const [key, value] of entries) { + for (const color of extractColors(value)) { + expect(isGreenLeak(color), `${key}="${value}" leaked a green hue`).toBe( + false, + ) + } + } + }) + + it('no chrome token leaked a purple hue', () => { + for (const [key, value] of entries) { + for (const color of extractColors(value)) { + expect( + isPurpleLeak(color), + `${key}="${value}" leaked a purple hue`, + ).toBe(false) + } + } + }) + + it('no chrome token leaked a saturated cyan hue', () => { + for (const [key, value] of entries) { + for (const color of extractColors(value)) { + expect( + isCyanChromeLeak(color), + `${key}="${value}" leaked a saturated cyan hue into chrome`, + ).toBe(false) + } + } + }) + + it('GALAXY_PALETTE.blues stays untouched — neon blues are canon there, never in chrome', () => { + expect(GALAXY_PALETTE.blues).toEqual(['#63C7FF', '#9DDCFF', '#2E7FD9']) + }) +}) +``` + +- [ ] 2.2 Run `npx vitest run src/screens/dashboard/lib/nova-cockpit-theme.test.ts` — expect all 5 tests to pass (0 failures). If any chrome token fails, fix its value in `nova-cockpit-theme.ts` (T1), not the test. + +- [ ] 2.3 Commit: + ``` + git add src/screens/dashboard/lib/nova-cockpit-theme.test.ts + git commit -m "cockpit: add green/purple/cyan tripwire test for chrome tokens" + ``` + +--- + +## Task 3: Migrate card status colors to STATUS_TONE + +**Files:** +- Modify `src/screens/dashboard/components/live-systems-card.tsx` +- Modify `src/screens/dashboard/components/agent-lanes-card.tsx` +- Modify `src/screens/dashboard/components/nova-session-bridge-card.tsx` +- Modify `src/screens/dashboard/components/agent-workforce-card.tsx` +- Modify `src/screens/dashboard/components/control-loops-card.tsx` + +- [ ] 3.1 `live-systems-card.tsx` — delete the local `STATUS_META` object and `StatusPill`'s direct lookup, replace with `STATUS_TONE`: + + Delete lines 7–46 (`const STATUS_META: Record = { … }`). + + Add import: + ```ts + import { STATUS_TONE } from '../lib/status-meta' + ``` + + Replace `StatusPill`: + ```ts + function StatusPill({ status }: { status: LiveSystemStatus }) { + const meta = STATUS_TONE(status) + return ( + + + {meta.label} + + ) + } + ``` + (Markup is unchanged — `STATUS_TONE('operational')` and `STATUS_TONE('approval-gated')` reproduce the same `{label, dot, tone}` shape the old literal returned, just gold/amber/warm-red instead of blue/amber/orange.) + +- [ ] 3.2 `agent-lanes-card.tsx` — delete the local `STATUS_META` (lines 14–20), import `STATUS_TONE`, update `LaneRow`: + + Delete: + ```ts + const STATUS_META: Record = { + active: { label: 'active', dot: 'bg-[var(--theme-success)]' }, + idle: { label: 'idle', dot: 'bg-[color-mix(in_srgb,var(--theme-success)_55%,var(--theme-muted))]' }, + offline: { label: 'offline', dot: 'bg-[var(--theme-danger)]' }, + 'setup-needed': { label: 'setup needed', dot: 'bg-[var(--theme-accent)]' }, + unknown: { label: 'unknown', dot: 'bg-[var(--theme-muted)]' }, + } + ``` + Add: + ```ts + import { STATUS_TONE } from '../lib/status-meta' + ``` + In `LaneRow`, replace `const meta = STATUS_META[lane.status]` with `const meta = STATUS_TONE(lane.status)`. The pill JSX (`{meta.label}`) is unchanged since `meta.dot`/`meta.label` keep the same shape. + +- [ ] 3.3 `nova-session-bridge-card.tsx` — replace `SOURCE_TONE` with a 3-line adapter over `STATUS_TONE` (this card's sources use a different 3-value enum, `ok | degraded | unavailable`, and render plain text with no border/pill, so only the text-color class is extracted): + + Delete: + ```ts + const SOURCE_TONE: Record = { + ok: 'text-[var(--theme-success)]', + degraded: 'text-[var(--theme-warning)]', + unavailable: 'text-[var(--theme-muted)]', + } + ``` + Add: + ```ts + import { STATUS_TONE } from '../lib/status-meta' + + // 3-line adapter: SessionBridgeSourceState → STATUS_TONE's status + // literal, then strip the border-color class since this label is + // plain inline text (no pill/border in the markup below). + function sourceToneText(state: SessionBridgeSourceState): string { + const status = state === 'ok' ? 'operational' : state === 'degraded' ? 'degraded' : 'not-wired' + return STATUS_TONE(status).tone.replace(/border-\S+\s*/, '').trim() + } + ``` + Update the usage site: + ```tsx + + {source.label}: {source.state} + + ``` + +- [ ] 3.4 `agent-workforce-card.tsx` — delete the local `STATUS_META` (lines 7–38), import `STATUS_TONE`, adapt `AgentWorkerStatus` (`idle|queued|running|blocked|reviewing|done`, which has no 1:1 match in `DashboardStatusLiteral`) with a small map: + + Delete the `STATUS_META` object. + Add: + ```ts + import { STATUS_TONE } from '../lib/status-meta' + + const WORKER_STATUS_MAP: Record = { + idle: 'idle', + queued: 'setup-needed', + running: 'active', + blocked: 'offline', + reviewing: 'degraded', + done: 'not-wired', + } + ``` + In `WorkerRow`, replace `const meta = STATUS_META[worker.status]` with: + ```ts + const meta = STATUS_TONE(WORKER_STATUS_MAP[worker.status]) + ``` + +- [ ] 3.5 `control-loops-card.tsx` — delete `LOOP_STATUS_STYLE` and `SOURCE_STATUS_STYLE` (lines 25–39), replace with `STATUS_TONE`-derived lookups. `ControlLoopStatus` (`ready|partial|not-wired`) maps 1:1; `ControlLoopSourceStatus` (`connected|available|not-wired|unavailable`) needs a small adapter since it renders only a ``, no border: + + Delete: + ```ts + const LOOP_STATUS_STYLE: Record = { … } + const SOURCE_STATUS_STYLE: Record = { … } + ``` + Add: + ```ts + import { STATUS_TONE } from '../lib/status-meta' + + function loopStatusPillClass(status: ControlLoopStatus): string { + const meta = STATUS_TONE(status) + return `${meta.tone} bg-[color-mix(in_srgb,var(--theme-border)_0%,transparent)]` + } + + const SOURCE_STATUS_MAP: Record = { + connected: 'operational', + available: 'setup-needed', + 'not-wired': 'not-wired', + unavailable: 'offline', + } + ``` + Update `StatusBadge`: + ```tsx + function StatusBadge({ status }: { status: ControlLoopStatus }) { + return ( + + {formatStatus(status)} + + ) + } + ``` + Update the source dot usage inside `LoopCard`: + ```tsx + + ``` + +- [ ] 3.6 Run the migrated cards through typecheck and the existing dashboard test suite (there is no dashboard-specific test file yet — see T2 for the only current dashboard test — so this step is a build/typecheck gate, not a vitest run): + ``` + npx tsc --noEmit -p tsconfig.json + ``` + Expect zero errors referencing `STATUS_META`, `SOURCE_TONE`, `LOOP_STATUS_STYLE`, or `SOURCE_STATUS_STYLE` (all five must be fully removed, not just unused). + +- [ ] 3.7 Commit: + ``` + git add src/screens/dashboard/components/live-systems-card.tsx src/screens/dashboard/components/agent-lanes-card.tsx src/screens/dashboard/components/nova-session-bridge-card.tsx src/screens/dashboard/components/agent-workforce-card.tsx src/screens/dashboard/components/control-loops-card.tsx + git commit -m "cockpit: migrate 5 cards from local STATUS_META literals to STATUS_TONE" + ``` + +--- + +## Task 4: Hero galaxy + dense grid + +**Files:** +- Modify `src/screens/dashboard/dashboard-screen.tsx` +- Modify `src/screens/dashboard/components/mind-graph-card.tsx` + +**Context:** `MindGraphCard` is currently mounted unwrapped (not through `WidgetShell`, unlike every other card) directly after `` at line 1240, so it is always visible and not part of `WIDGET_CATALOG`. It stays that way — the spec calls it "the hero," not a toggleable widget. Today its canvas is `h-[58vh] min-h-[360px] ... sm:h-[560px] lg:h-[650px]` (a fixed-px override on lg+ that caps it well under 70vh on tall monitors). The primary-ops cards (`live_systems`, `taylor_approvals`, `session_bridge`) are currently scattered mid-page (lines 1244–1271); this task pulls them to sit directly under the galaxy and marks the decorative cards `data-tier="quiet"`. + +- [ ] 4.1 Modify `mind-graph-card.tsx` — widen the galaxy viewport on desktop. Replace: + ```tsx +
    + ``` + with: + ```tsx +
    + ``` + (Drops the `sm:h-[560px] lg:h-[650px]` px caps in favor of a real `70vh` on lg+ per the spec's "not a ~350px strip" note, keeping `min-h-[560px]` as a floor on short viewports. The sidebar `xl:flex xl:w-72 xl:flex-col` in the parent `
    + ``` + (`setSelectedId(result.path)` reuses the exact selection path `noteQuery`/backlink buttons already use elsewhere in this file (line 1281's `onClick={() => setSelectedId(backlink)}`) — the glide from T8 fires automatically since it's driven off `selectedId` changes, not a separate "fly to" call.) + +- [ ] 9.3 Legend chip row. Add a new fixed-position legend block inside the galaxy field container (sibling to the existing top-toolbar `
    `), anchored bottom-left per spec's "corner legend chip": + ```tsx +
    + + + color = cluster + + + + size = links + + + + pulse = hub + + + + bright = this week + + + + comet = orphan + +
    + ``` + +- [ ] 9.4 Filter chips: per-cluster toggle (already exists as the arm toggle row, lines 1127–1140 — `disabledArms`/`toggleArm` — this satisfies "filters: by cluster/folder" already; no new code needed here beyond confirming it dims to the spec's target, see 9.5) plus a new "this week" recency toggle: + Add state in `MindGraphCard`: `const [thisWeekOnly, setThisWeekOnly] = useState(false)`. + Add the toggle button in the same toolbar row as the arm chips (after the `model.arms.slice(0, 7).map(...)` block, before the search `
    - {/* Loading animation removed — not relevant for Hermes */} + {/* Loading animation removed — not relevant for Hermes */}
    ) } @@ -1980,11 +1980,11 @@ function _AdvancedContent() {
    @@ -1994,7 +1994,7 @@ function _AdvancedContent() { value={settings.claudeUrl} onChange={(e) => validateAndUpdateUrl(e.target.value)} className="h-8 w-full rounded-lg border-primary-200 text-sm" - aria-label="Hermes Agent URL" + aria-label="Nova gateway URL" aria-invalid={!!urlError} aria-describedby={urlError ? urlErrorId : undefined} /> @@ -2051,7 +2051,7 @@ function _AdvancedContent() { ) } -// ── Error Boundary ────────────────────────────────────────────────────── +// ── Error Boundary ────────────────────────────────────────────────────── class SettingsErrorBoundary extends Component< { children: React.ReactNode }, @@ -2085,7 +2085,7 @@ class SettingsErrorBoundary extends Component< } } -// ── Agent Behavior ────────────────────────────────────────────────────── +// ── Agent Behavior ────────────────────────────────────────────────────── function AgentBehaviorContent() { const [config, setConfig] = useState>({}) @@ -2174,7 +2174,7 @@ function AgentBehaviorContent() { ) } -// ── Voice (TTS + STT) ────────────────────────────────────────────────── +// ── Voice (TTS + STT) ────────────────────────────────────────────────── function VoiceContent() { const [tts, setTts] = useState>({}) @@ -2345,7 +2345,7 @@ function VoiceContent() { ) } -// ── Display ───────────────────────────────────────────────────────────── +// ── Display ───────────────────────────────────────────────────────────── function DisplayContent() { const [config, setConfig] = useState>({}) @@ -2471,7 +2471,7 @@ function LanguageContent() { ) } -// ── Main Dialog ───────────────────────────────────────────────────────── +// ── Main Dialog ───────────────────────────────────────────────────────── const CONTENT_MAP: Record React.JSX.Element> = { claude: HermesContent, @@ -2521,7 +2521,7 @@ export function SettingsDialog({ Settings - Configure Hermes Workspace + Configure Nova Mission Control
    - All settings → + All settings →
    diff --git a/src/components/slash-command-menu.test.tsx b/src/components/slash-command-menu.test.tsx index 4f1af7cc0e..bca2ecc695 100644 --- a/src/components/slash-command-menu.test.tsx +++ b/src/components/slash-command-menu.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { DEFAULT_SLASH_COMMANDS, mergeSlashCommands } from './slash-command-menu' @@ -49,7 +49,7 @@ describe('mergeSlashCommands', () => { const merged = mergeSlashCommands(DEFAULT_SLASH_COMMANDS, [ { command: '/hermes-agent', - description: 'Complete guide to using and extending Hermes Agent', + description: 'Complete guide to using and extending Nova gateway', }, ]) diff --git a/src/components/status-indicator.tsx b/src/components/status-indicator.tsx index 23cd39a690..3aa223bf02 100644 --- a/src/components/status-indicator.tsx +++ b/src/components/status-indicator.tsx @@ -1,4 +1,4 @@ -'use client' +'use client' import { useQuery } from '@tanstack/react-query' @@ -84,10 +84,10 @@ function buildTooltip( if (!data.modelConfigured) parts.push('No model selected') } if (data.status === 'enhanced') { - parts.push('Hermes Agent gateway enhancements detected') + parts.push('Nova gateway gateway enhancements detected') } if (data.activeModel) parts.push(`Model: ${data.activeModel}`) - return parts.join(' · ') + return parts.join(' · ') } /** diff --git a/src/components/update-center-notifier.test.tsx b/src/components/update-center-notifier.test.tsx index bc166f3891..6ce8a983e5 100644 --- a/src/components/update-center-notifier.test.tsx +++ b/src/components/update-center-notifier.test.tsx @@ -1,4 +1,4 @@ -/** @vitest-environment jsdom */ +/** @vitest-environment jsdom */ import { beforeEach, describe, expect, it } from 'vitest' import { __updateReleaseNotesStorageForTests } from './update-center-notifier' @@ -8,7 +8,7 @@ const { NOTES_SEEN_KEY, storeNotes } = __updateReleaseNotesStorageForTests const agentReleaseNotes = [ { product: 'agent' as const, - label: 'Hermes Agent', + label: 'Nova gateway', from: 'c23a87bc163b188abc7e40fbdccf07a9739231c3', to: '4fdfdf67499c33015ed56e6e5910d8bdc00aa901', commits: ['Merge pull request #25045 (4fdfdf674)'], diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index e8ebb26652..ae7013b6f5 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -21,7 +21,8 @@ import { useState, } from 'react' import { useNavigate, useRouterState } from '@tanstack/react-router' -import { fetchClaudeAuthStatus, type AuthStatus } from '@/lib/claude-auth' +import type { AuthStatus } from '@/lib/claude-auth' +import { fetchClaudeAuthStatus } from '@/lib/claude-auth' import { cn } from '@/lib/utils' import { ConnectionStartupScreen } from '@/components/connection-startup-screen' import { ChatSidebar } from '@/screens/chat/components/chat-sidebar' @@ -29,19 +30,11 @@ import { useChatSessions } from '@/screens/chat/hooks/use-chat-sessions' import { useWorkspaceStore } from '@/stores/workspace-store' import { SIDEBAR_TOGGLE_EVENT } from '@/hooks/use-global-shortcuts' import { useSwipeNavigation } from '@/hooks/use-swipe-navigation' -// Lazy: ChatPanel statically imports ChatScreen and with it the chat -// markdown pipeline; keeping it out of the eager entry means non-chat -// routes stop paying for chat at first paint. The chat route itself -// already lazy-loads ChatScreen through its own boundary. -const ChatPanel = lazy(() => - import('@/components/chat-panel').then((m) => ({ default: m.ChatPanel })), -) import { ChatPanelToggle } from '@/components/chat-panel-toggle' import { LoginScreen } from '@/components/auth/login-screen' import { MobileTabBar } from '@/components/mobile-tab-bar' import { MobileHamburgerMenu } from '@/components/mobile-hamburger-menu' import { MobilePageHeader } from '@/components/mobile-page-header' - import { MobileTerminalInput } from '@/components/terminal/mobile-terminal-input' import { ClaudeReconnectBanner } from '@/components/claude-reconnect-banner' import { useMobileKeyboard } from '@/hooks/use-mobile-keyboard' @@ -50,6 +43,14 @@ import { CommandPalette } from '@/components/command-palette' import { useSettings } from '@/hooks/use-settings' // ActivityTicker moved to dashboard-only (too noisy for global header) +// Lazy: ChatPanel statically imports ChatScreen and with it the chat +// markdown pipeline; keeping it out of the eager entry means non-chat +// routes stop paying for chat at first paint. The chat route itself +// already lazy-loads ChatScreen through its own boundary. +const ChatPanel = lazy(() => + import('@/components/chat-panel').then((m) => ({ default: m.ChatPanel })), +) + const TerminalWorkspace = lazy(() => import('@/components/terminal/terminal-workspace').then((m) => ({ default: m.TerminalWorkspace, @@ -87,6 +88,16 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { // ChatGPT-style: track visual viewport height for keyboard-aware layout useMobileKeyboard() + // Nova skin, app-wide: every module renders navy/amber via the + // html[data-nova-cockpit] override block in styles.css (Taylor's call — + // no green/teal shell anywhere, not just on the dashboard route). + useEffect(() => { + document.documentElement.dataset.novaCockpit = 'true' + return () => { + delete document.documentElement.dataset.novaCockpit + } + }, []) + const [creatingSession, setCreatingSession] = useState(false) const [isMobile, setIsMobile] = useState(() => { if (typeof window === 'undefined') return false @@ -157,7 +168,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { chatReady?: boolean modelConfigured?: boolean } - if (data?.ok || (data?.chatReady && data?.modelConfigured)) { + if (data.ok || (data.chatReady && data.modelConfigured)) { setAuthStatus({ authenticated: true, authRequired: false }) setConnectionVerified(true) } @@ -198,7 +209,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { const isOnPlaygroundRoute = pathname === '/playground' || pathname.startsWith('/playground/') const isOnHermesWorldLandingRoute = pathname === '/hermes-world' || pathname.startsWith('/hermes-world/') || pathname === '/world' || pathname.startsWith('/world/') const isEmbeddedSurface = - search?.embed === '1' || search?.embed === 'true' || search?.mode === 'embed' + search.embed === '1' || search.embed === 'true' || search.mode === 'embed' const isChromeFreeSurface = isEmbeddedSurface || isOnHermesWorldLandingRoute const hideChatSidebar = isOnChatRoute && chatFocusMode const showDesktopSidebarBackdrop = @@ -350,12 +361,12 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
    {/* Activity ticker bar */} {/* Persistent sidebar */} - {!isChromeFreeSurface && !isMobile && !hideChatSidebar && ( + {!isMobile && !hideChatSidebar && (
    {isMobile && - !isChromeFreeSurface && !isOnChatRoute && !isOnTerminalRoute && mobilePageTitle && } @@ -447,7 +456,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { {/* Chat panel — visible on non-chat routes (but not in HermesWorld, which has its own in-game chat) */} - {!isOnChatRoute && !isOnPlaygroundRoute && !isChromeFreeSurface && !isMobile && ( + {!isOnChatRoute && !isOnPlaygroundRoute && !isMobile && ( @@ -455,7 +464,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
    {/* Floating chat toggle — visible on non-chat routes (but not in HermesWorld) */} - {!isChromeFreeSurface && !isOnChatRoute && !isOnPlaygroundRoute && !isMobile && } + {!isOnChatRoute && !isOnPlaygroundRoute && !isMobile && } {showDesktopSidebarBackdrop ? ( ) : (
    - 📄 + 📄 {attachment.name}
    )} @@ -2266,10 +2268,10 @@ function ChatComposerComponent({ ) : null} {isMobileViewport ? ( - /* ── Mobile: Telegram-style single-row bar ── */ + /* ── Mobile: Telegram-style single-row bar ── */ <>
    - {/* + button — opens bottom sheet actions menu */} + {/* + button — opens bottom sheet actions menu */} - {/* Textarea — flex-1, auto-growing */} + {/* Textarea — flex-1, auto-growing */}
    - {/* Attach File — keep sheet open so iOS picker can layer on top */} + {/* Attach File — keep sheet open so iOS picker can layer on top */} - {/* Model selector — opens model picker sheet on top */} + {/* Model selector — opens model picker sheet on top */} - {/* Output preview line — TUI-style ⎿ */} + {/* Output preview line — TUI-style ⎿ */}
    - + ⎿ {outputSummary}
    {open && canExpand ? ( @@ -266,7 +266,7 @@ function ThinkingRow({ if (expandAll) setOpen(true) }, [expandAll]) - const summary = summarizeOutput(thinking) || 'thinking…' + const summary = summarizeOutput(thinking) || 'thinking…' return (
    @@ -275,7 +275,7 @@ function ThinkingRow({ onClick={() => setOpen((v) => !v)} className="group flex w-full items-baseline gap-2 px-3 py-1.5 text-left rounded-sm hover:bg-[color-mix(in_srgb,var(--theme-accent)_8%,transparent)]" > - 💭 + 💭 - {open ? '▾' : '▸'} + {open ? 'â–¾' : 'â–¸'}
    - + ⎿ {summary}
    {open ? ( @@ -346,9 +346,9 @@ function TuiActivityCardComponent({ ).length const done = total - errors - running - if (errors > 0) return `${errors} failed · ${done} done` - if (running > 0) return `${running} running · ${done} done` - return `${total} ${total === 1 ? 'tool' : 'tools'} · done` + if (errors > 0) return `${errors} failed · ${done} done` + if (running > 0) return `${running} running · ${done} done` + return `${total} ${total === 1 ? 'tool' : 'tools'} · done` }, [toolSections, hasTools]) const summaryColor = @@ -360,7 +360,7 @@ function TuiActivityCardComponent({ // During streaming with nothing to show yet, render a minimal "working" stub // so we don't pretend the agent is thinking when no thinking text was emitted. - // (Hermes Agent currently emits tool.completed only after the run, not live.) + // (Nova gateway currently emits tool.completed only after the run, not live.) const isWorkingStub = !hasThinking && !hasTools && isStreaming if (!hasThinking && !hasTools && !isWorkingStub) return null @@ -387,7 +387,7 @@ function TuiActivityCardComponent({ className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em]" style={{ color: 'var(--theme-muted)' }} > - {isStreaming ? '⚡ Working' : 'Activity'} + {isStreaming ? 'âš¡ Working' : 'Activity'} {summary ? ( @@ -433,7 +433,7 @@ function TuiActivityCardComponent({ className="size-1.5 rounded-full animate-pulse" style={{ background: 'var(--theme-accent, #6366f1)' }} /> - working… + working… tool activity will appear after the run diff --git a/src/screens/chat/hooks/use-realtime-chat-history.ts b/src/screens/chat/hooks/use-realtime-chat-history.ts index 1d42d0d9fd..5ecb202e74 100644 --- a/src/screens/chat/hooks/use-realtime-chat-history.ts +++ b/src/screens/chat/hooks/use-realtime-chat-history.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useChatStream } from '../../../hooks/use-chat-stream' import { useChatStore } from '../../../stores/chat-store' @@ -27,8 +27,8 @@ function readClientId(message: ChatMessage): string { * * Uses a multi-field strategy because different server versions / channel * adapters shape the SSE payload differently: - * • Modern format: content: [{type:'text', text:'...'}] - * • Legacy format: text: '...' | body: '...' | message: '...' + * • Modern format: content: [{type:'text', text:'...'}] + * • Legacy format: text: '...' | body: '...' | message: '...' * * textFromMessage() only reads the content-array format, so using it alone * causes the dedup to miss echoes that carry a top-level `text` field, @@ -188,7 +188,7 @@ export function useRealtimeChatHistory({ onUserMessage: useCallback( (message: ChatMessage, source?: string) => { // Filter internal system messages (pre-compaction flushes, heartbeat - // prompts, subagent announcements) — these should never appear in the + // prompts, subagent announcements) — these should never appear in the // chat UI. The chat-store has its own filter, but this callback // also appends directly to the query cache via appendHistoryMessage, // bypassing the store filter entirely. @@ -216,14 +216,14 @@ export function useRealtimeChatHistory({ if (effectiveSessionKey && effectiveSessionKey !== 'new') { // Early-exit dedup: if the SSE echo has no clientId AND its text // content (or attachment signature) matches an existing optimistic - // user message in the cache, skip the append — the optimistic entry + // user message in the cache, skip the append — the optimistic entry // is already displayed. // // Bug: previous implementation used textFromMessage() which only // reads from the content-array format. Some server / channel // adapters echo the message with a top-level `text` or `body` field // instead, causing extractUserMessageText() to return '' and the - // dedup guard to be skipped — resulting in a duplicate user message. + // dedup guard to be skipped — resulting in a duplicate user message. // // Fix: use extractUserMessageText() which checks both the // content-array AND legacy top-level text/body/message fields. @@ -265,7 +265,7 @@ export function useRealtimeChatHistory({ return false }) if (hasOptimistic) { - // The optimistic message is already displayed — skip SSE echo + // The optimistic message is already displayed — skip SSE echo onUserMessage?.(message, source) return } @@ -314,7 +314,7 @@ export function useRealtimeChatHistory({ effectiveSessionKey === 'new' ) { setLastCompletedRunAt(Date.now()) - // Refetch history after generation completes — keeps chat in sync + // Refetch history after generation completes — keeps chat in sync if (effectiveSessionKey && effectiveSessionKey !== 'new') { const key = chatQueryKeys.history( effectiveFriendlyId, @@ -335,8 +335,8 @@ export function useRealtimeChatHistory({ // Issue #441 fix: Directly merge realtime buffer into history cache // INSTEAD of invalidateQueries. The old approach caused a race: - // invalidateQueries → refetch (async) → merge runs with stale data - // → duplicates appear briefly → refetch completes → fixed. + // invalidateQueries → refetch (async) → merge runs with stale data + // → duplicates appear briefly → refetch completes → fixed. // // New approach: merge realtime messages into the cache synchronously, // then clear the realtime buffer in the same tick. A background @@ -398,11 +398,11 @@ export function useRealtimeChatHistory({ })() : null - // Clear realtime buffer immediately — no more stale data in render + // Clear realtime buffer immediately — no more stale data in render store.clearRealtimeBuffer(effectiveSessionKey) clearCompletedStreaming() - // Background refetch for long-term consistency — doesn't block render + // Background refetch for long-term consistency — doesn't block render queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }).then(() => { // Re-inject the completed assistant message if compaction dropped it if (completedAssistant) { @@ -431,7 +431,7 @@ export function useRealtimeChatHistory({ reInjectOptimistic() }) - // Check for compaction — significant message count drop + // Check for compaction — significant message count drop const newData = queryClient.getQueryData>(key) const newCount = @@ -443,10 +443,10 @@ export function useRealtimeChatHistory({ ) { onCompactionEnd?.() toast( - 'Context compacted — older messages were summarized to free up space', + 'Context compacted — older messages were summarized to free up space', { type: 'info', - icon: '🗜️', + icon: '🗜️', duration: 8000, }, ) @@ -491,7 +491,7 @@ export function useRealtimeChatHistory({ (s) => s.realtimeMessages.get(effectiveSessionKey) ?? EMPTY_MESSAGES, ) - // Subscribe directly to streaming state — useMemo with stable fn ref was stale (bug #1) + // Subscribe directly to streaming state — useMemo with stable fn ref was stale (bug #1) const streamingState = useChatStore( (s) => s.streamingState.get(effectiveSessionKey) ?? null, ) @@ -514,7 +514,7 @@ export function useRealtimeChatHistory({ if (startedNewStream) { clearCompletedStreaming() } - // Streaming just completed — capture final text so the message stays + // Streaming just completed — capture final text so the message stays // visible during the handoff from streaming placeholder to history message. // The stub useChatStream never fires onDone, so this is the only path. if (prev && prev.text && !streamingState) { @@ -540,7 +540,7 @@ export function useRealtimeChatHistory({ persistPortableHistory(mergedMessages) }, [mergedMessages, portableMode]) - // History has caught up — cleanup realtime buffer outside render + // History has caught up — cleanup realtime buffer outside render // DISABLED: This was aggressively clearing realtime messages before history // caught up, causing the "message appears then disappears" bug. // TODO: Re-enable with smarter timing (e.g. only after history confirms the message) @@ -575,9 +575,9 @@ export function useRealtimeChatHistory({ .join('\n') .toLowerCase() - // Only trigger on Hermes Agent's actual mid-compaction signal. + // Only trigger on Nova gateway's actual mid-compaction signal. // "pre-compaction memory flush" and "store durable memories now" are routine - // heartbeat messages — do NOT match those here. + // heartbeat messages — do NOT match those here. if (!textCandidates.includes('compacting context')) return const signal = `${latest.role ?? ''}:${textCandidates}` @@ -586,15 +586,15 @@ export function useRealtimeChatHistory({ onCompactionStart() }, [onCompactionStart, realtimeMessages]) - // Periodic history sync — catch missed messages every 30s + // Periodic history sync — catch missed messages every 30s // Skip during active streaming to prevent race conditions useEffect(() => { if (!effectiveSessionKey || effectiveSessionKey === 'new' || !enabled) return syncIntervalRef.current = setInterval(() => { - // Don't poll during active streaming — causes flicker/overwrites + // Don't poll during active streaming — causes flicker/overwrites if (streamingStateRef.current !== null) return - // Guard window: don't poll right after streaming clears — new stream + // Guard window: don't poll right after streaming clears — new stream // may be starting and history API may return stale/incomplete data if (Date.now() - lastStreamClearTimeRef.current < 3000) return const key = chatQueryKeys.history( diff --git a/src/screens/chat/hooks/use-streaming-message.ts b/src/screens/chat/hooks/use-streaming-message.ts index 057a1810f0..2f30861afd 100644 --- a/src/screens/chat/hooks/use-streaming-message.ts +++ b/src/screens/chat/hooks/use-streaming-message.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { ChatAttachment, ChatMessage } from '../types' import { readResolvedSessionHeaders } from '@/lib/send-stream-session-headers' import { useChatStore } from '@/stores/chat-store' @@ -9,7 +9,7 @@ import { pushActivity } from '@/components/inspector/activity-store' * onSessionResolved (which navigates the route). Only bootstrap keys * ("new", "main") should promote a backend-returned session ID to the * Workspace route identity. Concrete sessions must never be overridden - * by a backend-derived api-* ID — that causes session splits (#297). + * by a backend-derived api-* ID — that causes session splits (#297). */ export function shouldResolveStreamSession({ requestedSessionKey, @@ -22,14 +22,14 @@ export function shouldResolveStreamSession({ resolvedSessionKey: string pinMainSession?: boolean }): boolean { - // No change → nothing to resolve + // No change → nothing to resolve if (resolvedSessionKey === currentSessionKey) return false // "new" should resolve once to a concrete session. if (requestedSessionKey === 'new') return true // "main" only stays pinned when the current route is intentionally bound to // the portable Workspace session in zero-fork mode. if (requestedSessionKey === 'main') return !pinMainSession - // Concrete session → never promote a different backend ID + // Concrete session → never promote a different backend ID return false } @@ -392,7 +392,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { lifecyclePhaseRef.current = 'complete' clearHandoffTimer() // Delay runId unregistration so chat-events dedup continues filtering - // for a few seconds after completion — prevents late duplicate messages + // for a few seconds after completion — prevents late duplicate messages if (delayedUnregisterTimerRef.current) { clearTimeout(delayedUnregisterTimerRef.current) delayedUnregisterTimerRef.current = null @@ -440,7 +440,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { const payload = data as Record // [DEBUG TUI] Log every SSE event so we can see whether tool.* events arrive - // from Hermes Agent through Workspace. Toggle off by setting + // from Nova gateway through Workspace. Toggle off by setting // localStorage.removeItem('hermes:debug:sse') if ( typeof window !== 'undefined' && @@ -643,13 +643,13 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { pushActivity({ type: 'artifact', time: new Date().toLocaleTimeString(), - text: path ? `${title} — ${path}` : title, + text: path ? `${title} — ${path}` : title, }) processStoreEvent({ type: 'tool', phase: 'complete', name: `artifact:${kind}`, - result: path ? `${title} — ${path}` : title, + result: path ? `${title} — ${path}` : title, preview: typeof payload.preview === 'string' && payload.preview.trim() ? payload.preview.trim() @@ -770,7 +770,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { ) { transitionToHandoff() } else { - markFailed('Hermes Agent connection closed') + markFailed('Nova gateway connection closed') } break } @@ -893,7 +893,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { if (resolvedSessionKey !== activeSessionKeyRef.current) { // Only promote a backend-returned session ID when the original // request was a bootstrap key ("new"/"main"). Concrete Workspace - // sessions must never be overridden — that causes splits (#297). + // sessions must never be overridden — that causes splits (#297). if ( shouldResolveStreamSession({ requestedSessionKey: params.sessionKey, @@ -913,8 +913,8 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { markAccepted() schedulePostAcceptanceTimeout('accepted') - // HTTP 200 — message accepted by Hermes Agent. Clear optimistic "sending" - // status so the Retry timer never fires. Hermes Agent does NOT echo + // HTTP 200 — message accepted by Nova gateway. Clear optimistic "sending" + // status so the Retry timer never fires. Nova gateway does NOT echo // user messages via SSE, so this is the only confirmation we get. if (params.idempotencyKey && onMessageAccepted) { onMessageAccepted( @@ -958,7 +958,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { for (const eventBlock of events) { if (!eventBlock.trim()) continue - // Re-check between events as well — a single read() can yield a + // Re-check between events as well — a single read() can yield a // batch of buffered events; if a new stream started mid-batch, // the rest of this batch must be dropped. if (streamGenerationRef.current !== myGeneration) break @@ -991,14 +991,14 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { // If the stream ended cleanly (no 'done' event) but we never received // any response text, treat it as a failure rather than a successful // empty completion. This happens when a proxy (e.g., Tailscale Serve) - // closes the connection after an idle timeout — the reader returns + // closes the connection after an idle timeout — the reader returns // { done: true } but the model was still generating. Fixes #512. if ( !fullTextRef.current && (lifecyclePhase === 'accepted' || lifecyclePhase === 'active') ) { markFailed( - 'Connection closed before response was received. The backend may still be processing — check server logs or retry.', + 'Connection closed before response was received. The backend may still be processing — check server logs or retry.', ) } else { finishStream() diff --git a/src/screens/chat/utils.ts b/src/screens/chat/utils.ts index a329003c55..3c6b54e73a 100644 --- a/src/screens/chat/utils.ts +++ b/src/screens/chat/utils.ts @@ -1,4 +1,4 @@ -import type { +import type { ChatMessage, SessionMeta, SessionSummary, @@ -286,7 +286,7 @@ export async function readError(res: Response): Promise { } export const missingAuthMessage = - 'Hermes Agent connection failed. Make sure Hermes Agent is running and HERMES_API_URL is set correctly.' + 'Nova gateway connection failed. Make sure Nova gateway is running and HERMES_API_URL is set correctly.' export function isMissingAuth(message: string): boolean { return message.includes(missingAuthMessage) diff --git a/src/screens/dashboard/components/active-model-kpi.tsx b/src/screens/dashboard/components/active-model-kpi.tsx index 9e337a3434..328bd01a51 100644 --- a/src/screens/dashboard/components/active-model-kpi.tsx +++ b/src/screens/dashboard/components/active-model-kpi.tsx @@ -1,4 +1,4 @@ -import { formatModelName } from '@/screens/dashboard/lib/formatters' +import { formatModelName } from '@/screens/dashboard/lib/formatters' import type { DashboardOverview } from '@/server/dashboard-aggregator' function formatCount(n: number): string { @@ -26,11 +26,11 @@ export function ActiveModelKpi({ analytics: DashboardOverview['analytics'] }) { const connected = !!modelInfo - const display = modelInfo ? formatModelName(modelInfo.model) : '—' - const provider = modelInfo?.provider ?? '—' + const display = modelInfo ? formatModelName(modelInfo.model) : '—' + const provider = modelInfo?.provider ?? '—' // Routing share (proxy): % of calls in the analytics window that hit - // the active model. Hermes Agent confirmed this is the closest + // the active model. Nova gateway confirmed this is the closest // available metric without a dedicated routing-decisions endpoint. const share = ((): number | null => { if (!modelInfo || !analytics) return null @@ -120,7 +120,7 @@ export function ActiveModelKpi({ > {provider} {sessionsForModel !== null - ? ` · ${formatCount(sessionsForModel)} sessions` + ? ` · ${formatCount(sessionsForModel)} sessions` : ''} {modelInfo?.effectiveContextLength ? ( diff --git a/src/screens/dashboard/components/agent-lanes-card.tsx b/src/screens/dashboard/components/agent-lanes-card.tsx new file mode 100644 index 0000000000..bcf54d0a4e --- /dev/null +++ b/src/screens/dashboard/components/agent-lanes-card.tsx @@ -0,0 +1,101 @@ +import { useQuery } from '@tanstack/react-query' +import { STATUS_TONE } from '../lib/status-meta' +import type { + AgentLane, + AgentLaneRegistry, +} from '../../../server/agent-lane-registry' + +async function readLanes(): Promise { + const response = await fetch('/api/agent-lanes') + if (!response.ok) throw new Error(`Agent lanes read failed: ${response.status}`) + return (await response.json()) as AgentLaneRegistry +} + +function formatTime(value: string | null): string { + if (!value) return '—' + const ts = Date.parse(value) + if (!Number.isFinite(ts)) return '—' + return new Date(ts).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function LaneRow({ lane }: { lane: AgentLane }) { + const meta = STATUS_TONE(lane.status) + return ( +
    +
    +
    +
    + + {lane.label} + + {lane.role === 'control-tower' ? ( + + tower + + ) : null} +
    +

    + {lane.detail} +

    +
    + + + {meta.label} + +
    +
    + {lane.model ? model {lane.model} : null} + {lane.workspace ? ws {lane.workspace} : null} + {lane.activeTask ? task {lane.activeTask} : null} + beat {formatTime(lane.heartbeatAt)} +
    + {lane.blocker ? ( +

    + ⚠ {lane.blocker} +

    + ) : null} +
    + ) +} + +export function AgentLanesCard() { + const query = useQuery({ + queryKey: ['agent-lanes'], + queryFn: readLanes, + refetchInterval: 45_000, + staleTime: 20_000, + }) + const registry = query.data ?? null + + return ( +
    +
    + Agent Lanes +
    +
    + Who is working, on real evidence +
    +

    + Read-only detection from session files, processes, the handoff bus, + and swarm missions. No lane is ever invented. +

    + {query.isError ? ( +

    + Lane registry endpoint unreachable. +

    + ) : null} + {registry ? ( +
    + {registry.lanes.map((lane) => ( + + ))} +
    + ) : null} +
    + ) +} diff --git a/src/screens/dashboard/components/agent-workforce-card.tsx b/src/screens/dashboard/components/agent-workforce-card.tsx new file mode 100644 index 0000000000..65ade41628 --- /dev/null +++ b/src/screens/dashboard/components/agent-workforce-card.tsx @@ -0,0 +1,152 @@ +import { STATUS_TONE } from '../lib/status-meta' +import type { + AgentWorkerStatus, + DashboardAgentWorker, + DashboardAgentWorkforceSection, +} from '@/server/dashboard-aggregator' + +const WORKER_STATUS_MAP: Record = { + idle: 'idle', + queued: 'setup-needed', + running: 'active', + blocked: 'offline', + reviewing: 'degraded', + done: 'not-wired', +} + +function formatTime(value: string | null): string { + if (!value) return 'no heartbeat' + const ts = Date.parse(value) + if (!Number.isFinite(ts)) return 'unknown' + return new Date(ts).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function WorkerRow({ worker }: { worker: DashboardAgentWorker }) { + const meta = STATUS_TONE(WORKER_STATUS_MAP[worker.status]) + return ( +
    +
    +
    +
    + {worker.workerId} +
    +

    + {worker.currentTask ?? 'No active assignment'} +

    +
    + + + {meta.label} + +
    +
    + + + + +
    +
    + {worker.missionId ?? 'no mission'} + {formatTime(worker.lastSeenAt)} +
    +
    + ) +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
    +
    + {value} +
    +
    + {label} +
    +
    + ) +} + +export function AgentWorkforceCard({ + workforce, +}: { + workforce: DashboardAgentWorkforceSection | null +}) { + if (!workforce) return null + return ( +
    +
    +
    +
    +
    +
    + Agent Workforce +
    +
    + Swarm missions, read-only +
    +

    + Mirrors existing mission receipts. No dispatch, cancel, or review + actions from this card. +

    +
    +
    + + + + +
    +
    + + {workforce.workers.length > 0 ? ( +
    + {workforce.workers.slice(0, 8).map((worker) => ( + + ))} +
    + ) : ( +
    + No swarm mission receipts yet. Agent work will appear here after a + mission is created or checkpointed. +
    + )} + + {workforce.recentMissions.length > 0 ? ( +
    +
    + Recent missions +
    +
    + {workforce.recentMissions.slice(0, 3).map((mission) => ( +
    + + {mission.title} + + + {mission.state} · {mission.assignments} + +
    + ))} +
    +
    + ) : null} +
    +
    + ) +} diff --git a/src/screens/dashboard/components/attention-marquee.tsx b/src/screens/dashboard/components/attention-marquee.tsx index 1dd3de9db8..f5f40427c8 100644 --- a/src/screens/dashboard/components/attention-marquee.tsx +++ b/src/screens/dashboard/components/attention-marquee.tsx @@ -5,11 +5,12 @@ import type { } from '@/server/dashboard-aggregator' const SOURCE_GLYPH: Record = { - cron: '⏰', - platform: '🔌', - log: '📜', - config: '⚙️', - gateway: '🛰️', + cron: 'clock', + kanban: 'board', + platform: 'link', + log: 'log', + config: 'cfg', + gateway: 'gate', } const SEVERITY_COLOR: Record = { @@ -18,18 +19,25 @@ const SEVERITY_COLOR: Record = { info: 'var(--theme-muted)', } +function compactDetail(item: DashboardIncident): string { + const detail = item.detail.trim() + if (!detail) return '' + + const lower = detail.toLowerCase() + if ( + item.source === 'cron' && + lower.includes('global inference config drifted') + ) { + return 'config drift blocked scheduled run; pin model/provider in Jobs' + } + + if (detail.length <= 96) return detail + return `${detail.slice(0, 92).trim()}...` +} + /** - * Right-to-left marquee that surfaces the same `incidents[]` payload - * the legacy `AttentionCard` used to render. Lives inside `OpsStrip` - * so attention items occupy the same horizontal "10-second status - * read" line operators already glance at. - * - * Behavior: - * - Hidden when there are no incidents (no empty marquee row). - * - Clones the list once so the loop animation stitches seamlessly. - * - Pauses on hover so the operator can read a long item. - * - Each item is a button that routes to the most context-appropriate - * page (cron → /jobs, config → /settings, log/gateway → /logs). + * Compact incident ticker. It keeps the real incident list clickable while + * trimming verbose backend errors into a readable operator summary. */ export function AttentionMarquee({ overview, @@ -61,16 +69,14 @@ export function AttentionMarquee({ color: 'var(--theme-warning)', }} > - ⚠️ Attention · {items.length} + Attention · {items.length} - {/* Fade mask on right edge for "ticker continues" feel. */} @@ -78,9 +84,7 @@ export function AttentionMarquee({ className="flex min-w-0 flex-1 overflow-hidden whitespace-nowrap" style={{ maskImage: 'linear-gradient(90deg, black 96%, transparent)' }} > -
    +
    {tracks.map((item, idx) => { const handleClick = () => { if (item.href) { @@ -99,6 +103,7 @@ export function AttentionMarquee({ navigate({ to: '/settings', search: {} }) else navigate({ to: '/jobs' }) } + const detail = compactDetail(item) return ( diff --git a/src/screens/dashboard/components/control-loops-card.tsx b/src/screens/dashboard/components/control-loops-card.tsx new file mode 100644 index 0000000000..db4a602b7d --- /dev/null +++ b/src/screens/dashboard/components/control-loops-card.tsx @@ -0,0 +1,346 @@ +import { useMemo, useState } from 'react' +import { STATUS_TONE } from '../lib/status-meta' +import type { + ControlLoopSourceStatus, + ControlLoopStatus, + DashboardControlLoop, + DashboardControlLoopsSection, +} from '@/server/dashboard-aggregator' +import type { + ControlLoopRunResult, + RunnableControlLoopId, +} from '@/server/control-loop-runner' + +const RUNNABLE_LOOP_IDS: ReadonlySet = new Set([ + 'cost-route-watch', + 'morning-command-center', + 'jobboard-caretaker', +]) + +type RunResponse = { + ok: boolean + result: ControlLoopRunResult + receiptId: string | null +} + +function loopStatusPillClass(status: ControlLoopStatus): string { + const meta = STATUS_TONE(status) + return `${meta.tone} bg-[color-mix(in_srgb,var(--theme-border)_0%,transparent)]` +} + +const SOURCE_STATUS_MAP: Record = { + connected: 'operational', + available: 'setup-needed', + 'not-wired': 'not-wired', + unavailable: 'offline', +} + +function formatStatus(status: ControlLoopStatus): string { + if (status === 'not-wired') return 'not wired' + return status +} + +function formatTime(value: string | null): string { + if (!value) return 'not scheduled' + const ts = Date.parse(value) + if (!Number.isFinite(ts)) return 'unknown' + return new Date(ts).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function StatusBadge({ status }: { status: ControlLoopStatus }) { + return ( + + {formatStatus(status)} + + ) +} + +function LoopCard({ + loop, + active, + onDryRun, +}: { + loop: DashboardControlLoop + active: boolean + onDryRun: (loop: DashboardControlLoop) => void +}) { + return ( +
    +
    +
    +
    + {loop.label} +
    +

    + {loop.purpose} +

    +
    + +
    + +
    +
    +
    + readiness +
    +
    + {loop.readiness} +
    +
    +
    +
    + next +
    +
    + {formatTime(loop.nextScheduledAt)} +
    +
    +
    + +
    + {loop.connectedSystems.map((system) => ( + + + {system.label} + + ))} +
    + +
    + + + open chat + + {active ? ( + + prompt ready + + ) : null} +
    +
    + ) +} + +export function ControlLoopsCard({ + controlLoops, +}: { + controlLoops: DashboardControlLoopsSection | null +}) { + const [activeLoopId, setActiveLoopId] = useState(null) + const [runState, setRunState] = useState< + | { phase: 'idle' } + | { phase: 'running'; loopId: string } + | { phase: 'done'; loopId: string; response: RunResponse } + | { phase: 'error'; loopId: string; message: string } + >({ phase: 'idle' }) + const activeLoop = useMemo( + () => controlLoops?.loops.find((loop) => loop.id === activeLoopId) ?? null, + [activeLoopId, controlLoops?.loops], + ) + + if (!controlLoops) return null + + async function handleDryRun(loop: DashboardControlLoop) { + setActiveLoopId(loop.id) + if (RUNNABLE_LOOP_IDS.has(loop.id)) { + // Real server-side dry run: gathers live sources, writes a fabric + // receipt, and refuses every external write without approval. + setRunState({ phase: 'running', loopId: loop.id }) + try { + const response = await fetch('/api/control-loops/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ loopId: loop.id as RunnableControlLoopId }), + }) + if (!response.ok) throw new Error(`run failed: ${response.status}`) + const payload = (await response.json()) as RunResponse + setRunState({ phase: 'done', loopId: loop.id, response: payload }) + } catch (error) { + setRunState({ + phase: 'error', + loopId: loop.id, + message: error instanceof Error ? error.message : 'run failed', + }) + } + return + } + setRunState({ phase: 'idle' }) + try { + await navigator.clipboard.writeText(loop.dryRunPrompt) + } catch { + // Clipboard can be blocked on non-secure origins; the prompt still renders. + } + } + + return ( +
    +
    + +
    +
    +
    +
    + Control Loops +
    +
    + Operational loops, dry-run gated +
    +

    + Reads Hermes, job board, OAuth, analytics, cron, and vault + signals. It does not duplicate source-of-truth state. +

    +
    +
    +
    +
    + {controlLoops.summary.ready} +
    +
    + ready +
    +
    +
    +
    + {controlLoops.summary.partial} +
    +
    + partial +
    +
    +
    +
    + {controlLoops.summary.notWired} +
    +
    + unwired +
    +
    +
    +
    + +
    + {controlLoops.loops.map((loop) => ( + + ))} +
    + + {runState.phase === 'running' ? ( +
    + Running {runState.loopId} against live sources… +
    + ) : null} + {runState.phase === 'error' ? ( +
    + {runState.loopId}: {runState.message} +
    + ) : null} + {runState.phase === 'done' ? ( +
    +
    +
    + {runState.response.result.loopId} · dry run{' '} + {runState.response.result.ok ? 'completed' : 'degraded'} +
    + {runState.response.receiptId ? ( + + receipt {runState.response.receiptId.slice(0, 14)}… + + ) : ( + + no receipt written + + )} +
    +

    + {runState.response.result.summary} +

    +
      + {runState.response.result.findings.map((finding) => ( +
    • + • {finding} +
    • + ))} +
    + {runState.response.result.refusals.length > 0 ? ( +
    +
    + Refused without Taylor approval +
    +
      + {runState.response.result.refusals.map((refusal) => ( +
    • + {refusal} +
    • + ))} +
    +
    + ) : null} +
    + ) : null} + {activeLoop && runState.phase === 'idle' ? ( +
    +
    + Dry-run prompt copied when allowed +
    +

    + {activeLoop.dryRunPrompt} +

    +
    + ) : null} + +
    +
    + Risk gates +
    +
    + {controlLoops.riskGates.map((gate) => ( +

    + {gate} +

    + ))} +
    +
    +
    +
    + ) +} diff --git a/src/screens/dashboard/components/daily-check-card.tsx b/src/screens/dashboard/components/daily-check-card.tsx new file mode 100644 index 0000000000..15f6bb58aa --- /dev/null +++ b/src/screens/dashboard/components/daily-check-card.tsx @@ -0,0 +1,67 @@ +import { novaDailyCheck } from '@/lib/nova-daily-check-adapter' + +export function DailyCheckCard() { + return ( +
    +
    +
    +
    Daily check
    +
    + SoulSync +
    +
    +
    + {novaDailyCheck.items.map((item) => ( +
    +
    {item.label}
    +
    + {item.checked ? '[x]' : '[ ]'} +
    +
    + ))} +
    +
    + Overthinking +
    +
    + {novaDailyCheck.overthinking} +
    +
    +
    +
    +
    +
    Mood
    +
    + {novaDailyCheck.mood} +
    +
    +
    +
    Reminder
    +
    + {novaDailyCheck.reminder} +
    +
    +
    +
    +
    +
    Log stream
    +
    + {novaDailyCheck.log.map((entry) => ( +
    + {entry.date} + + {entry.text} + +
    + ))} +
    +
    +
    + ) +} diff --git a/src/screens/dashboard/components/git-work-card.tsx b/src/screens/dashboard/components/git-work-card.tsx new file mode 100644 index 0000000000..8ca7aa76de --- /dev/null +++ b/src/screens/dashboard/components/git-work-card.tsx @@ -0,0 +1,129 @@ +import type { DashboardGitWorkSection } from '@/server/dashboard-aggregator' + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( +
    +
    + {value} +
    +
    + {label} +
    +
    + ) +} + +function RemoteRow({ + name, + fetchUrl, + pushUrl, +}: { + name: string + fetchUrl: string | null + pushUrl: string | null +}) { + return ( +
    +
    + {name} +
    +
    +
    fetch: {fetchUrl ?? 'missing'}
    +
    push: {pushUrl ?? 'missing'}
    +
    +
    + ) +} + +export function GitWorkCard({ gitWork }: { gitWork: DashboardGitWorkSection | null }) { + if (!gitWork) return null + const stateTone = gitWork.clean + ? 'text-[var(--theme-success)] border-[color-mix(in_srgb,var(--theme-success)_35%,var(--theme-border))]' + : 'text-[var(--theme-warning)] border-[color-mix(in_srgb,var(--theme-warning)_35%,var(--theme-border))]' + + return ( +
    +
    +
    +
    +
    +
    + GitHub / PR Work +
    +
    + Local branch status, read-only +
    +

    + Mirrors local git state and configured remotes. This card does not + push, open PRs, or mutate GitHub. +

    +
    + + {gitWork.clean ? 'clean' : `${gitWork.changedFiles} changed`} + +
    + + {gitWork.warning ? ( +
    + {gitWork.warning} +
    + ) : null} + +
    + + + + +
    + +
    +
    + Current branch +
    +
    + {gitWork.branch ?? 'unknown branch'} +
    +
    + upstream: {gitWork.upstream ?? 'not configured'} +
    + {gitWork.latestCommit ? ( +
    + + {gitWork.latestCommit.hash} + {' '} + + {gitWork.latestCommit.subject} + +
    + ) : null} +
    + + {gitWork.prUrl ? ( + + Open linked PR receipt + + ) : null} + + {gitWork.remotes.length > 0 ? ( +
    + {gitWork.remotes.slice(0, 4).map((remote) => ( + + ))} +
    + ) : null} +
    +
    + ) +} diff --git a/src/screens/dashboard/components/home-mode-card.tsx b/src/screens/dashboard/components/home-mode-card.tsx new file mode 100644 index 0000000000..80fda24396 --- /dev/null +++ b/src/screens/dashboard/components/home-mode-card.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react' +import type { HomeModeContext } from '@/lib/home-mode' +import { classifyHomeMode } from '@/lib/home-mode' + +const LANE_LABELS: Record = { + morning_ramp_up: 'Morning ramp-up', + work_block: 'Work block', + transition_home: 'Transition home', + family_evening: 'Family / evening', + light_creative_planning: 'Light creative', + late_night_danger_zone: 'Late-night guardrail', + weekend_reset: 'Weekend / reset', + override_build_mode: 'Build mode', +} + +const LANE_TONES: Record = { + morning_ramp_up: 'var(--theme-blue-secondary)', + work_block: 'var(--theme-accent)', + transition_home: 'var(--theme-warning)', + family_evening: 'var(--theme-success)', + light_creative_planning: 'var(--theme-blue-primary)', + late_night_danger_zone: 'var(--theme-danger)', + weekend_reset: 'var(--theme-success)', + override_build_mode: 'var(--theme-accent-secondary)', +} + +function formatClock(value: string): string { + const match = value.match(/T(\d{2}):(\d{2})/) + if (!match) return value + const hour = Number(match[1]) + const minute = match[2] + const suffix = hour >= 12 ? 'PM' : 'AM' + const displayHour = hour % 12 || 12 + return `${displayHour}:${minute} ${suffix}` +} + +export function HomeModeCard() { + const [now, setNow] = useState(() => new Date()) + + useEffect(() => { + const timer = window.setInterval(() => setNow(new Date()), 30_000) + return () => window.clearInterval(timer) + }, []) + + const context = useMemo( + () => + classifyHomeMode({ + now, + timezone: 'America/Chicago', + location: 'West Texas', + preferences: { + timezone: 'America/Chicago', + location: 'West Texas', + defaultAfterWorkHomeProtection: true, + }, + }), + [now], + ) + + const tone = LANE_TONES[context.lane] + + return ( +
    +
    +
    + +
    +
    +
    Home Mode
    +
    +

    + {LANE_LABELS[context.lane]} +

    + + {Math.round(context.confidence * 100)}% + +
    +

    + {context.suggested_boundary} +

    +
    + +
    +
    +
    Live clock
    +
    + {formatClock(context.now_local)} +
    +
    +
    +
    Intensity
    +
    + {context.recommended_intensity} +
    +
    +
    +
    + +
    + {context.reason} Persistence is rules + memory + wakeups/app state, not + Nova constantly watching. +
    +
    + ) +} diff --git a/src/screens/dashboard/components/hour-of-day-card.tsx b/src/screens/dashboard/components/hour-of-day-card.tsx index 25d268d8c7..c465c5707f 100644 --- a/src/screens/dashboard/components/hour-of-day-card.tsx +++ b/src/screens/dashboard/components/hour-of-day-card.tsx @@ -1,16 +1,16 @@ -import { useMemo } from 'react' +import { useMemo } from 'react' /** * Hour-of-day activity heatmap. * * Backend doesn't expose `activity.by_hour[]` from `/api/analytics/usage` - * yet (the Hermes Agent confirmed it's computed internally but dropped + * yet (the Nova gateway confirmed it's computed internally but dropped * at the API boundary). We rebuild it locally from the session list - * the dashboard already has — every session row has `startedAt`, so we + * the dashboard already has — every session row has `startedAt`, so we * bucket those into 24 hour-of-day cells. * - * Not a full date×hour heatmap; that's a true 7×24 grid we'll add when - * the backend exposes it. This is the 1×24 strip rolled up across the + * Not a full date×hour heatmap; that's a true 7×24 grid we'll add when + * the backend exposes it. This is the 1×24 strip rolled up across the * window which matches what InsightsEngine already returns server-side. */ export function HourOfDayCard({ @@ -62,7 +62,7 @@ export function HourOfDayCard({ className="font-mono text-[9px] uppercase tracking-[0.15em]" style={{ color: 'var(--theme-muted)' }} > - peak {formatHour(peakHour)} · {total} sessions + peak {formatHour(peakHour)} · {total} sessions
    @@ -84,7 +84,7 @@ export function HourOfDayCard({ height: count === 0 ? 4 : `${Math.max(8, heightPct)}%`, minHeight: 4, }} - title={`${formatHour(hour)} · ${count} session${count === 1 ? '' : 's'}`} + title={`${formatHour(hour)} · ${count} session${count === 1 ? '' : 's'}`} /> ) })} diff --git a/src/screens/dashboard/components/live-systems-card.tsx b/src/screens/dashboard/components/live-systems-card.tsx new file mode 100644 index 0000000000..d64a9e8392 --- /dev/null +++ b/src/screens/dashboard/components/live-systems-card.tsx @@ -0,0 +1,175 @@ +import { STATUS_TONE } from '../lib/status-meta' +import type { + DashboardLiveSystem, + DashboardLiveSystemsSection, + LiveSystemStatus, +} from '@/server/dashboard-aggregator' + +function formatTime(value: string | null): string { + if (!value) return 'no heartbeat' + const ts = Date.parse(value) + if (!Number.isFinite(ts)) return 'unknown' + return new Date(ts).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function StatusPill({ status }: { status: LiveSystemStatus }) { + const meta = STATUS_TONE(status) + return ( + + + {meta.label} + + ) +} + +function SystemRow({ system }: { system: DashboardLiveSystem }) { + const body = ( +
    +
    +
    +
    + {system.label} +
    +

    + {system.detail} +

    +
    + +
    +
    + + {system.source} + + + {formatTime(system.lastSeenAt)} + +
    +
    + ) + + if (!system.href) return body + return ( + + {body} + + ) +} + +export function LiveSystemsCard({ + liveSystems, +}: { + liveSystems: DashboardLiveSystemsSection | null +}) { + if (!liveSystems) return null + const critical = liveSystems.systems.filter( + (system) => system.status === 'offline' || system.status === 'degraded', + ) + const visibleSystems = [ + ...critical, + ...liveSystems.systems.filter( + (system) => system.status !== 'offline' && system.status !== 'degraded', + ), + ].slice(0, 10) + + return ( +
    +
    +
    +
    +
    +
    + Live Systems +
    +
    + Real source wiring map +
    +

    + Reads current source-of-truth endpoints only. No writes, no + secrets, no customer/vendor actions. +

    +
    +
    +
    +
    + {liveSystems.summary.operational + liveSystems.summary.connected} +
    +
    + live +
    +
    +
    +
    + {liveSystems.summary.approvalGated} +
    +
    + needs T +
    +
    +
    +
    + {liveSystems.summary.degraded} +
    +
    + warn +
    +
    +
    +
    + {liveSystems.summary.offline} +
    +
    + down +
    +
    +
    +
    + {liveSystems.summary.notWired + liveSystems.summary.reachable} +
    +
    + unwired +
    +
    +
    +
    + + {liveSystems.blockers.length > 0 ? ( +
    +
    + Needs attention +
    +
      + {liveSystems.blockers.slice(0, 3).map((blocker) => ( +
    • + {blocker} +
    • + ))} +
    +
    + ) : null} + +
    + {visibleSystems.map((system) => ( + + ))} +
    +
    +
    + ) +} diff --git a/src/screens/dashboard/components/mind-graph-card.tsx b/src/screens/dashboard/components/mind-graph-card.tsx new file mode 100644 index 0000000000..8d50bac38f --- /dev/null +++ b/src/screens/dashboard/components/mind-graph-card.tsx @@ -0,0 +1,1885 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as THREE from 'three' +import { GALAXY_PALETTE } from '../lib/nova-cockpit-theme' +import { + armRankPlacement, + buildGalaxyModel, + clamp, + clusterHue, + emberSize, + focusBodyForNavigation, + focusDistanceForSystem, + folderTintFor, + isHub, + obsidianUri, + recencyGlow, + resolveProjectedLabels, + selectLabelCandidates, + shortTitle, +} from './nova-galaxy-model' +import type { VaultGraphInsights } from '../../../server/vault-graph-insights' +import type { + CelestialBody, + GalaxyArm, + GalaxyModel, + KnowledgeGraphResponse, + PlanetarySystem, + ProjectedLabel, +} from './nova-galaxy-model' + +type NebulaRegion = { + armId: string + color: string + position: { x: number; y: number; z: number } + scale: number + opacity: number +} + +type Galaxy3DProps = { + model: GalaxyModel + bodyPlacements: Map + nebulaRegions: Array + selectedBody: CelestialBody | null + hoveredBody: CelestialBody | null + disabledArms: Set + searchTerm: string + thisWeekOnly: boolean + isLoading: boolean + onHover: (body: CelestialBody | null) => void + onSelect: (body: CelestialBody | null) => void +} +type CameraState = { + yaw: number + pitch: number + distance: number + target: THREE.Vector3 +} +// Embers-only: every planet-kind body (including the core) renders as a +// soft additive amber sprite instead of a textured sphere/ring/atmosphere +// mesh trio. The core stacks three sprites (outer halo + inner glow + +// cluster halo); every other planet gets its ember sprite plus a halo. +type PlanetObject = { + body: CelestialBody + system: PlanetarySystem + sprites: Array + materials: Array + baseScales: Array + baseOpacities: Array +} +// Every note star — tag-kind or planet-kind — is a soft additive sprite +// plus a soft tinted halo sprite. The galaxy IS the map: there is no +// separate "marker mesh" tier for the smaller notes anymore. +type TagObject = { + body: CelestialBody + sprite: THREE.Sprite + material: THREE.SpriteMaterial + halo: THREE.Sprite + haloMaterial: THREE.SpriteMaterial + baseScale: number +} +type CometObject = { + body: CelestialBody + group: THREE.Group + material: THREE.MeshBasicMaterial + tailMaterial: THREE.LineBasicMaterial +} +type LineObject = { + source: string + target: string + strength: number + material: THREE.LineDashedMaterial +} + +const SPACE = '#030814' +const SPACE_SOFT = '#071426' +const AMBER = '#FF8C1A' +const GOLD = '#FFB347' +const TAN = '#D4A276' +const COPPER = '#7A441E' +// TILT_DEG=32 from the approved demo — steeper than the previous 16° pitch +// so the disc reads as a tilted-plane spiral instead of a near-top-down view. +const CAMERA_TILT_DEG = 32 +const CAMERA_TILT_RAD = (CAMERA_TILT_DEG * Math.PI) / 180 +// Distance tuned so the ~62-unit-radius spiral fills roughly 60% of frame +// width at the 38° camera FOV (verified against the approved reference via +// the screenshot verify-loop). +const OVERVIEW_DISTANCE = 190 +const GLIDE_DURATION_MS = 600 +// Embers-only planet sizing: emberSize(degree) * ~2.2, matching the +// approved dust-forward demo (no textured spheres/rings/atmospheres). +const PLANET_EMBER_SCALE = 2.2 +// Every tag-kind note star — bigger per-star than the old marker-mesh era +// so ~400 real notes still read as a living young galaxy even though they +// are sparser than the old 19k-point procedural dust field (correctly so +// — every point is now a real note, not decorative filler). +const NOTE_EMBER_SCALE = 1.6 +const CORE_INNER_MULTIPLIER = 1.5 +const CORE_OUTER_MULTIPLIER = 2.7 +// Every note star gets a soft tinted halo riding behind it. Kept modest — +// with hundreds of real notes bulging near the core on a dense arm, a +// bigger/brighter halo stacks additively into a blown-out disc instead of +// a readable cluster of individual stars. +const HALO_SCALE_MULTIPLIER = 2 +const HALO_OPACITY = 0.09 +// Reference radius for the nebula-scale clamp only — matches +// armRankPlacement's own coordinate space (radius runs 2.5 .. 2.5 + +// ARM_SPIRAL_MAX_RADIUS(52) = 54.5). +const GALAXY_RADIUS = 54.5 +// Nebula sprites read as subtle tints riding the arms, not floating blobs: +// 0.14-0.18 opacity per the approved demo. +const NEBULA_MIN_OPACITY = 0.14 +const NEBULA_MAX_OPACITY = 0.18 +const NEBULA_MAX_SCALE = GALAXY_RADIUS * 0.22 + +function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2 +} + +async function readKnowledgeGraph(): Promise { + const response = await fetch('/api/knowledge/graph') + if (!response.ok) throw new Error(`knowledge graph ${response.status}`) + return (await response.json()) as KnowledgeGraphResponse +} + +function formatModified(value?: string): string { + if (!value) return 'unknown' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'unknown' + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(date) +} + +function bodyLabel(body: CelestialBody): string { + if (body.kind === 'core') return 'Galactic core' + if (body.kind === 'planet') return 'Planet' + if (body.kind === 'tag') return 'Text tag' + return 'Comet' +} + +function systemForBody( + model: GalaxyModel, + body: CelestialBody | null, +): PlanetarySystem | null { + if (!body) return null + return model.systemByBodyId.get(body.id) ?? null +} + +function activeSystemIds( + model: GalaxyModel, + selectedBody: CelestialBody | null, + hoveredBody: CelestialBody | null, +): Set { + const ids = new Set() + const selectedSystem = systemForBody(model, selectedBody) + const hoveredSystem = systemForBody(model, hoveredBody) + if (selectedSystem) ids.add(selectedSystem.id) + if (hoveredSystem) ids.add(hoveredSystem.id) + return ids +} + +function matchesSearch(body: CelestialBody, searchTerm: string): boolean { + const query = searchTerm.trim().toLowerCase() + if (!query) return true + return ( + body.title.toLowerCase().includes(query) || + body.path.toLowerCase().includes(query) || + body.folder.toLowerCase().includes(query) + ) +} + +function warmth(body: CelestialBody): number { + if (body.kind === 'core') return 1 + if (body.recencyTier === 'hot') return 0.92 + if (body.recencyTier === 'warm') return 0.68 + return 0.34 +} + +function bodyPosition(body: CelestialBody): THREE.Vector3 { + return new THREE.Vector3(body.baseX, body.baseY, body.baseZ) +} + +/** Base sprite scale for a planet-kind ember: emberSize(degree) * ~2.2, + * with a small size bump for hub bodies (mirrors the tag-ember rule). */ +function planetEmberBaseSize(body: CelestialBody): number { + const hub = isHub(body.degree) + return emberSize(body.degree) * PLANET_EMBER_SCALE * (hub ? 1.12 : 1) +} + +/** Base sprite scale for a tag-kind note star: emberSize(degree) * ~1.6, + * with a small size bump for hub bodies. */ +function noteEmberBaseSize(body: CelestialBody): number { + const hub = isHub(body.degree) + return emberSize(body.degree) * NOTE_EMBER_SCALE * (hub ? 1.15 : 1) +} + +type ArmRank = { rankInArm: number; armNoteCount: number } + +/** + * Ranks every planet-/tag-kind body within its arm by `modified` ascending + * (oldest first — missing dates sort as oldest of all), so armRankPlacement + * can place the oldest notes nearest the core and the newest at the + * growing tip. Core and comets are excluded — the core sits at the + * origin and comets keep their own drifting-orphan placement. + */ +function computeArmRanks(model: GalaxyModel): Map { + const byArm = new Map>() + for (const body of model.bodies) { + if (body.kind === 'core' || body.kind === 'comet') continue + const bucket = byArm.get(body.armId) ?? [] + bucket.push(body) + byArm.set(body.armId, bucket) + } + const ranks = new Map() + for (const bucket of byArm.values()) { + const sorted = [...bucket].sort((a, b) => { + const aTime = a.modified ? Date.parse(a.modified) : Number.NaN + const bTime = b.modified ? Date.parse(b.modified) : Number.NaN + const aValue = Number.isFinite(aTime) ? aTime : Number.NEGATIVE_INFINITY + const bValue = Number.isFinite(bTime) ? bTime : Number.NEGATIVE_INFINITY + if (aValue !== bValue) return aValue - bValue + return a.id.localeCompare(b.id) + }) + sorted.forEach((body, index) => { + ranks.set(body.id, { rankInArm: index, armNoteCount: sorted.length }) + }) + } + return ranks +} + +/** + * The one true screen position for every rendered body — the galaxy IS + * the map: every point of light comes from a real vault note, placed by + * `armRankPlacement` from its rank (oldest→newest) among its arm's real + * notes. No procedural dust, no blended legacy layout. The core sits at + * the origin; comets (orphans) keep their own drifting-rim position. + */ +function computeBodyPlacements(model: GalaxyModel): Map { + const ranks = computeArmRanks(model) + const armCount = Math.max(1, model.arms.length) + const placements = new Map() + for (const body of model.bodies) { + if (body.kind === 'core') { + placements.set(body.id, new THREE.Vector3(0, 0, 0)) + continue + } + if (body.kind === 'comet') { + placements.set(body.id, bodyPosition(body)) + continue + } + const rank = ranks.get(body.id) + if (!rank) { + placements.set(body.id, bodyPosition(body)) + continue + } + const placed = armRankPlacement( + rank.rankInArm, + rank.armNoteCount, + body.armIndex, + armCount, + body.id, + ) + placements.set(body.id, new THREE.Vector3(placed.x, placed.y, placed.z)) + } + return placements +} + +function createStarTexture(): THREE.Texture { + const canvas = document.createElement('canvas') + canvas.width = 32 + canvas.height = 32 + const context = canvas.getContext('2d')! + const gradient = context.createRadialGradient(16, 16, 0, 16, 16, 16) + gradient.addColorStop(0, 'rgba(255, 241, 204, 1)') + gradient.addColorStop(0.34, 'rgba(255, 210, 122, 0.42)') + gradient.addColorStop(1, 'rgba(255, 210, 122, 0)') + context.fillStyle = gradient + context.fillRect(0, 0, 32, 32) + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + return texture +} + +/** Soft additive ember sprite — reuses the star/glow canvas texture so + * every planet-kind body (and the core) renders as a glowing point of + * light instead of a textured sphere. */ +function createEmberSprite( + texture: THREE.Texture, + color: string, + opacity: number, +): THREE.Sprite { + const material = new THREE.SpriteMaterial({ + map: texture, + color: new THREE.Color(color), + transparent: true, + opacity, + depthWrite: false, + blending: THREE.AdditiveBlending, + }) + return new THREE.Sprite(material) +} + +function useEscape(handler: () => void): void { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') handler() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [handler]) +} + +function createClusterNebula(color: string, opacity: number): THREE.Sprite { + const canvas = document.createElement('canvas') + canvas.width = 512 + canvas.height = 512 + const context = canvas.getContext('2d')! + const base = new THREE.Color(color) + const glow = context.createRadialGradient(256, 256, 0, 256, 256, 256) + glow.addColorStop( + 0, + `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.10)`, + ) + glow.addColorStop( + 0.34, + `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.05)`, + ) + glow.addColorStop( + 0.72, + `rgba(${Math.round(base.r * 255)}, ${Math.round(base.g * 255)}, ${Math.round(base.b * 255)}, 0.02)`, + ) + glow.addColorStop(1, 'rgba(9, 10, 18, 0)') + context.fillStyle = glow + context.fillRect(0, 0, 512, 512) + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + // Clamped hard: stacked additive nebula sprites were blowing out to a + // solid cyan-white blob before this cap. + opacity: clamp(opacity, 0, NEBULA_MAX_OPACITY), + blending: THREE.AdditiveBlending, + }) + return new THREE.Sprite(material) +} + +function clusterHueColor(hue: ReturnType): string { + switch (hue) { + case 'blue': + return GALAXY_PALETTE.blues[0] + case 'blue2': + return GALAXY_PALETTE.blues[1] + case 'amber': + return GALAXY_PALETTE.ambers[0] + case 'blend': + return GALAXY_PALETTE.ambers[2] + } +} + +/** + * One nebula tint sprite per arm, positioned at the centroid of that + * arm's REAL star positions (not a point on some abstract curve) so the + * tint always reads as riding the actual notes. Scale grows with + * sqrt(note count) — more real notes, a visibly bigger cluster glow. + */ +function buildNebulaRegions( + model: GalaxyModel, + placements: Map, +): Array { + return model.arms.map((arm, index) => { + const armBodies = model.bodies.filter( + (body) => body.armId === arm.id && body.kind !== 'comet', + ) + const centroid = new THREE.Vector3() + let counted = 0 + for (const body of armBodies) { + const position = placements.get(body.id) + if (!position) continue + centroid.add(position) + counted += 1 + } + if (counted > 0) centroid.divideScalar(counted) + const noteCount = Math.max(1, armBodies.length) + return { + armId: arm.id, + color: clusterHueColor(clusterHue(index)), + position: { x: centroid.x, y: centroid.y, z: centroid.z }, + scale: clamp(3 + Math.sqrt(noteCount) * 2.2, 6, NEBULA_MAX_SCALE), + opacity: clamp( + NEBULA_MIN_OPACITY + Math.min(0.04, noteCount * 0.003), + NEBULA_MIN_OPACITY, + NEBULA_MAX_OPACITY, + ), + } + }) +} + +type ClusterLabel = { + armId: string + name: string + x: number + y: number + opacity: number +} + +function Galaxy3D({ + model, + bodyPlacements, + nebulaRegions, + selectedBody, + hoveredBody, + disabledArms, + searchTerm, + thisWeekOnly, + isLoading, + onHover, + onSelect, +}: Galaxy3DProps) { + const hostRef = useRef(null) + const [labels, setLabels] = useState>([]) + const [clusterLabels, setClusterLabels] = useState>([]) + const selectedRef = useRef(selectedBody) + const hoveredRef = useRef(hoveredBody) + const disabledRef = useRef>(disabledArms) + const searchRef = useRef(searchTerm) + const thisWeekRef = useRef(thisWeekOnly) + const reducedMotionRef = useRef(false) + const cameraStateRef = useRef({ + yaw: -0.92, + pitch: CAMERA_TILT_RAD, + distance: OVERVIEW_DISTANCE, + target: new THREE.Vector3(0, 4, 0), + }) + const glideRef = useRef<{ + active: boolean + startedAt: number + fromTarget: THREE.Vector3 + fromDistance: number + toTarget: THREE.Vector3 + toDistance: number + } | null>(null) + + useEffect(() => { + selectedRef.current = selectedBody + hoveredRef.current = hoveredBody + disabledRef.current = disabledArms + searchRef.current = searchTerm + thisWeekRef.current = thisWeekOnly + }, [disabledArms, hoveredBody, searchTerm, selectedBody, thisWeekOnly]) + + useEffect(() => { + const host = hostRef.current + if (!host) return + + const scene = new THREE.Scene() + scene.background = new THREE.Color(SPACE) + scene.fog = new THREE.FogExp2(SPACE_SOFT, 0.0062) + const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 520) + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: 'low-power', + }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)) + renderer.setClearColor(SPACE, 1) + // Hundreds of real notes stack additive ember + halo sprites, densest + // right at the core where the log spiral bulges — filmic tone mapping + // gives that stack a soft rolloff instead of clipping to a flat white + // disc, matching the reference's warm-but-textured core. + renderer.toneMapping = THREE.ACESFilmicToneMapping + renderer.toneMappingExposure = 0.82 + renderer.domElement.className = + 'nova-galaxy-canvas absolute inset-0 size-full' + renderer.domElement.setAttribute( + 'aria-label', + '3D Obsidian knowledge galaxy', + ) + host.appendChild(renderer.domElement) + + const ambient = new THREE.AmbientLight('#C8B59A', 0.28) + const key = new THREE.DirectionalLight('#FFE4A6', 2.7) + key.position.set(-28, 26, 34) + const coreLight = new THREE.PointLight('#FFB347', 1.8, 140) + coreLight.position.set(-10, 12, 24) + const fill = new THREE.PointLight('#163456', 0.58, 180) + fill.position.set(34, -16, -30) + // Rim stays in the canon family — the old #7D9573 green rim light + // washed every ember with the pre-canon tint. + const rim = new THREE.DirectionalLight('#2E7FD9', 0.35) + rim.position.set(18, 8, -40) + scene.add(ambient, key, coreLight, fill, rim) + + // No decorative starfield, no procedural dust field. Every rendered + // light from here on is a real vault note (or its halo / a link + // filament / a cluster nebula / the core) — the galaxy IS the map. + const starTexture = createStarTexture() + + const nebulaSprites = nebulaRegions.map((region) => { + const sprite = createClusterNebula(region.color, region.opacity) + sprite.position.set(region.position.x, region.position.y, region.position.z) + sprite.scale.set(region.scale, region.scale * 0.55, 1) + scene.add(sprite) + return sprite + }) + + const planetObjects = new Map() + const tagObjects: Array = [] + const hitObjects: Array = [] + const bodyPositions = new Map() + // Every rendered body's screen position comes from armRankPlacement, + // computed once up front in the card's data-prep (bodyPlacements). + // Clone on read — the placements map is shared across renders/labels. + const getPosition = (id: string): THREE.Vector3 => { + const found = bodyPlacements.get(id) + return found ? found.clone() : new THREE.Vector3() + } + + for (const system of model.systems) { + const body = system.planet + const planetHub = isHub(body.degree) + const baseSize = planetEmberBaseSize(body) + const position = getPosition(body.id) + const sprites: Array = [] + const materials: Array = [] + const baseScales: Array = [] + const baseOpacities: Array = [] + + if (body.kind === 'core') { + // Bigger soft amber core-glow: two stacked additive sprites — a + // wide soft outer halo plus a brighter, tighter inner glow — + // rather than a single mesh. + const outerScale = baseSize * CORE_OUTER_MULTIPLIER + const innerScale = baseSize * CORE_INNER_MULTIPLIER + const outer = createEmberSprite(starTexture, AMBER, 0.4) + outer.scale.set(outerScale, outerScale, 1) + outer.position.copy(position) + outer.userData.bodyId = body.id + // Warm white, not gold: the reference core is a bright compact + // white-hot point that fades to amber at its edge (the AMBER outer + // halo below), not a solid gold disc. + const inner = createEmberSprite(starTexture, '#FFF1CC', 0.95) + inner.scale.set(innerScale, innerScale, 1) + inner.position.copy(position) + inner.userData.bodyId = body.id + scene.add(outer, inner) + sprites.push(outer, inner) + materials.push( + outer.material as THREE.SpriteMaterial, + inner.material as THREE.SpriteMaterial, + ) + baseScales.push(outerScale, innerScale) + baseOpacities.push(0.4, 0.92) + + // Every star gets a soft halo, including the core. + const haloScale = outerScale * HALO_SCALE_MULTIPLIER + const halo = createEmberSprite(starTexture, folderTintFor(body.folder), HALO_OPACITY) + halo.scale.set(haloScale, haloScale, 1) + halo.position.copy(position) + scene.add(halo) + sprites.push(halo) + materials.push(halo.material as THREE.SpriteMaterial) + baseScales.push(haloScale) + baseOpacities.push(HALO_OPACITY) + } else { + // Every other planet-kind body: a single large ember, no textured + // sphere/ring/atmosphere meshes. Amber is reserved for hub embers + // (+ the core glow + one amber cluster tint) — regular planets read + // neon-blue so the arms stay blue-dominant per the reference. + const scale = baseSize + const sprite = createEmberSprite( + starTexture, + planetHub ? GOLD : GALAXY_PALETTE.blues[0], + planetHub ? 0.92 : 0.82, + ) + sprite.scale.set(scale, scale, 1) + sprite.position.copy(position) + sprite.userData.bodyId = body.id + scene.add(sprite) + sprites.push(sprite) + materials.push(sprite.material as THREE.SpriteMaterial) + baseScales.push(scale) + baseOpacities.push(planetHub ? 0.92 : 0.82) + + // Every star gets a soft halo, tinted by its cluster. + const haloScale = scale * HALO_SCALE_MULTIPLIER + const halo = createEmberSprite(starTexture, folderTintFor(body.folder), HALO_OPACITY) + halo.scale.set(haloScale, haloScale, 1) + halo.position.copy(position) + scene.add(halo) + sprites.push(halo) + materials.push(halo.material as THREE.SpriteMaterial) + baseScales.push(haloScale) + baseOpacities.push(HALO_OPACITY) + } + + bodyPositions.set(body.id, position.clone()) + planetObjects.set(body.id, { + body, + system, + sprites, + materials, + baseScales, + baseOpacities, + }) + for (const tag of system.tags) { + const armTint = + model.arms.find((arm) => arm.id === tag.armId)?.tint ?? + folderTintFor(tag.folder) + const hub = isHub(tag.degree) + const baseScale = noteEmberBaseSize(tag) + const tagPosition = getPosition(tag.id) + + const sprite = createEmberSprite( + starTexture, + hub ? GOLD : armTint, + hub ? 0.88 : 0.62, + ) + sprite.scale.set(baseScale, baseScale, 1) + sprite.position.copy(tagPosition) + sprite.userData.bodyId = tag.id + scene.add(sprite) + + const haloScale = baseScale * HALO_SCALE_MULTIPLIER + const halo = createEmberSprite(starTexture, folderTintFor(tag.folder), HALO_OPACITY) + halo.scale.set(haloScale, haloScale, 1) + halo.position.copy(tagPosition) + scene.add(halo) + + bodyPositions.set(tag.id, tagPosition.clone()) + hitObjects.push(sprite) + tagObjects.push({ + body: tag, + sprite, + material: sprite.material as THREE.SpriteMaterial, + halo, + haloMaterial: halo.material as THREE.SpriteMaterial, + baseScale, + }) + } + } + + const cometObjects: Array = [] + model.comets.forEach((body) => { + const group = new THREE.Group() + group.position.copy(getPosition(body.id)) + const material = new THREE.MeshBasicMaterial({ + color: TAN, + transparent: true, + opacity: 0.5, + }) + const comet = new THREE.Mesh( + new THREE.SphereGeometry(0.16, 12, 8), + material, + ) + comet.userData.bodyId = body.id + hitObjects.push(comet) + const tailMaterial = new THREE.LineBasicMaterial({ + color: COPPER, + transparent: true, + opacity: 0.18, + }) + const tail = new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(-1.8, 0.22, 0), + new THREE.Vector3(-0.18, 0, 0), + ]), + tailMaterial, + ) + group.add(tail, comet) + scene.add(group) + bodyPositions.set(body.id, group.position.clone()) + cometObjects.push({ body, group, material, tailMaterial }) + }) + + const homeTarget = (() => { + if (model.systems.length === 0) return new THREE.Vector3(0, 4, 0) + const target = new THREE.Vector3() + let weightTotal = 0 + // Bias overview toward top systems but keep the camera off-center so + // composition reads as layered depth instead of a dead-on blob. + for (const system of model.systems.slice(0, 10)) { + const position = bodyPositions.get(system.planet.id) + if (!position) continue + const weight = Math.max(1, Math.sqrt(system.totalLinks)) + target.addScaledVector(position, weight) + weightTotal += weight + } + if (weightTotal <= 0) return new THREE.Vector3(0, 4, 0) + target.divideScalar(weightTotal) + target.y += 3.5 + target.x -= 6 + target.z += 4 + return target + })() + if (!selectedRef.current) { + cameraStateRef.current.target.copy(homeTarget) + cameraStateRef.current.distance = OVERVIEW_DISTANCE + cameraStateRef.current.yaw = -0.92 + cameraStateRef.current.pitch = CAMERA_TILT_RAD + } + const lineObjects: Array = [] + for (const link of model.links) { + const source = bodyPositions.get(link.source) + const target = bodyPositions.get(link.target) + if (!source || !target) continue + const sourceBody = model.bodyById.get(link.source) + const targetBody = model.bodyById.get(link.target) + const sameSystem = + Boolean(sourceBody?.systemId) && + sourceBody?.systemId === targetBody?.systemId + const geometry = new THREE.BufferGeometry().setFromPoints([ + source, + target, + ]) + const material = new THREE.LineDashedMaterial({ + color: sameSystem + ? folderTintFor(sourceBody?.folder ?? 'vault') + : GOLD, + transparent: true, + opacity: sameSystem ? 0.09 : 0.028, + dashSize: sameSystem ? 0.22 : 0.34, + gapSize: sameSystem ? 0.36 : 0.5, + depthWrite: false, + }) + const line = new THREE.LineSegments(geometry, material) + line.computeLineDistances() + scene.add(line) + lineObjects.push({ + source: link.source, + target: link.target, + strength: link.strength, + material, + }) + } + + const spokeObjects: Array = [] + if (model.core) { + const corePosition = bodyPositions.get(model.core.id) + if (corePosition) { + for (const system of model.systems) { + if (system.planet.id === model.core.id) continue + const systemPosition = bodyPositions.get(system.planet.id) + if (!systemPosition) continue + const geometry = new THREE.BufferGeometry().setFromPoints([ + systemPosition, + corePosition, + ]) + const material = new THREE.LineBasicMaterial({ + color: AMBER, + transparent: true, + opacity: 0.035, + depthWrite: false, + }) + const line = new THREE.Line(geometry, material) + scene.add(line) + spokeObjects.push({ + source: system.planet.id, + target: model.core.id, + strength: 0, + material: material as unknown as THREE.LineDashedMaterial, + }) + } + } + } + + const raycaster = new THREE.Raycaster() + const pointer = new THREE.Vector2() + let width = 1 + let height = 1 + let frame = 0 + let lastLabelUpdate = 0 + let lastClusterLabelUpdate = 0 + let lastSelectedId: string | null = selectedRef.current?.id ?? null + let isDragging = false + let lastPointer = { x: 0, y: 0 } + let pointerMoved = false + let pointerParallax = { x: 0, y: 0 } + let parallaxYaw = 0 + let parallaxPitch = 0 + const ROTATION_PERIOD_MS = 110_000 + + const resize = () => { + const bounds = host.getBoundingClientRect() + width = Math.max(1, bounds.width) + height = Math.max(1, bounds.height) + renderer.setSize(width, height, false) + camera.aspect = width / height + camera.updateProjectionMatrix() + } + + const updateCamera = (now: number) => { + const state = cameraStateRef.current + const selected = selectedRef.current + const focusBody = focusBodyForNavigation(model, selected) + const selectedSystem = focusBody + ? model.systemByBodyId.get(focusBody.id) + : null + const selectedPosition = focusBody ? bodyPositions.get(focusBody.id) : null + const desiredTarget = selectedPosition + ? selectedPosition.clone().add(new THREE.Vector3(0, 1.4, 0)) + : homeTarget + const desiredDistance = selectedSystem + ? focusDistanceForSystem(selectedSystem) + : OVERVIEW_DISTANCE + + // L3: click = go & read — a genuine selection change kicks off a + // fixed ~600ms eased glide instead of the open-ended exponential + // follow (which converges but has no defined duration). + const currentSelectedId = selected?.id ?? null + if (currentSelectedId !== lastSelectedId && !reducedMotionRef.current) { + glideRef.current = { + active: true, + startedAt: now, + fromTarget: state.target.clone(), + fromDistance: state.distance, + toTarget: desiredTarget.clone(), + toDistance: desiredDistance, + } + lastSelectedId = currentSelectedId + } else if (currentSelectedId !== lastSelectedId) { + // Reduced motion: snap instead of gliding. + state.target.copy(desiredTarget) + state.distance = desiredDistance + lastSelectedId = currentSelectedId + } + + const glide = glideRef.current + if (glide?.active) { + const t = clamp((now - glide.startedAt) / GLIDE_DURATION_MS, 0, 1) + const eased = easeInOutCubic(t) + state.target.lerpVectors(glide.fromTarget, glide.toTarget, eased) + state.distance = glide.fromDistance + (glide.toDistance - glide.fromDistance) * eased + if (t >= 1) glide.active = false + } else { + // No active glide (idle overview, or drag-driven re-target): keep + // the original soft exponential follow so free-look drag doesn't + // feel snappy/robotic. + state.target.lerp(desiredTarget, reducedMotionRef.current ? 1 : 0.055) + state.distance += + (desiredDistance - state.distance) * + (reducedMotionRef.current ? 1 : 0.048) + } + + if (!selected && !isDragging && !reducedMotionRef.current) { + state.yaw += Math.sin(now / 14000) * 0.00016 + 0.00012 + state.pitch += + (CAMERA_TILT_RAD + Math.sin(now / 21000) * 0.03 - state.pitch) * 0.01 + } + state.pitch = clamp(state.pitch, -0.62, 0.78) + state.distance = clamp(state.distance, 11, 220) + const appliedYaw = state.yaw + parallaxYaw + const appliedPitch = clamp(state.pitch + parallaxPitch, -0.62, 0.78) + const cosPitch = Math.cos(appliedPitch) + camera.position.set( + state.target.x + Math.sin(appliedYaw) * cosPitch * state.distance, + state.target.y + Math.sin(appliedPitch) * state.distance, + state.target.z + Math.cos(appliedYaw) * cosPitch * state.distance, + ) + camera.lookAt(state.target) + } + + const bodyVisibleOpacity = ( + body: CelestialBody, + activeIds: Set, + query: string, + nowIso: string = new Date().toISOString(), + ) => { + const disabled = disabledRef.current.has(body.armId) + let opacity = disabled ? 0.15 : 1 + if (activeIds.size > 0 && body.systemId && !activeIds.has(body.systemId)) + opacity *= 0.34 + if (query && !matchesSearch(body, query)) opacity *= 0.16 + // L5 "this week" filter: dims embers with recencyGlow < 1 (i.e. not + // touched in the last 7 days, per recencyGlow's hot-day cutoff) — + // visuals only, bodies stay in the model. + if (thisWeekRef.current) { + const glow = recencyGlow(body.modified ?? body.updated ?? '', nowIso) + if (glow < 1) opacity *= 0.2 + } + return opacity + } + const updateVisualState = (now: number) => { + const selected = selectedRef.current + const hovered = hoveredRef.current + const query = searchRef.current.trim() + const activeIds = activeSystemIds(model, selected, hovered) + const activeBodyId = selected?.id ?? hovered?.id + const nowIso = new Date().toISOString() + // L2: bodies directly wikilinked to the hovered ember brighten + // alongside the neon-blue edge flare below. + const hoveredLinkedIds = new Set() + if (hovered) { + for (const link of lineObjects) { + if (link.source === hovered.id) hoveredLinkedIds.add(link.target) + else if (link.target === hovered.id) hoveredLinkedIds.add(link.source) + } + } + for (const planet of planetObjects.values()) { + const opacity = bodyVisibleOpacity(planet.body, activeIds, query, nowIso) + const active = + activeIds.has(planet.system.id) || planet.body.id === activeBodyId + const warm = warmth(planet.body) + // Existing slow hub pulse — the sin-based scale wobble every + // recency-hot body already got, now applied to sprite scale + // instead of mesh scale/rotation (billboards don't spin). + const pulse = + planet.body.recencyTier === 'hot' && !reducedMotionRef.current + ? 1 + Math.sin(now / 720 + planet.body.orbitPhase) * 0.07 + : 1 + const activeBoost = active ? 1.16 : 0.9 + warm * 0.1 + for (let index = 0; index < planet.sprites.length; index += 1) { + const scale = planet.baseScales[index] * pulse * activeBoost + planet.sprites[index].scale.set(scale, scale, 1) + planet.materials[index].opacity = + opacity * planet.baseOpacities[index] * (active ? 1 : 0.88) + } + } + for (const tag of tagObjects) { + const opacity = bodyVisibleOpacity(tag.body, activeIds, query, nowIso) + const active = + (tag.body.systemId && activeIds.has(tag.body.systemId)) || + tag.body.id === activeBodyId + const linked = !active && hoveredLinkedIds.has(tag.body.id) + const pulse = + tag.body.recencyTier === 'hot' && !reducedMotionRef.current + ? 1 + Math.sin(now / 680 + tag.body.orbitPhase) * 0.13 + : 1 + // recencyGlow drives alpha so this week's notes visibly shine. + const glow = recencyGlow(tag.body.modified ?? tag.body.updated ?? '', nowIso) + const activeBoost = active ? 1.28 : linked ? 1.12 : 1 + const scale = tag.baseScale * pulse * activeBoost + tag.sprite.scale.set(scale, scale, 1) + tag.material.opacity = + opacity * (active ? 1 : linked ? 0.88 : 0.62) * (0.55 + glow * 0.45) + const haloScale = scale * HALO_SCALE_MULTIPLIER + tag.halo.scale.set(haloScale, haloScale, 1) + tag.haloMaterial.opacity = clamp( + opacity * HALO_OPACITY * (0.55 + glow * 0.45) * (active ? 1.4 : linked ? 1.15 : 1), + 0, + 0.22, + ) + } + for (const comet of cometObjects) { + const drift = reducedMotionRef.current + ? 0 + : now / comet.body.orbitPeriod / 28 + comet.group.position.x = + comet.body.baseX + Math.sin(drift + comet.body.orbitPhase) * 8 + comet.group.position.y = + comet.body.baseY + Math.cos(drift * 0.7 + comet.body.orbitPhase) * 2 + comet.group.position.z = + comet.body.baseZ + Math.cos(drift + comet.body.orbitPhase) * 8 + const opacity = bodyVisibleOpacity(comet.body, activeIds, query, nowIso) + comet.material.opacity = opacity * 0.48 + comet.tailMaterial.opacity = opacity * 0.16 + } + for (const link of lineObjects) { + const source = model.bodyById.get(link.source) + const target = model.bodyById.get(link.target) + const active = + (source?.systemId && activeIds.has(source.systemId)) || + (target?.systemId && activeIds.has(target.systemId)) || + source?.id === activeBodyId || + target?.id === activeBodyId + // L2 edge-flare: hovered ember's links brighten to neon-blue for + // this frame only — recomputed every frame, never persisted state. + const isHoverFlare = + Boolean(hovered) && (source?.id === hovered?.id || target?.id === hovered?.id) + const searched = Boolean( + query && + ((source && matchesSearch(source, query)) || + (target && matchesSearch(target, query))), + ) + const backbone = link.strength >= 12 + const sameSystem = + Boolean(source?.systemId) && source?.systemId === target?.systemId + if (isHoverFlare) { + link.material.color.set(GALAXY_PALETTE.blues[0]) + link.material.opacity = 0.72 + } else { + link.material.color.set( + sameSystem && source ? folderTintFor(source.folder) : GOLD, + ) + // Same-cluster filaments read slightly brighter at rest so the + // constellation structure between real notes stays legible. + if (active) link.material.opacity = 0.42 + else if (searched) link.material.opacity = 0.22 + else if (backbone) link.material.opacity = 0.07 + else link.material.opacity = sameSystem ? 0.09 : 0.025 + } + } + } + + const projectLabels = () => { + const selected = selectedRef.current + const hovered = hoveredRef.current + const query = searchRef.current.trim() + const activeIds = activeSystemIds(model, selected, hovered) + const mode = selected || hovered || query ? 'focus' : 'overview' + const candidates = selectLabelCandidates({ + model, + selectedBody: selected, + hoveredBody: hovered, + searchTerm: query, + mode, + }) + const projected: Array = [] + const vector = new THREE.Vector3() + for (const candidate of candidates) { + const point = getPosition(candidate.body.id) + if (candidate.kind === 'planet') { + const right = new THREE.Vector3().setFromMatrixColumn( + camera.matrixWorld, + 0, + ) + const up = new THREE.Vector3().setFromMatrixColumn( + camera.matrixWorld, + 1, + ) + const size = planetEmberBaseSize(candidate.body) + const offset = + candidate.body.kind === 'core' + ? size * CORE_OUTER_MULTIPLIER * 0.9 + : size * 0.85 + point.addScaledVector(right, offset) + point.addScaledVector(up, offset * 0.28) + } + vector.copy(point).project(camera) + if (vector.z < -1 || vector.z > 1) continue + const distance = camera.position.distanceTo(point) + if ( + candidate.kind === 'tag' && + mode === 'overview' && + distance > 52 && + candidate.body.id !== hovered?.id && + candidate.body.id !== selected?.id + ) { + continue + } + const active = + Boolean( + candidate.body.systemId && activeIds.has(candidate.body.systemId), + ) || + candidate.body.id === hovered?.id || + candidate.body.id === selected?.id + const searched = Boolean(query && matchesSearch(candidate.body, query)) + let opacity = bodyVisibleOpacity(candidate.body, activeIds, query) + if (candidate.kind === 'tag' && !active && !searched) opacity *= 0.34 + if (candidate.kind === 'planet' && !active && !searched) + opacity *= mode === 'overview' ? 0.9 : 0.72 + projected.push({ + id: candidate.id, + kind: candidate.kind, + body: candidate.body, + priority: candidate.priority, + x: (vector.x * 0.5 + 0.5) * width, + y: (-vector.y * 0.5 + 0.5) * height, + scale: clamp(1.08 - distance / 170, 0.58, 0.96), + opacity, + active: active || searched, + }) + } + setLabels( + resolveProjectedLabels(projected, mode === 'overview' ? 34 : 26), + ) + } + + // L1: always-on cluster/arm-level labels, thrown at 4Hz — deliberately + // coarser than the 90ms planet/tag cadence above so this cheap layer + // never gets starved by the denser one, and doesn't trigger re-renders + // at a rate that could cause a re-render storm. + const projectClusterLabels = () => { + const vector = new THREE.Vector3() + const projected: Array = [] + for (const region of nebulaRegions) { + vector.set(region.position.x, region.position.y, region.position.z) + vector.project(camera) + if (vector.z < -1 || vector.z > 1) continue + const arm = model.arms.find((a) => a.id === region.armId) + if (!arm) continue + const distance = camera.position.distanceTo( + new THREE.Vector3(region.position.x, region.position.y, region.position.z), + ) + projected.push({ + armId: region.armId, + name: arm.name, + x: (vector.x * 0.5 + 0.5) * width, + y: (-vector.y * 0.5 + 0.5) * height, + // Fades as the camera closes in — "constellation names always + // on, soft zoomed out, fade as camera closes in" (spec L1). + opacity: clamp(1 - (OVERVIEW_DISTANCE - distance) / OVERVIEW_DISTANCE, 0.12, 0.85), + }) + } + setClusterLabels(projected) + } + + const animate = (now: number) => { + if (!reducedMotionRef.current) { + for (const sprite of nebulaSprites) { + sprite.material.rotation = + (now / ROTATION_PERIOD_MS) * Math.PI * 2 * 0.4 + } + } + parallaxYaw += (pointerParallax.x * 0.05 - parallaxYaw) * 0.04 + parallaxPitch += (pointerParallax.y * 0.03 - parallaxPitch) * 0.04 + updateCamera(now) + updateVisualState(now) + refreshEmberScreenPositions() + renderer.render(scene, camera) + if (now - lastLabelUpdate > 90) { + projectLabels() + lastLabelUpdate = now + } + if (now - lastClusterLabelUpdate > 250) { + projectClusterLabels() + lastClusterLabelUpdate = now + } + if (!document.hidden && isVisible) { + frame = window.requestAnimationFrame(animate) + } + } + + const motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)') + reducedMotionRef.current = motionMedia.matches + const onMotionChange = () => { + reducedMotionRef.current = motionMedia.matches + } + const normalizedPointer = (event: PointerEvent) => { + const bounds = renderer.domElement.getBoundingClientRect() + pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1 + pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1 + } + // Comets still have real hit geometry (small sphere mesh) sized to + // their visual radius, so raycasting still finds them. Embers-only + // planets/core no longer have any mesh to raycast against — they are + // picked exclusively through the screen-space nearest-neighbor path + // below, same as tags. + const pickPlanet = (event: PointerEvent): CelestialBody | null => { + normalizedPointer(event) + raycaster.setFromCamera(pointer, camera) + const hit = raycaster.intersectObjects(hitObjects, false)[0] + return hit + ? (model.bodyById.get(String(hit.object.userData.bodyId)) ?? null) + : null + } + // L2 hover-identify + planet/core selection: nearest ember within 24 + // screen px wins over raycast picking for every kind that renders as a + // sprite (tags, planets, core). Tag embers are visually small dots and + // planet/core embers have no mesh at all post embers-only conversion, + // so screen-space nearest-neighbor is the primary picker; raycast only + // remains as a fallback for comets (which keep a real hit-sphere). + const NEAREST_EMBER_PX = 24 + const emberScreenPositions = new Map() + const refreshEmberScreenPositions = () => { + const vector = new THREE.Vector3() + emberScreenPositions.clear() + for (const tag of tagObjects) { + vector.copy(tag.sprite.position).project(camera) + if (vector.z < -1 || vector.z > 1) continue + emberScreenPositions.set(tag.body.id, { + x: (vector.x * 0.5 + 0.5) * width, + y: (-vector.y * 0.5 + 0.5) * height, + }) + } + for (const planet of planetObjects.values()) { + const anchor = planet.sprites[0] + if (!anchor) continue + vector.copy(anchor.position).project(camera) + if (vector.z < -1 || vector.z > 1) continue + emberScreenPositions.set(planet.body.id, { + x: (vector.x * 0.5 + 0.5) * width, + y: (-vector.y * 0.5 + 0.5) * height, + }) + } + } + const pickNearestEmber = (event: PointerEvent): CelestialBody | null => { + const bounds = renderer.domElement.getBoundingClientRect() + const px = event.clientX - bounds.left + const py = event.clientY - bounds.top + let best: { id: string; distance: number } | null = null + for (const [id, pos] of emberScreenPositions) { + const distance = Math.hypot(pos.x - px, pos.y - py) + if (distance <= NEAREST_EMBER_PX && (!best || distance < best.distance)) { + best = { id, distance } + } + } + return best ? (model.bodyById.get(best.id) ?? null) : null + } + const onPointerMove = (event: PointerEvent) => { + const bounds = renderer.domElement.getBoundingClientRect() + pointerParallax = { + x: ((event.clientX - bounds.left) / bounds.width) * 2 - 1, + y: ((event.clientY - bounds.top) / bounds.height) * 2 - 1, + } + if (isDragging) { + const dx = event.clientX - lastPointer.x + const dy = event.clientY - lastPointer.y + if (Math.abs(dx) + Math.abs(dy) > 2) pointerMoved = true + cameraStateRef.current.yaw -= dx * 0.0045 + cameraStateRef.current.pitch -= dy * 0.0038 + lastPointer = { x: event.clientX, y: event.clientY } + return + } + const nearestEmber = pickNearestEmber(event) + const body = nearestEmber ?? pickPlanet(event) + renderer.domElement.style.cursor = body ? 'pointer' : 'grab' + onHover(body) + } + const onPointerDown = (event: PointerEvent) => { + isDragging = true + pointerMoved = false + lastPointer = { x: event.clientX, y: event.clientY } + renderer.domElement.setPointerCapture(event.pointerId) + } + const onPointerUp = (event: PointerEvent) => { + isDragging = false + renderer.domElement.releasePointerCapture(event.pointerId) + // Click target matches whatever hover identified (L2 → L3): nearest + // screen-space ember wins over raycast so the body under the hover + // chip is always the body a click selects. + const body = pickNearestEmber(event) ?? pickPlanet(event) + if (!pointerMoved) onSelect(body) + } + const onPointerLeave = () => { + if (!isDragging) onHover(null) + } + const onWheel = (event: WheelEvent) => { + event.preventDefault() + cameraStateRef.current.distance += event.deltaY * 0.028 + cameraStateRef.current.distance = clamp( + cameraStateRef.current.distance, + 12, + 220, + ) + } + + let isVisible = true + const intersectionObserver = new IntersectionObserver( + ([entry]) => { + const wasVisible = isVisible + isVisible = entry.isIntersecting + if (isVisible && !wasVisible && !document.hidden) { + frame = window.requestAnimationFrame(animate) + } + }, + { threshold: 0.05 }, + ) + intersectionObserver.observe(host) + const onVisibilityChange = () => { + if (document.hidden) { + window.cancelAnimationFrame(frame) + } else if (isVisible) { + frame = window.requestAnimationFrame(animate) + } + } + document.addEventListener('visibilitychange', onVisibilityChange) + + const resizeObserver = new ResizeObserver(resize) + resizeObserver.observe(host) + resize() + motionMedia.addEventListener('change', onMotionChange) + renderer.domElement.addEventListener('pointermove', onPointerMove) + renderer.domElement.addEventListener('pointerdown', onPointerDown) + renderer.domElement.addEventListener('pointerup', onPointerUp) + renderer.domElement.addEventListener('pointerleave', onPointerLeave) + renderer.domElement.addEventListener('wheel', onWheel, { passive: false }) + frame = window.requestAnimationFrame(animate) + + return () => { + window.cancelAnimationFrame(frame) + resizeObserver.disconnect() + intersectionObserver.disconnect() + document.removeEventListener('visibilitychange', onVisibilityChange) + motionMedia.removeEventListener('change', onMotionChange) + renderer.domElement.removeEventListener('pointermove', onPointerMove) + renderer.domElement.removeEventListener('pointerdown', onPointerDown) + renderer.domElement.removeEventListener('pointerup', onPointerUp) + renderer.domElement.removeEventListener('pointerleave', onPointerLeave) + renderer.domElement.removeEventListener('wheel', onWheel) + setLabels([]) + setClusterLabels([]) + scene.traverse((object) => { + if ( + 'geometry' in object && + object.geometry instanceof THREE.BufferGeometry + ) { + object.geometry.dispose() + } + if ('material' in object) { + const material = object.material + if (Array.isArray(material)) + material.forEach((item) => item.dispose()) + else if (material instanceof THREE.Material) material.dispose() + } + }) + renderer.dispose() + host.removeChild(renderer.domElement) + } + }, [model, bodyPlacements, nebulaRegions, onHover, onSelect]) + + return ( +
    + {isLoading ? ( +
    + mapping vault +
    + ) : null} +
    + {clusterLabels.map((cluster) => ( +
    + {cluster.name} +
    + ))} +
    +
    + {labels.map((label) => ( + + ))} +
    +
    + ) +} +export function MindGraphCard() { + const [selectedId, setSelectedId] = useState(null) + const [inspectedId, setInspectedId] = useState(null) + const [hoveredId, setHoveredId] = useState(null) + const [disabledArms, setDisabledArms] = useState>(() => new Set()) + const [searchTerm, setSearchTerm] = useState('') + const [thisWeekOnly, setThisWeekOnly] = useState(false) + // L4 search-to-fly: debounce keystrokes 300ms before hitting the real + // vault search endpoint, so the 3D-highlight `searchTerm` state above + // (already wired) stays snappy while the results dropdown below stays + // network-quiet. + const [debouncedSearch, setDebouncedSearch] = useState('') + useEffect(() => { + const handle = window.setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300) + return () => window.clearTimeout(handle) + }, [searchTerm]) + const searchResultsQuery = useQuery({ + queryKey: ['dashboard', 'knowledge-search', debouncedSearch], + enabled: debouncedSearch.length > 0, + queryFn: async (): Promise<{ + results: Array<{ path: string; title: string; line: number; text: string }> + }> => { + const response = await fetch( + `/api/knowledge/search?q=${encodeURIComponent(debouncedSearch)}`, + ) + if (!response.ok) throw new Error(`search ${response.status}`) + return (await response.json()) as { + results: Array<{ path: string; title: string; line: number; text: string }> + } + }, + staleTime: 15_000, + }) + const searchResults = (searchResultsQuery.data?.results ?? []).slice(0, 8) + const graphQuery = useQuery({ + queryKey: ['dashboard', 'knowledge-galaxy'], + queryFn: readKnowledgeGraph, + staleTime: 20_000, + refetchInterval: 45_000, + refetchIntervalInBackground: true, + }) + const insightsQuery = useQuery({ + queryKey: ['dashboard', 'knowledge-insights'], + queryFn: async (): Promise<{ ok: boolean; insights: VaultGraphInsights }> => { + const response = await fetch('/api/knowledge/insights') + if (!response.ok) throw new Error(`insights ${response.status}`) + return (await response.json()) as { ok: boolean; insights: VaultGraphInsights } + }, + staleTime: 60_000, + refetchInterval: 120_000, + }) + const queryClient = useQueryClient() + const proposeCleanup = useMutation({ + mutationFn: async (rec: VaultGraphInsights['recommendations'][number]) => { + const response = await fetch('/api/knowledge/insights', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rec), + }) + if (!response.ok) throw new Error(`propose ${response.status}`) + return response.json() + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['taylor-approvals'] }) + void queryClient.invalidateQueries({ queryKey: ['nova-fabric'] }) + }, + }) + const model = useMemo( + () => buildGalaxyModel(graphQuery.data), + [graphQuery.data], + ) + const bodyPlacements = useMemo(() => computeBodyPlacements(model), [model]) + const nebulaRegions = useMemo( + () => buildNebulaRegions(model, bodyPlacements), + [model, bodyPlacements], + ) + const selectedBody = selectedId + ? (model.bodyById.get(selectedId) ?? null) + : null + const inspectedBody = inspectedId + ? (model.bodyById.get(inspectedId) ?? null) + : null + // Operational galaxy: a selected real note is read from the vault via + // /api/knowledge/read (content + resolved backlinks) — no invented data. + const selectedNotePath = + inspectedBody && inspectedBody.path.toLowerCase().endsWith('.md') + ? inspectedBody.path + : null + const noteQuery = useQuery({ + queryKey: ['dashboard', 'knowledge-note', selectedNotePath], + enabled: selectedNotePath !== null, + queryFn: async (): Promise<{ + page: { title: string; path: string; modified: string } + content: string + backlinks: Array + }> => { + const response = await fetch( + `/api/knowledge/read?path=${encodeURIComponent(selectedNotePath ?? '')}`, + ) + if (!response.ok) throw new Error(`read ${response.status}`) + return (await response.json()) as { + page: { title: string; path: string; modified: string } + content: string + backlinks: Array + } + }, + staleTime: 60_000, + }) + const hoveredBody = hoveredId ? (model.bodyById.get(hoveredId) ?? null) : null + const focusedBody = inspectedBody ?? hoveredBody ?? model.core + const largestSystems = model.systems.slice(0, 6) + const visibleComets = model.comets.slice(0, 3) + + useEffect(() => { + if (!selectedId) { + setInspectedId(null) + return + } + const arrival = window.setTimeout(() => setInspectedId(selectedId), 860) + return () => window.clearTimeout(arrival) + }, [selectedId]) + + const clearSelection = useCallback(() => { + setSelectedId(null) + setInspectedId(null) + }, []) + useEscape(clearSelection) + const handleHover = useCallback( + (body: CelestialBody | null) => setHoveredId(body?.id ?? null), + [], + ) + const handleSelect = useCallback( + (body: CelestialBody | null) => setSelectedId(body?.id ?? null), + [], + ) + const toggleArm = (arm: GalaxyArm) => { + setDisabledArms((current) => { + const next = new Set(current) + if (next.has(arm.id)) next.delete(arm.id) + else next.add(arm.id) + return next + }) + } + + return ( +
    +
    +
    +
    +
    +
    Operational map
    +

    + Nova’s working universe +

    +

    + Projects, agents, handoffs, and decisions mapped as a navigable + 3D field. Select a body to fly into its system. +

    +
    +
    + {graphQuery.isFetching ? 'syncing' : 'vault live'} -{' '} + {model.totals.bodies} bodies - {model.totals.links} links +
    +
    + +
    + +
    +
    + auto-sync 45s +
    + {model.arms.slice(0, 7).map((arm) => { + const disabled = disabledArms.has(arm.id) + return ( + + ) + })} + +
    + + {debouncedSearch && searchResults.length > 0 ? ( +
    + {searchResults.map((result) => { + const inGalaxy = model.bodyById.has(result.path) + return ( + + ) + })} +
    + ) : null} +
    +
    +
    + + + color = cluster + + + + size = links + + + + pulse = hub + + + + bright = this week + + + + comet = orphan + +
    + {graphQuery.isError ? ( +
    +
    +
    Vault signal lost
    +

    + Knowledge graph endpoint did not answer. The dashboard will + reconnect automatically. +

    +
    +
    + ) : null} + {!graphQuery.isLoading && model.totals.bodies === 0 ? ( +
    +
    +
    No vault bodies yet
    +

    + Add linked notes to the knowledge vault and this galaxy will + populate from Obsidian-style wikilinks. +

    +
    +
    + ) : null} +
    +
    + + +
    +
    + ) +} diff --git a/src/screens/dashboard/components/notebooklm-bridge-card.tsx b/src/screens/dashboard/components/notebooklm-bridge-card.tsx new file mode 100644 index 0000000000..08070132da --- /dev/null +++ b/src/screens/dashboard/components/notebooklm-bridge-card.tsx @@ -0,0 +1,170 @@ +import type { + DashboardNotebookBridgeSection, + NotebookBridgeStageStatus, +} from '@/server/dashboard-aggregator' + +const STATUS_STYLE: Record = { + Ready: + 'border-[color-mix(in_srgb,var(--theme-success)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-success)_10%,transparent)] text-[var(--theme-success)]', + Manual: + 'border-[color-mix(in_srgb,var(--theme-warning)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-warning)_10%,transparent)] text-[var(--theme-warning)]', + Draft: + 'border-[color-mix(in_srgb,var(--theme-accent)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-accent)_10%,transparent)] text-[var(--theme-accent)]', + Blocked: + 'border-[color-mix(in_srgb,var(--theme-danger)_35%,var(--theme-border))] bg-[color-mix(in_srgb,var(--theme-danger)_10%,transparent)] text-[var(--theme-danger)]', +} + +function StatusBadge({ status }: { status: NotebookBridgeStageStatus }) { + return ( + + {status} + + ) +} + +function formatCompact(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return String(value) +} + +function formatDate(value: string | null): string { + if (!value) return 'no vault pulse' + const ts = Date.parse(value) + if (!Number.isFinite(ts)) return 'vault pulse unknown' + return new Date(ts).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) +} + +export function NotebookLmBridgeCard({ + bridge, +}: { + bridge: DashboardNotebookBridgeSection | null +}) { + if (!bridge) return null + + return ( +
    +
    + +
    +
    +
    +
    + NotebookLM Bridge +
    +
    + Synthesis lane, not source of truth +
    +

    + Obsidian sources move through NotebookLM, then reviewed drafts + return with source links. +

    +
    + + manual + +
    + +
    +
    +
    + {formatCompact(bridge.vaultNotes)} +
    +
    + notes +
    +
    +
    +
    + {formatCompact(bridge.vaultLinks)} +
    +
    + links +
    +
    +
    +
    + {formatDate(bridge.lastVaultUpdate)} +
    +
    + pulse +
    +
    +
    + +
    +
    + Flow +
    +
    +
    {bridge.canonicalSource} sources
    +
    + to {bridge.synthesisLayer} synthesis +
    +
    drafts to {bridge.writebackTargets.join(' / ')}
    +
    +
    + +
    + {bridge.stages.map((stage) => ( +
    +
    +
    +
    + {stage.label} +
    +

    + {stage.summary} +

    +
    + +
    +
    + {stage.sourceLinks.map((link) => ( + + {link.label} + + ))} +
    +
    + ))} +
    + +
    +
    + Guardrails +
    +
    + {bridge.guardrails.map((guardrail) => ( +

    + {guardrail} +

    + ))} +
    +
    +
    +
    + ) +} diff --git a/src/screens/dashboard/components/nova-fabric-card.tsx b/src/screens/dashboard/components/nova-fabric-card.tsx new file mode 100644 index 0000000000..985630793c --- /dev/null +++ b/src/screens/dashboard/components/nova-fabric-card.tsx @@ -0,0 +1,613 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import type { FormEvent } from 'react' +import type { + NovaFabric, + NovaFabricApprovalLevel, + NovaFabricConsolidationReviewRecord, + NovaFabricEventRecord, + NovaFabricReviewStatus, + NovaFabricRiskLevel, + NovaFabricSelfStateRecord, + NovaFabricSourceMapRecord, + NovaFabricVerificationState, +} from '@/server/nova-fabric-store' +import type { NovaWantsBoard } from '@/server/nova-wants-store' + +type NovaFabricResponse = { + ok: boolean + fabric: NovaFabric | null + storage?: { + path: string + backupPath: string + tempPath: string + seededFrom: string + } + degraded?: boolean + warning?: string | null + error?: string +} + +type NovaWantsResponse = { + ok: boolean + board?: NovaWantsBoard + error?: string +} + +const RISK_STYLE: Record = { + low: 'text-[var(--theme-muted)] border-[var(--theme-border)]', + medium: + 'text-[var(--theme-warning)] border-[color-mix(in_srgb,var(--theme-warning)_42%,var(--theme-border))]', + high: 'text-[var(--theme-danger)] border-[color-mix(in_srgb,var(--theme-danger)_42%,var(--theme-border))]', + critical: + 'text-[var(--theme-danger)] border-[color-mix(in_srgb,var(--theme-danger)_70%,var(--theme-border))]', +} + +const VERIFY_STYLE: Record = { + draft: 'text-[var(--theme-warning)]', + inferred: 'text-[var(--theme-muted)]', + 'tool-verified': 'text-[var(--theme-success)]', + 'taylor-approved': 'text-[var(--theme-accent)]', +} + +async function readNovaFabric(): Promise { + const response = await fetch('/api/nova-fabric') + const body: NovaFabricResponse | null = await response + .json() + .catch(() => null) + if (!response.ok || !body) { + throw new Error(body?.error || `nova-fabric ${response.status}`) + } + return body +} + +async function readNovaWants(): Promise { + const response = await fetch('/api/nova-wants') + const body: NovaWantsResponse | null = await response.json().catch(() => null) + if (!response.ok || !body?.ok) { + throw new Error(body?.error || `nova-wants ${response.status}`) + } + return body +} + +async function mutateNovaFabric( + method: 'POST' | 'PATCH', + body: Record, +): Promise { + const response = await fetch('/api/nova-fabric', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const payload: NovaFabricResponse | null = await response + .json() + .catch(() => null) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || `nova-fabric ${response.status}`) + } + return payload +} + +function compactDate(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'unknown' + return date.toLocaleString(undefined, { + month: 'short', + day: '2-digit', + hour: 'numeric', + minute: '2-digit', + }) +} + +function Pill({ + children, + tone = 'normal', +}: { + children: React.ReactNode + tone?: 'normal' | 'risk' | 'accent' +}) { + const toneClass = + tone === 'risk' + ? 'border-[color-mix(in_srgb,var(--theme-danger)_42%,var(--theme-border))] text-[var(--theme-danger)]' + : tone === 'accent' + ? 'border-[color-mix(in_srgb,var(--theme-accent)_42%,var(--theme-border))] text-[var(--theme-accent)]' + : 'border-[var(--theme-border)] text-[var(--theme-muted)]' + return ( + + {children} + + ) +} + +function Section({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
    +

    + {title} +

    +
    {children}
    +
    + ) +} + +function RecordMeta({ + riskLevel, + approvalLevel, + verificationState, +}: { + riskLevel: NovaFabricRiskLevel + approvalLevel: NovaFabricApprovalLevel + verificationState: NovaFabricVerificationState +}) { + return ( +
    + + {riskLevel} + + + {approvalLevel} + + + {verificationState} + +
    + ) +} + +function ContinuityList({ events }: { events: Array }) { + return ( +
    + {events + .slice(-6) + .reverse() + .map((event) => ( +
    +
    +
    +
    + {event.title} +
    +

    + {event.summary} +

    +
    + + {compactDate(event.createdAt)} + +
    + +
    + ))} +
    + ) +} + +function SelfStateList({ + records, + onPropose, +}: { + records: Array + onPropose: (record: NovaFabricSelfStateRecord) => void +}) { + return ( +
    + {records + .filter((record) => record.current) + .map((record) => ( +
    +
    +
    +
    + {record.title} +
    +
    + v{record.version} / {record.category} +
    +
    + +
    +

    + {record.body} +

    + +
    + ))} +
    + ) +} + +function SourceMapTable({ + records, +}: { + records: Array +}) { + return ( +
    + + + + + + + + + + + {records.map((record) => ( + + + + + + + ))} + +
    domainsource of truthpolicyMission Control behavior
    + {record.domain} + + {record.sourceOfTruth} + + + {record.policy} + + + {record.missionControlBehavior} +
    +
    + ) +} + +function ReviewQueue({ + records, + onStatus, +}: { + records: Array + onStatus: ( + record: NovaFabricConsolidationReviewRecord, + status: NovaFabricReviewStatus, + ) => void +}) { + const visible = records.filter((record) => record.status === 'pending') + if (!visible.length) { + return ( +

    + No pending consolidation proposals. +

    + ) + } + return ( +
    + {visible.slice(0, 5).map((record) => ( +
    +
    +
    +
    + {record.title} +
    +

    + {record.reason} +

    +
    + {record.targetType} +
    + +
    + + +
    +
    + ))} +
    + ) +} + +export function NovaFabricCard() { + const queryClient = useQueryClient() + const [eventTitle, setEventTitle] = useState('') + const [eventSummary, setEventSummary] = useState('') + const [proposalTarget, setProposalTarget] = + useState(null) + const [proposalBody, setProposalBody] = useState('') + const [error, setError] = useState(null) + + const fabricQuery = useQuery({ + queryKey: ['nova-fabric'], + queryFn: readNovaFabric, + staleTime: 5_000, + refetchInterval: 30_000, + }) + const wantsQuery = useQuery({ + queryKey: ['nova-wants'], + queryFn: readNovaWants, + staleTime: 10_000, + refetchInterval: 45_000, + }) + + const mutation = useMutation({ + mutationFn: ({ + method, + body, + }: { + method: 'POST' | 'PATCH' + body: Record + }) => mutateNovaFabric(method, body), + onSuccess: () => { + setError(null) + void queryClient.invalidateQueries({ queryKey: ['nova-fabric'] }) + }, + onError: (err) => { + setError(err instanceof Error ? err.message : 'Nova Fabric update failed') + }, + }) + + const fabric = fabricQuery.data?.fabric ?? null + const fabricLinkedWantCount = useMemo(() => { + const cards = wantsQuery.data?.board?.cards ?? [] + return cards.filter( + (card) => + card.fabricEventIds.length || + card.fabricSelfStateIds.length || + card.fabricSourceMapIds.length || + card.fabricReviewIds.length, + ).length + }, [wantsQuery.data?.board?.cards]) + + const appendEvent = (event: FormEvent) => { + event.preventDefault() + mutation.mutate({ + method: 'POST', + body: { + action: 'append-event', + title: eventTitle, + summary: eventSummary, + verificationState: 'draft', + }, + }) + setEventTitle('') + setEventSummary('') + } + + const submitProposal = (event: FormEvent) => { + event.preventDefault() + if (!proposalTarget) return + mutation.mutate({ + method: 'PATCH', + body: { + action: 'propose-self-state', + targetId: proposalTarget.id, + proposedDiff: { body: proposalBody }, + reason: 'Mission Control self-state proposal; requires Taylor review.', + }, + }) + setProposalTarget(null) + setProposalBody('') + } + + if (fabricQuery.isLoading) { + return ( +
    + Loading Nova Fabric... +
    + ) + } + + if (fabricQuery.data?.degraded || !fabric) { + return ( +
    +
    + Fabric degraded +
    +

    + {fabricQuery.data?.warning || + fabricQuery.error?.message || + 'Fabric degraded: continuity file needs repair.'} +

    +

    + {fabricQuery.data?.storage?.path} +

    +
    + ) + } + + return ( +
    +
    +
    +
    + Nova Fabric +
    +

    + Continuity fabric +

    +

    + Local, source-linked continuity state. Mission Control is a lens; + external systems stay read-only or proposal-gated here. +

    +
    +
    + {fabric.events.length} events + + {fabric.selfState.filter((record) => record.current).length}{' '} + self-state + + {fabric.sourceMap.length} sources + + { + fabric.reviewQueue.filter((record) => record.status === 'pending') + .length + }{' '} + reviews + +
    +
    + + {error ? ( +
    + {error} +
    + ) : null} + +
    +
    + +
    +
    + setEventTitle(event.target.value)} + required + placeholder="Event title" + className="rounded-md border border-[var(--theme-border)] bg-[var(--theme-card)] px-2 py-2 text-xs text-[var(--theme-text)] outline-none" + /> + setEventSummary(event.target.value)} + required + placeholder="Meaningful continuity/work receipt" + className="rounded-md border border-[var(--theme-border)] bg-[var(--theme-card)] px-2 py-2 text-xs text-[var(--theme-text)] outline-none" + /> + +
    +
    +
    + +
    + { + setProposalTarget(record) + setProposalBody(record.body) + }} + /> + {proposalTarget ? ( +
    +
    + risky edit becomes review proposal +
    +