Typography round 4 (owner): the snap to the ladder — prose 16.5→16, xxs rung merged into xs

The system: the ladder is the truth, everything snaps to it.

- Prose snaps to the ladder's 16px rung (--lh-prose-size 16.5→16; the
  half-pixel was a reskin-era hand-tune with no visible payoff and a
  system cost). lh-prose, lh-input, and the session titles all read the
  one variable — one reading size, now a ladder citizen. @theme inline
  inlines literals without emitting runtime vars, so the variable can't
  var()-link to --text-lg: documented as the one deliberate duplication
  (change one, change both).
- The 10px rung merges into 11px: one pixel apart was a distinction with
  no visible difference, and 10px sits at the mono legibility floor —
  quiet labels (Sessions, Background agents, dock hints, model-picker
  footers) get their quiet from color + tracking, not a notch of size.
  All 13 text-xxs uses → text-xs; --text-xxs token removed.
- Derived constant follows: TEXT_CAP_SCALE 16.5/12.5 → 16/12.5 (1.32 →
  1.28; test pin 232.32px → 225.28px) — the plan-graph text cap now
  tracks the snapped prose size.

Live: title 16px, Sessions/Background-agents labels 11px, composer 16px
with the 20px lh-input line box. Gates: typecheck, lint, 469/469.

Lane note: components/graph-context-marker.tsx (graph-stream file) had a
mechanical text-xxs → text-xs swap — required: the token removal
otherwise leaves a dead class in their file.
This commit is contained in:
amosblomqvist
2026-09-17 13:43:26 +03:00
parent d01759ec32
commit 2a8554af27
9 changed files with 40 additions and 25 deletions
+20 -7
View File
@@ -40,7 +40,12 @@
/* Emphasis coloring in teaching prose — ready for the settings UI. */ /* Emphasis coloring in teaching prose — ready for the settings UI. */
--lh-ink-strong: #c96b4a; --lh-ink-strong: #c96b4a;
--lh-ink-italic: #6fa9d6; --lh-ink-italic: #6fa9d6;
--lh-prose-size: 16.5px; /* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be
var()-linked: @theme inline inlines literals into utilities without
emitting runtime vars — so this is the one documented duplication:
change one, change both. (owner, typography round 4: snapped from
the reskin's hand-tuned 16.5 to the ladder's 16.) */
--lh-prose-size: 16px;
--lh-prose-leading: 1.5; --lh-prose-leading: 1.5;
} }
@@ -67,7 +72,12 @@
--ring: #8d8d8d; --ring: #8d8d8d;
--lh-ink-strong: #c96b4a; --lh-ink-strong: #c96b4a;
--lh-ink-italic: #6fa9d6; --lh-ink-italic: #6fa9d6;
--lh-prose-size: 16.5px; /* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be
var()-linked: @theme inline inlines literals into utilities without
emitting runtime vars — so this is the one documented duplication:
change one, change both. (owner, typography round 4: snapped from
the reskin's hand-tuned 16.5 to the ladder's 16.) */
--lh-prose-size: 16px;
--lh-prose-leading: 1.5; --lh-prose-leading: 1.5;
} }
@@ -93,9 +103,12 @@
--font-mono: var(--font-jetbrains), ui-monospace, SFMono-Regular, monospace; --font-mono: var(--font-jetbrains), ui-monospace, SFMono-Regular, monospace;
--font-sans: var(--font-sans3), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--font-sans3), ui-sans-serif, system-ui, sans-serif;
/* The modular type ladder — each size carries its own line-height. /* The modular type ladder — each size carries its own line-height.
Component sizes must pull from this ladder. */ Component sizes must pull from this ladder. (owner, typography
--text-xxs: 10px; round 4: the 10px rung is merged into 11 — one pixel apart was a
--text-xxs--line-height: 1.4; distinction with no visible difference, and 10px sits at the mono
legibility floor; quiet labels get their quiet from color +
tracking, not a notch of size. Reading prose = the 16px rung via
--lh-prose-size.) */
--text-xs: 11px; --text-xs: 11px;
--text-xs--line-height: 1.4; --text-xs--line-height: 1.4;
--text-sm: 12.5px; --text-sm: 12.5px;
@@ -166,9 +179,9 @@ body {
/* lh-input (owner, typography round 2): lh-prose's 1.5 READING leading /* 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 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 box (24px at 16px text, ~2× the glyph height). Inputs are not
paragraphs: a tighter 1.25 leading brings the caret to the font-metric 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 floor (~20px, as close to the glyphs as Chromium will draw it) while
wrapped lines in growing textareas still read fine. Rendered prose keeps wrapped lines in growing textareas still read fine. Rendered prose keeps
1.5 — the two share family and size, they differ only in leading. */ 1.5 — the two share family and size, they differ only in leading. */
.lh-input { .lh-input {
+1 -1
View File
@@ -274,7 +274,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
// alone (the cost span is population, not decoration). // alone (the cost span is population, not decoration).
return ( return (
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2"> <section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
<div className="mb-1 flex items-center gap-1.5 text-xxs uppercase tracking-widest text-muted-foreground/70"> <div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
<ActivityIcon className="size-3" /> <ActivityIcon className="size-3" />
Background agents Background agents
{blobs.length > 0 && ( {blobs.length > 0 && (
+2 -2
View File
@@ -72,7 +72,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} )}
> >
{/* Owner round 8: a touch smaller than the text (size-4→size-3.5, {/* Owner round 8: a touch smaller than the text (size-4→size-3.5,
≈14px vs the 16.5px row) while centered. */} ≈14px vs the 16px row) while centered. */}
{(key || sel) && <GlyphMark kind={key ? "check" : "x"} className="size-3.5" />} {(key || sel) && <GlyphMark kind={key ? "check" : "x"} className="size-3.5" />}
</span> </span>
)} )}
@@ -101,7 +101,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{answer.note && ( {answer.note && (
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3"> <div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
<span className="mb-0.5 block text-xxs font-medium uppercase tracking-wide text-muted-foreground"> <span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your thinking Your thinking
</span> </span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.note}</span> <span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.note}</span>
+1 -1
View File
@@ -91,7 +91,7 @@ export function GraphContextList({ data }: { data: GraphContextData }): ReactNod
<li key={p.id} className="flex items-baseline gap-2"> <li key={p.id} className="flex items-baseline gap-2">
<span className="shrink-0 tabular-nums text-muted-foreground">{i + 1}.</span> <span className="shrink-0 tabular-nums text-muted-foreground">{i + 1}.</span>
{p.level && ( {p.level && (
<span className="shrink-0 rounded bg-foreground/10 px-1 py-0.5 text-xxs text-muted-foreground"> <span className="shrink-0 rounded bg-foreground/10 px-1 py-0.5 text-xs text-muted-foreground">
{p.level} {p.level}
</span> </span>
)} )}
+4 -4
View File
@@ -190,7 +190,7 @@ export function ModelControl({
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted" className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
> >
<span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span> <span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span>
<span aria-hidden className="text-xxs text-muted-foreground">▾</span> <span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button> </button>
<button <button
type="button" type="button"
@@ -208,7 +208,7 @@ export function ModelControl({
<span className="text-card-foreground"> <span className="text-card-foreground">
{effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)} {effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)}
</span> </span>
<span aria-hidden className="text-xxs text-muted-foreground">▾</span> <span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button> </button>
{open === "model" && ( {open === "model" && (
@@ -267,7 +267,7 @@ export function ModelControl({
</li> </li>
))} ))}
</ul> </ul>
<div className="mt-1 border-t px-2 pt-1.5 text-xxs text-muted-foreground"> <div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">
Applies to the next turn · background agents stay config-pinned Applies to the next turn · background agents stay config-pinned
</div> </div>
</div> </div>
@@ -299,7 +299,7 @@ export function ModelControl({
</button> </button>
))} ))}
{error && <div className="mt-1 text-destructive">{error}</div>} {error && <div className="mt-1 text-destructive">{error}</div>}
<div className="mt-1 border-t px-2 pt-1.5 text-xxs text-muted-foreground"> <div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">
Levels this model actually supports Levels this model actually supports
</div> </div>
</div> </div>
+4 -3
View File
@@ -192,8 +192,9 @@ export const GLOW_BLUR = 5;
export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR; export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR;
/** Node titles render at text-xs (12.5px) in svg units. The in-chat block /** Node titles render at text-xs (12.5px) in svg units. The in-chat block
* scales up to fill the column but NEVER past the chat prose size * scales up to fill the column but NEVER past the chat prose size
* (--lh-prose-size, 16.5px) — the width cap that makes that hold. */ * (--lh-prose-size, 16px — the ladder's reading rung) — the width cap
export const TEXT_CAP_SCALE = 16.5 / 12.5; * that makes that hold. */
export const TEXT_CAP_SCALE = 16 / 12.5;
// ─── The owner's arrowhead (plan-graph round 5) ────────────────────────────── // ─── The owner's arrowhead (plan-graph round 5) ──────────────────────────────
@@ -808,7 +809,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
/> />
</div> </div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xxs text-muted-foreground/60">Drag to pan · scroll to zoom</span> <span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
+4 -4
View File
@@ -307,7 +307,7 @@ function QuestionDockForm({
)} )}
> >
{isSel && ( {isSel && (
<span className={cn(qd.mode === "multi-select" ? "text-xxs" : "size-1.5 rounded-full bg-current")}> <span className={cn(qd.mode === "multi-select" ? "text-xs" : "size-1.5 rounded-full bg-current")}>
{qd.mode === "multi-select" ? "✓" : ""} {qd.mode === "multi-select" ? "✓" : ""}
</span> </span>
)} )}
@@ -373,7 +373,7 @@ function QuestionDockForm({
)} )}
> >
{sel.otherSelected && ( {sel.otherSelected && (
<span className={cn(qd.mode === "multi-select" ? "text-xxs" : "size-1.5 rounded-full bg-current")}> <span className={cn(qd.mode === "multi-select" ? "text-xs" : "size-1.5 rounded-full bg-current")}>
{qd.mode === "multi-select" ? "✓" : ""} {qd.mode === "multi-select" ? "✓" : ""}
</span> </span>
)} )}
@@ -412,7 +412,7 @@ function QuestionDockForm({
data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"} data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"}
className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50" className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
> >
<div className="px-2.5 pt-2 text-xxs font-medium uppercase tracking-wide text-muted-foreground"> <div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{qd.mode === "text" ? "Your answer" : "Your thinking"} {qd.mode === "text" ? "Your answer" : "Your thinking"}
</div> </div>
{qd.mode === "text" ? ( {qd.mode === "text" ? (
@@ -455,7 +455,7 @@ function QuestionDockForm({
)} )}
<div className="flex items-center justify-between gap-2 p-1"> <div className="flex items-center justify-between gap-2 p-1">
{qd.mode !== "text" && ( {qd.mode !== "text" && (
<span aria-hidden className="pl-1.5 text-xxs text-muted-foreground"> <span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
⌘/Ctrl + ↵ submits ⌘/Ctrl + ↵ submits
</span> </span>
)} )}
+1 -1
View File
@@ -218,7 +218,7 @@ function SessionsSidebar() {
</button> </button>
<div className="mx-3 mb-1 flex items-center gap-2"> <div className="mx-3 mb-1 flex items-center gap-2">
<span className="text-xxs uppercase tracking-widest text-muted-foreground/70">Sessions</span> <span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
<span className="relative flex-1"> <span className="relative flex-1">
<Input <Input
data-testid="session-search" data-testid="session-search"
+3 -2
View File
@@ -130,9 +130,10 @@ describe("plan block render", () => {
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome"); 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 + // 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 // 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, /<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 // Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on
// every side (strokes/glow never clip at the edges). // every side (strokes/glow never clip at the edges).
assert.match(html, /viewBox="-8 -8 176 152"/); assert.match(html, /viewBox="-8 -8 176 152"/);