Commit Graph
420 Commits
Author SHA1 Message Date
amosblomqvist 2678f04e7a feat: extraction coverage — message-granular ledger + token-batched runs (d132373 vision)
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.
2026-09-15 17:41:56 +03:00
amosblomqvist e95fc26309 docs: vision for extraction coverage — message-granular ledger, token-batched runs (2026-09-15 ideation)
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'.
2026-09-15 17:16:31 +03:00
amosblomqvist c9ba4a1b06 Minimal-color round 2 (owner): the chip is label-only — spinner out of the top bar
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.
2026-09-15 17:10:49 +03:00
amosblomqvist 326222f53c Minimal-color round 1 (owner): phase color retired — the braille spinner is the one working indicator
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.
2026-09-15 17:01:01 +03:00
amosblomqvist 31978f3fc3 fix: background-delivery ground rules — the dock is the source of truth, delivered content shows the moment it persists
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.
2026-09-15 16:34:25 +03:00
amosblomqvist 65dfda2342 Withdraw the double-shaft edge glyph — both variants rejected live
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.
2026-09-15 08:38:13 +03:00
amosblomqvist e8fa9403ca follows glyph: two complete offset arrows — no convergence, no head merge
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.
2026-09-15 08:20:04 +03:00
amosblomqvist 4022e3f0d0 fix: the knowledge-context slider is a discrete 6-stop scale — off, then powers of four from 64
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.
2026-09-15 08:09:56 +03:00
amosblomqvist e9348b7baf follows renders as the double-shaft glyph (⟹), supports stays single
Per docs/vision-edge-glyph.md: a `follows` edge (the derivation backbone)
draws two parallel shaft tails offset along the edge normal by a fixed
SCREEN-space gap (FOLLOWS_GAP_RATIO x edgeWidth — constant on screen at
every zoom, like the stroke width), converging on the shared single
arrowhead; `supports` keeps the plain single line (dashed was rejected
earlier — not revisited).

- CanvasEdge carries the edge type; graph-view passes it from the snapshot.
- followsEdgeGeometry (pure, tested): offset tails + convergence at the
  head base — a strictly-parallel pair would poke out beside the head
  whenever the screen-fixed gap exceeds the head's world-scaled base
  half-width. The offset tails sit farther from the source center than
  the axis start, so the source disc never clips them.
- Edge paths leave buildGeometry: their gap is anchored to the CURRENT
  camera scale, so buildEdgePaths(k) rebuilds on geoVersion changes and
  camera-scale drift (> EDGE_PATHS_ZOOM_EPSILON) — 0.08ms per rebuild at
  211 edges; pans and settle frames reuse the cached paths.
- Legend entry bottom-right: ⟹ follows · → supports.

Live-verified: isolated follows edge (search "likelihood") shows two 1px
shafts with a ~3px centerline gap converging into one 8px-base shared
head; gap measured constant on screen at 0.2x, 1x, and 5x fit zoom while
heads/nodes scale. Gates: tsc, 435/435 tests, lint, build.
2026-09-15 08:08:30 +03:00
amosblomqvist f6b0c3fb86 fix: the knowledge-context readout shows the full number (1024 tok, no k-rounding) 2026-09-15 08:02:07 +03:00
amosblomqvist b1416089c6 fix: the knowledge-context readout aligns left (it read right-aligned off the slider) 2026-09-15 08:01:33 +03:00
amosblomqvist 4c7ad48c73 fix: the knowledge-context slider lives in the stats bar's left slot — the session-data spot — with a "tok" readout
The original vision, restored (owner, 2026-09-15): pre-chat the session-
data side of the under-composer stats bar is free real estate ("—" only),
so the budget slider mounts exactly where session data will sit once a
session exists — via a new StatsBar leftSlot — vertically centered with
the model + thinking pills by the row's own geometry. Short 96px slider
instead of a full-width one; readout abbreviates "1k tok" so >1k budgets
stay on one line ("4.1k tokens" wrapped). Home and /chat/new both.
2026-09-15 08:00:04 +03:00
amosblomqvist 1d45d77303 feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)
One shot per chat, fired at the first turn's onFinish: the session's
topic (its only typed message) is embedded as an EmbeddingGemma retrieval
query; the semantically closest graph propositions are selected under the
session's token budget (pure budget, no similarity threshold — the
decided doctrine) and delivered as a marker-only steer through the wake
pipeline, queuing behind pending questions like every sub-agent result.

- contracts: ChatSettings.graphContextTokens (default 1024, max 4096,
  step 128, 0 = off); GraphContextData + graphContextText; the
  graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>)
- core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill
  under budget, most-recent UnderstandingLevel fold, count cap 64,
  parse-don't-cast at the marker seam
- server: embedQuery (role-asymmetric query format, same warm singleton
  — vector-space parity by construction); the runner (startGraphContext-
  Injection one-shot gates; runGraphContextJob validates ledger parity);
  wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs
  re-deliver or contextRespawn); abortJob kills context jobs SILENTLY
  (nothing was promised in the lesson); turn-service trigger seam
- adapter: data-graph-context flattens to the canonical block (malformed
  parts have no model face)
- UI: the budget slider (home dock + /chat/new draft, the session-data
  strip); the quiet SystemLine "~537 · 10 propositions"; dock kind
  "Knowledge context"; model-picker PUT now spreads settings (a model
  pick can no longer clear the sibling override)

Live-smoked end to end on the real embedder + real GLM: seeded ledger
(10 propositions), session on "modal logic: possible worlds and the
necessity box" — job in the dock, queued behind the prober's question,
delivered on the answer, SystemLine in the transcript, wake turn reads
the block and continues the probe. Gates: tsc, lint, 459/459, build.
2026-09-15 07:54:50 +03:00
amosblomqvist ecb293a77d Merge feat/ui-polish: owner refinement rounds — chat top bar, stats-bar dropdown discipline, plan as marginalia, dock → sidebar background-agents section, graph-view chrome rounds 2026-09-15 07:30:00 +03:00
amosblomqvist b68fb8a78a docs: vision — graph context injection + the follows edge glyph (ideation settled) 2026-09-15 07:19:43 +03:00
amosblomqvist bbee72f354 Background agents → sidebar section (owner): the retired floating dock's successor
The old activity dock (fixed bottom-right, every view) was a cross-session
widget parked inside the session view — the same boundary confusion that
got the plan window moved. The sidebar is the app's cross-session surface,
so the agents section lives at its bottom strip now (user-locked design):

- ALWAYS present: empty means empty — the header ('Background agents',
  matching the Sessions label language) sits alone; nothing hides and
  nothing decorates (the total-cost span only renders when populated).
- Populates and grows when processes run: same blobs (sub-agents +
  extraction runs, one poll, inferred cost, elapsed heartbeat), capped by
  max-h-[40dvh] + internal scroll so a pile never crowds the session list.
- Per-blob END button (unchanged abort contract); the detail popover
  becomes an INLINE expansion — the section scrolls, a floating popover
  would clip at its bounds.
- Sidebar-native skin: border-t separator matching the sidebar's edge,
  session-row hover language, no floating-card chrome.
- Sidebar closed = the learner opted out; no ambient signal for now
  (user decision — invisibility until a clearer observability story).
- Narrow-mode count badge/drawer machinery retired with the dock.

app/layout.tsx: ActivityDock mount removed, ActivityProvider stays (the
sidebar consumes the context). Live-verified: populated rows with inline
detail + session link, abort makes the row vanish, idle renders the
header alone, closed sidebar hides the section with nothing floating.
Gates: typecheck, lint, 423/423 tests.
2026-09-14 11:56:22 +03:00
amosblomqvist 38d77204ed Marginalia resize round (owner): graceful shrink + phantom-fold mute
Three fixes from the owner's resize report:

- Continuous shrink: the marginalia now mounts whenever a question owns
  the band and its box width is pure CSS, so it shrinks continuously as
  the display narrows. Below MARGIN_MIN_W (220, the owner's floor) it
  FADES over the normal 300ms transition instead of hard-unmounting;
  widening past the floor fades it back in. No mid-question unmount, no
  pop, no invisible limbo.
- Live flank tracking: a ResizeObserver on the dock block replaces the
  render-time measurement — the fade floor reacts instantly at any
  width (window resize, sidebar toggle, scrollbar changes). The old
  measurement went stale during drag-resizes (nothing re-rendered on
  macOS until the question rewrapped), so the mount decision popped
  late and left a sub-floor sliver visible.
- Phantom-fold mute: resizing rewraps the question text, the browser
  re-clamps scrollTop and emits the clamp as a scroll event — the fold
  machine read the transient position and flickered the box to the bar
  and back. Scroll events within 400ms of a WIDTH change are now
  ignored (width-only: stamping the dock's own height morphs
  self-muted the machine and swallowed the at-bottom auto re-expand,
  leaving the box stuck collapsed — caught in live verification).

Scroll-up/scroll-down fold behavior itself unchanged (verified live:
mute-window scroll ignored, post-mute scroll folds, bottom re-expand
works). Gates: typecheck, lint, 423/423 tests.
2026-09-14 11:39:57 +03:00
amosblomqvist 93033fac77 Plan placement (owner): the plan as marginalia — dock band's right flank + top-bar chip
Retires the fixed bottom-left plan window (which sat ON the sidebar and
bled into the session). Two placements replace it, per the owner's
lock-in:

- The plan chip in the top bar beside the phase chip — session-level
  structure beside session state; opens the full-DAG popup; present at
  every width; the access point.
- The plan marginalia: the BARE diagram (no card, no border, no shadow)
  in the dock band's right flank — marginalia on the lesson's page, not a
  floating widget. The band's reserved height (tall while a quiz is
  open) is its canvas; meet-fitted (PlanDiagram's new fill mode) so it
  fits both the flank's width and the band's height; live statuses +
  the yellow current-node glow; click opens the popup. Mounted only
  while a question owns the band — composer mode keeps the reading
  view pristine. Folding the dock fades + blurs it out of existence
  (300ms), returning to the bottom fades it back in. Rendered only when
  the flank holds a real diagram (>= 240px); narrower windows keep the
  chip alone.

Files: chat.tsx (chip, DockOverlay marginalia wrapper + flank measure,
PlanWindow removed), plan-window.tsx (PlanWindow → PlanMarginalia),
plan-block.tsx (PlanDiagram fill prop: h-full w-full meet-fit),
tests/plan-window.test.tsx (rewritten for the marginalia).
2026-09-14 11:23:28 +03:00
amosblomqvist 873c153525 Stats bar round 4 (owner): optical spacing — tight to the composer, floating below
The rhythm agreed with the owner: the stats/controls row hugs the
composer (8px — reads as the card's metadata, one object), and the
whole cluster floats with deliberate air above the screen bottom
(20px). The 8px straight-edge gap optically matches the 6px pill-to-
pill gap: two rounded pills' corridor reads wider than geometric, so
rounded-rounded gaps run ~2px tighter than straight-edge gaps.

- stats-bar.tsx: pt-4 -> pt-2 (8px under the composer) — shared by
  chat and home automatically
- chat.tsx dock block + home.tsx composer wrapper: pb-4 -> pb-5

The dock's reserve auto-measures the block, so the spacer/fold
machinery adapts on its own.
2026-09-14 10:21:56 +03:00
amosblomqvist 5dac2a6620 Stats bar round 3 (owner): symmetric dropdown closing + vertically centered row
The asymmetry: the stats bar's outside-close ref wrapped the WHOLE
bar, which CONTAINS the model control — so clicking model/thinking
while stats was open counted as 'inside' and nothing closed, while
the reverse direction (model's ref wraps only its own buttons) closed
fine. The ref now anchors to the summary wrapper (button + popover),
matching the model picker's pattern. Both directions close now.

Also centers the stats row vertically in the dock: pt-1.5 -> pt-4,
equal to the block's pb-4 — the row sits midway between the
composer's bottom and the page bottom. (Font size already matched:
both sides compute to 11px JetBrains Mono; the owner chose to keep
the color difference — muted stats vs bright labels.)

Verified live: model->stats, stats->model, stats->thinking all close
the open dropdown and open the new one; row has 16px above (visible
text) and 16px below.
2026-09-14 10:10:01 +03:00
amosblomqvist e13762fe9a Stats bar round 2 (owner): popover centered over the summary button
Round 1 centered the popover over the whole bar ROW — the owner meant
the stats bar's own bottom-left content (the context · cost summary
button). The popover now anchors to a relative wrapper around the
button and centers over IT (left-1/2 -translate-x-1/2 of the wrapper),
so it hangs balanced over the trigger, not offset toward the composer
row's center. Outside-click + Esc closing unchanged from round 1.
2026-09-14 10:06:01 +03:00
amosblomqvist d4b05a0d90 Stats bar round 1 (owner): model-control dropdown discipline for the stats popover
Same treatment the model/thinking controls got (df4433f): the
per-turn stats popover now closes on any pointerdown outside the
bar and on Escape. Also centered over the stats bar
(left-1/2 -translate-x-1/2) instead of left-aligning with the quiz
box, which looked odd.

Verified live: popover centered over the bar (centers within 2px);
Escape closes; click outside closes; click inside stays open.
2026-09-14 10:04:20 +03:00
amosblomqvist c4cf8fd0b2 Chat top bar round 5 (owner): bottom outline matching the sidebar separator
border-b border-border — the same 1px border-border line that
separates the left sidebar (border-r on its container), so the top
bar now reads as its own block with the app's standard separator.
2026-09-14 09:57:37 +03:00
amosblomqvist ad5778484d Chat top bar round 4 (owner): plain block — revert all top-gradient rounds
Rounds 1–3 chased a soft fade under the title; the owner called the
final overlay look wrong too. Back to the simple picture: the top bar
is its own block — in flow above the scroller, plain page background,
no gradient, no border, no tone change. It reads as a block because
the transcript starts below it. (Also reverts round 3's overlay
header + pt-14 entirely.)
2026-09-14 09:56:45 +03:00
amosblomqvist 1350f2d094 Chat top fade round 3 (owner): the transcript runs UNDER the title
Root cause of the 'solid bar' look: the scroller began BELOW the
header, so the fade zone sat over plain page background — any opacity
there read as a solid bar, no matter the stops. The owner's picture:
content goes under the title bar, easing beneath it.

- The header is now an overlay (absolute top, z-20) over the
  scroller, which fills the column to the very top — same floating-
  chrome pattern as the home graph view.
- The wash lives INSIDE the header, behind the title content:
  60% background at the very top, ramping down to transparent over
  48px — never solid, supports the title, content melts beneath it.
- The transcript gets pt-14 (header 40px + its original 16px), so
  scrolled-to-top content clears the floating title.
- Header is pointer-events-none except the sidebar toggle, so the
  canvas area under the wash stays draggable/selectable.

Verified live: scroller top = 0 under the 40px overlay header; wash
48px ramping from 60%; first message at exactly 56px on scroll-to-top;
the old below-header edge fade is gone.
2026-09-14 09:54:02 +03:00
amosblomqvist e285e2bacf Chat top fade round 2 (owner): 60% peak at the top, 40px ramp down
Round 1 (h-24, 65% hold via 25% mid) read as a faint tail plus an
abruptly solid band and stretched far into the chat. What the owner
wanted: peak ~60% right under the title, ramping down to transparent
a little below — no full opacity anywhere, short reach. Now h-10
from-background/60 to-transparent: a single gentle ramp.
2026-09-14 09:50:53 +03:00
amosblomqvist ae749fe27d Chat top fade round 1 (owner): long soft wash instead of a hard 20px bar
The header fade was h-5 (20px) from full background opacity — it
read as a solid bar with a quick blur on the edge. Now a 96px wash:
65% under the title, holding softly through ~25% at the midpoint,
melting to transparent — supports the title, never reads as a bar.
2026-09-14 09:48:33 +03:00
amosblomqvist 0f6e36f85f feat: the sessionless draft view — "+ New session" creates nothing
The sidebar button no longer POSTs a record (no more "Untitled session"
litter): it opens /chat/new, a sessionless draft view visually identical
to a fresh chat (same header + probe chip, the same ConversationEmptyState,
the same composer blob + stats dock). The session comes to exist only
when the learner sends the first message — and that message becomes its
title (startDraftSession: POST /api/chats {title: msg} → PUT the lifted
pre-chat settings → redirect /chat/<id>?prompt=<msg>, whose initialPrompt
path delivers the message as turn 1 — the home composer's proven contract).

- app/chat/new/page.tsx: the static draft route (static beats [id]).
- components/new-session.tsx: DraftShell (pure render seam) + NewSession
  (router wiring); the model picker's empty-chatId branch defers its save
  and lifts the selection, so pre-chat model/thinking rides the creation.
- components/sidebar.tsx: newSession is a plain router.push("/chat/new").
- tests/render-new-session.test.tsx: 6 pins — the create-on-send contract
  (title = message, settings PUT, no-op on empty text) + the shell render.

Live-verified end to end: button → draft (0 creations), pre-chat Qwen
pick, send → exactly one session titled with the message, settings
persisted, redirect + turn 1 running. Gates 431/431.
2026-09-14 09:06:48 +03:00
amosblomqvist 0b65838519 Graph-view round 3 (owner): gear moved to the far right — its panel drops at the right edge
Row order is now search → import → gear, so the settings panel (which
right-aligns with the gear) opens at the very right edge of the view,
covering less of the graph than when it hung off the middle control.
Pure reorder, no behavior change.
2026-09-14 08:54:39 +03:00
amosblomqvist 9832e3464f Graph-view round 2 (owner): search / settings / import split into their own containers
The top-right chrome was one shared w-64 panel (search input with the
gear inside it, settings expanding beneath, import button below). Per
the owner, three separate floating containers, still anchored at the
top right:

- Search — its own container (icon + input, keeps the no-match note).
- Rendering settings — its own COMPONENT (GraphSettingsControl): the
  gear is its own 44px card button; clicking it expands/collapses the
  panel, which now drops BELOW the button right-aligned with it. All
  sliders/Regions/Physics move there verbatim; the open state moved
  into the component; the gear highlights while open (aria-expanded).
- Import notes — its own container (icon + label button).

All three sit in one top-right row, equal height (44px + border), 8px
apart, same floating-card language (rounded-xl border bg-card/85 shadow
backdrop-blur). No renderer/data changes — pure chrome restructure.

NOTE (lane): components/graph-view.tsx is graph-stream-owned; done on
the owner's direct instruction, flagged for master.

Verified live in headless chromium: three containers top-aligned at
y=12, heights 46px outer, rightmost edge at the 12px corner inset;
gear toggles the 256px panel with every section present.
2026-09-14 08:52:04 +03:00
amosblomqvist 28a78beb97 Graph-view round 1 (owner): full-bleed canvas — no padding, under the floating chrome
The home graph view had breathing room on every side (main px-4 py-2,
the composer docked below in flow, the header above). Per the owner:

- The canvas now owns the whole column — absolute inset-0, from the
  sidebar to the right edge of the screen, bottom to top, zero padding.
- The header floats over the graph (pointer-events-none except the
  sidebar toggle, so the canvas stays draggable around it).
- The new-session blob + stats bar dock OVER the graph at the bottom,
  no gradient scrims anywhere — the graph runs clean under everything
  to the very edges.
- The renderer itself is untouched: same canvas, same data, same fit
  logic — only its container grew. The composer keeps chat's DOCK
  geometry (max-w-[46rem] px-4) so nothing shifts when a session starts.

Verified live in headless chromium (1280×800, sidebar open): canvas
spans x 288→1280 (sidebar edge → right edge), y 0→800 (bottom to top);
the header overlays the top; the dock overlays the bottom with its
content ending 16px above the viewport edge.
2026-09-14 08:46:07 +03:00
amosblomqvist 7f001cdb46 chore: package-lock after the plan-rendering merge (dagre-d3-es + d3-shape install) 2026-09-14 08:37:32 +03:00
amosblomqvist b3d7bf5491 Merge feat/ui-polish: plan graph rendering (dagre engine, owner skin), stats-control polish, overlay dock scroll
# Conflicts:
#	package.json
2026-09-14 08:36:52 +03:00
amosblomqvist b2a72136e0 Scroll round 3 (owner): fold threshold 64 → 96px (tolerate small peeks) 2026-09-14 08:33:39 +03:00
amosblomqvist 2e20bc6298 Scroll round 2 (owner): the scrollbar stays visible + wheel works over the dock
Two round-1 follow-ups (owner):

- The overlay painted over the scroller's scrollbar track. The scroller
  reserves symmetric gutters (scrollbar-gutter: stable both-edges), so the
  overlay's right edge is now inset by the measured gutter — half of
  offsetWidth − clientWidth; zero on macOS overlay scrollbars, the real
  width on classic platforms. Re-measured on window resize. The headless
  shell renders classic scrollbars (11px), so the path is live-verified.

- Wheel over the dock did nothing: the overlay is a SIBLING of the
  scroller, so native scroll chaining stops at the overlay (the old
  in-flow dock chained for free — a real regression, not a preference).
  A non-passive wheel listener on the dock block forwards the delta to
  the transcript ONLY when nothing under the cursor can take it: the
  question area, textarea overflow, and the model menus keep their
  native scrolling, including their own edge chaining; an unhandled
  wheel (over a fully-visible box, or at an internal scroller's edge)
  scrolls the transcript — so wheel-up over the quiz itself now folds
  the box under the cursor.

Verified live: wheel-up over the question box scrolled the transcript
(9460→9100) and folded to the bar; wheel-down handled cleanly; the
overlay inset matches the measured gutter exactly.
2026-09-14 08:31:46 +03:00
amosblomqvist 07415048df Scroll round 1 (owner): overlay dock + spacer — the fold fires as soon as you leave the bottom
The question box folded far too late because the old dock was IN FLOW
inside the scroller: folding it shrank the scroll content, so the fold
had to wait until the learner was a full dock-height past the bottom —
any closer and the browser's scrollTop clamp pinned them back to the
bottom, re-expanding the box (the oscillation the old hysteresis
comments worked around).

The dock now overlays the column bottom (absolute — a sibling of the
scroller, the use-stick-to-bottom pattern, like the scroll button), and
a spacer at the transcript's end reserves its height (DockOverlay
measures its settled block and reports upward; ChatView's dockReserve
state feeds the spacer). At-rest geometry is identical: the spacer
reserves exactly the overlay footprint.

The fold is now VISUAL ONLY: COLLAPSE_DIST 64px (was dock-height+64),
EXPAND_DIST 16px, position-based, no velocity. The reserved tail GROWS
immediately (typing, new variants) but never drops while a question is
open — a shrinking tail would reintroduce the clamp; in chat mode drops
apply (the composer typing-shrink is the old harmless behavior).
expandIntent (2s bar-click suppression) and the teach reveal gate are
unchanged.

Verified live (headless chromium, dev 3001, pending-quiz chat): teach
invitation mounts collapsed; click → expand + bottom; scroll up 200px →
folds to the bar at d=200 with the spacer held (no geometry change, no
yank); back to bottom → re-expands; spacer ≡ overlay footprint.

Note: the overlay spans the column's full width, so on platforms with
classic (non-overlay) scrollbars its gradient/background strip covers
the bottom of the scrollbar track — invisible on macOS overlay
scrollbars; flagged for master.
2026-09-14 08:28:07 +03:00
amosblomqvist 6505f12d37 Stats-control round 1 (owner): bare labels + intuitive dropdown close
Model + thinking control refinements (components/model-picker.tsx):
- Drop the '·' separator between the two buttons.
- The thinking button shows just the level ('High', 'Max') — same
  doctrine as the model button showing just the model name; '—' when
  unknown. Pinned in render-stats-bar (label + no-dot assertions).
- The dropdowns now close on any pointerdown outside the control
  (the toggle buttons stay inside the container and keep their own
  toggling) and on Esc — the intuitive dropdown contract, instead of
  the button being the only way out.

Note: file is graph-stream-owned per SCOPE; touching it per the owner's
direct request (same as cd518a4) — flagged for master.
2026-09-14 08:10:27 +03:00
amosblomqvist a207e6b4a2 Plan-graph round 14 (owner): entry-angle clamp — heads enter within ±40° of the face perpendicular
The owner measured the arrowhead in Photoshop: past ~40° off the
perpendicular of the box edge, one barb crosses the tip's level and the
silhouette breaks. edgePathD now takes the target box and clamps the
final approach: the entry direction may tilt at most ±ENTRY_ANGLE_LIMIT
(40°) off the entry face's outward normal — a steeper natural approach
swings to the cone edge (same rotation side as the route), the shaft
stub and head follow the clamped direction.

Docking refined: the tip floats DOCK_GAP past the border along the FACE
NORMAL (constant gap at any tilt; perpendicular entries are bit-identical
to before — pinned). Faces are derived from where dagre's border point
sits on the target box (top/bottom/left/right all supported). Edges
without a known target box keep the unclamped geometry. Pinned: cone-edge
swing at 40° exactly, normal-gap docking, unclamped shallow entries.
2026-09-14 08:04:31 +03:00
amosblomqvist d7790f895e Plan-graph round 13 (owner): arrowheads point again — no zero-length final segment
Round-12 regression: manually repeating the mouth point as a control
point made d3 emit a degenerate final path command (a zero-length L).
orient="auto" derives the marker angle from the direction of the LAST
path segment; a zero-length segment has none, so every head fell back
to 0° — pointing right. Fix: drop the manual repeat — curveBasis already
repeats the endpoint internally, so controls ending [Q, M] keep the
exact end tangent (the approach direction) AND a real final segment
(the straight Q→M stub). Left a comment in edgePathD so this never
regresses again.
2026-09-14 07:56:01 +03:00
amosblomqvist 847caf48d3 Plan-graph round 12 (owner): docking gap + straight shaft stub
Two edge-drawing refinements (owner), both pure post-processing in
edgePathD on dagre's waypoints:

- Docking gap (4 units ≈ half a head length): the head's tip floats
  short of the target face instead of touching it — the mouth pulls
  back by tip overhang + gap, so the head kisses nothing and tilted
  entries stop biting into the box.
- Shaft stub (12 units ≈ 1.5 head lengths): from Q, every control
  point sits exactly on the approach line, so the basis spline renders
  that stretch perfectly straight and the head lands on a straight
  shaft; the corner where the route squares up is rounded by the
  curve, and the repeated mouth point makes the end tangent exactly
  the approach direction. Collinear routes render identically
  (pinned: on-axis start to mouth).

Angle alignment/perpendicular entry deliberately NOT touched yet —
owner wants to evaluate this first (round 13 candidate).
2026-09-14 07:53:54 +03:00
amosblomqvist 0f6b2d5c1e Plan-graph round 11 (owner): mermaid's edge drawing — dagre routes, curveBasis draws
The old lines were our hand-rolled shortcut: every arrow from the
source's bottom-center to the target's top-center as a single S-bezier.
Dagre actually routes each edge as border-intersected waypoints that
leave/enter nodes on whatever side the route needs — mermaid's
behavior. We now keep that routing (owner: adopt it).

blockLayout also returns edgePts — dagre's per-edge waypoint lists.
edgePathD draws them through d3's curveBasis, the exact recipe
dagre-d3's own renderer uses (and mermaid's default flowchart curve):
endpoints repeat so the line starts at the source border and the END
pulls back by the tip overhang so the arrowhead's tip lands on the
target border with its mouth at the path's end. Pinned in tests.

Dangling/unroutable edges draw nothing. d3-shape added as a direct
dependency (^3.2.0, already in the tree via dagre-d3-es — lane touch,
flagged to master with dagre-d3-es).
2026-09-14 07:38:13 +03:00
amosblomqvist 90ed65f208 Plan-graph round 10 (owner): the glow paints above the box fill
Paint order inside a node group: box fill/stroke → yellow glow → text.
The round-9 background fill no longer dims the glow inside the current
node's box, edges behind boxes still dim under the fill (round 9
intact), and the title text stays crisp above the glow.
2026-09-14 07:34:01 +03:00
amosblomqvist b97a34aea4 Plan-graph round 9 (owner): white nodes dim — no longer fully transparent
Arrows routed behind a node showed straight through the fill-none
boxes. The white (visited/current/monochrome) boxes now carry a 50%
opacity fill of the standard app background color (fill-background/50):
an edge passing behind composites UNDER the fill — dimmed to half, not
shown through — while the no-fill look survives on the background
surface. Upcoming (grayed) nodes keep their solid card fill.
2026-09-14 07:33:00 +03:00
amosblomqvist 4d98525338 Plan-graph round 8 (owner): placement now runs on dagre — mermaid's engine
The owner chose: adopt the same layout engine regular mermaid uses, keep
our SVG skin. That engine is dagre (Sugiyama-style layered layout) — and
mermaid 11 ships dagre-d3-es 7.0.14, so we import exactly that fork, via
the same deep subpath mermaid itself uses. No new code shipped; pinned
verbatim in package.json (lane touch — flagged to master).

blockLayout keeps its signature ({ x, y, nodeH, width, height }, all
consumers unchanged): it feeds dagre our geometry (NODE_W boxes, GAP_X/
GAP_Y separations, zero margin, per-node grown heights from round 7),
runs the layout (deterministic, synchronous, DOM-free — SSR and render
tests stay exact), and converts dagre's node centers back to top-lefts.
Dangling edges are ignored defensively (a ghost endpoint would spawn an
unmeasured node and break the measurer).

topoFor stays exported for order/depth semantics (tests); placement no
longer calls it. dagre's network-simplex ranker + crossing minimization
now handle multi-parent DAGs, so future plan shapes route like mermaid
diagrams do. On the existing tree fixtures dagre's numbers agree exactly
with the old hand-rolled layout — every render pin survives verbatim.
2026-09-14 07:30:53 +03:00
amosblomqvist 7c766753cc Plan-graph refinement round 7 (owner): no fly-in on open, nodes never truncate
Fly-in, take two: round 4 kept settled OFF until the first fit, but the
flag flipped in the SAME commit as the transform change — the browser saw
the new transform + newly-added transition in one style update and
animated from the initial top-left zoom:1 state. The transition now
enables one paint AFTER the fit (requestAnimationFrame), so the popup
opens settled and only glides for user input afterwards.

Nodes show their FULL content (owner): wrapTitle no longer caps lines
or ellipsizes — oversized words are hard-chunked, nothing is dropped.
Boxes grow with their wrapped lines (NODE_H + LINE_H per extra line);
layout rows take their tallest node, edges leave from the source's real
bottom, the glow rect and the plan-window's centering follow the grown
height, and the text block centers in the grown box.
2026-09-14 07:12:38 +03:00
amosblomqvist 9eb30edb40 Plan-graph refinement round 6 (owner): grayed arrows, static glow, glow-sized frame pad
- Arrows into not-yet-visited nodes are grayed out like the nodes
  (stroke-border line + a fill-border copy of the arrowhead); the
  in-chat block stays all-white.
- The yellow glow is static — the pulse is gone (owner).
- Smaller blur: stdDeviation 7 → 5, glow rect margin 10 → 8.
- The glow clipped at the frame on narrow graphs: its visible reach
  (GLOW_MARGIN + 3·GLOW_BLUR = 23 units) far exceeded GRAPH_PAD (8).
  Surfaces that show the glow now pad by GLOW_REACH — the popup's
  viewBox AND its fit box both use POPUP_PAD = GLOW_REACH, so the blur
  never clips; the monochrome in-chat block stays tight at GRAPH_PAD.
2026-09-14 07:06:25 +03:00
amosblomqvist 43ada6ce23 Plan-graph refinement round 5 (owner): the owner's arrowhead lands
arrowhead_1.svg (the CM/MJX long-arrow head) baked in — transform chain
collapsed to a clean scale 0.2 + y-flip: viewBox 0 0 50.2 104, tip at
(50.2, 52), notch mouth at x 27.2, tail opening 9.6 tall (≈10 at the
owner's full height 104).

Sizing per the owner's spec: line width stays 1.5; head height 16 makes
the mouth opening tuck the line flush (the LaTeX proportion). markerUnits
userSpaceOnUse, refX at the mouth (27.2) so the line terminates at the
notch — never at the narrow tip, where it would show through. Edges
shorten by the tip overhang (≈3.54 units) so the head's tip lands
exactly on the target node's border.
2026-09-14 06:59:38 +03:00
amosblomqvist 739a6aed56 Plan-graph refinement round 4 (owner): the simple white look + no entrance fly-in
Look (rectangular, white language — owner spec):
- Boxes: no corner rounding (rx dropped everywhere).
- Visited: fill-none, white stroke, white text. Upcoming: today's gray
  (fill-card, border stroke, muted text) — kept per owner. Current:
  white stroke/text + a faint yellow highlight — a blurred yellow fill
  (feGaussianBlur) that bleeds out over the node's edges, still breathing.
- Edges + interim arrowhead: white (marker swaps to the owner's SVG when
  it lands: right-pointing, solid fill, tight viewBox, tip at right edge).
- The IN-CHAT plan block renders monochrome — every node white, no
  status language, no glow; the popup/plan-window show the progression.

Popup entrance fix: the transition from round 3 animated the first fit
(graph flew in from the top-left zoom:1 state). The transition now stays
off until the first fit has been applied — the popup paints settled.
2026-09-14 06:55:10 +03:00
amosblomqvist b92237fd56 Plan-graph refinement round 3 (owner): higher zoom ceiling + eased zooming
- ZOOM_MAX 2× → 4×: node titles reach ~50px rendered, comfortable for
  close reading. Single tunable constant.
- Smooth zoom: a 180ms ease-out CSS transition on the transform wrapper —
  CSS retargets from the current computed value, so chained wheel ticks
  and the fit/button zooms all glide instead of snapping. Disabled while
  dragging (per-frame pans must not rubber-band). panning is React state
  toggled at drag start/end.
2026-09-14 06:30:05 +03:00
amosblomqvist 6464b34a79 Plan-graph refinement round 2 (owner): the popup fits, centers, and never clips
Root cause of the popup's fit/center failure: PlanDiagram rendered
w-full inside the popup's shrink-to-fit absolute wrapper — the svg
resolved to the popup's width regardless of its viewBox extent, so the
transform math (1 svg unit = 1 px at zoom 1) was off and the 'fit floor'
neither fitted nor centered.

- naturalSize: the popup's diagram renders at the viewBox width in px,
  so fit/pan/zoom math holds.
- The popup's fit box is now the PADDED ink box (GRAPH_PAD all around):
  centered strokes and the glow halo never clip at the frame edge.
- fitZoomFor lost its 0.15 artificial floor — for wide graphs it sat
  ABOVE the true fit and broke 'fully zoomed out = the whole graph
  visible, whichever axis binds' (owner's rule). The fit itself is the
  floor; a 0.02 epsilon only guards degenerate viewports.
2026-09-14 06:26:55 +03:00