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:
+6
-27
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user