diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 7ac8e5a..0f01528 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,14 +1,17 @@ -import { createChat, isTurnRunning, listChats, listJobs, titleFromPrompt } from "@/src/server"; +import { createChat, isTurnRunning, listChats, titleFromPrompt } from "@/src/server"; import type { ChatListItem } from "@/src/core"; /** The session picker's list: record-derived summaries (phase, pending - * question) + live liveness — a running turn OR a background job marks - * the session busy (contracts.ts ChatListItem; the store can't see - * liveness, the route is the merge point). */ + * question) + live liveness — a running turn marks the session busy + * (contracts.ts ChatListItem; the store can't see liveness, the route is + * the merge point). Owner, minimal-color round: background jobs NO LONGER + * count — the sidebar's Background agents section is their channel now + * (a running spinner per row would double-signal them), so busy means + * exactly "a teach turn is in flight". */ export async function GET() { const chats: ChatListItem[] = listChats().map((c) => ({ ...c, - busy: isTurnRunning(c.id) || listJobs(c.id).some((j) => j.status === "running"), + busy: isTurnRunning(c.id), })); return Response.json({ chats }); } diff --git a/app/globals.css b/app/globals.css index dd14142..7ad08e1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -40,7 +40,12 @@ /* Emphasis coloring in teaching prose — ready for the settings UI. */ --lh-ink-strong: #c96b4a; --lh-ink-italic: #6fa9d6; - --lh-prose-size: 16.5px; + /* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be + var()-linked: @theme inline inlines literals into utilities without + emitting runtime vars — so this is the one documented duplication: + change one, change both. (owner, typography round 4: snapped from + the reskin's hand-tuned 16.5 to the ladder's 16.) */ + --lh-prose-size: 16px; --lh-prose-leading: 1.5; } @@ -67,7 +72,12 @@ --ring: #8d8d8d; --lh-ink-strong: #c96b4a; --lh-ink-italic: #6fa9d6; - --lh-prose-size: 16.5px; + /* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be + var()-linked: @theme inline inlines literals into utilities without + emitting runtime vars — so this is the one documented duplication: + change one, change both. (owner, typography round 4: snapped from + the reskin's hand-tuned 16.5 to the ladder's 16.) */ + --lh-prose-size: 16px; --lh-prose-leading: 1.5; } @@ -82,14 +92,23 @@ @theme inline { /* Fonts: chrome = JetBrains Mono, prose = Source Sans 3, titles = CMU - Serif bold. The JetBrains/SourceSans variables attach in app/layout.tsx. */ + Serif bold. The JetBrains/SourceSans variables attach in app/layout.tsx. + (owner, typography round) The Tailwind defaults are ALIASED to the app + fonts: `font-mono`/`font-sans` utilities would otherwise fall to the + SYSTEM stacks — a fourth font the app never chose — and streamdown's + code-block chrome + mermaid-error + demo-card all use them. */ --font-ui: var(--font-jetbrains), ui-monospace, SFMono-Regular, monospace; --font-prose: var(--font-sans3), ui-sans-serif, system-ui, sans-serif; --font-title: "lh-cmu", serif; + --font-mono: var(--font-jetbrains), ui-monospace, SFMono-Regular, monospace; + --font-sans: var(--font-sans3), ui-sans-serif, system-ui, sans-serif; /* The modular type ladder — each size carries its own line-height. - Component sizes must pull from this ladder. */ - --text-xxs: 10px; - --text-xxs--line-height: 1.4; + Component sizes must pull from this ladder. (owner, typography + round 4: the 10px rung is merged into 11 — one pixel apart was a + distinction with no visible difference, and 10px sits at the mono + legibility floor; quiet labels get their quiet from color + + tracking, not a notch of size. Reading prose = the 16px rung via + --lh-prose-size.) */ --text-xs: 11px; --text-xs--line-height: 1.4; --text-sm: 12.5px; @@ -158,6 +177,19 @@ body { color: var(--lh-ink-italic); } +/* lh-input (owner, typography round 2): lh-prose's 1.5 READING leading + makes typing carets tower over the glyphs — the caret fills the line + box (24px at 16px text, ~2× the glyph height). Inputs are not + paragraphs: a tighter 1.25 leading brings the caret to the font-metric + floor (~20px, as close to the glyphs as Chromium will draw it) while + wrapped lines in growing textareas still read fine. Rendered prose keeps + 1.5 — the two share family and size, they differ only in leading. */ +.lh-input { + font-family: var(--font-prose); + font-size: var(--lh-prose-size); + line-height: 1.25; +} + /* Thin dark native scrollbar (app-wide): dark thumb, transparent track. */ * { scrollbar-width: thin; @@ -232,53 +264,44 @@ body { } /* ============================================================ - * Session status — phase colors + status dots - * (picker strips, header chip, transcript dividers: one language) + * Session status — the braille spinner (the one working indicator) * ============================================================ */ -/* Phase colors: blue = probing, violet = planning, green = teaching. - Tuned per theme (light needs darker, dark lighter). These color the - session dots, header chip and transcript dividers — one language. */ -:root { - --phase-probe: oklch(0.55 0.17 253); - --phase-plan: oklch(0.54 0.19 300); - --phase-teach: oklch(0.55 0.14 160); -} +/* (owner, minimal-color round) Phase color-coding is RETIRED: phases only + advance (every session ends in Teaching), so phase-as-signal was + decoration; the --phase-* tokens and every phase-dot state are gone. + What state deserved — "a turn is in flight" — is carried by MOTION + alone: the terminal braille spinner (components/braille-spinner.tsx), + monochrome, existing only while true. */ -.dark { - --phase-probe: oklch(0.72 0.13 250); - --phase-plan: oklch(0.72 0.14 300); - --phase-teach: oklch(0.74 0.12 160); -} - -@theme inline { - --color-phase-probe: var(--phase-probe); - --color-phase-plan: var(--phase-plan); - --color-phase-teach: var(--phase-teach); -} - -/* Session dot motion (picker): the ONLY animated state is "teacher is - working" — a quiet phase-colored breathing. Pending questions are the - dominant resting state and stay perfectly still (solid dot). */ -@keyframes lh-dot-breathe { +/* One keyframes, all ten frames: each frame holds opacity 1 for its + 10% slice of the 800ms cycle and 0 elsewhere; frames stagger via + positive animation-delay (frame k starts at k×80ms). Static contexts + (SSR, reduced motion) show frame 0 — see the base rules below. */ +@keyframes braille-frame { 0%, - 100% { - opacity: 0.35; - transform: scale(0.75); + 10% { + opacity: 1; } - 50% { - opacity: 1; - transform: scale(1); + 10.001%, + 100% { + opacity: 0; } } -.lh-dot-breathe { - animation: lh-dot-breathe 1.4s ease-in-out infinite; +.braille-frame { + animation: braille-frame 800ms linear infinite; + opacity: 0; +} + +/* Static fallback: frame 0 (⠋) is the glyph a non-animated context shows. */ +.braille-frame[data-frame="0"] { + opacity: 1; } @media (prefers-reduced-motion: reduce) { - .lh-dot-breathe { + .braille-frame { animation: none; } } diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 46b8237..b89df03 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -274,7 +274,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { // alone (the cost span is population, not decoration). return (
-
+
Background agents {blobs.length > 0 && ( diff --git a/components/answered-card.tsx b/components/answered-card.tsx index 112097b..5bc7dd5 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -72,7 +72,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { )} > {/* Owner round 8: a touch smaller than the text (size-4→size-3.5, - ≈14px vs the 16.5px row) while centered. */} + ≈14px vs the 16px row) while centered. */} {(key || sel) && } )} @@ -101,7 +101,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {answer.note && (
- + Your thinking {answer.note} diff --git a/components/braille-spinner.tsx b/components/braille-spinner.tsx new file mode 100644 index 0000000..344f568 --- /dev/null +++ b/components/braille-spinner.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/lib/utils"; + +/** + * THE BRAILLE SPINNER (owner, minimal-color round): the one working-state + * indicator, in the terminal's own glyph language. Pure function + CSS — + * no JS frame timer, no React re-render per frame; each frame is a tiny + * whose opacity is compositor-animated, so the cost is below the old + * breathing dot's (which animated opacity AND transform). + * + * Geometry: the classic cli-spinner sequence (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏, 10 frames, + * 80ms each — 800ms cycle) as EXACT dot bitmaps in a 2×4 braille cell + * (dots 1-3 = left column, 4-6 = right). One shared keyframes (globals.css + * .braille-frame) holds each frame visible for its 10% slice; frames + * stagger via positive animation-delay, so frame 0 leads from first paint. + * + * Static fallback (SSR pre-hydrate, prefers-reduced-motion, print): frame + * 0 (⠋) shows — a legible glyph, not a blank. Motion is honest here: it + * exists only while a turn is in flight and vanishes the moment it ends. + */ +const FRAMES: ReadonlyArray<{ dots: ReadonlyArray }> = [ + { dots: [[1, 1], [1, 3], [3, 1]] }, + { dots: [[1, 1], [3, 1], [3, 3]] }, + { dots: [[1, 1], [3, 1], [3, 3], [3, 5]] }, + { dots: [[3, 1], [3, 3], [3, 5]] }, + { dots: [[1, 5], [3, 1], [3, 3], [3, 5]] }, + { dots: [[1, 5], [3, 3], [3, 5]] }, + { dots: [[1, 3], [1, 5], [3, 5]] }, + { dots: [[1, 1], [1, 3], [1, 5], [3, 5]] }, + { dots: [[1, 1], [1, 3], [1, 5]] }, + { dots: [[1, 1], [1, 3], [1, 5], [3, 1]] }, +]; + +/** The animation constants (mirrors globals.css .braille-frame). */ +export const SPINNER_FRAME_MS = 80; +export const SPINNER_FRAMES = FRAMES.length; + +export function BrailleSpinner({ + className, + label = "Teacher is working", +}: { + /** Sizing/position classes; height drives the scale (width is the cell's 1:2 aspect). */ + className?: string; + /** The accessible name of the state this spinner announces. */ + label?: string; +}) { + return ( + + {FRAMES.map((frame, k) => ( + + {frame.dots.map(([cx, cy]) => ( + + ))} + + ))} + + ); +} diff --git a/components/chat.tsx b/components/chat.tsx index 5a5ea65..625ccd6 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -584,12 +584,16 @@ export function Chat({ {/* The top bar is its own block (owner, top-fade round 4): in flow above the scroller on the plain page background — no gradient. Its bottom outline (border-b border-border) matches the one that - separates the left sidebar — same width, same color (owner, r5). h-10, not py-2: the toggle (a size-9 icon button) - renders only while the sidebar is CLOSED — an auto-height row - would collapse when it disappears and slide the title + chip up. */} -
+ separates the left sidebar — same width, same color (owner, r5). + h-15 (60px), not py-2: the toggle (a size-9 icon button) renders + only while the sidebar is CLOSED — an auto-height row would + collapse when it disappears and slide the title + chip up. + 60px is an ALIGNMENT, not just a size: it equals the sidebar's + LH wordmark band, so the header's border-b continues that + band's bottom edge as one line across the app (owner, round 6). */} +
-
{title}
+
{title}
{/* The plan chip (owner, plan-placement round): session-level structure beside the phase chip — access to the full-DAG popup at every width; the marginalia is the wide-screen glance view. */} diff --git a/components/composer.tsx b/components/composer.tsx index f2e7420..ea1dffa 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -59,7 +59,7 @@ export function Composer({ placeholder="Ask, answer, or steer the lesson…" autoFocus={autoFocus} rows={1} - className="field-sizing-content lh-prose max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" + className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> {verdict && !busy ? ( // The plan verdict (docs/probe-teach-split.md §4): accept diff --git a/components/graph-context-marker.tsx b/components/graph-context-marker.tsx index 7d3b833..5213be2 100644 --- a/components/graph-context-marker.tsx +++ b/components/graph-context-marker.tsx @@ -91,7 +91,7 @@ export function GraphContextList({ data }: { data: GraphContextData }): ReactNod
  • {i + 1}. {p.level && ( - + {p.level} )} diff --git a/components/graph-coverage.tsx b/components/graph-coverage.tsx index aec46c1..1571458 100644 --- a/components/graph-coverage.tsx +++ b/components/graph-coverage.tsx @@ -135,7 +135,7 @@ export function CoveragePanel({ Extraction coverage - uncovered first + uncovered first
  • {!anyUncovered && (

    @@ -156,7 +156,7 @@ function CoverageRow({ chat, busy, onExtract }: { chat: CoverageChat; busy: bool

    {chat.title}
    -
    +
    {busy ? ( extracting… @@ -181,7 +181,7 @@ function CoverageRow({ chat, busy, onExtract }: { chat: CoverageChat; busy: bool type="button" disabled={busy || chat.uncoveredTokens === 0} onClick={onExtract} - className="shrink-0 rounded-lg border px-2 py-1 text-xxs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent" + className="shrink-0 rounded-lg border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent" > {busy ? "Running" : "Extract"} diff --git a/components/home.tsx b/components/home.tsx index 5e5885b..30a3053 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -91,7 +91,7 @@ export function Home() { placeholder="What do you want to learn?" autoFocus rows={1} - className="field-sizing-content lh-prose max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" + className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> {open === "model" && ( @@ -222,7 +222,7 @@ export function ModelControl({ value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search models…" - className="lh-prose mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" + className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" /> {error &&
    {error}
    } {!catalog && ( @@ -267,7 +267,7 @@ export function ModelControl({ ))} -
    +
    Applies to the next turn · background agents stay config-pinned
    @@ -299,7 +299,7 @@ export function ModelControl({ ))} {error &&
    {error}
    } -
    +
    Levels this model actually supports
    diff --git a/components/new-session.tsx b/components/new-session.tsx index f309922..cf5dfbf 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -77,9 +77,11 @@ export function DraftShell({ return (
    -
    + {/* h-15 (60px) matches the chat header and the sidebar's LH wordmark + band (owner, round 6). */} +
    -
    New session
    +
    New session
    diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx index e3ac854..9d6f617 100644 --- a/components/phase-divider.tsx +++ b/components/phase-divider.tsx @@ -1,19 +1,27 @@ -import { cn } from "@/lib/utils"; import type { Phase3 } from "@/src/core"; /** - * The in-session phase presentation — the explicit face of what the picker - * shows as a color strip (one color system everywhere, globals.css - * --phase-*): the header chip next to the session title, and the dotted - * boundary rule the transcript draws where the session moves into a new - * phase. Replaces the old inline plan-divider (artifact registry): the - * boundary is a MESSAGE-level seam, so chat.tsx renders it at the message - * where the phase changes, not inside a tool part. + * The in-session phase presentation (owner, minimal-color round — + * monochrome now): the header chip next to the session title, and the + * dotted boundary rule the transcript draws where the session moves into a + * new phase. Phase COLOR is retired app-wide (phases only advance, so + * color-as-signal was decoration); the label text carries the phase, and + * the chip's old dot slot is repurposed for the braille spinner while a + * turn is in flight — motion is the honest state signal. Replaces the old + * inline plan-divider (artifact registry): the boundary is a MESSAGE-level + * seam, so chat.tsx renders it at the message where the phase changes, not + * inside a tool part. * * Quiet register (matches the old divider): hairline rules, small chips — * never a banner. Probe never renders a divider: phases only advance, so * probe is the start of the transcript and "Planning"/"Teaching" are the two * visible seams. + * + * Owner, minimal-color round 2: the chip is LABEL-ONLY, deliberately — a + * working-state spinner was tried here and rejected: the pill's curve and + * the square braille cell fought for space, and the chat surface already + * shows in-flight work (thinking shimmer, the dock). The chip states the + * phase; work-state is the sidebar row's spinner. */ /** Header-chip labels: the phases as the learner thinks of them. */ @@ -23,42 +31,28 @@ const CHIP_LABEL: Record = { teach: "Teaching", }; -const PHASE_DOT: Record = { - probe: "bg-phase-probe", - plan: "bg-phase-plan", - teach: "bg-phase-teach", -}; - -/** The header chip: a phase-colored dot + label next to the session title. */ +/** The header chip: the phase label, monochrome — label-only by design + * (see the component note: the pill's curve suits text, not glyphs). */ export function PhaseChip({ phase }: { phase: Phase3 }) { return ( - {CHIP_LABEL[phase]} ); } -/** Divider rule + chip colors per phase (probe never reaches here). */ -const DIVIDER_RULE: Partial> = { - plan: "border-phase-plan/50", - teach: "border-phase-teach/50", -}; -const DIVIDER_TEXT: Partial> = { - plan: "border-phase-plan/50 text-phase-plan", - teach: "border-phase-teach/50 text-phase-teach", -}; - /** - * The transcript boundary: a color-coded dotted rule at the message where - * the session enters `phase`. The plan seam carries its node count ("Plan · - * 4 nodes"); teaching is just the label. Null for probe (defensive — the - * fold never emits a probe transition after the start). + * The transcript boundary: a monochrome dotted rule at the message where + * the session enters `phase` (owner, minimal-color round — the label + * carries the transition; the phase color never did). The plan seam + * carries its node count ("Plan · 4 nodes"); teaching is just the label. + * Null for probe (defensive — the fold never emits a probe transition + * after the start). */ export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) { if (phase === "probe") return null; @@ -68,16 +62,11 @@ export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: : CHIP_LABEL[phase]; return (
    - - + + {label} - +
    ); } diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 2e6f977..ed19bcc 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -192,8 +192,9 @@ export const GLOW_BLUR = 5; export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR; /** Node titles render at text-xs (12.5px) in svg units. The in-chat block * scales up to fill the column but NEVER past the chat prose size - * (--lh-prose-size, 16.5px) — the width cap that makes that hold. */ -export const TEXT_CAP_SCALE = 16.5 / 12.5; + * (--lh-prose-size, 16px — the ladder's reading rung) — the width cap + * that makes that hold. */ +export const TEXT_CAP_SCALE = 16 / 12.5; // ─── The owner's arrowhead (plan-graph round 5) ────────────────────────────── @@ -808,7 +809,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa />
    - Drag to pan · scroll to zoom + Drag to pan · scroll to zoom
    @@ -412,7 +412,7 @@ function QuestionDockForm({ data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"} className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50" > -
    +
    {qd.mode === "text" ? "Your answer" : "Your thinking"}
    {qd.mode === "text" ? ( @@ -429,7 +429,7 @@ function QuestionDockForm({ }} placeholder="Type your answer…" rows={1} - className="field-sizing-content lh-prose max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" + className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> ) : (