Commit Graph
292 Commits
Author SHA1 Message Date
amosblomqvist fb759b7aa0 fix: top-bar polish — lights on the pill line, rename escape hitbox, inert phase pill, chrome carve-outs
Traffic lights (owner report: 'way too low, not aligned with the phase pill'):
measured in the running app — circles centered at 30.75pt vs the pill box's
29.25pt (band 0-59.5, pill box 19-39.5, pill ink 28.75; uppercase glyphs sit
high). trafficLightPosition y 24 -> 22.5 lands the circle centers at 29.25,
exactly on the pill. Owner confirmed aligned after this change.

Rename escape hitbox (owner report): entering rename was already width-of-
title only, but the editing input spanned flex-1 so clicking the rest of the
bar stayed inside the input. The input is now shrink-to-fit
([field-sizing:content], min 8ch / max 70%) and any mousedown outside the
input blurs it, running the existing onBlur commit path; the header drops
lh-drag while renaming so the drag region cannot eat the escape click.
Verified: click-away commits (exactly one PUT), Escape discards, Enter
commits.

Phase pill is pure decoration (owner report: 'should not be interactable
in any way'): no tooltip, pointer-events-none, select-none — part of the
drag surface instead of a carved-out target.

Graph chrome was unclickable in Electron (owner report): home.tsx's
floating drag strip underlays the z-10 controls row, and Electron's
app-region mask ignores z-index, so the region ate their clicks (plain
browser unaffected — DOM hit-testing is fine). lh-no-drag carve-outs on
the chrome row and the node detail card (it is placed imperatively
anywhere, including the top strip).

LhMark home link now mirrors the ghost toggle: same size-9 chip geometry
and hover:bg-accent treatment.
2026-09-17 17:18:41 +03:00
amosblomqvist 9a6d422d59 feat: merged title bar — drag bands, owner's mark, title-width rename hit-box
The system title bar disappears (titleBarStyle hiddenInset +
trafficLightPosition {16,24}): the app's own 60px header bands become the
window's drag surface, and the traffic lights float over the sidebar's
mark band, vertically centered on the same line as the chat header's
phase pill (pixel-verified: circle centers y 24–36.5pt, pill box center
30pt).

- electron/main.cjs: hiddenInset chrome; dev shell (LH_DEV_URL) gets its
  own userData (-dev suffix) so it never fights the packaged app's
  single-instance lock — verified: the lock silently killed the dev
  shell while the packaged app was open (that also proved the lock works
  across separately-launched builds).
- app/globals.css: .lh-electron-gated utilities — drag/no-drag bands,
  92px traffic-light gutter (.lh-lights-gutter / .lh-closed-toggle:
  lights end ~75pt + the lights' own 16px edge inset as the gap),
  home's floating strip becomes a drag surface only in the shell.
  Everything is inert in the plain browser.
- sidebar.tsx: SidebarProvider sets <html class=lh-electron> post-mount
  (UA detection; no hydration mismatch, absent in browser); LH wordmark
  text replaced by the owner's mark (components/lh-mark.tsx, inlined
  icon-no-padding.svg, 24px, home link kept); SidebarToggle gains the
  92px margin when closed so it never slides under the static lights.
- chat.tsx: the rename hit-box shrinks to the title text itself —
  flex-1 spanned the whole bar, so every click started a rename and the
  window lost its grip. Shrink-to-fit + a flex-1 drag spacer (chips stay
  right) + hover:bg-accent affordance (clear when hover renames vs
  drags; -mx/px cancel for zero shift). The renaming input keeps flex-1
  for edit room.
- home.tsx / new-session.tsx / phase-divider.tsx: drag bands + carve-outs.

Live-verified in the dev shell (pixel analysis) and headless browser:
lights/pill/mark centered, 17pt lights→mark gap, rename click→select-all
→Escape roundtrip, browser layout untouched.

Gates: typecheck, eslint, 500/500 tests, build.
2026-09-17 16:55:07 +03:00
amosblomqvist 7902984bd6 feat: Electron desktop wrapper — LearnHarness.app (macOS arm64)
The web app becomes a desktop app without changing shape: the Electron
shell spawns the Next standalone server on a loopback port and points a
sandboxed window at it. All app logic stays in the Next server.

Bands:

1. LH_DATA_DIR seam (config.ts): PATHS.data/chats/graph derive from
   process.env.LH_DATA_DIR (else repo data/, unchanged in dev). New
   PATHS.modelCache routes the transformers.js embedding-model cache
   (~300MB) to <data>/models when the seam is set — the default cache
   inside node_modules is read-only in a packaged bundle. All existing
   fs writes flow through PATHS; teacherPrompt stays ROOT-anchored
   (read-only asset, ships inside the bundle by design).
   tests/data-dir-seam.test.ts covers set/unset via child process.

2. Standalone build (next.config.ts + scripts/build-server.mjs):
   output 'standalone'; outputFileTracingExcludes data/**/* keeps the
   repo's real session data out of the bundle (verified: an unexcluded
   scratch build traced 34 files / 716K of real chats + ledgers).
   Two empirically-verified tracer gaps required outputFileTracingIncludes:
   pi-ai's provider catalog (JSON import attributes — 39 files, 500s on
   /api/models without them) and onnxruntime-node's sibling dylib
   (libonnxruntime.dlopen via @rpath). build-server.mjs copies public/
   and .next/static per docs, refuses a leaked data/ dir, and asserts
   the runtime-read prompt .md assets are traced.

3. Electron shell (electron/main.cjs, no preload):
   - single-instance lock (two instances would fight over the data dir);
   - free port via listen(0); spawn via process.execPath +
     ELECTRON_RUN_AS_NODE=1 with PORT/HOSTNAME=127.0.0.1/LH_DATA_DIR;
   - BYOK: userData/auth.json, flat {VAR: value} JSON, mode 0600,
     merged into the child env only when absent from the real env
     (auth UI is the settings milestone; file is hand-editable);
   - poll until the server answers (no ready event), then open a
     contextIsolation+sandbox window; deny-by-default permission
     handler (media allowed only from the loopback origin — mic/STT
     groundwork); will-navigate pinned to origin, window.open denied
     with external links to the system browser; macOS Edit-menu roles;
   - quit path SIGTERM→3s→SIGKILL + exit/SIGHUP nets; child stdout
     mirrored to userData/logs/main.log;
   - dev mode via LH_DEV_URL: no spawn, points at next dev (HMR).

4. Packaging (electron-builder.yml + build/entitlements.mac.plist):
   files = electron shell only; the whole .next payload ships via
   extraResources from .next (filter standalone/**/*) — NOT from
   .next/standalone directly: electron-builder hard-excludes a copy's
   root node_modules and the default pattern is a literal match, so
   node_modules/ and .next/ were silently dropped and the server died
   with 'Cannot find module next'. Asar holds no native code.
   Unsigned (identity: null) until a Developer ID cert exists; hardened
   runtime + audio-input entitlements laid now for the STT milestone.
   desktop:dev / desktop:dist scripts (electron 44, electron-builder 26).

Live smoke of the packaged .app: server up on a random loopback port,
all API routes 200, a real GLM chat turn answered end-to-end via
auth.json BYOK, chat persisted under userData/data, second instance
exited via the lock, no orphaned server child after quit.

Gates: typecheck, eslint, 500/500 tests (+2 data-dir-seam), build.
2026-09-17 15:32:28 +03:00
amosblomqvist 769a3da186 feat: session rename + graph chrome polish (popovers dismiss, no-shift no-match, coverage reseat)
Rename (owner ask): clicking the header title opens edit mode with the
whole title selected — typing replaces, arrows move inside. Enter or a
click away commits (PUT /api/chats/:id); Escape and an empty commit
discard. renameChat joins the ChatStore port; like saveSettings it is
NOT conversation activity — updatedAt stays put so sidebar order never
churns on a rename. The sidebar patch is instant: the header dispatches
lh:chat-renamed and SessionsSidebar folds it into its snapshot (the 2.5s
poll is only a backstop). Sidebar right-click rename is DELIBERATELY
deferred: it needs a real context-menu system, which gets its own pass
later (owner decision, 2026-09-17).

Graph chrome:
- The extraction-coverage button moves between search and import; the
  gear keeps the far-right corner. Coverage still renders on an EMPTY
  graph (that is exactly when it matters).
- The no-match search note no longer shifts its siblings: the chrome
  row is items-start (every button is 44px tall, so top alignment pins
  the row while the search container grows downward).
- Popovers dismiss properly: pointerdown anywhere outside the control's
  root (button + panel) closes it, and so does Escape. Two panels can
  never be open at once — the sibling chrome buttons are outside the open
  root, so pointerdown closes the first before the click opens the
  second. New shared hook components/popover-dismiss.ts, wired into
  both the settings gear and the coverage control.

Gates: typecheck, eslint, 498/498 (+2 rename store tests), build.
Live-verified: select-all/focus/Enter/Escape/empty/blur round-trip,
updatedAt untouched, sidebar instant patch; chrome order + frozen
positions under a no-match query; popover close via canvas, search
input, Esc, mutual exclusion, toggle intact.
2026-09-17 14:28:20 +03:00
amosblomqvist 5c380de669 Merge lh-ui refinement wave (feat/ui-polish @ 8163437) into main
11 commits on fbeca36: minimal-color rounds 1-2 (phase color retired,
the braille spinner is the one working indicator; the chip is label-only),
typography rounds 1-5 (three-font rule enforced — CMU titles, Source Sans
session titles, JetBrains mono; snapped type ladder in globals.css with
the xxs rung merged into xs; lh-input caret at the font-metric floor;
session titles at prose size, message counts and active-row weight dropped,
ISO dates), header rounds 6/6b/6c/6d (60px top bar aligned to the sidebar
LH band; the open-sidebar toggle matches the close glyph at size-4 both
ways; SidebarToggle always-mounted with a lockstep animated slot — no more
one-frame title jump; title at text-3xl).

Semantic change folded in: `busy` in the chats list now means exactly "a
teach turn is in flight" — background jobs no longer spin the session row
(the sidebar's Background agents section is their channel).

Merge fallout fixed in the merge: components/graph-coverage.tsx (main's
coverage panel) held the last three text-xxs uses; with the rung deleted
they silently lost their size — mechanically swapped to text-xs, matching
lh-ui's own swap in graph-context-marker.tsx.

No file overlap with main's waves since fbeca36 (extraction coverage,
lh-graph merge, world-scale bake) — clean auto-merge. Gates: typecheck,
eslint, 496/496 tests, build. Live-verified on the dev server: 60px
header with 32px CMU title, label-only chip, snapped ladder active
(--lh-prose-size 16px, no --text-xxs rule anywhere), sidebar rows with
ISO dates, animated sidebar toggle with no header jump.
2026-09-17 14:11:23 +03:00
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 0f93124c7e feat: canonical world-scale renderer — sizes become world units + baked tuning + nice-number ladders
The world-scale pivot (2026-09-17): node/label/arrowhead sizes were
calibrated as "screen px at fit zoom", a unit whose meaning depended on
the graph's own extent — a 13-node graph at kFit 3.65 rendered 5.3x
smaller nodes than a 338-node graph at kFit 0.69, with no slider values
able to reproduce the dense feel. Sizes are now canonical WORLD units
(graph-canvas.ts drops the ÷kFit division in geometry/regions/hit-test);
kFit survives only as the label-gate 1x reference. The renderer's module
doc and the SIZE SEMANTICS block in render-settings.ts carry the story.

Camera becomes pure persisted view state: {k, x, y} stored to localStorage
(lh.graph.camera.v1, 250ms debounce, flush on unmount), restored on mount
via the new initialCamera constructor arg; the hardcoded fit() runs only
on first-ever open. A pin test locks the d3 zoomIdentity composition.

Region blur becomes world units too (the last span-coupled quantity): the
old bitmap-px reach scaled with graph span, so the 2026-09-14
search-filter collapse fix is now structural (blurW = tuning.blur; the
cellRef anchor is gone). Charge default bumps to -240 (single source:
CHARGE_STRENGTH in layout.ts; CHARGE_RANGE re-anchored so -240 sits
dead-center).

The 2026-09-17 bake — the user's live-tuned defaults (nodeSize 2,
edgeWidth 1, arrowLength 2, labelSize 3, labelZoom 4 / "8x", dilation 20,
blur 20, edgeAlpha 0.2, bitmap 256) — plus the ladder pivot: every
non-physics knob moves on a hand-authored 11-stop ladder (x1.5/x1.33
series, quarter/half steps at the low end), default dead-center at slider
position 50, step 10 = one stop per detent. The sanitizer snaps ANY
incoming value to the nearest stop (ties break low), so stored files can
never hold 2.18137...977-style artifacts; pre-pivot files (sizeUnits !=
"world") reset only the four resemanticized size fields. Physics sliders
keep the fine-grained log mapping.

The label cross-fade becomes PROPORTIONAL to the gate (span = gate x
LABELS_FADE_RATIO): the old absolute 0.5-ratio span read as a 100%
relative change at the old 0.5 gate but a 12% sliver at the tuned 8x —
"lower zoom levels naturally produce a longer transitionary period".
At the 8x default the node→cluster handoff now fades across one full
ladder stop (8x -> 12x). labelZoomDisplay is absolute (ratio / 0.5) so
the tuned gate keeps reading "8x" now that 4 IS the default.

Gates: 495/495 tests, tsc/eslint/build clean.
2026-09-17 12:29:17 +03:00
amosblomqvist 5ce78d0754 Merge lh-graph refinement wave (feat/edge-glyph @ 66e6212) into main
Edge glyph (⟹ follows arrow) WITHDRAWN by owner after two live-rejected
variants — CanvasEdge.type plumbing + ArrowGeom retained, differentiation
approach still open (docs/vision-edge-glyph.md decision record).

Keepers: regions.enabled "Background colors" compute off-switch (skips
splat/blur/readback/blit), clusterLabelSize (default 14, topmost draw
layer, screen-px) with Cluster labels toggle + size slider in the Regions
section, dependency graying across the settings panels, physics panel
reorder (general forces first, Depth anchor + conditionals last).

Merged cleanly over master's coverage-chrome changes in graph-view.tsx
(their gated fragment + the ungated GraphCoverageControl coexist); data/
scratch not carried (untracked, master's real render-settings.json picked
up clusterLabelSize=14 via sanitizer fallback). Gates 488/488; live-
verified: chrome intact, Background colors graying works, physics order,
coverage panel opens (11 sessions).
2026-09-17 09:48:41 +03:00
amosblomqvist 926ae2f43d Physics panel: general forces first, depth anchor block last
Presentation-only reorder (user 2026-09-17): the Depth anchor select
and its three gray-out-able sliders (Depth strength, Depth spacing,
Base ring radius) move below Friction to the very bottom of the
Physics section, so the always-active general forces come first and
the conditional group reads as a self-contained sub-section at the
end. No schema, wiring, or behavior changes — verified identical DOM
order live, gray-out still follows the stored depth mode.
2026-09-17 09:43:40 +03:00
amosblomqvist 3c5c48f502 Settings panel: gray out dependent controls when their parent is off
Dependency graying everywhere, consistent across sections (user
2026-09-17 — chose "gray out children" over hiding rows, so the layout
stays stable and the required-by relationship stays visible):

- Background colors off → Dilation, Blur, Edge alpha, Bitmap
  resolution dim (opacity-40) + lock (native disabled). It's a COMPUTE
  switch, so the whole region tune section follows it.
- Cluster labels off → Cluster label size dims + locks. Decoupled from
  Background colors (labels follow their own toggle, by design
  2026-09-16): label controls stay live while regions are off.
- Depth anchor off → Depth strength + Depth spacing dim + lock.
- Base ring radius is radial-only: dims + locks unless Depth anchor is
  Radial (vertical doesn't use a ring).

SettingSlider gains an optional `disabled` prop (row opacity-40 +
range input disabled); the two select rows dim + disable inline. Pure
UI — no schema or renderer changes.
2026-09-17 09:41:43 +03:00
amosblomqvist 2f4b9c82e4 Cluster labels: topmost layer + user-facing size setting
- Draw order: the cluster-label block moves from layer 5 to THE LAST
  layer of draw() — labels can never be occluded by node labels or
  hover names (user 2026-09-17; chose "topmost" after reassessing that
  discs were never actually on top — the white-out over discs is
  source-over compositing of the 85%-alpha text over near-white node
  fill, an optical contrast flip, not a draw-order bug).
- clusterLabelSize: new persisted setting (default 14 = the old
  REGION_LABEL_SIZE constant, so rendering is pixel-identical). Screen
  px at ANY zoom (drawn at size/k — camera-compensated, UI-like, same
  law as before). Log-slider range [7, 28] puts the default dead-center
  per the house slider law. Sanitized: old persisted files (no field)
  fall back to 14.
- Panel: the Cluster labels checkbox moves from the Labels section
  into Regions (user: cluster settings live with regions), joined by
  the new "Cluster label size" slider. Labels stay decoupled from the
  Background colors compute switch, as designed on 2026-09-16.
2026-09-17 09:35:11 +03:00
amosblomqvist b24bce3f83 Abandon velocity-weighted region opacity (owner-rejected, buggy at scale)
Per-node speed is invisible inside a cluster blob (still neighbors'
skeleton strokes dominate the local area), and per-cluster max-speed
couples everything: dragging ONE node dimmed the whole cluster's blob
to nothing — including disconnected members at near-zero velocity.
Neither granularity is right, so the idea is withdrawn entirely.

Manual revert of the velocity parts (a git revert of 154f6c8 would
have resurrected the frame fade): weight ramp, position stamps, EMA
state, motion-pending flush, per-edge stroke batching, and the tests.
Region splat is back to full-opacity coverage at rest AND in motion
(2026-09-14 no-stacking invariant). The regions.enabled compute off
switch stays.
2026-09-16 11:35:28 +03:00
amosblomqvist 74c14931af Velocity-weighted region opacity replaces the (sub-perceptual) frame fade
The linear prev→cur bitmap blend between rebuilds was verified working
but made no visible difference — the 3-frame bitmap cadence was already
below perceptual threshold for large blurred blobs; the churn during
fast drags is the SHAPE, not the cadence. Rolled back entirely (no
extra per-frame blits).

In its place, per the owner: while nodes move fast, their blob fades
out and fades back in on settling. Speed per node = screen px per
frame from position stamps between rebuilds, EMA-smoothed. The blob is
a CLUSTER attribute (still neighbors' skeleton strokes pass right
through a moving node's area — per-node weighting measurably did
nothing), so each cluster's weight = f(max member speed): linear 1→0
between 1.5 and 8 px/frame. Strokes and discs paint at that alpha —
before the blur + final edgeAlpha multiply, so coverage semantics are
unchanged at rest (all weights 1 = the 2026-09-14 no-stacking fix).

Stamps reset on data-object change, NOT geoVersion (the live sim bumps
geoVersion every frame — resetting on it zeroed all speeds; caught
live). regionMotionPending keeps rebuilding after the sim stops until
weights converge back to 1, so a blob never freezes at partial opacity.
2026-09-16 11:28:39 +03:00
amosblomqvist 173059eb8f Region fade between rebuilds + compute off switch
The region bitmap rebuilds only every REGION_EVERY-th frame while the
live sim runs, so during drags the tints stepped at 1/3 the frame
rate. Between rebuilds draw() now linearly blends the previous final
bitmap into the current one ('lighter' + globalAlpha weights = a true
lerp of the premultiplied pixels — no source-over alpha stacking).
Because box blur and the end-alpha scale are linear operators, lerping
the finished bitmaps is equivalent to fading coverage before the blur,
the mathematically exact form of the user's preferred pipeline. Two
tiny GPU blits per frame; splat/blur/readback still only on rebuilds.

New RegionTuning.enabled is a compute switch (not a paint toggle):
off skips the entire build (splat, blur, readback, blit) — the settings
panel exposes it as "Background colors". Cluster labels stay decoupled
and follow clusterLabels. Old persisted files predate the field and
sanitize to enabled: true.
2026-09-16 10:54:05 +03:00
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