Merge lh-ui refinement wave (feat/ui-polish @ 8163437) into main
11 commits on fbeca36: minimal-color rounds 1-2 (phase color retired, the braille spinner is the one working indicator; the chip is label-only), typography rounds 1-5 (three-font rule enforced — CMU titles, Source Sans session titles, JetBrains mono; snapped type ladder in globals.css with the xxs rung merged into xs; lh-input caret at the font-metric floor; session titles at prose size, message counts and active-row weight dropped, ISO dates), header rounds 6/6b/6c/6d (60px top bar aligned to the sidebar LH band; the open-sidebar toggle matches the close glyph at size-4 both ways; SidebarToggle always-mounted with a lockstep animated slot — no more one-frame title jump; title at text-3xl). Semantic change folded in: `busy` in the chats list now means exactly "a teach turn is in flight" — background jobs no longer spin the session row (the sidebar's Background agents section is their channel). Merge fallout fixed in the merge: components/graph-coverage.tsx (main's coverage panel) held the last three text-xxs uses; with the rung deleted they silently lost their size — mechanically swapped to text-xs, matching lh-ui's own swap in graph-context-marker.tsx. No file overlap with main's waves since fbeca36 (extraction coverage, lh-graph merge, world-scale bake) — clean auto-merge. Gates: typecheck, eslint, 496/496 tests, build. Live-verified on the dev server: 60px header with 32px CMU title, label-only chip, snapped ladder active (--lh-prose-size 16px, no --text-xxs rule anywhere), sidebar rows with ISO dates, animated sidebar toggle with no header jump.
This commit is contained in:
@@ -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<string, unknown>).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,34 @@ 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("NEVER shows a working spinner — the pill's curve suits text, not glyph cells (owner, round 2)", () => {
|
||||
const html = renderToStaticMarkup(<PhaseChip phase="teach" />);
|
||||
assert.ok(!html.includes("braille-spinner"), "the chip is label-only by design");
|
||||
assert.ok(!html.includes("braille-frame"), "no spinner frames leak in");
|
||||
});
|
||||
});
|
||||
|
||||
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(<PhaseDivider phase="plan" nodeCount={3} />);
|
||||
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(<PhaseDivider phase="teach" />);
|
||||
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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -130,9 +130,10 @@ describe("plan block render", () => {
|
||||
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
|
||||
// Fills the column UP TO the chat-font cap: the svg is w-full +
|
||||
// mx-auto with maxWidth = extent × TEXT_CAP_SCALE, so node text never
|
||||
// renders past the 16.5px prose size (owner, plan-graph round 1).
|
||||
// renders past the 16px prose size (owner, plan-graph round 1; the
|
||||
// snap 16.5→16 landed in typography round 4).
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
|
||||
assert.match(html, /max-width:232\.32px/); // (160+16) × 1.32
|
||||
assert.match(html, /max-width:225\.28px/); // (160+16) × 1.28
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on
|
||||
// every side (strokes/glow never clip at the edges).
|
||||
assert.match(html, /viewBox="-8 -8 176 152"/);
|
||||
|
||||
@@ -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 <SessionRow> 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
|
||||
* <SessionRow> pins every visible state. Same tsx classic-JSX shim as
|
||||
* render-cards.test.tsx.
|
||||
*/
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).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>): ChatListItem {
|
||||
return {
|
||||
@@ -33,74 +36,80 @@ 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");
|
||||
|
||||
// Owner, typography round 5: the message count is dropped — it
|
||||
// informed no decision. Idle meta is just how long ago.
|
||||
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");
|
||||
assert.ok(!idle.includes("messages"), "the count is gone (flip-flop: one line in sessionMeta)");
|
||||
assert.match(idle, /\d{4}-\d{2}-\d{2}/, "idle shows the ISO date");
|
||||
});
|
||||
|
||||
it("active row is visually marked", () => {
|
||||
it("active row is visually marked by the background alone (owner, round 5: no weight change)", () => {
|
||||
const html = render(item({}), true);
|
||||
assert.ok(html.includes("font-medium"), "active emphasis");
|
||||
assert.ok(html.includes("bg-accent"), "active background");
|
||||
assert.ok(!html.includes("font-medium"), "weight is not a second channel doing the same job");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeAgo", () => {
|
||||
const now = new Date("2026-01-02T12:00:00.000Z");
|
||||
|
||||
it("just now / minutes / hours / days, then falls back to a date", () => {
|
||||
it("just now / minutes / hours / days, then the ISO date (owner, round 5)", () => {
|
||||
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");
|
||||
assert.equal(timeAgo("2025-06-02T12:00:00.000Z", now), "2025-06-02", "beyond a week: ISO — unambiguous, sortable");
|
||||
});
|
||||
|
||||
it("negative drift (clock skew) reads as just now, never a future date", () => {
|
||||
|
||||
Reference in New Issue
Block a user