From 326222f53c47760b4a63a18ad733f9e2a0722415 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 15 Sep 2026 17:01:01 +0300 Subject: [PATCH 01/11] =?UTF-8?q?Minimal-color=20round=201=20(owner):=20ph?= =?UTF-8?q?ase=20color=20retired=20=E2=80=94=20the=20braille=20spinner=20i?= =?UTF-8?q?s=20the=20one=20working=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing exists purely for decoration: phase color-coding is retired app-wide (phases only advance — every session ends Teaching — so phase-as-signal was decoration with a one-time function), and what state genuinely deserved — 'a turn is in flight' — is now carried by MOTION alone, in the terminal's own glyph language. - BrailleSpinner (components/braille-spinner.tsx): the canonical cli-spinner sequence (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) as exact dot bitmaps in a 2×4 braille cell, 80ms/frame (800ms cycle). Pure CSS: one shared keyframes, frames staggered via animation-delay; no JS timer, no re-render per frame — cheaper than the old breathing dot. Static contexts (SSR, prefers-reduced-motion) show frame 0 — a legible glyph, not a blank. - Sidebar rows: spinner ONLY while a teach turn is in flight; idle and question-pending render NOTHING (a resting state has no scan value — the meta line's 'Waiting for your answer' text carries it). The three-state phase dot (pulse/solid/hollow) and PHASE_FILL/RING maps are gone. - Top bar PhaseChip: label-only pill; the old dot slot becomes the spinner's home while a turn is in flight (busy prop wired from ChatView's busy = localStream || turnInFlight). Transcript PhaseDividers drop phase colors for monochrome rules + labels. - API narrowing (app/api/chats/route.ts + ChatListItem contract): busy = isTurnRunning ONLY — background jobs no longer count (the sidebar's Background agents section is their channel; a per-row spinner would double-signal them). - globals.css: --phase-* tokens + lh-dot-breathe removed (dead after the sweep — no remaining users). Live-verified: idle/pending rows render no indicator, /chat/new chip is label-only, 10 CSSAnimations at 800ms with correct 80ms stagger, static frame 0 under reduced motion. Gates: typecheck, lint, 470/470, build. Lane crossings to flag at merge: app/api/chats/route.ts + the ChatListItem contract comment (src/core) — the busy-semantics narrowing; components/chat.tsx one-line busy prop. --- app/api/chats/route.ts | 13 +++-- app/globals.css | 61 ++++++++++------------- components/braille-spinner.tsx | 68 ++++++++++++++++++++++++++ components/chat.tsx | 2 +- components/phase-divider.tsx | 64 ++++++++++-------------- components/sidebar.tsx | 76 +++++++---------------------- src/core/contracts/chat.ts | 10 ++-- tests/render-phase-divider.test.tsx | 37 ++++++++++---- tests/render-sidebar.test.tsx | 65 ++++++++++++------------ 9 files changed, 214 insertions(+), 182 deletions(-) create mode 100644 components/braille-spinner.tsx 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..af3eee1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -232,53 +232,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/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..38f36c8 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -605,7 +605,7 @@ export function Chat({ Plan )} - +
diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx index e3ac854..56aac68 100644 --- a/components/phase-divider.tsx +++ b/components/phase-divider.tsx @@ -1,14 +1,17 @@ -import { cn } from "@/lib/utils"; +import { BrailleSpinner } from "@/components/braille-spinner"; 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 @@ -23,14 +26,10 @@ 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. */ -export function PhaseChip({ phase }: { phase: Phase3 }) { +/** The header chip: the phase label, monochrome; while a turn is in + * flight the old dot slot shows the braille spinner (motion = the honest + * state signal — see BrailleSpinner). */ +export function PhaseChip({ phase, busy = false }: { phase: Phase3; busy?: boolean }) { return ( - + {busy && } {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 +59,11 @@ export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: : CHIP_LABEL[phase]; return (
- - + + {label} - +
); } diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 1c612ae..4324d0b 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -5,10 +5,11 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react"; import { ActivitySection } from "@/components/activity-dock"; +import { BrailleSpinner } from "@/components/braille-spinner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; -import type { ChatListItem, Phase3 } from "@/src/core"; +import type { ChatListItem } from "@/src/core"; /** * Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It @@ -67,62 +68,20 @@ export function SidebarToggle() { ); } -/** Phase colors for the session dot — one system with the header chip - * and the transcript dividers (globals.css --phase-*). Static class - * strings per phase: Tailwind can't compile dynamic class names. */ -const PHASE_FILL: Record = { - probe: "bg-phase-probe", - plan: "bg-phase-plan", - teach: "bg-phase-teach", -}; -const PHASE_RING: Record = { - probe: "border-phase-probe", - plan: "border-phase-plan", - teach: "border-phase-teach", -}; - /** - * The session dot, left of the title. TWO channels, one dot: COLOR is the - * phase (probe/plan/teach — sky/violet/emerald), FILL+MOTION is what's - * owed. Pulsing = the teacher is working (rare, time-sensitive — the only - * state that deserves motion). Solid = a question awaits the learner's - * answer (the DOMINANT resting state of real sessions — calm by design, - * no animation). Hollow = nothing owed, idle. A pending answer outranks - * "working": if a question is owed, that's the blocking fact. + * The row's status indicator (owner, minimal-color round): the braille + * spinner, ONLY while a teach turn is in flight — monochrome motion, the + * terminal's own glyph, existing exactly as long as the work does and + * vanishing the moment it ends. Every other state renders NOTHING: + * phases only advance (all sessions end in Teaching — phase-as-signal was + * decoration, now retired app-wide), and question-pending is the dominant + * RESTING state of real sessions — a resting state has no scan value, so + * the meta line's "Waiting for your answer" text carries it. Idle is the + * absence of a signal, by design. */ -function SessionDot({ phase, pending, busy }: { phase: Phase3; pending: boolean; busy: boolean }) { - if (pending) { - return ( - - Waiting for your answer - - ); - } - if (busy) { - return ( - - Teacher is working - - ); - } - return ( - - ); +function SessionStatus({ busy }: { busy: boolean }) { + if (!busy) return null; + return ; } /** Compact "how long ago": "just now", "5m ago", "3h ago", "2d ago", @@ -150,8 +109,9 @@ function sessionMeta(chat: ChatListItem): string { } /** - * One session row: the status dot, then title + state-aware meta. Exported - * for render tests — pure function of the list item. + * One session row: the status spinner (only while working), then title + + * state-aware meta. Exported for render tests — pure function of the + * list item. */ export function SessionRow({ chat, @@ -171,7 +131,7 @@ export function SessionRow({ active && "bg-accent font-medium", )} > - + {chat.title} {sessionMeta(chat)} diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 3e77c11..2a777d6 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -286,10 +286,12 @@ export interface ChatSummary { } /** The sidebar's session row: a summary + live liveness. The chats list - * route attaches `busy` from the turn-liveness registry and the job store - * (a running turn OR a background job = the session is working). The store - * itself can't see liveness (import law: store never imports server), - * hence the split at exactly the record/liveness seam. */ + * route attaches `busy` from the turn-liveness registry ONLY — a running + * teach turn (owner, minimal-color round: background jobs no longer + * count; the sidebar's Background agents section is their channel, and + * the row spinner means exactly "a turn is in flight"). The store itself + * can't see liveness (import law: store never imports server), hence the + * split at exactly the record/liveness seam. */ export interface ChatListItem extends ChatSummary { busy: boolean; } diff --git a/tests/render-phase-divider.test.tsx b/tests/render-phase-divider.test.tsx index 2054625..72f2005 100644 --- a/tests/render-phase-divider.test.tsx +++ b/tests/render-phase-divider.test.tsx @@ -1,8 +1,10 @@ /** - * Phase presentation render tests — the in-session face of the picker's - * color strips (components/phase-divider.tsx): the header chip and the - * dotted transcript boundary rules. Same tsx classic-JSX shim as - * render-cards.test.tsx. (Replaces the old part-level PlanDivider tests.) + * Phase presentation render tests — the in-session face (components/ + * phase-divider.tsx), monochrome since the minimal-color round: the header + * chip (label + the braille spinner in the old dot slot while a turn is in + * flight) and the dotted transcript boundary rules. Same tsx classic-JSX + * shim as render-cards.test.tsx. (Replaces the old part-level PlanDivider + * tests.) */ import React from "react"; (globalThis as Record).React = React; @@ -13,7 +15,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { PhaseChip, PhaseDivider, planNodeCount } from "../components/phase-divider.tsx"; describe("PhaseChip (header)", () => { - it("renders a phase-colored dot + the learner-facing label", () => { + it("renders the learner-facing label — no colored dot (phase color retired)", () => { for (const [phase, label] of [ ["probe", "Probing"], ["plan", "Planning"], @@ -23,25 +25,40 @@ describe("PhaseChip (header)", () => { assert.ok(html.includes('data-testid="phase-chip"')); assert.ok(html.includes(`data-phase="${phase}"`)); assert.ok(html.includes(label), `${phase} label`); - assert.ok(html.includes(`bg-phase-${phase}`), `${phase} dot color`); + assert.ok(!html.includes("bg-phase-"), `${phase} dot color retired`); + assert.ok(!html.includes("rounded-full bg-"), "no dot of any color — label only"); } }); + + it("busy: the old dot slot becomes the braille spinner (motion = turn in flight)", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="braille-spinner"'), "spinner in the chip"); + assert.ok(html.includes("Teaching"), "label stays"); + assert.ok((html.match(/class="braille-frame"/g) ?? []).length === 10, "all ten frames"); + }); + + it("idle: no spinner — the chip is the label alone", () => { + const html = renderToStaticMarkup(); + assert.ok(!html.includes("braille-spinner")); + }); }); describe("PhaseDivider (transcript boundary)", () => { - it("the plan seam: dotted rules in the plan color + node count", () => { + it("the plan seam: monochrome dotted rules + node count", () => { const html = renderToStaticMarkup(); assert.ok(html.includes('data-testid="phase-divider-plan"')); assert.match(html, /Plan · 3 nodes/); assert.ok(html.includes("border-dotted"), "dotted rule"); - assert.ok(html.includes("border-phase-plan"), "plan-colored rule"); + assert.ok(html.includes("border-border"), "monochrome rule"); + assert.ok(!html.includes("border-phase-"), "phase-colored rules are retired"); }); - it("the teach seam: dotted rules in the teach color, no count", () => { + it("the teach seam: monochrome rule, no count", () => { const html = renderToStaticMarkup(); assert.ok(html.includes('data-testid="phase-divider-teach"')); assert.match(html, /Teaching/); - assert.ok(html.includes("border-phase-teach")); + assert.ok(!html.includes("border-phase-")); + assert.ok(!html.includes("text-phase-")); assert.ok(!html.includes("nodes")); }); diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx index ee9f56f..b8913e3 100644 --- a/tests/render-sidebar.test.tsx +++ b/tests/render-sidebar.test.tsx @@ -1,9 +1,12 @@ /** - * Session-row render tests — the picker's status language. One dot, two - * channels: COLOR = phase (sky/violet/emerald), FILL+MOTION = what's owed - * (pulsing = teacher working, solid = answer owed, hollow = idle). Pure - * function of the list item, so SSR of pins every visible - * state. Same tsx classic-JSX shim as render-cards.test.tsx. + * Session-row render tests — the picker's status language (owner, + * minimal-color round): ONE indicator, the braille spinner, present ONLY + * while a teach turn is in flight; every other state (idle, question + * pending) renders nothing — phases only advance, so phase-as-signal was + * decoration, and question-pending is the dominant RESTING state (the + * meta line's text carries it). Pure function of the list item, so SSR of + * pins every visible state. Same tsx classic-JSX shim as + * render-cards.test.tsx. */ import React from "react"; (globalThis as Record).React = React; @@ -12,7 +15,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; import { SessionRow, timeAgo } from "../components/sidebar.tsx"; -import type { ChatListItem, Phase3 } from "../src/core/index.ts"; +import type { ChatListItem } from "../src/core/index.ts"; function item(overrides: Partial): ChatListItem { return { @@ -33,50 +36,52 @@ function render(chat: ChatListItem, active = false) { } describe("SessionRow", () => { - it("no phase strips — the dot alone carries the phase color", () => { + it("no phase strips, no phase colors — the retired decoration", () => { const html = render(item({ phase: "teach" })); assert.ok(!html.includes("phase-strip"), "strip removed by design (too busy)"); - assert.ok(!html.includes("absolute top-2"), "no leftover strip geometry"); + assert.ok(!html.includes("bg-phase-"), "phase fills are retired app-wide"); + assert.ok(!html.includes("border-phase-"), "phase rings are retired app-wide"); }); - it("idle session: hollow phase-colored ring, perfectly still", () => { - for (const phase of ["probe", "plan", "teach"] as Phase3[]) { + it("idle session: NO indicator — absence is the state", () => { + for (const phase of ["probe", "plan", "teach"] as const) { const html = render(item({ phase })); - assert.ok(html.includes("session-dot-idle"), `${phase} idle dot present`); - assert.ok(html.includes(`border-phase-${phase}`), `${phase} ring carries its phase color`); - assert.ok(!html.includes("lh-dot-breathe"), "idle is never animated"); - assert.ok(!html.includes("bg-phase-"), "hollow: no fill"); + assert.ok(!html.includes("session-dot"), "no dots — the status language is spinner-only"); + assert.ok(!html.includes("braille-spinner"), "idle never shows the spinner"); } }); - it("a pending question renders the SOLID phase dot — no animation (the dominant state stays calm)", () => { + it("a pending question renders NO indicator — resting state, carried by the meta text", () => { const html = render(item({ phase: "teach", pendingQuestion: true })); - assert.ok(html.includes("session-dot-awaiting"), "awaiting dot present"); - assert.ok(html.includes("bg-phase-teach"), "solid fill in the phase color"); - assert.ok(!html.includes("lh-dot-breathe"), "motion is reserved for working"); - assert.ok(!html.includes("bg-attention"), "amber channel is gone"); - assert.ok(html.includes("Waiting for your answer"), "label"); + assert.ok(!html.includes("session-dot"), "no dots"); + assert.ok(!html.includes("braille-spinner"), "motion is reserved for working"); + assert.ok(html.includes("Waiting for your answer"), "the meta line's text"); }); - it("a working session renders the pulsing phase dot", () => { + it("a working session (turn in flight) renders the braille spinner — the ONLY indicator", () => { const html = render(item({ phase: "plan", busy: true })); - assert.ok(html.includes("session-dot-working"), "working dot present"); - assert.ok(html.includes("lh-dot-breathe"), "breathing animation"); - assert.ok(html.includes("bg-phase-plan"), "the pulse is phase-colored"); - assert.ok(html.includes("Teacher is working"), "label"); + assert.ok(html.includes('data-testid="braille-spinner"'), "the spinner"); + assert.ok(html.includes("braille-frame"), "CSS-animated frames"); + assert.ok((html.match(/class="braille-frame"/g) ?? []).length === 10, "all ten frames"); + assert.ok(html.includes('data-frame="0"'), "frame 0 leads (static fallback)"); + assert.ok(html.includes("Teacher is working"), "accessible label"); + assert.ok(!html.includes("bg-attention"), "no colored state fill — monochrome by design"); }); - it("a pending answer outranks 'working' when both are true", () => { - const html = render(item({ pendingQuestion: true, busy: true })); - assert.ok(html.includes("session-dot-awaiting")); - assert.ok(!html.includes("session-dot-working"), "one dot at a time — awaiting wins"); + it("busy does not double-signal background agents (that is the sidebar section's channel)", () => { + // The narrowing lives in the API route (busy = isTurnRunning only); + // the row itself just renders busy faithfully. Pinned here so the + // contract can't silently re-widen: an extraction-only "working" + // row would be a double signal. + const html = render(item({ busy: true, pendingQuestion: false })); + assert.ok(html.includes("Teacher is working"), "the spinner's label names the turn, not agents"); }); it("the meta line: state replaces the count when the state is the useful fact", () => { const awaiting = render(item({ pendingQuestion: true, updatedAt: "2026-01-02T01:00:00.000Z" })); assert.ok(awaiting.includes("Waiting for your answer · "), "awaiting meta"); assert.ok(!awaiting.includes("messages"), "count yields to the state"); - assert.ok(!awaiting.includes("lh-dot-breathe"), "…and the dot is still solid"); + assert.ok(!awaiting.includes("braille-spinner"), "…and the spinner stays off"); const working = render(item({ busy: true })); assert.ok(working.includes("Teacher is working…"), "working meta"); From c9ba4a1b066a9823a8d6e3f3bbc13d9d43e1974d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 15 Sep 2026 17:10:49 +0300 Subject: [PATCH 02/11] =?UTF-8?q?Minimal-color=20round=202=20(owner):=20th?= =?UTF-8?q?e=20chip=20is=20label-only=20=E2=80=94=20spinner=20out=20of=20t?= =?UTF-8?q?he=20top=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-1 experiment put the braille spinner in the chip's old dot slot; rejected live: the pill's curve and the square braille cell fought for space (visually squeezed), and the chat surface already shows in-flight work (thinking shimmer, the dock) — the chip stating the phase is its whole job. PhaseChip back to a label-only pill (busy prop removed, single-child gap dropped); work-state lives in exactly one place: the sidebar row's spinner. Doc note + test pin the decision so it doesn't get re-tried blind. Gates: typecheck, lint, 469/469, build. --- components/chat.tsx | 2 +- components/phase-divider.tsx | 17 ++++++++++------- tests/render-phase-divider.test.tsx | 12 +++--------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 38f36c8..5a5ea65 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -605,7 +605,7 @@ export function Chat({ Plan )} - +
diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx index 56aac68..9d6f617 100644 --- a/components/phase-divider.tsx +++ b/components/phase-divider.tsx @@ -1,4 +1,3 @@ -import { BrailleSpinner } from "@/components/braille-spinner"; import type { Phase3 } from "@/src/core"; /** @@ -17,6 +16,12 @@ import type { Phase3 } from "@/src/core"; * 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. */ @@ -26,18 +31,16 @@ const CHIP_LABEL: Record = { teach: "Teaching", }; -/** The header chip: the phase label, monochrome; while a turn is in - * flight the old dot slot shows the braille spinner (motion = the honest - * state signal — see BrailleSpinner). */ -export function PhaseChip({ phase, busy = false }: { phase: Phase3; busy?: boolean }) { +/** 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 ( - {busy && } {CHIP_LABEL[phase]} ); diff --git a/tests/render-phase-divider.test.tsx b/tests/render-phase-divider.test.tsx index 72f2005..71b00f3 100644 --- a/tests/render-phase-divider.test.tsx +++ b/tests/render-phase-divider.test.tsx @@ -30,16 +30,10 @@ describe("PhaseChip (header)", () => { } }); - it("busy: the old dot slot becomes the braille spinner (motion = turn in flight)", () => { - const html = renderToStaticMarkup(); - assert.ok(html.includes('data-testid="braille-spinner"'), "spinner in the chip"); - assert.ok(html.includes("Teaching"), "label stays"); - assert.ok((html.match(/class="braille-frame"/g) ?? []).length === 10, "all ten frames"); - }); - - it("idle: no spinner — the chip is the label alone", () => { + it("NEVER shows a working spinner — the pill's curve suits text, not glyph cells (owner, round 2)", () => { const html = renderToStaticMarkup(); - assert.ok(!html.includes("braille-spinner")); + assert.ok(!html.includes("braille-spinner"), "the chip is label-only by design"); + assert.ok(!html.includes("braille-frame"), "no spinner frames leak in"); }); }); From 1b7dbe2898856f2ddcc49efdb3500c9426be9e97 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 13:13:26 +0300 Subject: [PATCH 03/11] Typography round 1 (owner): the three-font rule enforced + Source Sans session titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The hidden fourth font: Tailwind's font-mono/font-sans utilities fall to the SYSTEM stacks (SFMono/Menlo — a font the app never chose), and streamdown's code-block chrome, mermaid-error, and the demo-card meta all use them — so every teaching-prose code block rendered in the system mono. Fixed at the token level: @theme now aliases --font-mono → JetBrains Mono and --font-sans → Source Sans 3, so every utility use resolves to the app's three fonts (chrome=JetBrains, prose=Source Sans, display=CMU bold). - Session titles: Source Sans 3 (the human-words voice — the same font the words were typed in; mono = machine data stays on the meta line). One rule, finally applied to the picker too. CMU bold titles are the queued experiment if this reads wrong live. Gates: typecheck, lint, 469/469. Live: title "Source Sans 3" 12.5px, meta "JetBrains Mono" 11px; code blocks now JetBrains. --- app/globals.css | 8 +++++++- components/sidebar.tsx | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/globals.css b/app/globals.css index af3eee1..ca56ace 100644 --- a/app/globals.css +++ b/app/globals.css @@ -82,10 +82,16 @@ @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; diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 4324d0b..b046ed2 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -133,7 +133,12 @@ export function SessionRow({ > - {chat.title} + {/* Titles are the learner's/teacher's WORDS — Source Sans 3, the + same voice the words were typed in (every input is lh-prose); + the meta line below stays JetBrains Mono (machine data). One + rule, finally applied to the picker too (owner, typography + round): mono = chrome, Source Sans = human words. */} + {chat.title} {sessionMeta(chat)} From 5e6d5f124c70441cdbf7fc4e81c5dd49d49f2173 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 13:15:35 +0300 Subject: [PATCH 04/11] Typography round 2 (owner): lh-input (caret at the font-metric floor) + 14px session titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lh-input: lh-prose's 1.5 reading leading made typing carets tower (~25px box on 16.5px text, ~2.1× the glyphs — the caret fills the line box in Chromium). Inputs are not paragraphs: 1.25 leading drops the caret to ~21px, the font-metric floor (as close to the glyphs as Chromium will draw). Swapped on every typing surface — composer (chat + home), the thinking field, answer + editable-Other inputs, the model search; rendered prose (message.tsx container, option descriptions, chat.tsx pre-wrap) keeps lh-prose 1.5. Same family and size as prose; only leading differs. - Session titles: 14px (text-base) — Source Sans at 12.5px read as small as the 11px mono meta under them; the title now holds its rank. Live: title "Source Sans 3" 14px vs meta "JetBrains Mono" 11px, row 52px; composer line box 20.625px (was 24.75px). Gates: typecheck, lint, 469/469. --- app/globals.css | 13 +++++++++++++ components/composer.tsx | 2 +- components/home.tsx | 2 +- components/model-picker.tsx | 2 +- components/question-dock.tsx | 6 +++--- components/sidebar.tsx | 2 +- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/globals.css b/app/globals.css index ca56ace..abffdab 100644 --- a/app/globals.css +++ b/app/globals.css @@ -164,6 +164,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 (24.75px at 16.5px text, ~2.1× the glyph height). Inputs are not + paragraphs: a tighter 1.25 leading brings the caret to the font-metric + floor (~21px, 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; 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/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" />
@@ -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" /> ) : (