Owner round (2026-10-05): the same act (a human thinking in text) had two
visual languages — the quiz cards' quote block vs the practice container
with a collapsing chevron and raw pre-wrap text that read oversized.
- components/thinking-block.tsx: the quiz quote-block recipe verbatim
(rail + uppercase label + lh-prose at foreground/90) carried by a
faint user-side shell — ml-auto, max-w-[95%] mirroring the user
bubble's geometry, px-4/py-3 rhythm. Never collapsible. Rails reuse
the existing vocabulary: primary = committed register, muted =
working register.
- ThinkingCard becomes the practice adapter: 'Your solution' (primary)
/ 'Your thinking' (muted) — one component, two registers.
- The reading channel LIFTS out of the answered card: chronologically
prior to the question, it renders above it as its own user-side
block under the plain 'Your thinking' label; the in-card inset and
the 'while reading' phrasing are gone.
- lh-card-enter (globals.css): the one-shot 150ms fade+rise mount,
the app's motion register; honors reduced-motion.
Root-cause find: the sidebar 'highlighting' on ←/→ was :focus-visible —
pointer-clicked elements match it only after a keypress, so any key in
the quiz lit the still-focused session row. The arrows never moved
focus. Fix: the no-ring law (owner) — *:focus/:focus-visible outlines
removed app-wide in globals.css; surfaces keep their own affordances
and the dock's keyboard walk keeps its ring utilities.
Slider repairs:
- mousedown-preventDefault had killed click + drag in Chromium —
removed (with tabIndex -1); the slider is natively interactive again,
and the ring law makes its focus invisible anyway.
- Focused arrows step the STOP index on the input itself (native would
step 1% and snap straight back — dead keys); the global handler
skips INPUT targets, so no double-step. Enter still submits.
- Empty side: var(--muted) matched the card and vanished — now #141414
(the owner's main-background pick), and the pill carries a border
again (border-border).
- Dashed tick per snap stop below the bar, inset by the thumb's
half-width so each tick sits under the thumb's center at that stop.
The chains-overlay TopBar had dead drag zones (counts, around search,
close gap) — cause: app-region subtraction resolves by DOM ORDER, not
z-index; the graph chrome's lh-no-drag column came later in the DOM and
punched holes through the overlay's drag band despite sitting visually
underneath (z-40 vs z-10).
Two laws, per the owner's standardization ruling:
- AUTO CARVE-OUT (globals.css): inside any .lh-drag band, every native
interactive element (button/input/textarea/select/a/summary/
[contenteditable]) is automatically a no-drag island — draggable
anywhere that is not a clickable element becomes structural. Manual
lh-no-drag is only for clickable non-natives.
- THE OVERLAY DOM LAW: a surface containing a TopBar that covers other
content must be the LAST child of its container — ChainTableOverlay
moved after the chrome column (verified live: last child, after the
column; auto-carve rule compiled; search/close/sidebar-toggle all
still click).
Two owner rulings (2026-09-26):
READING FONT — the free-text CommitInput is retired; a searchable
dropdown of the user's INSTALLED fonts over model-menu's exact
fuzzy + arrow-walk + Enter machine (FontMenuList in model-menu.tsx,
fed by window.queryLocalFonts in the desktop shell):
- The DEFAULT row (Source Sans 3) is a fuzzy CANDIDATE, not a ghost:
fontMenuResults (pure, pinned) matches it on empty/source/sans/
default queries only, so an unfiltered list opens with the default
first and a searched list drops it honestly — Enter can never pick
a row that isn't on screen. Picking it writes fontFamily null
(empty = Source Sans 3 semantics preserved).
- The enumeration is CLICK-bound (queryLocalFonts requires user
activation — it cannot be a mount effect), deduped to unique
families, the factory family excluded (the DEFAULT row is it),
cached for the section's life; a failed enumeration falls to the
curated list rather than an empty picker.
- Dev-browser fallback is honest, never fabricated: queryLocalFonts
absent → a small curated list (the app's own knowns + ubiquitous
macOS families) with the footer 'Common families — your installed
list needs the desktop app'. The manual escape lives inside the
menu: a custom-family input + Set commits any family name the
enumeration didn't surface (the old free-text input's heir).
- composeFontFamily (pure, pinned): null/blank → null, else verbatim.
- The trigger gets a Hint (tooltip wave convention); the row's
empty-state hint keeps reading 'Empty = Source Sans 3'.
⚠ electron/main.cjs — one line, FLAGGED FOR PLATFORM: 'local-fonts'
joined the allowedPermissions set (the Local Font Access grant the
picker needs in the desktop shell; the loopback-origin discipline is
untouched).
EMPHASIS INKS — the factory #ee928c/#6fa9d6 are gone: both globals.css
theme blocks now default --lh-ink-strong/--lh-ink-italic to
var(--foreground) — unset emphasis renders the regular text color.
Explicit hex overrides ride unchanged (the appearance seam was already
null-honest); the rows' empty-state labels now say 'same as text'.
Tests (+16, 983 total): fontMenuResults default-row candidacy +
filtering, composeFontFamily null/blank/verbatim, the curated roster
(no factory-family duplicate), FontMenuList SSR shape (default row,
families, effective highlight, custom escape, loading state), the
trigger-replaces-input pin, and the globals.css factory pin (no old
hexes in the declarations; var(--foreground) ×2 in both blocks).
Live-verified on :3001 (dev browser — the curated-fallback path):
dropdown opens with the default row + curated list + honest footer;
search filters ('serif' → CMU Serif only); picking Georgia lands
record + --font-prose live; the default row unsets (var cleared);
the custom input commits Baskerville verbatim; Esc closes the menu
WITHOUT closing the dialog. Ink: strong/em computed = rgb(230,230,230)
(the text color) at factory; #ff0000 through the dialog renders red
and clears back cleanly. The desktop queryLocalFonts path cannot run
in this environment — the seam is click-bound + cached with the
failure fallback, and needs one desktop-shell smoke.
Owner ruling on the empty graph (honesty principle — dead controls are
lies): the top-chrome actions row now gates per control.
- Import Notes renders ALWAYS (graph enabled) — the one action that
works on an empty graph, the entry point itself. It used to hide with
everything else, leaving the empty state a dead end with no entry.
- Search + settings render only when the snapshot has nodes, and their
appear-once mount carries a deliberate 200ms fade (graph-chrome-fade-in,
globals.css — the settings-pop register, reduced-motion appears
instantly). Computed animationName verified live.
- The row's body is now the exported pure GraphChromeRow (the
DeleteNodeBody pattern) — the no-match note's truth (visibleIds +
snapshot) stays upstream, computed by the wrapper.
- The empty-state placeholder copy is LEFT AS-IS (SCOPE rule: it already
has copy — icon, title, and the session-mapping paragraph).
Pins (tests/render-graph-chrome.test.tsx): empty → Import Notes present,
search/settings absent, no fade carriers; populated → all three, exactly
two fade carriers; the no-match note follows its upstream truth. SSR of
GraphChromeRow both ways.
Live-verified on :3001 with scratch data: empty graph → Import Notes
only + placeholder intact; a real import through the button (file input
via DataTransfer) → nodes land, search + settings fade in (computed
chrome-fade-in on both carriers); wipe → reload → back to Import Notes
only (the reverse path). Scratch graph restored byte-identical.
932/932, tsc/lint/build green.
Five small fixes (owner refinement round, 2026-09-24):
1. RING POPOVER OPENS DOWN: the ring lives in the chat top bar —
the window's top edge is right above it, so the upward popover was
clipped. top-full mt-1.5 now: it drops below the ring.
2. SESSION MENU ORDER FLIPPED: Rename/Delete first, hairline, then
the extraction coverage block + Extract at the bottom — the info
reads after the actions (was the reverse, the block on top).
3. UNIT TYPED OUT: the sidebar block reads "2k tok uncovered" now —
the tokens unit is explicit (owner: "tok").
4. MARKDOWN LIST INDENT: streamdown ships list-inside with no
container padding — top-level markers sat ON the text edge (only
nested lists got their pl-6 via [li_&]). .lh-prose :where(ul, ol)
{ padding-left: 1.5rem } indents both list kinds as blocks, 24px —
the same measure as the nested-case pl-6. Unlayered CSS beats the
utility layer; :where keeps specificity zero.
5. THINKING LEVELS LOWERCASE EVERYWHERE: the composer pill and setup
dialog rendered THINKING_LABEL (Off/Minimal/…/Max) while every
menu body renders the raw lowercase level — the map is now
lowercase, the level string IS the display form, everywhere.
Live-verified on :3001: list content inset 24px vs paragraphs
(padding-left computed 24px, nested fine); the composer pill reads
"low" with options low/high/max; menu order Rename → Delete… →
hairline → block. 901/901.
Owner default change (2026-09-23): the factory strong-color token
moves to the softer coral. The factory lives in the stylesheet
token (both theme blocks in globals.css) - settings' boldColor:null
means "factory" by design, so no record/schema change. The settings
row hint + placeholder follow, plus the placeholder test and two
comments that cited the old literal.
Live-verified: unset boldColor renders rgb(238,146,140) (#ee928c) on
the real <strong>; dialog hint reads "Empty = #ee928c".
- .font-title now requests font-weight: 900 — with the serif toggle OFF,
the prose font is a VARIABLE font and was rendering titles at 400, a
whisper next to CMU's bold-only face; 900 lands directly on Source
Sans 3 and CMU's single 700 face snaps to nearest, unchanged. Same
rendering with serif on, real weight with it off.
- The model + thinking pills match the graph view's floating top-right
chrome (import notes / gear / coverage): border bg-card/85 shadow-lg
backdrop-blur — the graph no longer bleeds through the pills.
- The dictate button gets the same blurring at lower opacity
(bg-card/50 backdrop-blur), staying quieter than the model pills.
Gates: tsc clean, 866/866, lint 0, build ok. Live-verified: pills/dictate
computed styles byte-identical to the reference chrome (bg .85/.5 alpha +
blur(8px) + same shadow stack); title requests 900 in both serif states.
- The session-name-serif toggle looked dead because --font-title was an
@theme INLINE token: the .font-title utility carried a baked copy of the
family, so the runtime var the toggle wrote was never read by any
consumer. Moved --font-title to a plain @theme block — the utility now
reads var(--font-title) and the :root factory default emits — the toggle
flips the session name's computed font (lh-cmu ↔ Source Sans 3).
- Empty reading-font-size and heading-weight commits wrote the clamp
minimum (Number("") is 0 → 12 / 100) instead of unsetting. Empty now
omits the key — absence is the delta schema's unset for numbers — so the
field stays empty, the record unsets, and the hint carries the resolved
default (16px / 900). Typed values still clamp visibly (-5 → 12).
Gates: tsc clean, 866/866, lint 0, build ok. Live-verified on :3001.
Round 1 (27d4f33) listed sidebar.tsx, keyboard-shortcuts.tsx,
app/settings/page.tsx and the globals.css keyframes in its message but
they were never staged. This commits exactly that content: the sidebar
Settings row as a button calling the dialog provider, the mod+, chord
opening the dialog, /settings redirecting to /chat/new, and the
settings-pop keyframes (reduced-motion aware). No new changes —
catch-up staging only. (next-env.d.ts churn left untracked-in-commit as
dev-server noise.)
The validation surface for the settings foundation — deliberately thin,
real read/write against the delta record, polish is a later wave.
Mount (owner decisions): dedicated page, single scroll; entry = sidebar
bottom row + ⌘/Ctrl+, (open-settings in the registry); chat defaults NOT
shown (they live on /chat/new); mode dark-only (light palette = design
wave); final tool list = research/demo/visual/pdf (master-adjudicated).
Sections (PUT /api/settings/[section], replace-whole per section):
- General: auto-update toggle.
- Agent: six background-role model+thinking selects (Default = config
pin; a stored id stays VISIBLE even when the catalog hasn't loaded or
dropped it — a select showing Default over a live override would lie),
the four tool toggles, prompt override switches (file-gated) with
source/override text editors over the segment routes.
- Appearance: applied LIVE as runtime CSS variables by an
AppearanceProvider at the root layout — proseFontSize → the one
reading-size var, fontFamily → --font-prose, background → --background
+ the derived raised/accent/edge palette (dark curve, today's exact
spacing: +8/+22/+47 per channel, clamped), sessionNameSerif →
--font-title (titles flip with ZERO chat.tsx edits), headingWeight →
new --lh-heading-weight token, bold/italic colors → the lh-ink vars.
Mode renders honestly DISABLED.
- Graph: the ladder grays a rung when any ANCESTOR is effectively off
(stored values persist, resolve inert — switch shows stored, graying
shows effective).
- Keyboard: view/rebind/reset over the registry; overrides persist in
lh:keymap and apply INSTANTLY (dispatcher reads per keydown); bare
modifier presses are ignored while armed; displayOnly actions list but
never rebind.
First-boot fix: {settings: null} is no-record, not fetch-failure.
CROSS-LANE (flagged for master): src/core/keyboard.ts gained the
open-settings action/binding and exports saveOverrides/loadOverrides
(one-liners; no existing pins); app/layout.tsx mounts the provider.
Live-verified: structure, record writes (general/tools/graph/appearance),
prose-size applying live (16→20px), rebind + chord navigation from
another page, ladder graying, no console errors. Gates: typecheck,
lint, 689/689 (28 new), build.
The shortcut architecture (owner-approved plan, 2026-09-17):
- src/keyboard.ts: pure action registry (id, label, scope, default
binding) + lh:keymap localStorage overrides + event normalization.
The settings milestone renders this same registry as its editor.
- components/keyboard-shortcuts.tsx: the single document-level keydown
listener (window level, bubble) owning the pieces no surface owns:
- Tab is DEAD (owner decision): browser tab-focus navigation is
abandoned; Tab is suppressed everywhere, Shift+Tab cycles the
thinking level.
- Esc is a layered dismiss: topmost popover first (popovers
stopPropagation their own Esc — usePopoverDismiss + ModelControl),
then dictation stop (lh:kbd-stop-dictation), then abort of an
in-flight turn (lh:kbd-abort-turn; server-side wake turns are
honestly non-stoppable — the seam only stops local streams).
- Global actions dispatch lh:kbd-<action> window events (the
lh:chat-renamed pattern); surfaces that aren't mounted simply
don't listen.
- The input guard: inside a typing surface only mod/alt combos (and
Tab) fire; bare keys pass through to the field.
- Cheat sheet overlay (?) renders from the registry, grouped by scope.
Bindings wired this wave: cmd+d dictation toggle, cmd+k model picker,
shift+tab thinking cycle, cmd+b sidebar, cmd+shift+f sessions search,
alt+up/down + alt+enter session cycling, cmd+g graph view, cmd+n new
session, f2 rename (chat), ? cheat sheet. Wave B adds the question
keyboard machine; wave C the per-surface bindings (find, import).
Verified live in the dev shell (CDP): all of the above via
Input.dispatchKeyEvent.
Co-Authored-By: Claude Fable 4.5 plus-plus <noreply@anthropic.com>
Live-resize frame lag: the native window surface resizes before the
compositor has a frame at the new size; the gap showed Electron's
default WHITE window background — glaring on a dark app, at the edges
on shrink. The standard treatment, all three:
- BrowserWindow backgroundColor '#141414' fills the lag with the app bg
- show: false + ready-to-show — the window appears only after the page
has rendered (kills the first-paint white window)
- color-scheme: dark — native widgets and scrollbars render dark
Owner resize-verified in the Electron dev shell.
The home header carried -webkit-app-region: drag after <main> in the
tree, re-adding drag over the graph chrome row's no-drag carve-out —
Electron's mask applies carve-outs only to regions added EARLIER in the
DOM walk, so the gear/search/coverage/import buttons dragged the window.
Fix: a dedicated 52px drag-mask div placed FIRST in the home page DOM;
every carved control (chrome row, node detail card, toggle) comes later
and subtracts correctly. The header loses its app-region entirely — the
mask routes the pixels, the header stays a pure pointer-events container.
Retires the .lh-float-drag CSS class.
Verified live in the Electron dev shell by the owner: chrome row,
sidebar close button, logo, and window drag all work.
The system title bar disappears (titleBarStyle hiddenInset +
trafficLightPosition {16,24}): the app's own 60px header bands become the
window's drag surface, and the traffic lights float over the sidebar's
mark band, vertically centered on the same line as the chat header's
phase pill (pixel-verified: circle centers y 24–36.5pt, pill box center
30pt).
- electron/main.cjs: hiddenInset chrome; dev shell (LH_DEV_URL) gets its
own userData (-dev suffix) so it never fights the packaged app's
single-instance lock — verified: the lock silently killed the dev
shell while the packaged app was open (that also proved the lock works
across separately-launched builds).
- app/globals.css: .lh-electron-gated utilities — drag/no-drag bands,
92px traffic-light gutter (.lh-lights-gutter / .lh-closed-toggle:
lights end ~75pt + the lights' own 16px edge inset as the gap),
home's floating strip becomes a drag surface only in the shell.
Everything is inert in the plain browser.
- sidebar.tsx: SidebarProvider sets <html class=lh-electron> post-mount
(UA detection; no hydration mismatch, absent in browser); LH wordmark
text replaced by the owner's mark (components/lh-mark.tsx, inlined
icon-no-padding.svg, 24px, home link kept); SidebarToggle gains the
92px margin when closed so it never slides under the static lights.
- chat.tsx: the rename hit-box shrinks to the title text itself —
flex-1 spanned the whole bar, so every click started a rename and the
window lost its grip. Shrink-to-fit + a flex-1 drag spacer (chips stay
right) + hover:bg-accent affordance (clear when hover renames vs
drags; -mx/px cancel for zero shift). The renaming input keeps flex-1
for edit room.
- home.tsx / new-session.tsx / phase-divider.tsx: drag bands + carve-outs.
Live-verified in the dev shell (pixel analysis) and headless browser:
lights/pill/mark centered, 17pt lights→mark gap, rename click→select-all
→Escape roundtrip, browser layout untouched.
Gates: typecheck, eslint, 500/500 tests, build.
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.
- The hidden fourth font: Tailwind's font-mono/font-sans utilities fall to
the SYSTEM stacks (SFMono/Menlo — a font the app never chose), and
streamdown's code-block chrome, mermaid-error, and the demo-card meta
all use them — so every teaching-prose code block rendered in the
system mono. Fixed at the token level: @theme now aliases --font-mono →
JetBrains Mono and --font-sans → Source Sans 3, so every utility use
resolves to the app's three fonts (chrome=JetBrains, prose=Source Sans,
display=CMU bold).
- Session titles: Source Sans 3 (the human-words voice — the same font
the words were typed in; mono = machine data stays on the meta line).
One rule, finally applied to the picker too. CMU bold titles are the
queued experiment if this reads wrong live.
Gates: typecheck, lint, 469/469. Live: title "Source Sans 3" 12.5px,
meta "JetBrains Mono" 11px; code blocks now JetBrains.
Nothing exists purely for decoration: phase color-coding is retired
app-wide (phases only advance — every session ends Teaching — so
phase-as-signal was decoration with a one-time function), and what
state genuinely deserved — 'a turn is in flight' — is now carried by
MOTION alone, in the terminal's own glyph language.
- BrailleSpinner (components/braille-spinner.tsx): the canonical
cli-spinner sequence (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) as exact dot bitmaps in a 2×4
braille cell, 80ms/frame (800ms cycle). Pure CSS: one shared keyframes,
frames staggered via animation-delay; no JS timer, no re-render per
frame — cheaper than the old breathing dot. Static contexts (SSR,
prefers-reduced-motion) show frame 0 — a legible glyph, not a blank.
- Sidebar rows: spinner ONLY while a teach turn is in flight; idle and
question-pending render NOTHING (a resting state has no scan value —
the meta line's 'Waiting for your answer' text carries it). The
three-state phase dot (pulse/solid/hollow) and PHASE_FILL/RING maps
are gone.
- Top bar PhaseChip: label-only pill; the old dot slot becomes the
spinner's home while a turn is in flight (busy prop wired from
ChatView's busy = localStream || turnInFlight). Transcript
PhaseDividers drop phase colors for monochrome rules + labels.
- API narrowing (app/api/chats/route.ts + ChatListItem contract):
busy = isTurnRunning ONLY — background jobs no longer count (the
sidebar's Background agents section is their channel; a per-row
spinner would double-signal them).
- globals.css: --phase-* tokens + lh-dot-breathe removed (dead after
the sweep — no remaining users).
Live-verified: idle/pending rows render no indicator, /chat/new chip is
label-only, 10 CSSAnimations at 800ms with correct 80ms stagger, static
frame 0 under reduced motion. Gates: typecheck, lint, 470/470, build.
Lane crossings to flag at merge: app/api/chats/route.ts + the
ChatListItem contract comment (src/core) — the busy-semantics narrowing;
components/chat.tsx one-line busy prop.
--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.
- Palette per the owner's hexes: background #141414, raised #1C1C1C,
edges #434343, ink tokens, destructs unchanged; :root and .dark carry
identical values (the app is always dark).
- Type ladder declared once (11px base, each size carries its own
line-height); radii ladder 4/8/12/16; spacing rhythm = Tailwind's 4px
scale. All documented in the globals.css header.
- Fonts: JetBrains Mono (chrome) + Source Sans 3 (prose) via
next/font/google in app/layout.tsx; CMU Serif bold bundled locally
(public/fonts/cmunbx.ttf) behind the lh-cmu @font-face.
- Prose/emphasis tokens: --lh-ink-strong (rust bold), --lh-ink-italic
(blue italic), .lh-prose wrapper — ready for the future settings UI.
- l.h-ink-strong/italic defaults sampled from the reference mockup.
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.
- SessionRow (extracted, exported for tests): leftmost phase strip
(bookmark tick, per-phase color: probe/plan/teach) + status dot left of
the title — pulsing amber attention dot when an answer is owed, quiet
breathing dot while the teacher works, nothing when it's the learner's
turn. A pending answer outranks 'working'.
- globals.css: phase + attention color tokens (one system shared with the
upcoming header chip and dividers) and the two dot animations, both
disabled under prefers-reduced-motion.
- SessionsSidebar polls /api/chats every 2.5s while open (payload is tiny
now that busy/phase/pendingQuestion come from the list) and pauses while
the tab is hidden; fetch-on-navigation preserved.
- src/core/math-delims.ts: pure preprocessing for the \(...\)/\[...\] math
contract — balanced delimiters rewrite to the two-dollar forms remark-math
parses ($ is never math). Skips code fences/spans, escaped delimiters, and
leaves unbalanced (mid-stream) delimiters untouched. Display math renders
as a block: mid-paragraph gets blank-line padding; indented (list items)
degrades to inline so list continuations don't half-parse.
- message.tsx: transform in the render path (children-memoized); math plugin
now singleDollarTextMath: false — the currency hazard dies structurally.
remark-breaks via the cjk remarkPluginsAfter slot (the bare remarkPlugins
prop would replace the [remark-gfm, codeMeta] defaults).
- mermaid: dark theme + sans font via Streamdown's mermaid prop; dark-styled
error fallback (components/mermaid-error.tsx) with error, source in
<details>, and retry.
- globals.css (appended): @source for streamdown dist — its internal UI
chrome (code blocks, mermaid containers, tables) was entirely unstyled
because Tailwind v4 never scans node_modules; --color-sidebar → card; wide
mermaid SVGs capped to the column; .katex-display scrolls.
- tests/render-markdown.test.tsx: 26 tests (transform unit + render). The
math describe block in render-cards.test.tsx is updated to the new
contract ($...$ now renders as literal text — pinned).
- 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.
The teaching agent no longer runs on pi. Stack:
- ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL,
default anthropic/claude-sonnet-4.5 with reasoning tokens
- quiz/ask_user_question = client-side tools (no server execute): the call
lands as a typed tool part in input-available state; the card grades via
src/core/question-core.ts; addToolOutput sends the verdict; the teacher's
reaction continues the same assistant message
- resume-mid-quiz is free: the pending tool part persists in the transcript
- persistence: one JSON per chat (data/chats/), full UIMessage transcripts,
saved onFinish (also on abort)
- UI: AI Elements (owned copy-paste components, pruned to the five in use)
+ button-based QuizCard/AnsweredCard (ask answers never graded)
- pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom
protocol all deleted (recoverable from git history)
- tests: question-core units carry over; chat-flow integration drives
handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens);
render smoke tests guard the cards (renderToStaticMarkup)
Carried over unchanged: teacher system prompt, question-core, schema/
ledger/derive/extract pipeline, data model.