Owner reframe (2026-09-18): the model + thinking + context controls on
the home graph view and /chat/new are not a draft overlay on a baked-in
default — they ARE the settings page for the default model, and the
user's last choice IS the default. BYOK first boot has no keys and
possibly no OpenRouter, so there is NO default to show: the controls
render 'no model' ('—') until the first pick.
The flash was the deeper symptom: the surfaces rendered from client
state that started empty and fetched truth afterward, so every visit
flashed the config pin (GLM/low — a dev artifact) for a few frames.
Fix at the root: both pages are now force-dynamic server components
that read the record (readDefaultSettings → initialSettings) and hand
it to the client — the first frame shows truth. Dynamic routes aren't
cached client-side (staleTimes.dynamic = 0), so every navigation
re-reads the record; the useDefaultSettings hook keeps only save-side
state (optimistic set + PUT). The GET-on-mount and its touch-guard are
gone.
ModelControl gains noDefaultModel (the default surfaces only): unset
settings render no model/level, never the catalog pin; the thinking
menu no-ops until a model exists. Chat surfaces keep the catalog
fallback — the engine runs the config pin when a session record has no
override, so the control keeps showing what will actually run.
Renamed draft→default throughout: data/default-settings.json,
GET/PUT /api/settings/defaults, useDefaultSettings. New sessions
still inherit the default on creation exactly as before.
Live-verified: first-boot 'no model' with zero flash, picker +
Shift+Tab cycle, values held across session→/chat/new→home roundtrips
and a reload (10×100ms samples, never the old default), zero console
errors.
Both new-session pages (the home graph view's topic dock and /chat/new)
held their model + thinking + knowledge-context choices in plain
useState(undefined) — every navigation reset them to config defaults.
Now both surfaces read/write ONE server-side record
(data/draft-settings.json via GET/PUT /api/settings/draft, the
render-settings pattern): whatever the learner last left the controls on
is what every later visit shows. useDraftSettings loads on mount with a
touch-guard (a slow GET never clobbers a fast choice), saves
optimistically. Session creation still carries the choice into the new
chat exactly as before; per-session settings are untouched.
The PUT validation is the exact per-chat contract — parseSettingsBody
extracted verbatim from app/api/chats/[id]/settings and shared by both
routes (unknown model/level → 400 with the supported list, never a
silent clamp). 400s on garbage, {} = the clear-overrides write.
Live-verified: Cmd+K+Enter model pick, Shift+Tab thinking cycle, context
slider — all held across home → session → home → /chat/new, and carried
into a created session's record.
The wake pump fires its turn with the steer INSIDE the request body but
dequeues the queue entry only after the turn returns 200 — so the entry
is still queued when the handler claims. The record doesn't carry the
steer yet (write-ahead persist hasn't run), so claimSteers claimed it a
SECOND time and the handler appended it: outgoing = [...body, ...claimed]
held two copies of the same deterministic steer id, and the write-ahead
persist saved that for the whole wake turn. The client's adoption poll
rendered both -> React duplicate-key warning (steer_ctx_<chatId>).
withMessageIds deduped only at onFinish, so the final record looked clean.
Fix: claimSteers(chatId, incoming) — a queued steer whose id is already in
the turn's incoming list is being delivered BY this very turn: finalized
and resolved, never appended again.
The walked row's ring-1 is a box-shadow painted OUTSIDE the border box;
the list's overflow-y-auto makes overflow-x clip at the padding box too
(CSS: a non-visible axis forces the other to auto), so the 1px halo was
shaved on both sides. Replaced with the quiz rows' bg-accent/40 filled
tint and added px-1 to the list so the highlight never touches the
edges (owner round 5).
Owner polish pair before the settings milestone:
- Model menu: arrows walk the filtered results (clamped, no wrap) while
focus stays in the search field; Enter picks the walked row or, with no
walk yet, the first result; typing resets the walk; click and Enter
share one pickModel path; walked row gets a muted ring + scrollIntoView;
cheat-sheet display entries added.
- Graph: window resizes re-anchor the camera so the world point under the
pre-resize viewport center stays under the new center — the graph stays
visually still while scaling (was top-left pinned). viewW/H tracked in
syncSize; re-anchor applied through the zoom behavior (persistence and
card-dismiss unchanged); kFit refresh as before. Pixel-verified: 7/7
center samples identical across a 300x200 shrink.
Tab/arrow focus on quiz/question option buttons and the don't-know row
showed Chromium's default focus ring — the abandoned look. outline-none
on the row buttons keeps the walk ring (bg-accent/40) as the only focus
indicator, so the appearance is identical before and after Tab.
Owner live-test rounds on waves A/B:
- Alt+arrows no longer ALSO walk the question rows (dock ignores
alt/meta/ctrl combos; session cycling keeps them exclusively)
- question walk clamps at the ends — no wrap (faster to navigate)
- plain Tab toggles focus between the thinking field and the answer
rows (walked row by default); focus follows the walk while in the
rows; Shift+Tab stays the global thinking-level cycle
- Esc in the sessions search blurs back to normal (claims the key so
the window Esc chain does not fire)
- cheat sheet gains the Tab-in-question display entry
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>
The settings panel (16 sliders ≈ 900px) overflowed past the window
bottom at short heights and stretched the whole page scrollable. The
coverage dropdown's pattern is the standard for top-anchored popups
overflowing viewport bounds: max-h-[60vh] keeps a floor of free space
above the window bottom at any height, and the inner column gets
overflow-y-auto (which also zeroes the flex item's automatic
min-height so it actually shrinks) — the panel scrolls internally, the
page never scrolls.
Live-verified in the dev shell at emulated 640px and 1000px viewports:
panel caps at exactly 60vh (384px @ 640h, 192px bottom gap), inner
scroll reaches the physics section (412px travel), docScrollable false
at both heights.
Owner's second live test:
- DEFERRED transcript: finals accumulate in a buffer during the run and
flush into the primary input once, when dictation ends (Stop →
CloseStream → Deepgram's last finals → socket close → one joined
append; the 3s safety net covers a missing close). Nothing textual
renders while recording — the meter is the only live feedback. Live
output may return as a toggleable setting at the settings milestone;
deferred is the default behavior.
- ADAPTIVE meter: cell count derives from the measured strip width (4px
pitch, 8-48 cells, ResizeObserver; cells drop their minimum width) so
narrow rows beside the knowledge-context slider fit — verified in the
dev shell: draft 20 cells/83px, chat 48 cells/273px, no overlap.
Owner's first live mic test (2026-09-17) drove four changes:
- ONE primary transcription input at all times: while a quiz/ask
question waits, finals land in its field (text mode → 'Your answer',
select modes → 'Your thinking') via a module-level claim registry
(useDictationTarget in dictation.tsx); the composer is the fallback.
Esc in a question field also stops dictation (threaded through
DockOverlay → QuestionDock).
- No interim results: live words were distracting and shifted the meter.
Params now match the pi dictate extension exactly (no interim_results,
endpointing=300, no utterance_end_ms); the meter's width stays fixed.
- Optimistic meter: the click enters the meter view immediately (Stop
pill + meter within one frame, verified at 150ms in the dev shell);
token mint and mic acquisition run concurrently behind it.
- Wide idle button: the stats-bar middle is one full-width affordance —
a quiet bordered 'Dictate' button when idle (failure text replaces the
label), Stop pill + fixed meter while live.
Live-verified in the Electron dev shell: wide button renders on home
(167px) and chat (358px), meter + Stop appear at 150ms after click,
flat 2px bars in silence, clean return to 'Dictate' on stop.
Two live-verified Deepgram facts from the first real mic-test round:
- /v1/auth/grant returns { access_token }, not { token } — the route now
parses the right field.
- Minted JWTs authenticate over the WebSocket via the "bearer"
subprotocol; ["token", jwt] is rejected at the handshake (token is
for raw API keys only, as in the pi dictate extension).
Also documents the env-precedence gotcha in docs/research/stt.md: a
DEEPGRAM_API_KEY exported in the shell (e.g. .zshrc) silently beats
.env.local in next dev and the desktop authEnv() seam.
Two-band architecture from docs/research/stt.md: renderer captures mic
audio in an AudioWorklet (linear16 16kHz mono, ~128ms frames, per-quantum
RMS for the meter) and streams it directly to Deepgram over WebSocket —
audio never loops through our server. The real key stays server-side;
/api/stt/token mints a short-lived grant token (TTL 300s) per run.
Patterns ported from the owner's pi dictate extension (tuned in real
use): generation counter guarding every async handler, the
latest-RMS-never-resets meter (-50..-10dB window, 60ms tick), mic-first
stop + CloseStream + 3s safety net, endpointing=300 with the
utterance_end_ms=1000 backstop.
UI (owner placement): mic button in the stats bar's middle slot on all
three surfaces (home graph-view, chat, draft); while recording the meter
unfurls to fill the row's free width with the interim ghost at its
flank. Esc ends dictation from the composer. Token mint comes before
getUserMedia so a keyless app fails fast without a TCC prompt; one
automatic reconnect on abnormal socket close, errors surface inline.
Live-smoked the full error path in the dev shell (bar renders on all
three pages, 403 grant failure shows inline, mic untouched). The full
audio path needs a Deepgram key with Member permission to mint tokens.
The sidebar no longer squeezes the page when the window narrows; the
page itself never goes below a comfortable floor (owner decisions,
2026-09-17: 640px min, widen-window on toggle, auto-restore):
- minWidth 640 / minHeight 600 on the BrowserWindow (macOS enforces)
- Below SIDEBAR_AUTOCLOSE (928 = 640 + 288 sidebar) the sidebar
auto-closes, and auto-RESTORES when the window widens past it again —
both fall out of one expression: rendered = pref && !narrow, where
pref is the persisted user choice. Manual closes persist and stay
closed; auto-close never touches the preference.
- Opening the sidebar while narrow asks the shell to widen the window
to 928 first (new lh:expand-window IPC, preload.cjs contextBridge —
the renderer's first and only Electron bridge): the toggle is never a
dead button, and the layout never has to survive narrow + sidebar.
In the plain browser there is no suppression and no widening — a
narrow tab opens the sidebar if the user says so.
- Graph chrome row wraps: max-w-[calc(100%-9rem)] + flex-wrap keeps the
row clear of the header toggle cluster and stacks it onto extra lines
below ~640px instead of overflowing left.
State machine verified in the Electron dev shell across all six
transitions (auto-close, auto-restore, manual-close-persists,
reopen-widens, browser fallback, wrap at 640/500/360).
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.
Traffic lights (owner report: 'way too low, not aligned with the phase pill'):
measured in the running app — circles centered at 30.75pt vs the pill box's
29.25pt (band 0-59.5, pill box 19-39.5, pill ink 28.75; uppercase glyphs sit
high). trafficLightPosition y 24 -> 22.5 lands the circle centers at 29.25,
exactly on the pill. Owner confirmed aligned after this change.
Rename escape hitbox (owner report): entering rename was already width-of-
title only, but the editing input spanned flex-1 so clicking the rest of the
bar stayed inside the input. The input is now shrink-to-fit
([field-sizing:content], min 8ch / max 70%) and any mousedown outside the
input blurs it, running the existing onBlur commit path; the header drops
lh-drag while renaming so the drag region cannot eat the escape click.
Verified: click-away commits (exactly one PUT), Escape discards, Enter
commits.
Phase pill is pure decoration (owner report: 'should not be interactable
in any way'): no tooltip, pointer-events-none, select-none — part of the
drag surface instead of a carved-out target.
Graph chrome was unclickable in Electron (owner report): home.tsx's
floating drag strip underlays the z-10 controls row, and Electron's
app-region mask ignores z-index, so the region ate their clicks (plain
browser unaffected — DOM hit-testing is fine). lh-no-drag carve-outs on
the chrome row and the node detail card (it is placed imperatively
anywhere, including the top strip).
LhMark home link now mirrors the ghost toggle: same size-9 chip geometry
and hover:bg-accent treatment.
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 web app becomes a desktop app without changing shape: the Electron
shell spawns the Next standalone server on a loopback port and points a
sandboxed window at it. All app logic stays in the Next server.
Bands:
1. LH_DATA_DIR seam (config.ts): PATHS.data/chats/graph derive from
process.env.LH_DATA_DIR (else repo data/, unchanged in dev). New
PATHS.modelCache routes the transformers.js embedding-model cache
(~300MB) to <data>/models when the seam is set — the default cache
inside node_modules is read-only in a packaged bundle. All existing
fs writes flow through PATHS; teacherPrompt stays ROOT-anchored
(read-only asset, ships inside the bundle by design).
tests/data-dir-seam.test.ts covers set/unset via child process.
2. Standalone build (next.config.ts + scripts/build-server.mjs):
output 'standalone'; outputFileTracingExcludes data/**/* keeps the
repo's real session data out of the bundle (verified: an unexcluded
scratch build traced 34 files / 716K of real chats + ledgers).
Two empirically-verified tracer gaps required outputFileTracingIncludes:
pi-ai's provider catalog (JSON import attributes — 39 files, 500s on
/api/models without them) and onnxruntime-node's sibling dylib
(libonnxruntime.dlopen via @rpath). build-server.mjs copies public/
and .next/static per docs, refuses a leaked data/ dir, and asserts
the runtime-read prompt .md assets are traced.
3. Electron shell (electron/main.cjs, no preload):
- single-instance lock (two instances would fight over the data dir);
- free port via listen(0); spawn via process.execPath +
ELECTRON_RUN_AS_NODE=1 with PORT/HOSTNAME=127.0.0.1/LH_DATA_DIR;
- BYOK: userData/auth.json, flat {VAR: value} JSON, mode 0600,
merged into the child env only when absent from the real env
(auth UI is the settings milestone; file is hand-editable);
- poll until the server answers (no ready event), then open a
contextIsolation+sandbox window; deny-by-default permission
handler (media allowed only from the loopback origin — mic/STT
groundwork); will-navigate pinned to origin, window.open denied
with external links to the system browser; macOS Edit-menu roles;
- quit path SIGTERM→3s→SIGKILL + exit/SIGHUP nets; child stdout
mirrored to userData/logs/main.log;
- dev mode via LH_DEV_URL: no spawn, points at next dev (HMR).
4. Packaging (electron-builder.yml + build/entitlements.mac.plist):
files = electron shell only; the whole .next payload ships via
extraResources from .next (filter standalone/**/*) — NOT from
.next/standalone directly: electron-builder hard-excludes a copy's
root node_modules and the default pattern is a literal match, so
node_modules/ and .next/ were silently dropped and the server died
with 'Cannot find module next'. Asar holds no native code.
Unsigned (identity: null) until a Developer ID cert exists; hardened
runtime + audio-input entitlements laid now for the STT milestone.
desktop:dev / desktop:dist scripts (electron 44, electron-builder 26).
Live smoke of the packaged .app: server up on a random loopback port,
all API routes 200, a real GLM chat turn answered end-to-end via
auth.json BYOK, chat persisted under userData/data, second instance
exited via the lock, no orphaned server child after quit.
Gates: typecheck, eslint, 500/500 tests (+2 data-dir-seam), build.
Rename (owner ask): clicking the header title opens edit mode with the
whole title selected — typing replaces, arrows move inside. Enter or a
click away commits (PUT /api/chats/:id); Escape and an empty commit
discard. renameChat joins the ChatStore port; like saveSettings it is
NOT conversation activity — updatedAt stays put so sidebar order never
churns on a rename. The sidebar patch is instant: the header dispatches
lh:chat-renamed and SessionsSidebar folds it into its snapshot (the 2.5s
poll is only a backstop). Sidebar right-click rename is DELIBERATELY
deferred: it needs a real context-menu system, which gets its own pass
later (owner decision, 2026-09-17).
Graph chrome:
- The extraction-coverage button moves between search and import; the
gear keeps the far-right corner. Coverage still renders on an EMPTY
graph (that is exactly when it matters).
- The no-match search note no longer shifts its siblings: the chrome
row is items-start (every button is 44px tall, so top alignment pins
the row while the search container grows downward).
- Popovers dismiss properly: pointerdown anywhere outside the control's
root (button + panel) closes it, and so does Escape. Two panels can
never be open at once — the sibling chrome buttons are outside the open
root, so pointerdown closes the first before the click opens the
second. New shared hook components/popover-dismiss.ts, wired into
both the settings gear and the coverage control.
Gates: typecheck, eslint, 498/498 (+2 rename store tests), build.
Live-verified: select-all/focus/Enter/Escape/empty/blur round-trip,
updatedAt untouched, sidebar instant patch; chrome order + frozen
positions under a no-match query; popover close via canvas, search
input, Esc, mutual exclusion, toggle intact.
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.
When the sidebar toggled, the header's toggle mounted/unmounted in a
single frame, so its 48px footprint (button + gap) popped and dragged
the title and chips sideways while the sidebar was still mid-slide —
'kind of masked by the motion, but very much noticeable.'
The toggle is now ALWAYS MOUNTED, owning a slot whose width+margin
animate in lockstep with the sidebar's own width transition (same
duration-200, same easing, both gated on mount so no load flash):
- when open the slot is exactly ZERO (w-0 with -mr-3 cancelling the
header's gap-3) — resting layouts are pixel-identical to the old
conditional render;
- the icon fades with the slot (opacity in the same 200ms) and is
clipped by it as the slot collapses — no popping in either direction;
hidden state is pointer-events-none, aria-hidden, tabIndex -1;
- the mount-gating rule grows to transition-[width,margin,opacity].
rAF-verified both directions: the title's x-path is monotone from
frame one (old bug teleported 48px in one frame; the margin blip
caught on the way is also gone), icon opacity eases 0.12→1 over the
slide. Gates: typecheck, lint, 469/469.
The header toggle's PanelLeftOpenIcon was size-5 while the sidebar's
close icon was size-4 — the button visibly grew when the sidebar closed.
The small look is the standard: both glyphs are size-4 in identical
size-9 boxes now.
Live: open glyph 16px, close glyph 16px, button boxes 36px both states.
Gates: typecheck, lint, 469/469.
h-10 → h-15 (60px) in both the chat header and the /chat/new draft
header. 60px is an alignment, not just a size: it equals the sidebar's
LH wordmark band, so the header's border-b continues that band's bottom
edge as one horizontal line across the app — the sidebar's 'New session'
row begins exactly where the chat column's chrome ends.
Live: header border-b at y=60, wordmark band bottom at y=60. Gates:
typecheck, lint, 469/469.
Three declutter moves, all trivially reversible:
- The meta line drops the message count (idle meta is now just how long
ago). The count informed no decision — clutter. messageCount still
rides the API payload, so this is a one-line flip in sessionMeta if
it's ever wanted.
- The active row is marked by the background alone: weight was a second
channel doing the same job, and the flip made selected titles jump.
- Dates beyond the week render ISO (YYYY-MM-DD, zero-padded) instead of
M/D/YYYY — the US convention swapped for the international standard:
unambiguous in any locale, sorts lexically. A user-locale setting can
come later — it's a format-function swap.
Also dropped the row's vestigial text-sm: both lines carry explicit
sizes now (16px title, 11px meta).
Live: active weight 400 + bg-accent, metas '17m ago' /
'Waiting for your answer · 1d ago', no counts. Gates: typecheck, lint,
469/469.
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.
Titles were 14px (text-base); the user read them as still-small next to
the prose they open into. They now pull --lh-prose-size (16.5px) — the
same token lh-prose/lh-input render at, so the picker's titles and the
session's prose share one standard Source Sans 3 size (text-(length:…) —
the v4 var shorthand alone doesn't type-resolve). Meta stays 11px mono.
Live: title 16.5px vs meta 11px, row 54px. Gates: typecheck, lint, 469/469.
- 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.
The world-scale pivot (2026-09-17): node/label/arrowhead sizes were
calibrated as "screen px at fit zoom", a unit whose meaning depended on
the graph's own extent — a 13-node graph at kFit 3.65 rendered 5.3x
smaller nodes than a 338-node graph at kFit 0.69, with no slider values
able to reproduce the dense feel. Sizes are now canonical WORLD units
(graph-canvas.ts drops the ÷kFit division in geometry/regions/hit-test);
kFit survives only as the label-gate 1x reference. The renderer's module
doc and the SIZE SEMANTICS block in render-settings.ts carry the story.
Camera becomes pure persisted view state: {k, x, y} stored to localStorage
(lh.graph.camera.v1, 250ms debounce, flush on unmount), restored on mount
via the new initialCamera constructor arg; the hardcoded fit() runs only
on first-ever open. A pin test locks the d3 zoomIdentity composition.
Region blur becomes world units too (the last span-coupled quantity): the
old bitmap-px reach scaled with graph span, so the 2026-09-14
search-filter collapse fix is now structural (blurW = tuning.blur; the
cellRef anchor is gone). Charge default bumps to -240 (single source:
CHARGE_STRENGTH in layout.ts; CHARGE_RANGE re-anchored so -240 sits
dead-center).
The 2026-09-17 bake — the user's live-tuned defaults (nodeSize 2,
edgeWidth 1, arrowLength 2, labelSize 3, labelZoom 4 / "8x", dilation 20,
blur 20, edgeAlpha 0.2, bitmap 256) — plus the ladder pivot: every
non-physics knob moves on a hand-authored 11-stop ladder (x1.5/x1.33
series, quarter/half steps at the low end), default dead-center at slider
position 50, step 10 = one stop per detent. The sanitizer snaps ANY
incoming value to the nearest stop (ties break low), so stored files can
never hold 2.18137...977-style artifacts; pre-pivot files (sizeUnits !=
"world") reset only the four resemanticized size fields. Physics sliders
keep the fine-grained log mapping.
The label cross-fade becomes PROPORTIONAL to the gate (span = gate x
LABELS_FADE_RATIO): the old absolute 0.5-ratio span read as a 100%
relative change at the old 0.5 gate but a 12% sliver at the tuned 8x —
"lower zoom levels naturally produce a longer transitionary period".
At the 8x default the node→cluster handoff now fades across one full
ladder stop (8x -> 12x). labelZoomDisplay is absolute (ratio / 0.5) so
the tuned gate keeps reading "8x" now that 4 IS the default.
Gates: 495/495 tests, tsc/eslint/build clean.
Presentation-only reorder (user 2026-09-17): the Depth anchor select
and its three gray-out-able sliders (Depth strength, Depth spacing,
Base ring radius) move below Friction to the very bottom of the
Physics section, so the always-active general forces come first and
the conditional group reads as a self-contained sub-section at the
end. No schema, wiring, or behavior changes — verified identical DOM
order live, gray-out still follows the stored depth mode.
Dependency graying everywhere, consistent across sections (user
2026-09-17 — chose "gray out children" over hiding rows, so the layout
stays stable and the required-by relationship stays visible):
- Background colors off → Dilation, Blur, Edge alpha, Bitmap
resolution dim (opacity-40) + lock (native disabled). It's a COMPUTE
switch, so the whole region tune section follows it.
- Cluster labels off → Cluster label size dims + locks. Decoupled from
Background colors (labels follow their own toggle, by design
2026-09-16): label controls stay live while regions are off.
- Depth anchor off → Depth strength + Depth spacing dim + lock.
- Base ring radius is radial-only: dims + locks unless Depth anchor is
Radial (vertical doesn't use a ring).
SettingSlider gains an optional `disabled` prop (row opacity-40 +
range input disabled); the two select rows dim + disable inline. Pure
UI — no schema or renderer changes.
- Draw order: the cluster-label block moves from layer 5 to THE LAST
layer of draw() — labels can never be occluded by node labels or
hover names (user 2026-09-17; chose "topmost" after reassessing that
discs were never actually on top — the white-out over discs is
source-over compositing of the 85%-alpha text over near-white node
fill, an optical contrast flip, not a draw-order bug).
- clusterLabelSize: new persisted setting (default 14 = the old
REGION_LABEL_SIZE constant, so rendering is pixel-identical). Screen
px at ANY zoom (drawn at size/k — camera-compensated, UI-like, same
law as before). Log-slider range [7, 28] puts the default dead-center
per the house slider law. Sanitized: old persisted files (no field)
fall back to 14.
- Panel: the Cluster labels checkbox moves from the Labels section
into Regions (user: cluster settings live with regions), joined by
the new "Cluster label size" slider. Labels stay decoupled from the
Background colors compute switch, as designed on 2026-09-16.
Per-node speed is invisible inside a cluster blob (still neighbors'
skeleton strokes dominate the local area), and per-cluster max-speed
couples everything: dragging ONE node dimmed the whole cluster's blob
to nothing — including disconnected members at near-zero velocity.
Neither granularity is right, so the idea is withdrawn entirely.
Manual revert of the velocity parts (a git revert of 154f6c8 would
have resurrected the frame fade): weight ramp, position stamps, EMA
state, motion-pending flush, per-edge stroke batching, and the tests.
Region splat is back to full-opacity coverage at rest AND in motion
(2026-09-14 no-stacking invariant). The regions.enabled compute off
switch stays.
The linear prev→cur bitmap blend between rebuilds was verified working
but made no visible difference — the 3-frame bitmap cadence was already
below perceptual threshold for large blurred blobs; the churn during
fast drags is the SHAPE, not the cadence. Rolled back entirely (no
extra per-frame blits).
In its place, per the owner: while nodes move fast, their blob fades
out and fades back in on settling. Speed per node = screen px per
frame from position stamps between rebuilds, EMA-smoothed. The blob is
a CLUSTER attribute (still neighbors' skeleton strokes pass right
through a moving node's area — per-node weighting measurably did
nothing), so each cluster's weight = f(max member speed): linear 1→0
between 1.5 and 8 px/frame. Strokes and discs paint at that alpha —
before the blur + final edgeAlpha multiply, so coverage semantics are
unchanged at rest (all weights 1 = the 2026-09-14 no-stacking fix).
Stamps reset on data-object change, NOT geoVersion (the live sim bumps
geoVersion every frame — resetting on it zeroed all speeds; caught
live). regionMotionPending keeps rebuilding after the sim stops until
weights converge back to 1, so a blob never freezes at partial opacity.
The region bitmap rebuilds only every REGION_EVERY-th frame while the
live sim runs, so during drags the tints stepped at 1/3 the frame
rate. Between rebuilds draw() now linearly blends the previous final
bitmap into the current one ('lighter' + globalAlpha weights = a true
lerp of the premultiplied pixels — no source-over alpha stacking).
Because box blur and the end-alpha scale are linear operators, lerping
the finished bitmaps is equivalent to fading coverage before the blur,
the mathematically exact form of the user's preferred pipeline. Two
tiny GPU blits per frame; splat/blur/readback still only on rebuilds.
New RegionTuning.enabled is a compute switch (not a paint toggle):
off skips the entire build (splat, blur, readback, blit) — the settings
panel exposes it as "Background colors". Cluster labels stay decoupled
and follow clusterLabels. Old persisted files predate the field and
sanitize to enabled: true.
THE MESSAGE IS THE UNIT OF COVERAGE (vision-extraction-coverage.md): a
per-chat append-only ledger (coverage.jsonl) records which messages the
pipeline has extracted, by content fingerprint over exactly what the
extraction view sees (one shared line builder — core/coverage.ts); the
uncovered remainder is DERIVED at read time, never dual-tracked. A quiz
answer merging into the asking message re-opens only that message;
plan-block data-part attach never re-opens anything.
- The trigger (turn-service) replaces every-3-quiz-answers with
uncovered-tokens >= EXTRACTION.chunkTokens (4096) — quiz-less sessions
extract too, content size is the honest signal.
- runExtraction is now chunked: token-budgeted batches of the oldest
uncovered consecutive messages (a tool call + result never split), each
merged batch checkpointing its messages' coverage atomically — an abort
or error loses at most the in-flight batch; merged batches stay covered.
- Observation refs are batch-tagged (obs:<batchTag>:<sourceId>): re-runs
of the same batch dedupe; a re-opened message's fresh understanding
lands as a new event. Fixes the old obs:p1 position-collision.
- New API: GET /api/graph/coverage (exact per-session derived coverage +
running flags, most-uncovered first) and POST /api/graph/coverage/extract
(manual run for one session, 409 while in flight).
- Graph view chrome: the coverage button opens a panel with every
session's covered/uncovered tokens and a per-session Extract button —
the manual answer to aborted runs, errors, and dormant tails. Renders
even when the graph is empty: an empty graph with uncovered sessions is
exactly when it matters. Polls only while open.
- Notes imports keep the whole-file checkpoint path (coverage undefined).
- Tests: pure-core coverage suite (11), chunked-pipeline suite (3:
multi-batch + no-op, abort-keeps-earlier-batches + remainder re-run,
re-open alone + data-part invisibility), panel render suite (5);
pendingQuizAnswers and its tests deleted as dead code. 486/486.
Live-smoked on real GLM: manual extract of "teach me modal realism" ran
one batch, wrote 4 coverage entries, panel flipped to "1.3k covered ·
complete" with the button disabled, graph refetched (nodes 11), usage
persisted with real stats.
The extraction pipeline stops re-reading whole sessions: coverage is a
per-chat covered-fingerprint ledger at MESSAGE granularity (tool call and
result never separated — they share a message; quiz events not special —
they ride re-opened messages normally), uncovered content is derived at
read time (never dual-tracked), the trigger becomes uncovered tokens >= a
chunk budget (fixing quiz-less sessions that never extract), and runs
checkpoint coverage incrementally per merged batch — abort/error loses
at most one in-flight batch. Manual extract-the-rest falls out of the
diff. Pointer line under vision.md 'Construction'.
The round-1 experiment put the braille spinner in the chip's old dot
slot; rejected live: the pill's curve and the square braille cell fought
for space (visually squeezed), and the chat surface already shows
in-flight work (thinking shimmer, the dock) — the chip stating the phase
is its whole job. PhaseChip back to a label-only pill (busy prop
removed, single-child gap dropped); work-state lives in exactly one
place: the sidebar row's spinner. Doc note + test pin the decision so
it doesn't get re-tried blind. Gates: typecheck, lint, 469/469, build.
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.
The owner's three-part test of the context-injection flow surfaced
timing/visibility bugs, fixed as standing ground rules for ALL background
processes (owner rules, 2026-09-15):
1. THE JOB STARTS AT SEND. triggerGraphContext moves from onFinish to
right after the write-ahead persist in handleChatRequest: its only
input is the first typed message (the topic), just made durable — so
embedding + cosine scan run CONCURRENT with the prober's turn and
the dock blob appears the moment the session starts, not after turn
one finishes. Extraction stays at onFinish (it needs answers
accumulated DURING the turn).
2. DELIVERED CONTENT SHOWS MID-TURN. Steers inserted server-side
(claimed at turn start, persisted write-ahead) used to wait for the
next turn's completion to appear — the full record sync mid-stream
is forbidden (it breaks the SDK's in-place continuation merge), so
adoption was deferred to idle. New append-only seam: core
mergeAdopted() inserts the record's new message ids in canonical
order (in-flight streaming tail kept last) and never rewrites a
local message; any other shape bails to the full-sync-at-idle net.
Wired at three points, no new standing polls:
- turn-start window: three bounded record fetches (400ms/1s/2s)
after a send catch the claimed steer while the turn streams;
self-terminating, survives the submitted->streaming flip.
- jobs-poll settle: mid-turn settles adopt append-only NOW, the
owed full sync still runs at idle.
- wake-adoption poll: each tick adopts persisted wake content, so
steers show at the wake turn's START, not its finish.
The dock stays honest with zero gap: jobs hold "running" while
queued behind a pending question ("delivering context"), then flip
done at claim/delivery — visible from start to delivery, and the
turn-start nudge (useActivity().refresh) clears it within ~150ms.
3. THE MARKER IS CLICKABLE (observability). The knowledge-context
SystemLine becomes GraphContextMarker: quiet line, popover with the
delivered propositions IN DELIVERY ORDER (most similar first — the
order the budget filled), each with its folded understanding level;
Esc / outside pointerdown close. The part data was already the audit
trail — now it's readable at the seam where it landed.
Tests: mergeAdopted unit pins (6), marker render pins (3) — 468/468,
tsc, eslint, build all green; live-smoked end-to-end: blob at send,
marker at t+500ms mid-turn, popover order + levels, instant dock clear.
The converged variant read as two arrows feeding one point; the two-
complete-arrows variant as two arrows next to each other, not coherent.
Owner withdrew the double-shaft idea entirely (2026-09-15); dashed was
already rejected 2026-09-14. Both relation types draw the same plain
single arrow again.
Kept: CanvasEdge.type plumbing from the snapshot (the next
differentiator will need it) and the ArrowGeom named type. Removed:
followsEdgeGeometry, the gap constants, the zoom-drift edge-path
rebuild (edges are back to a single loop in buildGeometry), the ⟹
legend entry, and the glyph tests. Vision doc records the withdrawal
and keeps the still-valid constraints.
Gates: tsc, 429/429 tests, lint, build. Live: canvas coverage 0.477,
no console errors, no legend remnants.
Owner rejected the first cut: two tails converging on one shared head
reads as two arrows feeding a single point, not as parallel strokes.
- followsEdgeGeometry now returns two COMPLETE regular arrows (own shaft
+ own head each), translated ±half-gap along the edge normal —
parallel shafts AND parallel heads, nothing converges. Offset shafts
never clip the source disc; offset tips land a hair outside the target
disc edge (accepted price of parallel heads).
- Gap floors: screen-anchored 3x edgeWidth (dominates at fit/zoomed-out,
unchanged look) OR head-anchored 1.2x arrowLen (world) — without the
head floor, the world-scaled heads merge into one blob at deep zoom
(measured: one 10px band at 5x). max() keeps >= 0.4 arrowLen of clear
space between the heads past the ~1.5x crossover.
- Vision doc revised (2026-09-15) to record the rejection.
Live-verified at 5x fit zoom: two shafts (11.5px gap = widened floor),
two separate ~8px-base heads with clear space between. At fit: 3px gap,
two heads, unchanged. Gates: tsc, 435/435 tests, lint, build.
The continuous 0-4096 dial was precision theater (a 128-token budget is
~2 propositions; nobody wants that granularity). Decided stops (owner,
2026-09-15): 0 / 64 / 256 / 1024 / 4096 / 16384 — the native range walks
positions 0-5 and maps through GRAPH_CONTEXT_TOKENS_STOPS in contracts.
Max rises to 16384 (route range follows); the server still accepts any
integer in range so a stale client cannot brick settings; non-stop
legacy values snap to the nearest stop for the thumb, readout stays
honest.