Commit Graph
184 Commits
Author SHA1 Message Date
amosblomqvist 40870544ba Header round 6d (owner): the title grows to text-3xl — matched in the draft shell 2026-09-17 14:09:22 +03:00
amosblomqvist 64c644f42c Header round 6c (owner): the sidebar toggle owns an animated slot — no more one-frame jumps
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.
2026-09-17 14:04:43 +03:00
amosblomqvist 9ddeb60e3f Header round 6b (owner): the open-sidebar toggle matches the close glyph — size-4 both ways
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.
2026-09-17 13:55:40 +03:00
amosblomqvist c451f4f144 Header round 6 (owner): the top bar grows to 60px — aligned to the sidebar's LH band
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.
2026-09-17 13:52:18 +03:00
amosblomqvist 70ddc21610 Typography round 5 (owner): drop the message count + active-row weight; ISO dates
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.
2026-09-17 13:48:04 +03:00
amosblomqvist 2a8554af27 Typography round 4 (owner): the snap to the ladder — prose 16.5→16, xxs rung merged into xs
The system: the ladder is the truth, everything snaps to it.

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

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

Lane note: components/graph-context-marker.tsx (graph-stream file) had a
mechanical text-xxs → text-xs swap — required: the token removal
otherwise leaves a dead class in their file.
2026-09-17 13:43:26 +03:00
amosblomqvist d01759ec32 Typography round 3 (owner): session titles at the prose size
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.
2026-09-17 13:29:21 +03:00
amosblomqvist 5e6d5f124c Typography round 2 (owner): lh-input (caret at the font-metric floor) + 14px session titles
- 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.
2026-09-17 13:15:35 +03:00
amosblomqvist 1b7dbe2898 Typography round 1 (owner): the three-font rule enforced + Source Sans session titles
- 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.
2026-09-17 13:13:26 +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 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 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 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 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