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.
- lh-input: lh-prose's 1.5 reading leading made typing carets tower
(~25px box on 16.5px text, ~2.1× the glyphs — the caret fills the line
box in Chromium). Inputs are not paragraphs: 1.25 leading drops the
caret to ~21px, the font-metric floor (as close to the glyphs as
Chromium will draw). Swapped on every typing surface — composer
(chat + home), the thinking field, answer + editable-Other inputs, the
model search; rendered prose (message.tsx container, option
descriptions, chat.tsx pre-wrap) keeps lh-prose 1.5. Same family and
size as prose; only leading differs.
- Session titles: 14px (text-base) — Source Sans at 12.5px read as
small as the 11px mono meta under them; the title now holds its rank.
Live: title "Source Sans 3" 14px vs meta "JetBrains Mono" 11px, row
52px; composer line box 20.625px (was 24.75px).
Gates: typecheck, lint, 469/469.
- ✓/✗ in the answered card: replaced literal glyphs with lucide CheckIcon/
XIcon (strokeWidth 2.5) inside a fixed size-4 flex box — text elements
never carried the right size/vertical alignment, vectors do. Test pins
updated (lucide-check/lucide-x classes).
- All user-typed INPUT text now matches markdown output: lh-prose class
replaces the earlier font-prose (family-only) band-aid on the composer,
question-dock text/thinking/Other fields, home topic input, model search
box, and answered-card 'your thinking'/free-text records. lh-prose sets
family+size (16.5px) so placeholders inherit correctly.
- Home view: StatsBar under the composer (model+thinking control) — the
DOCK geometry matches the chat view (max-w-[46rem] px-4) so nothing
shifts when a session starts; pre-chat selection persists via deferred
save (ModelControl chatId-empty) + PUT after POST /api/chats.
- Answer marks (✓/✗): same visual size as row text, vertically centered;
colored green-500/red-500 matching the verdict chips.
- All user-typed inputs (composer, question-dock text/thinking/Other,
answered-card 'your thinking' and free-text records) now render
Source Sans 3 via the font-prose utility. Placeholders inherit the
family via the element.
- Answered-card explanation: markdown wrapper made truly inline so the
'Why:' label + the paragraph sit on one line — removes the oversized
leading gap.
- New session: literal '+' glyph removed — one lucide PlusIcon (the
duplicated glyph was PlusIcon + text '+').
- Top bar: a small pointer-events-none gradient under the header eases
the scroller into the bar exactly like the dock's bottom fade.
- Option subtext (opt.description): .lh-prose — Source Sans 3; 'I don't
know' copy stays JetBrains Mono.
- 'I don't know': now layout-matched with a radio circle; dashed border
preserved as the deliberate non-answer.
- Separator spacing: options group pb-1 → pb-2 so the gap before the
divider equals the 8px inter-option gap.
--text-xxs (10px) joins the ladder; every component-level text-[9|10|11px]
literal in owned components now pulls from the ladder (graph-view.tsx and
home.tsx untouched — graph stream). Cosmetic pins updated where touched.
The architectural answer to the validation-crash question: validation IS
first at the model-facing boundary (pi validates arguments before
execute; failures return to the model same-turn — all three live
incidents retried successfully). But the same bytes also exist UNVALIDATED
by design in two other places: the live wire streams raw tool-input
deltas DURING generation, and the transcript keeps the raw input of
REJECTED calls (honesty + the replay invariant). The consumers were
supposed to trust part STATES — but that trust was enforced by
TypeScript casts ('part.input as X'), which are compile-time lies with
no runtime check behind them. That is the hole both crashes came
through, and it should not have been possible.
The structural rule now: model-authored INPUT is consumed only through
parsers running the SAME zod schema the model faced — boundary-valid ⇔
consumer-parseable, mechanically. Server/learner-authored OUTPUT stays
trusted (our code authors it).
- src/core/tool-parts.ts (new): parseQuizInput (display parse over the
shared quizInputBase — strips the teacher-only nodeId, accepts both
prober and teacher boundary shapes), parseAskInput, parseProbeDoneInput.
- contracts/probe.ts: probeDoneInputSchema is now core-owned zod — the
probe_done tool declares it (single source of truth), the seam runs
the literal same schema.
- question-dock.tsx: the dock parses its input at entry; unparsable
renders the quiet malformed-question fallback, never a crash.
- probe-done-card.tsx: the input preview goes through the parse; the
old state reasoning and shape guards collapse into it.
Pinned in tests/tool-parts.test.ts: both exact live crash shapes
(stringified quiz options, stringified assessment), boundary-parity
cases for all three schemas, nodeId stripping, mid-stream partials;
render-cards pins the dock fallback markup.
Gates: tsc, lint, build, 328/328.
Two live bugs from the modal-realism stress runs:
1. Multi-select ask: picking the custom 'Other…' row cleared every
already-picked option, and picking any option cleared a typed custom
answer — single-select displacement logic applied to both modes. The
dock's selection is now a pure state machine in question-core
(toggleOption/toggleOther over DockSelection, unit-tested): single
displaces (unchanged), multi coexists — neither channel clears the
other; the typed text always survives.
2. ProbeDoneCard crashed on a REJECTED probe_done call: the model
stringified 'assessment' (the same double-encoding habit as the
options bug), zod rejected the call, the model retried — but the
card's input-preview fallback trusted the errored part's unvalidated
input shape and died on assessment.strands.map. The preview now reads
input only while the call is pending or streaming (guarded with
Array.isArray); an output-error part renders a quiet
malformed-and-retried note instead of the garbage.
Pinned: question-core.test.ts (3 dock-selection machine cases),
render-cards.test.tsx (4 probe-done card cases incl. the exact live
stringified shape, plus the multi-select ask markup pin).
Gates: tsc, lint, build, 315/315.
Key-convention policy, settled: every field accepts ⌘/Ctrl+Enter
(metaKey || ctrlKey — Mac gets ⌘↵, Windows/Linux Ctrl↵, the Gmail
convention); plain Enter stays matched to the field's species —
sends in message fields (chat composer, free-text answers — the
universal chat convention, Shift+Enter still a newline), newline in
the thinking field (composition, for dumping).
- The learner's own typed Other answer now renders INSIDE the options
container in the answered card, styled exactly like its selected
siblings (border-primary bg-accent) — it is an option, not a loose
quote floating outside the list. Quotes keep the learner's words.
- The thinking field submits with ⌘↵ on macOS, Ctrl↵ elsewhere
(metaKey || ctrlKey — the Gmail convention, same handler on every
platform). Plain Enter stays a newline: the field is for dumping.
A quiet "⌘/Ctrl + ↵ submits" hint sits next to Submit for
discoverability (text mode keeps Enter-to-submit, no hint).
The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:
- A labeled composer-TWIN field ("Your thinking") between the options
and Submit, docked with the submit button inside it exactly like the
chat composer's send. The placeholder invites dumping ("Why this
answer? What are you unsure about? Say as much as you like — the
teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
that fills on selection + inline input, styled like its siblings — so
picking Other never displaces the thinking field; typed text survives
deselection.
- The answered card promotes the thinking to a quoted first-class
block above the verdict, labeled "Your thinking" — the transcript
reads as a dialogue of thinking, not a buried "Note:" footnote.
The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
Compared the port against the original pi setup
(vault/π/learn/.pi: quiz.ts, ask-user-question.ts, teach SKILL.md) —
the pi version never produced invalid correctAnswer values. Root cause
of the port's validation failures: the pi options schema's 'Give each
option a stable value; you reference the correct one by that value in
correctAnswer' sentence was dropped in the port. Without it the model
omits values and must copy a long natural-language label verbatim into
correctAnswer — and instead slugifies it ('cultural-diagnosis' for
'A cultural diagnosis') every turn.
Now matched to pi:
- quiz schema: pi's field order (question, details, options,
multiSelect, correctAnswer, explanation) and descriptions — options
carry the stable-value instruction, correctAnswer is phrased as a
value reference ('the value field of the option you intend'), not a
copy-exact-or-fail threat. nodeId (lh-only, graph) stays.
- ask schema: dropped the redundant 'mode' param (pi has none; the dock
already infers from options presence), pi's descriptions.
- quiz tool description: pi's full text (ask contrast, note field,
dontKnow semantics) minus terminal-only details, plus lh's turn
protocol sentence.
- system-prompt: fixed the stale 'exactly two tools' line (plan/research/
demo/visual tools have their own sections).
Not matched, deliberately: tool results stay JSON (pi sends curated
text; formatAnswerMessage exists if we want it later), shuffle param
(lh always shuffles seeded), pi's ask '(Recommended)' label hint.
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):
components/** -> "@/src/core" (pure core API, browser-safe)
app/** -> "@/src/core" + "@/src/server" (server entries)
Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.
Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
(the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
/api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
transport layer; GET /api/graph's fold side-effect now lives behind
graphView() instead of inline in the route
Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.
(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
- Animation: the blob's shell morphs height (WAAPI, 180ms) on every
variant change — chat ↔ question ↔ collapsed bar — plus a quick
fade/rise (160ms) on the swapped content. Gotcha pinned in code: never
measure the shell while a morph is in flight (getBoundingClientRect
reads the ANIMATED value and corrupts the next morph's start height);
cancel running morphs before measuring, refresh settled height only
when no morph is running.
- Gradient-over-scrollbar fixed structurally: the dock is no longer an
absolute overlay on the conversation root — it's sticky bottom-0 INSIDE
the scroller (in flow). The transcript scrolls fully clear of the dock,
the fade never touches the scrollbar lane, and the pb-hack clearances
are gone.
- New question while at the bottom scrolls to reveal the dock; scrolled
up → collapsed bar, no yank.
- Thin dark native scrollbar app-wide (8px webkit + scrollbar-width:thin,
dark thumb, transparent track). Note: use-stick-to-bottom sets
scrollbar-gutter: stable both-edges on its scroller — the lane is
outside the content flow by design.
- 'Writing a question…' transcript shimmer while a tool call streams its
input (same slot as 'Thinking…'). Protocol pinned by a test asserting
tool-input-start precedes tool-input-available; scripted mock now
streams tool input as deltas like real providers do. (OpenRouter/
Anthropic often delivers the whole input in ONE delta — then the dock
appears instantly and no indicator is needed or shown.)
Verified live: grow 54→349px and shrink 349→54px fire with correct
from/to; collapse/expand intact; gradient stops before the gutter;
Thinking… observed in-page; 58/58 tests.
- A pending quiz/ask now REPLACES the chat composer: one persistent
rounded-2xl shell in the transcript overlay swaps contents (composer ↔
question ↔ collapsed bar), never remounting — the structure a future
grow-morph animation needs.
- Unified bottom row inside the question: the note field (select modes),
the answer field (text-mode asks), or the Other text — one field, one
'Submit answer' button, Enter submits.
- Collapse on scroll: uses useStickToBottomContext — scroll up and the
dock folds to a slim bar (kind chip + LaTeX-flattened question teaser +
'tap to answer'); click scrolls to bottom and re-expands. The expanded
form stays mounted-hidden so a half-made selection survives.
- Pending/streaming tool parts no longer render inline; answered parts
land in the transcript as graded cards on submit. Question generation
shows a shimmer in the shell.
- Composer back to pure chat (no mode machinery); quiz-card.tsx and the
pendingText/registerSubmit bridge deleted; render tests re-pointed at
the dock (wrapped in StickToBottom).
Verified live: docked quiz submit (note+selection), ask-text Enter
submit, collapse/expand with selection survival, bar teaser text.