Session picker: one phase-colored dot, state via fill + motion

Redesign per review feedback: the colored phase strips were too busy —
removed. One small dot now carries two channels:

- COLOR = phase (sky/violet/emerald, same system as chip + dividers)
- FILL + MOTION = what's owed:
  - pulsing  = teacher is working (the only state that deserves motion)
  - solid    = a question awaits the learner's answer — the dominant
               resting state of real sessions, so deliberately calm
  - hollow   = idle, nothing owed

The amber attention channel is gone entirely (it would read as a
phantom fourth phase). The meta line now leads with the useful fact:
"Waiting for your answer · 5m ago" / "Teacher is working…" — the
message count only shows when idle. Ordering stays pure recency.
This commit is contained in:
Amos
2026-09-01 19:49:47 +03:00
parent 475726a976
commit 4a5bc40243
3 changed files with 138 additions and 97 deletions
+6 -27
View File
@@ -164,48 +164,29 @@ body {
* ============================================================ */ * ============================================================ */
/* Phase colors: blue = probing, violet = planning, green = teaching. /* Phase colors: blue = probing, violet = planning, green = teaching.
Tuned per theme (light needs darker, dark lighter). The attention color Tuned per theme (light needs darker, dark lighter). These color the
(an answer the learner owes) is deliberately outside the phase system — session dots, header chip and transcript dividers — one language. */
dots and strips are separate channels and must not read as one. */
:root { :root {
--phase-probe: oklch(0.55 0.17 253); --phase-probe: oklch(0.55 0.17 253);
--phase-plan: oklch(0.54 0.19 300); --phase-plan: oklch(0.54 0.19 300);
--phase-teach: oklch(0.55 0.14 160); --phase-teach: oklch(0.55 0.14 160);
--attention: oklch(0.62 0.15 65);
} }
.dark { .dark {
--phase-probe: oklch(0.72 0.13 250); --phase-probe: oklch(0.72 0.13 250);
--phase-plan: oklch(0.72 0.14 300); --phase-plan: oklch(0.72 0.14 300);
--phase-teach: oklch(0.74 0.12 160); --phase-teach: oklch(0.74 0.12 160);
--attention: oklch(0.8 0.15 75);
} }
@theme inline { @theme inline {
--color-phase-probe: var(--phase-probe); --color-phase-probe: var(--phase-probe);
--color-phase-plan: var(--phase-plan); --color-phase-plan: var(--phase-plan);
--color-phase-teach: var(--phase-teach); --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 { @keyframes lh-dot-breathe {
0%, 0%,
100% { 100% {
@@ -224,9 +205,7 @@ body {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.lh-dot-breathe {
.lh-dot-attention,
.lh-dot-breathe {
animation: none; animation: none;
} }
} }
+74 -42
View File
@@ -65,49 +65,91 @@ export function SidebarToggle() {
); );
} }
/** The left-edge phase strip color — one system with the header chip and /** Phase colors for the session dot — one system with the header chip
* the transcript dividers (globals.css --phase-*). */ * and the transcript dividers (globals.css --phase-*). Static class
const PHASE_STRIP: Record<Phase3, string> = { * strings per phase: Tailwind can't compile dynamic class names. */
const PHASE_FILL: Record<Phase3, string> = {
probe: "bg-phase-probe", probe: "bg-phase-probe",
plan: "bg-phase-plan", plan: "bg-phase-plan",
teach: "bg-phase-teach", teach: "bg-phase-teach",
}; };
const PHASE_RING: Record<Phase3, string> = {
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 * The session dot, left of the title. TWO channels, one dot: COLOR is the
* attention ring when an answer is owed (your move — the session is blocked * phase (probe/plan/teach — sky/violet/emerald), FILL+MOTION is what's
* on the learner), a quietly breathing dot while the teacher works (a turn * owed. Pulsing = the teacher is working (rare, time-sensitive — the only
* running or a background job in flight), and NOTHING when it's simply the * state that deserves motion). Solid = a question awaits the learner's
* learner's turn to type — quiet is the correct default. A pending answer * answer (the DOMINANT resting state of real sessions — calm by design,
* outranks "working": there is nothing else for the learner to do first. * 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 }) { function SessionDot({ phase, pending, busy }: { phase: Phase3; pending: boolean; busy: boolean }) {
const label = pending ? "Waiting for your answer" : busy ? "Teacher is working" : null; if (pending) {
return (
<span
data-testid="session-dot-awaiting"
title="Waiting for your answer"
className={cn("size-2 shrink-0 rounded-full", PHASE_FILL[phase])}
>
<span className="sr-only">Waiting for your answer</span>
</span>
);
}
if (busy) {
return (
<span
data-testid="session-dot-working"
title="Teacher is working"
className={cn("size-2 shrink-0 rounded-full", PHASE_FILL[phase], "lh-dot-breathe")}
>
<span className="sr-only">Teacher is working</span>
</span>
);
}
return ( return (
<span className="flex w-3 shrink-0 items-center justify-center" title={label ?? undefined}> <span
{pending ? ( data-testid="session-dot-idle"
<span aria-hidden
data-testid="session-dot-attention" className={cn(
className="size-2 rounded-full bg-attention lh-dot-attention" "size-2 shrink-0 rounded-full border-2 bg-transparent",
/> PHASE_RING[phase],
) : busy ? (
<span
data-testid="session-dot-working"
className="size-2 rounded-full bg-muted-foreground lh-dot-breathe"
/>
) : (
/* empty slot: keeps every title on the same baseline */
<span className="size-2" />
)} )}
{label && <span className="sr-only">{label}</span>} />
</span>
); );
} }
/** 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 * One session row: the status dot, then title + state-aware meta. Exported
* with the header chip and transcript dividers), the status dot, then title * for render tests — pure function of the list item.
* + meta. Exported for render tests — pure function of the list item.
*/ */
export function SessionRow({ export function SessionRow({
chat, chat,
@@ -127,20 +169,10 @@ export function SessionRow({
active && "bg-accent font-medium", active && "bg-accent font-medium",
)} )}
> >
<span <SessionDot phase={chat.phase} pending={chat.pendingQuestion} busy={chat.busy} />
data-testid={`phase-strip-${chat.phase}`}
className={cn(
"absolute top-2 bottom-2 left-0.5 w-[3px] rounded-full",
PHASE_STRIP[chat.phase],
)}
aria-hidden
/>
<SessionStatusDot pending={chat.pendingQuestion} busy={chat.busy} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate">{chat.title}</span> <span className="block truncate">{chat.title}</span>
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
{chat.messageCount} messages · {new Date(chat.updatedAt).toLocaleDateString()}
</span>
</span> </span>
</button> </button>
); );
+58 -28
View File
@@ -1,7 +1,9 @@
/** /**
* Session-row render tests — the picker's status language (phase strips + * Session-row render tests — the picker's status language. One dot, two
* status dots). Pure function of the list item, so SSR of <SessionRow> pins * channels: COLOR = phase (sky/violet/emerald), FILL+MOTION = what's owed
* every visible state. Same tsx classic-JSX shim as render-cards.test.tsx. * (pulsing = teacher working, solid = answer owed, hollow = idle). Pure
* function of the list item, so SSR of <SessionRow> pins every visible
* state. Same tsx classic-JSX shim as render-cards.test.tsx.
*/ */
import React from "react"; import React from "react";
(globalThis as Record<string, unknown>).React = React; (globalThis as Record<string, unknown>).React = React;
@@ -9,7 +11,7 @@ import React from "react";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; 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"; import type { ChatListItem, Phase3 } from "../src/core/index.ts";
function item(overrides: Partial<ChatListItem>): ChatListItem { function item(overrides: Partial<ChatListItem>): ChatListItem {
@@ -31,45 +33,57 @@ function render(chat: ChatListItem, active = false) {
} }
describe("SessionRow", () => { describe("SessionRow", () => {
it("renders a phase strip colored per phase (one testid per phase)", () => { it("no phase strips — the dot alone carries the phase color", () => {
const phases: Phase3[] = ["probe", "plan", "teach"]; const html = render(item({ phase: "teach" }));
for (const phase of phases) { 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 })); const html = render(item({ phase }));
assert.ok(html.includes(`data-testid="phase-strip-${phase}"`), `${phase} strip present`); assert.ok(html.includes("session-dot-idle"), `${phase} idle dot present`);
assert.ok(html.includes(`bg-phase-${phase}`), `${phase} strip carries its phase color`); 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", () => { it("a pending question renders the SOLID phase dot — no animation (the dominant state stays calm)", () => {
const html = render(item({})); const html = render(item({ phase: "teach", pendingQuestion: true }));
assert.ok(!html.includes("session-dot-attention")); assert.ok(html.includes("session-dot-awaiting"), "awaiting dot present");
assert.ok(!html.includes("session-dot-working")); 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", () => { it("a working session renders the pulsing phase dot", () => {
const html = render(item({ pendingQuestion: true })); const html = render(item({ phase: "plan", busy: 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 }));
assert.ok(html.includes("session-dot-working"), "working dot present"); assert.ok(html.includes("session-dot-working"), "working dot present");
assert.ok(html.includes("lh-dot-breathe"), "breathing animation"); 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("Teacher is working"), "label");
}); });
it("a pending answer outranks 'working' when both are true", () => { it("a pending answer outranks 'working' when both are true", () => {
const html = render(item({ pendingQuestion: true, busy: true })); const html = render(item({ pendingQuestion: true, busy: true }));
assert.ok(html.includes("session-dot-attention")); assert.ok(html.includes("session-dot-awaiting"));
assert.ok(!html.includes("session-dot-working"), "one dot at a time — attention wins"); assert.ok(!html.includes("session-dot-working"), "one dot at a time — awaiting wins");
}); });
it("the title and meta line render", () => { it("the meta line: state replaces the count when the state is the useful fact", () => {
const html = render(item({ title: "Partial derivatives", messageCount: 7 })); const awaiting = render(item({ pendingQuestion: true, updatedAt: "2026-01-02T01:00:00.000Z" }));
assert.ok(html.includes("Partial derivatives")); assert.ok(awaiting.includes("Waiting for your answer · "), "awaiting meta");
assert.match(html, /7 messages · \d{1,2}\/\d{1,2}\/\d{4}/); 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", () => { it("active row is visually marked", () => {
@@ -77,3 +91,19 @@ describe("SessionRow", () => {
assert.ok(html.includes("font-medium"), "active emphasis"); 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");
});
});