diff --git a/app/globals.css b/app/globals.css index 8f6c102..7c824ce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -164,48 +164,29 @@ body { * ============================================================ */ /* Phase colors: blue = probing, violet = planning, green = teaching. - Tuned per theme (light needs darker, dark lighter). The attention color - (an answer the learner owes) is deliberately outside the phase system — - dots and strips are separate channels and must not read as one. */ + 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); - --attention: oklch(0.62 0.15 65); } .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); - --attention: oklch(0.8 0.15 75); } @theme inline { --color-phase-probe: var(--phase-probe); --color-phase-plan: var(--phase-plan); --color-phase-teach: var(--phase-teach); - --color-attention: var(--attention); -} - -/* Status dots (session picker). Attention — a slow expanding ring, drawing - the eye to "your move". Working — a quiet breathing dot, opacity only, - never loud enough to compete with the attention ring. */ -@keyframes lh-dot-attention { - 0% { - box-shadow: 0 0 0 0 color-mix(in oklab, var(--attention) 60%, transparent); - } - - 70%, - 100% { - box-shadow: 0 0 0 5px transparent; - } -} - -.lh-dot-attention { - animation: lh-dot-attention 1.6s ease-out infinite; } +/* 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 { 0%, 100% { @@ -224,9 +205,7 @@ body { } @media (prefers-reduced-motion: reduce) { - - .lh-dot-attention, - .lh-dot-breathe { + .lh-dot-breathe { animation: none; } } diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 9b27e5c..8eeb6c0 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -65,49 +65,91 @@ export function SidebarToggle() { ); } -/** The left-edge phase strip color — one system with the header chip and - * the transcript dividers (globals.css --phase-*). */ -const PHASE_STRIP: Record = { +/** 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-status dot, left of the title. One visual family: a pulsing - * attention ring when an answer is owed (your move — the session is blocked - * on the learner), a quietly breathing dot while the teacher works (a turn - * running or a background job in flight), and NOTHING when it's simply the - * learner's turn to type — quiet is the correct default. A pending answer - * outranks "working": there is nothing else for the learner to do first. + * 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. */ -function SessionStatusDot({ pending, busy }: { pending: boolean; busy: boolean }) { - const label = pending ? "Waiting for your answer" : busy ? "Teacher is working" : null; +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 ( - - {pending ? ( - - ) : busy ? ( - - ) : ( - /* empty slot: keeps every title on the same baseline */ - + {label}} - + /> ); } +/** Compact "how long ago": "just now", "5m ago", "3h ago", "2d ago", + * then a plain date. How long a session has been WAITING matters more + * than an absolute timestamp. Pure — exported for tests. */ +export function timeAgo(iso: string, now = new Date()): string { + const ms = now.getTime() - new Date(iso).getTime(); + if (ms < 60_000) return "just now"; + const min = Math.floor(ms / 60_000); + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); +} + +/** The row's second line: state replaces the message count when the state + * is the more useful fact (most resting sessions are awaiting an answer — + * that's what the learner scans for). */ +function sessionMeta(chat: ChatListItem): string { + if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`; + if (chat.busy) return "Teacher is working…"; + return `${chat.messageCount} messages · ${new Date(chat.updatedAt).toLocaleDateString()}`; +} + /** - * One session row: the leftmost phase strip (bookmark tick, one color system - * with the header chip and transcript dividers), the status dot, then title - * + meta. Exported for render tests — pure function of the list item. + * One session row: the status dot, then title + state-aware meta. Exported + * for render tests — pure function of the list item. */ export function SessionRow({ chat, @@ -127,20 +169,10 @@ export function SessionRow({ active && "bg-accent font-medium", )} > - - + {chat.title} - - {chat.messageCount} messages · {new Date(chat.updatedAt).toLocaleDateString()} - + {sessionMeta(chat)} ); diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx index 0b5add2..ee9f56f 100644 --- a/tests/render-sidebar.test.tsx +++ b/tests/render-sidebar.test.tsx @@ -1,7 +1,9 @@ /** - * Session-row render tests — the picker's status language (phase strips + - * status dots). 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. 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. */ import React from "react"; (globalThis as Record).React = React; @@ -9,7 +11,7 @@ import React from "react"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; -import { SessionRow } from "../components/sidebar.tsx"; +import { SessionRow, timeAgo } from "../components/sidebar.tsx"; import type { ChatListItem, Phase3 } from "../src/core/index.ts"; function item(overrides: Partial): ChatListItem { @@ -31,45 +33,57 @@ function render(chat: ChatListItem, active = false) { } describe("SessionRow", () => { - it("renders a phase strip colored per phase (one testid per phase)", () => { - const phases: Phase3[] = ["probe", "plan", "teach"]; - for (const phase of phases) { + it("no phase strips — the dot alone carries the phase color", () => { + 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"); + }); + + it("idle session: hollow phase-colored ring, perfectly still", () => { + for (const phase of ["probe", "plan", "teach"] as Phase3[]) { const html = render(item({ phase })); - assert.ok(html.includes(`data-testid="phase-strip-${phase}"`), `${phase} strip present`); - assert.ok(html.includes(`bg-phase-${phase}`), `${phase} strip carries its phase color`); + 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"); } }); - it("quiet state (learner's turn): no status dot", () => { - const html = render(item({})); - assert.ok(!html.includes("session-dot-attention")); - assert.ok(!html.includes("session-dot-working")); + it("a pending question renders the SOLID phase dot — no animation (the dominant state stays calm)", () => { + 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"); }); - it("a pending question renders the attention dot", () => { - const html = render(item({ pendingQuestion: true })); - assert.ok(html.includes("session-dot-attention"), "attention dot present"); - assert.ok(html.includes("bg-attention"), "attention dot uses the attention color"); - assert.ok(html.includes("Waiting for your answer"), "screen-reader + tooltip label"); - }); - - it("a working session renders the breathing dot", () => { - const html = render(item({ busy: true })); + it("a working session renders the pulsing phase dot", () => { + 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"); }); it("a pending answer outranks 'working' when both are true", () => { const html = render(item({ pendingQuestion: true, busy: true })); - assert.ok(html.includes("session-dot-attention")); - assert.ok(!html.includes("session-dot-working"), "one dot at a time — attention wins"); + assert.ok(html.includes("session-dot-awaiting")); + assert.ok(!html.includes("session-dot-working"), "one dot at a time — awaiting wins"); }); - it("the title and meta line render", () => { - const html = render(item({ title: "Partial derivatives", messageCount: 7 })); - assert.ok(html.includes("Partial derivatives")); - assert.match(html, /7 messages · \d{1,2}\/\d{1,2}\/\d{4}/); + 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"); + + const working = render(item({ busy: true })); + assert.ok(working.includes("Teacher is working…"), "working meta"); + + const idle = render(item({ title: "Partial derivatives", messageCount: 7 })); + assert.ok(idle.includes("Partial derivatives")); + assert.match(idle, /7 messages · \d{1,2}\/\d{1,2}\/\d{4}/, "idle keeps the count"); }); it("active row is visually marked", () => { @@ -77,3 +91,19 @@ describe("SessionRow", () => { assert.ok(html.includes("font-medium"), "active emphasis"); }); }); + +describe("timeAgo", () => { + const now = new Date("2026-01-02T12:00:00.000Z"); + + it("just now / minutes / hours / days, then falls back to a date", () => { + assert.equal(timeAgo("2026-01-02T11:59:30.000Z", now), "just now"); + assert.equal(timeAgo("2026-01-02T11:55:00.000Z", now), "5m ago"); + assert.equal(timeAgo("2026-01-02T09:00:00.000Z", now), "3h ago"); + assert.equal(timeAgo("2025-12-31T12:00:00.000Z", now), "2d ago"); + assert.match(timeAgo("2025-06-02T12:00:00.000Z", now), /\d/, "beyond a week: a date"); + }); + + it("negative drift (clock skew) reads as just now, never a future date", () => { + assert.equal(timeAgo("2026-01-02T12:01:00.000Z", now), "just now"); + }); +});