Commit Graph
67 Commits
Author SHA1 Message Date
amosblomqvist 7555da03b9 Top-row chrome: composer-matching 16px corners, equal 8px gutters
- Corner rounding back (owner, 2026-09-20): the four controls return to
  rounded-xl — 16px in this theme, identical to the composer shell's
  rounded-2xl (verified computed-style equal). Inner elements (search
  input, import button, cost hover) stay concentric: 16 - 4px padding
  = rounded-lg (12px), the composer's own concentric math.
- Equal spacing: the cluster pins 8px from BOTH edges (top-2 + right-2),
  still flush with the sidebar toggle's header band (top 8px).
2026-09-19 22:09:37 +03:00
amosblomqvist 3de911aa58 Top-row chrome: dropdowns layer above the cost number; slight (4px) corners
- Stacking: the actions row is now relative + z-20 — an open settings or
  coverage dropdown paints over the cost number below it (owner report:
  it layered ON TOP of the panel, still hoverable/clickable). The row is
  a stacking context inside the column, so its dropdowns cover the
  number and clicks land on the panel; the number resurfaces untouched
  when the panel closes (verified with elementFromPoint).
- Rectangular look (owner, 2026-09-20): the four top-row controls drop
  rounded-xl (16px theme) to rounded-sm (4px) — gear, coverage, search
  container + input, import container + button; the cost number's hover
  highlight joins the family. Dropdown panels keep their softer cards.
2026-09-19 22:06:19 +03:00
amosblomqvist 4e39f04cfa Header band: one row of controls, 36px, aligned with the sidebar toggle
Owner report: the controls folded onto two rows (search+coverage /
import+gear) — the 2026-09-17 flex-wrap kicking in, plus a real bug: my
previous wave moved max-w-[calc(100%-9rem)] onto the shrink-wrapped
column wrapper, where the percentage reference broke and the wrap fired
far too early. Owner wants the controls to read as header content.

- The width cap moves to the absolute column itself (100% resolves
  against the full-bleed parent, as before): max-w-[calc(100%-5.5rem)].
- One row, NEVER wraps (owner rejected the two-row fold): search is the
  flexible element (w-56 + min-w-0, shrinks under the cap); the other
  controls are shrink-0.
- All controls slimmed into the header band: top-2 (8px) matching the
  header's py-2, 36px tall matching the toggle's h-9 — size-9 buttons,
  h-7 import button, px-1 py-[3px] containers (border included = 36).
- Cost number + pipeline pills stay below the band, unchanged.
2026-09-19 22:02:19 +03:00
amosblomqvist 138d3d8ce6 Cost control: bare top-right number with dropdown; pipeline pills move under it
Owner-directed placement wave (2026-09-20): the bottom-left stack
overflowed the composer in narrow windows, and the pill chrome was
too prominent for a passive number.

- The cost surface is now JUST a number (cumulative dollars) top
  right, below the actions row — no pill container, hover-highlight
  kept, dropdown breakdown kept, viewport-capped + scrollable.
- Same dismiss pattern as the settings gear: outside click or Escape
  closes (shared usePopoverDismiss; two dropdowns never open at once).
- Running-pipeline pills moved under the cost number in the same
  top-right column — one right-aligned column grows downward from the
  top, so no window width can push chrome over the composer.
2026-09-19 21:55:02 +03:00
amosblomqvist 3e263a18cc Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel
- Cluster namer bills the money channel now: per-step usage + model
  captured via onStep, persisted as NamingRunUsage records to
  data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted
  runs persist too — money spent either way). callUsage promoted to
  extraction.ts module scope for reuse.
- core/cost-summary.ts: the pure fold behind the pill — chat runs,
  notes-source runs, and naming runs into stage totals (extraction /
  audit / naming) + newest-first recent runs. Dollars INFERRED read-time
  via prices.ts, never stored; unpriced models excluded-but-counted
  (render '—', never a silent zero); tokens always sum.
- GET /api/graph/costs (src/server graphCosts composition, thin route).
- Graph view: bottom-left cost pill (cumulative dollars headline) +
  expandable breakdown panel (stage rows with tokens secondary, recent
  runs with time/title/merged/cost); fetched on mount, refetched when a
  pipeline run finishes; merged into one bottom-left stack with the
  running-pipeline pills.
- Tests: pure-fold suite, naming capture wiring (full importNotes ->
  naming path), store idempotency + strict reads.
2026-09-19 20:55:35 +03:00
amosblomqvist 487f5d4cea wave 1 (platform stream): background-observability accuracy + owner-driven follow-ups
- observability: liveness-gated stale-job recovery, extraction run registry on globalThis with
  explicit 'embedding' phase, /api/jobs force-dynamic, client refresh nudges at every seam
- headless: providerSafePng canonicalizer (multi-IDAT Chrome PNGs → single-IDAT) fixing qwen 400s
- composer: Enter routes through pure enterAction — accepts a pending plan verdict (owner decision)
- markdown chrome: session-scoped mermaid overlay + md-blocks component set + pan-zoom math,
  corner-copy (clipboard grant in electron main), math/table/code block fixes, heading weight 900

543/543 tests; typecheck/lint/build clean
2026-09-19 13:16:34 +03:00
amosblomqvist 8b158972db feat: keyboard waves A/B — global shortcut registry, question row machine, Esc-cancel dictation
Wave A (owner key map): registry in src/core/keyboard.ts + KeyboardShortcuts
provider; Cmd+D dictation, Cmd+K model picker, Shift+Tab thinking cycle,
Cmd+B sidebar, Cmd+Shift+F sessions search, Alt+arrows session cycle,
Alt+Enter open, Cmd+G graph, Cmd+N new, F2 rename, ? cheat sheet. Tab
fully suppressed; Esc chain popover > dictation > abort (local stream).
Esc now CANCELS dictation (discard) — flush stays on Cmd+D/Stop. Owner
bug reports fixed: focus outline removed, Alt+Enter setState-in-render,
Cmd+F graph node search (surface-scoped lh:kbd-find).

Wave B: question dock row machine — arrows walk options + dont-know/Other
with wrap + ring highlight, Space picks (mirrors click semantics), Enter
submits when canSubmit; typing surfaces and in-dock keys keep native
behavior. aria-checked={isSel} restored on option buttons.

docs/keyboard.md records the full map.
2026-09-17 21:18:56 +03:00
amosblomqvist d1ba0c5c3c fix: graph settings panel viewport-capped like the coverage dropdown
The settings panel (16 sliders ≈ 900px) overflowed past the window
bottom at short heights and stretched the whole page scrollable. The
coverage dropdown's pattern is the standard for top-anchored popups
overflowing viewport bounds: max-h-[60vh] keeps a floor of free space
above the window bottom at any height, and the inner column gets
overflow-y-auto (which also zeroes the flex item's automatic
min-height so it actually shrinks) — the panel scrolls internally, the
page never scrolls.

Live-verified in the dev shell at emulated 640px and 1000px viewports:
panel caps at exactly 60vh (384px @ 640h, 192px bottom gap), inner
scroll reaches the physics section (412px travel), docScrollable false
at both heights.
2026-09-17 20:16:12 +03:00
amosblomqvist d46c3dae93 feat: narrow-window behavior — sidebar auto-close, chrome wrap, 640px floor
The sidebar no longer squeezes the page when the window narrows; the
page itself never goes below a comfortable floor (owner decisions,
2026-09-17: 640px min, widen-window on toggle, auto-restore):

- minWidth 640 / minHeight 600 on the BrowserWindow (macOS enforces)
- Below SIDEBAR_AUTOCLOSE (928 = 640 + 288 sidebar) the sidebar
  auto-closes, and auto-RESTORES when the window widens past it again —
  both fall out of one expression: rendered = pref && !narrow, where
  pref is the persisted user choice. Manual closes persist and stay
  closed; auto-close never touches the preference.
- Opening the sidebar while narrow asks the shell to widen the window
  to 928 first (new lh:expand-window IPC, preload.cjs contextBridge —
  the renderer's first and only Electron bridge): the toggle is never a
  dead button, and the layout never has to survive narrow + sidebar.
  In the plain browser there is no suppression and no widening — a
  narrow tab opens the sidebar if the user says so.
- Graph chrome row wraps: max-w-[calc(100%-9rem)] + flex-wrap keeps the
  row clear of the header toggle cluster and stacks it onto extra lines
  below ~640px instead of overflowing left.

State machine verified in the Electron dev shell across all six
transitions (auto-close, auto-restore, manual-close-persists,
reopen-widens, browser fallback, wrap at 640/500/360).
2026-09-17 18:57:02 +03:00
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 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 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 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 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 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 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 ab42ec81e6 Fix settings-load race: region/physics tuning never reached the renderer
The settings fetch resolves before the graph snapshot creates the
renderer, so the load-time run of the region/physics feed effects hit
a null rendererRef and never re-fired — every reload silently kept
the DEFAULT region/physics tuning (a stored blur 10 rendered as 6).
Feeds #4/#5 now carry isEmpty in their deps, the same guard Feed #2
(setSettings) already had. Sizes were unaffected only because their
stored values happened to equal the defaults.
2026-09-14 03:53:23 +03:00
amosblomqvist b42fdbec1e Region blur becomes a fixed-set dropdown (2/4/6/8/10, default 6)
Non-integer blur radii broke the rendering (the log slider mapped to
fractional values), so the control is now integer-only by design:
REGION_BLUR_OPTIONS with snap-to-nearest sanitization for any input,
same law as the bitmap resolution. The old off zone (blur 0) is gone;
stored 0s snap back into the set.
2026-09-14 03:03:04 +03:00
amosblomqvist 04a0236c36 User-facing persisted region + physics settings
All graph panel controls are now user-facing and server-persisted:
the old session-only dev number inputs are gone. GraphRenderSettings
gains nested regions/physics sections (backward compatible: old flat
files load with defaults), and RegionTuning/PhysicsTuning moved from
the renderer into core/render-settings.ts — one shape, one sanitizer,
one source for the store, the panel, and the renderer.

Physics/region clamps are sweep-validated safe ranges (each field at
both ends + all four corners settles cleanly on the real graph: no
NaN, no explosion — linkStrength caps at 4 since 8 explodes and 5
wobbles; friction floors at 0.1 since 0 never damps). Panel: log
sliders (default = geometric mean = position 50, step 5), off-zone
sliders for blur/cluster pull (position 0 = exactly 0), a fixed-set
dropdown for bitmap resolution, depth-anchor modes promoted to the
select.
2026-09-13 17:06:18 +03:00
amosblomqvist 0af63113f5 Revert "Region boundaries: iso-line contours of the blurred region field"
This reverts commit c7c1148989.
2026-09-13 16:23:21 +03:00
amosblomqvist c7c1148989 Region boundaries: iso-line contours of the blurred region field
Marching squares over each cluster's own contribution to the shared
blurred bitmap, recovered by projecting premultiplied pixels onto the
cluster's paint color (blends split proportionally between overlapping
halos — the natural divider). Boundary level = fraction of the
cluster's peak field, user-adjustable in the settings panel; 0 = off.

One paint, one blur, one readback; contouring is cheap typed-array
math over small per-cluster crops (stride-2 sampling: measured 1.2ms
for all 26 clusters, vs 7.3ms naive). During the live sim boundaries
recompute every 2nd region rebuild and stale paths ride along — drag
cadence matches the pre-boundary baseline (avg 8.46ms vs 8.37ms).
2026-09-13 16:00:28 +03:00
amosblomqvist f5edc7f0a5 Semantic topic clusters: local embeddings replace Louvain
Louvain only ever found the reasoning chains (the graph is disconnected
by nature — every connected component became its own cluster) and
edge-orphans could never join anything. Clusters are now RELATED TOPICS:
propositions grouped by meaning.

- Embedder (src/server/embeddings.ts): in-process transformers.js v4
  running EmbeddingGemma-300M q8 (MTEB 68.4, ~300MB one-time download),
  MRL-truncated to 512d, "title: none | text:" prefix on 100% of inputs,
  L2-normalized so cosine = dot. Model/dim mismatch voids the ledger
  (full re-embed). Injected at the composition root — the agent layer
  never imports the server layer (architecture law).
- Ledger (data/graph/embeddings.json): model + dim + vectors + the
  persisted cluster partition. Reclustering runs at the END of every
  embedding batch (post-merge hook, both front doors) — never per read
  (the agglomerative pass is ~1.5s at 338 nodes).
- Clustering (src/core/semantic-clusters.ts): hand-rolled average-linkage
  agglomerative over cosine (npm HDBSCAN is an abandoned alpha). Stragglers
  stay singletons by default; attachStragglers is the all-in toggle.
  Threshold 0.60 swept on the real graph: 26 topics, 74/90 edge-orphans
  land in real topics, 10 stragglers.
- Consumers (snapshot, settle, namer, replay) read the persisted
  partition — one cluster concept everywhere; core-survival label
  matching unchanged (20/26 topics kept labels).
- Louvain retired: detectCommunities + graphology* deps deleted
  (graphology-louvain had leaked into the client bundle since the
  clusters wave); scripts/bench-graph.ts (dead FA2 bench) removed.
- scripts/embed-graph.ts: backfill/cold-start (338 nodes in 150s
  including the one-time model download).
2026-09-13 14:29:16 +03:00
amosblomqvist 3a1c18b9de Inference-depth anchoring playground: vertical + radial modes
The graph's follows/supports edges encode derivation (premise ->
conclusion); the layout can now express that rank spatially, as a
session-only playground toggle (nothing promoted yet):

- inferenceDepths: longest chain from a base node, CYCLE-SAFE by
  SCC condensation (Tarjan, iterative) — the DAG property is typical,
  not guaranteed; cycle members share their component's depth (mutual
  support = equal footing). O(V+E), pure, deterministic.
- DepthAnchor force: per-node target scalar — vertical mode rows
  (baseline at the anchor centroid, arrows up), radial mode rings
  (foundations center, outer boundary = edge of knowledge). The rank
  is computed, not simulated: pairwise up-forces can't give baselines.
- Mode projections: gravity x-only in vertical / dropped in radial;
  cluster pull x-only in vertical / tangential-only in radial; the
  anchor re-blend preserves user arrangement in the FREE dimension
  only (x / angle) — meaning owns the constrained axis, memory owns
  the free one.
- setPhysicsTuning reheats (alpha 1): toggling a mode re-forms the
  graph live and commits the new rest state; drag release is springy
  along the constrained axis (node returns toward its level/ring).
- Sweep on the real graph: strength 0.8 = 215/216 arrows correct with
  organic sag; 1.5 = crisp rows. 174 base nodes, max chain depth 7,
  zero cycles in current data. replay-layout --depth=<mode> bakes a
  mode into the ledger for rest-state experiments.
2026-09-13 10:20:50 +03:00
amosblomqvist 7ebdf065c8 Physics controls + FA2 fallback retirement
User-facing live-sim controls (settings panel, Physics section):
node repulsion, edge attraction, edge length, central pull, cluster
pull, friction — session-only number inputs feeding createGraphSimulation
(the SAME engine the server settle runs, so the feel IS the physics).
Changing a value rebuilds the sim from current positions; drag pins
(fx/fy live on the node objects) survive; a mid-settle change re-anneals.

The graph-view FA2 bootstrap fallback is rewritten on incrementalLayout
(d3-force): the last graphology import in the shell is gone.
2026-09-13 09:52:14 +03:00
amosblomqvist 60686d7831 Region v2: line-only dilation, real premultiplied blur, dev tuning inputs
User feedback round 1 on the splat regions:
- Double counting: per-node gradient discs stacked with the edge-stroke
  dilation wherever nodes sat on edges (hub hot spots). A cluster is its
  CONNECTED skeleton — the region is now the dilated intra-cluster edge
  network ONLY (round caps make line-end points discs), per the user's
  definition. No per-node discs at all.
- The 'blur' was no blur: bilinear upscale of a 256px bitmap produced
  tent-ramp chunkiness at zoom (the low-quality-JPEG look). Now a REAL
  blur: 3 box-blur rounds on the premultiplied bitmap (separable running
  sum, pure + unit-tested; premultiply keeps halos out of transparent
  space). The upscale now has nothing left to make chunky.
- Live dev controls: number inputs (not sliders) in the settings panel —
  dilation (screen px at fit), blur (bitmap px), bitmap resolution,
  edge alpha. Session-only React state, never persisted; the renderer's
  sanitizeRegionTuning makes bad/empty values safe. Verified end-to-end
  through the real input (dilation 28 → 60 took effect).
- Premultiply un-recovery bug caught live: c = pm/a*255, not pm/a (the
  first version rounded every color to black; regions rendered as
  dark blobs with alpha 185).
- Region canvas: willReadFrequently (Chrome keeps it on the CPU for the
  per-rebuild getImageData); defaults dilation 55 → 28 after measuring
  screen coverage (63% → 55%).
- Perf: 120fps sustained through a drag with blur + rebuild-every-3rd-
  frame (45 frames / 360ms measured), zero idle frames after settle.
2026-09-13 09:39:22 +03:00
amosblomqvist 05712037b2 Live physics drag + splat cluster regions
The renderer grows a live d3 sim (createGraphSimulation, shared with
the server settle): pointerdown on a node pins fx/fy and pointer capture
(preventDefault kills the compat mousedown, so d3-zoom never pans); the
first CLICK_SLOP-exceeding move reheats to alphaTarget 0.3; release
un-pins and the sim anneals (release-and-relax) — alpha < 0.005 settles
it, a 250ms debounced onCommit PUTs the rest state. Verified live: 123fps
during a 320ms drag (regions rebuilding every 3rd frame), 85-tick settle,
then zero idle frames. A slop-free press stays a click (select, no reheat,
no commit). PUT /api/graph/positions sanitizes (finite coords, known ids)
and if the graph grew mid-drag runs ONE incremental settle with the
client's arrangement as anchors (50-id PUT test: anchored drift 0.15).

Cluster regions swap convex hulls for a low-res world-space SPLAT bitmap:
per-member radial-gradient discs (alpha 0.2) + intra-cluster edges as
round-cap strokes (Minkowski dilation — concave shapes finally render),
upscaled with bilinear smoothing (the upscale IS the blur, no ctx.filter:
Safari). One tiny blit per frame replaces 32 clusters x 4 offset
Path2D polygons re-rasterized at DPR 2 (the old GPU coil-whine path);
during the live sim the bitmap rebuilds every 3rd frame. cluster-geometry.ts
retires convexHull/offsetPolygon/traceRoundedPath (tests follow; clusterHue
stays). Region labels stay (centroid + clusterHue, inverse-gated on zoom).
2026-09-13 09:17:05 +03:00
amosblomqvist 0c14ea425d Option A: incremental anchored force layout, persisted server-side
The layout now EVOLVES instead of reshuffling on every extraction. A pure
core module (src/core/layout.ts) warm-starts forceAtlas2 from the positions
ledger (data/graph/positions.json, read-modify-write like sources.json,
updatedAt stamp for future live-physics conflict handling), re-blends every
previously-positioned node toward its anchor each iteration (retention 0.85
- old placements stay recognizable, strong forces still win), seeds new
nodes near their positioned neighbors, and seeds orphans on the rim outside
the core so they read as unconnected knowledge.

- Port/store: GraphStore.readPositions/writePositions; snapshot carries
  positions when the ledger exists (optional - fresh graphs unchanged).
- Extraction pipeline settles synchronously after every merge (chat and
  notes paths), before usage persist - the next /api/graph fetch already
  reflects new nodes.
- View: the ledger IS the layout; the local one-shot FA2 pass survives
  only as a fallback when no ledger exists.
- scripts/replay-layout.ts: simulates organic growth by replaying the
  current 338-node graph one imported file at a time (real import order,
  edges activate when both endpoints exist). Real run: 338 positions in
  0.3s, 90-degree-0 orphans collecting at mean r=338 vs connected r=194.
- Also restores the cluster region fills (hull background colors) that
  6692b19 had removed on a misreading of "no glow".

Live-verified: fit zoom derives from the ledger bbox, pickAt aligns with
ledger positions at every probe, hover/click/detail-card work, 408/408
tests, typecheck/lint/build green.

Future live physics stays cheap by design: the same pure function is the
merge-time settler and the future client simulator; a pinned node is just
an anchor with full retention.
2026-09-13 08:35:42 +03:00
amosblomqvist 11bca055dd Label gap, proportional glow, hover-out fade, centered coarse sliders
- LABEL_GAP 6 -> 2 (user: labels sit way too far below their nodes).
- Hover glow is now PROPORTIONAL to the disc — outer radius = node radius
  x 1.7 (GLOW_SCALE), matching the blur-a-copy-of-the-disc mental model,
  instead of the old constant +6px pad that read huge on small nodes and
  thin on big ones. GLOW_ALPHA 0.45 -> 0.3.
- Hover-OUT fade was broken by construction: draw() gated the glow on
  hoverId, which nulls instantly on leave, so the 180ms fade-out ran
  invisibly and the glow popped off in one frame. New hoverNode field
  outlives hoverId through the outgoing fade (cleared when the fade
  completes); verified live: 8 distinct frames over ~200ms then stable.
- Sliders rescaled per spec: ALL five are now log-scaled 0-100 with the
  default dead-center at 50 (ranges chosen as default/4..default*4:
  node/arrow/label size 0.5-8, edge width 0.25-4, labelZoom 0.0625-4),
  coarse step 5 (21 stops; labels-until-zoom was "way too granular" at
  201). Generic toSliderPosition/fromSliderPosition replace the
  labelZoom-only pair; sanitize clamps follow the same range constants;
  size sliders display compact values ("2", "1.6", "0.75").
EOF
2026-09-13 07:49:00 +03:00
amosblomqvist 6692b19a53 User-tuned defaults, label-size floor 1, cluster glow removed
- Defaults per user: nodeSize 2, edgeWidth 1, arrowLength 2, labelSize 2
  (labelZoom 0.5 stays — reads as the "1x" they confirmed good).
- Label size slider floor 3 -> 1 so the default 2 is not the smallest
  value; sanitize clamp widened to match.
- Cluster hull glow (layered low-alpha fills) removed for now: it read as
  jagged up close (straight-line hull approximation at high zoom) and
  does not belong until the layout settles. The solid viewer-relative
  border and the inverse-gated region labels stay. HULL_FILL_LAYERS
  constant deleted; hullShapes cache carries only the stroke path now.
- Saved render-settings.json reset so the new defaults apply on next load.
EOF
2026-09-13 07:40:58 +03:00
amosblomqvist c096854e87 Re-tune render sizes for the world-space model: smaller defaults, wide low ends
The defaults were calibrated in the screen-fixed era; world-scaling renders
sizes at setting x k/kFit, so at the default label gate (2x fit) everything
read ~2x too big ("labels way too large even at the minimum"). Fit-zoom
normalizes layout scale away, so the fix belongs in sizes, not layout:
spreading nodes further apart in world units changes nothing at fit.

- Defaults: nodeSize 6 -> 4, arrowLength 5 -> 4, labelSize 12 -> 7.
- Clamps/slider ranges widened at the low end: nodeSize 1-14 (step 0.5),
  edgeWidth 0.25-4 (step 0.25), arrowLength 1-15 (step 0.5),
  labelSize 3-24 (step 0.5).
- GLOW_PAD 9 -> 6 (halo dominated small discs), REGION_LABEL_SIZE 18 -> 14.
- Tests updated for the new clamp bounds plus a low-end pass-through case
  (403/403). The saved render-settings.json still shadows defaults by
  design; the local copy was reset so the new defaults showed immediately.
2026-09-13 07:26:22 +03:00
amosblomqvist ecadededd9 World-space rendering: geometry cached once, zoom pays only rasterization
Fixes the zoom frame-rate collapse and rebuilds draw() around a single
camera transform, per the user's Obsidian-style sizing proposal.

Bug fix first: the eased zoom leaked a queued rAF callback per frame --
advanceZoom applies each step through d3-zoom, whose zoom event called
markDirty() from inside the tick loop, scheduling an extra draw while the
loop scheduled its own. Draws multiplied per displayed frame; sustained
wheel ran at ~20fps against a 120Hz display. markDirty now skips
scheduling while inTick (verified: 62 frames/500ms during a 28x zoom
burst, matching pan).

World-space model: every draw call now runs in graph coordinates under
one setTransform(k). Sizes are defined as screen-px-at-fit and converted
once per geometry build (/ kFit), so nodes, node labels, arrowheads and
the hover halo scale with the graph as you zoom (0.25x painted area at
half zoom, exactly k^2) while edge widths and hull borders stay
viewer-relative hairlines (/ k). Cluster hull geometry (convex hull +
three offset fills + solid boundary -- dashes dropped for now) is built
as cached Path2Ds per data/visibility/settings change; camera moves do
zero geometry math, making zoom per-frame cost identical to pan. Label
wrapping runs once per build, not per frame. Region labels stay
viewer-relative (18px at any zoom) as UI chrome. Picking radius scales
with the world-sized discs.

Glide feel: ZOOM_EASE_MS 120 -> 45, convergence 0.1% -> 0.2%; a notch now
settles in ~260ms instead of ~750ms. Resize still paints synchronously.

Also fixes barrel-law lint violations from the settings wave (import
from @/src/core and @/src/server, not deep module paths).
2026-09-13 07:18:37 +03:00
amosblomqvist 8e2d2118a6 Graph view refinements: server-persisted settings, label size, eased zoom, resize-blank fix
Five refinements to the hand-rolled renderer, all user-directed:

1. Settings are SERVER-persisted (data/render-settings.json via new
   GET/PUT /api/graph/render-settings) - one source of truth for every
   browser, replacing localStorage. The client loads once after mount,
   saves debounced on user changes, and never lets a slow GET clobber a
   faster user (or mounting PUT defaults over the server copy). The
   shape/defaults/sanitizer moved to src/core/render-settings.ts - pure,
   shared by client, server store, and tests; every disk/network value
   passes through sanitizeRenderSettings.

2. New "Label size" control (default 12px - the previous hardcoded size).
   Font, wrap width, and line height all scale; verified live: +76k px
   with the label gate open.

3. "Labels until zoom" slider reworked: log-scaled over 0.0625-4 so the
   useful band (0.25-0.5 camera-ratio units) sits at 33%/50% of the
   track instead of crammed at the far left of the old linear 0.25-4
   slider. Display units are normalized: the default 0.5 reads as "1x",
   0.25 as "0.5x". The underlying labelZoom semantics are unchanged,
   so existing values survive.

4. Smooth wheel zoom: d3-zoom instant per-notch jumps (x1.2, bang) are
   replaced by an eased glide - wheel events accumulate a cursor-anchored
   target scale and each tick eases the camera toward it (exponential,
   120ms constant, frame-rate independent). Pan/pinch stay d3-owned; a
   pan cancels a glide in progress. Verified live: 8-frame glide.

5. Resize blanking fixed (graph vanished for the duration of a window
   drag, reappearing ~300ms after). Cause: every resize event
   reallocated the canvas backing store - which CLEARS it - and the
   redraw was deferred to rAF, which live resizes starve via relayout,
   so the canvas sat wiped for the whole drag. Fix: the ResizeObserver
   callback now redraws SYNCHRONOUSLY after reallocating (verified via
   a probe RO firing in the same delivery batch: canvas fully painted).
   kFit (the label gate 1x reference) is also recomputed on resize from
   the now-cached world bbox - the camera itself still persists.

tests/render-settings.test.ts: 10 tests pinning the sanitizer (garbage/
partial/clamps, legacy no-labelSize records) and the slider mapping
(default dead-center at 500, monotonic, exact round-trips, display
units). 402/402 green; typecheck, lint, build pass.
2026-09-13 05:56:27 +03:00
amosblomqvist 4e48d13fac Renderer swap: drop sigma.js for a hand-rolled single-canvas 2D renderer
Implements Option B from docs/research/graph-rendering.md. sigma.js v3
(WebGL) is replaced by one canvas, one rAF loop with a dirty flag, and
d3-zoom for the camera — eliminating the entire bug class from the
sigma-era bolt-on architecture:

- Hull/node desync during pan (two independent render loops) is now
  structurally impossible: hull fills, dashed hull strokes, edges with
  arrowheads, node discs, region labels, node labels, and hover glow
  are all drawn in ONE frame under ONE camera.
- The settings-change canvas-sizing race (everything shifting up-left)
  is gone: settings changes only redraw; the canvas and camera persist
  (verified live: camera identical to 15 decimals across a slider drag).
- The GPU floor is gone: idle schedules NO frames at all (verified via
  pixel-identity over 600ms idle); pan redraws exactly once per frame.

components/graph-canvas.ts: GraphCanvasRenderer class plus pure helpers
(zoomRatioOf, labelAlpha, fitTransform, arrowGeometry, pickNearest).
The sigma camera-ratio convention (1 = fit, smaller = zoomed in) is kept
verbatim so persisted labelZoom settings survive the swap; node labels
and cluster region labels cross-fade over the same LABELS_FADE_SPAN
(0.5, unchanged). Picking is a nearest-node distance check with the
10px pad that replaces the old padded-picking vertex shader. Hull math
stays in components/cluster-geometry.ts, reused as-is. No ctx.filter,
ever.

graph-view.tsx keeps every feature: fetch/refetch on runs-empty, FA2
run-then-freeze layout, localStorage render settings + sanitizers,
search filtering with edge drops and card-close, detail card with
flipped anchoring and per-source provenance, settings panel, import
button, pipeline status chips. Only the renderer plumbing changed.

tests/graph-canvas.test.ts pins the pure math: ratio direction, label
gate + inverse complement (alphas sum to 1), fit transform padding,
arrowhead tip-on-disc-edge and symmetry, picking pad/ties/null cases.

Live-verified end-to-end on the real 338-node graph (dev server
port 3002): single canvas, paint at fit, hover cursor+glow+fade, click
card open/anchored/close, stage click, search filter to zero with hint,
label gate opens via slider and zoom, cluster-labels toggle, zoom-to-
cursor 0.56px anchor drift, camera persistence across settings, zero
idle repaints. sigma dependency removed from package.json; d3-zoom and
d3-selection added.

Note: the settings feed effect needed isEmpty in its deps — without it
the renderer mounted with settings=null and every draw early-returned
(blank canvas); caught live, not by typecheck.
2026-09-13 05:19:15 +03:00
amosblomqvist 9df8044d61 Cluster hulls: fix inverted offset normal, kill per-frame blur, labels toggle
Three problems from the first cut (user report 2026-09-13), all rooted
and fixed:

1. Boundaries reshaping on zoom — an inward offset normal. In y-down
   screen coordinates, monotone chain yields clockwise-on-screen hulls,
   whose OUTWARD normal is (dy, -dx); the code used (-dy, dx), so the
   14px padding pulled every hull vertex 14-20px INWARD. Zoomed in this
   mostly passed; zoomed out, once a cluster projected smaller than ~2x
   the pad, vertices crossed the centroid and the polygon self-
   intersected — spiky garbage re-forming every frame. Geometry moved to
   components/cluster-geometry.ts (exported, unit-tested) with the
   corrected normal; tests pin the direction (offset moves every vertex
   AWAY from the centroid; stays sane when the hull is smaller than the
   pad; hull orientation asserted clockwise-on-screen).

2. GPU ~100% + audible coil whine on any interaction — the hull redraw
   was hooked to sigma's afterRender, which fires for EVERY render
   including ~a dozen hover-animation frames per hover, and each redraw
   rasterized ~32 fills through ctx.filter blur(12px), a GPU-heavy
   full-canvas filter. Now: redraws happen ONLY on camera updates and
   resizes (hover leaves the hull canvas untouched — verified live),
   and the blur is replaced with layered low-alpha fills (soft edge at
   a fraction of the cost, no canvas filter at all).

3. Cluster labels cluttering the view — a persisted 'Cluster labels'
   checkbox in the rendering settings (hulls stay, text toggles).

377/377 tests, typecheck, lint, build green. Live-verified: hover does
not repaint hulls, zoom does; far zoom-out yields 3 contiguous tinted
regions (no fragmentation); toggle clears/returns labels and persists.
2026-09-13 04:23:32 +03:00
amosblomqvist 71a17c5e0d Clusters phase D: hulls, blurred fills, and inverse-gated region labels
Two custom canvas layers around sigma's own stack (DOM order: hulls
behind everything; region labels above nodes, below node labels).
Per cluster (vision section 1): convex hull (monotone chain) of member
viewport positions, offset outward 14px, rounded corners, a blurred
low-opacity fill (ctx.filter, graceful degrade to flat fill) plus a
dashed boundary stroke, and the LLM label at the centroid. Stable hue
per cluster id (content hash) — a region keeps its color across
reshuffles because the id only changes when the members do.

Geometry is a pure function of positions at draw time: every
afterRender re-derives hulls through the current camera, so pan/zoom/
relayout can never invalidate it (the boundary decision pays off: no
stored geometry, nothing to recompute after a future physics change).

The inverse gate: the cluster label layer's opacity is the exact
complement of the node-label layer's over the fade span of THE zoom
control (labelZoom, default 0.5x) — the two label systems never
coexist.

Live-verified in the browser: hulls painting behind edges; region
labels above nodes; zoom-in crossfades cluster 1 -> 0 as node labels
0 -> 1 (0.176 + 0.824 mid-fade); zoom-out restores; hull pixel count
changes with zoom (geometry follows the camera); zero console errors;
layers removed on renderer teardown. 372/372 tests, typecheck, lint,
build green.

Also named the real graph: one 11.5s labeler call minted all 32
region labels for the 338-node notes graph (Relational Databases & SQL,
Differential Forms, Sales as Uncertainty Reduction, ...).
2026-09-13 04:07:01 +03:00
amosblomqvist b4245a209d Graph view settings: default label threshold 0.5x
The most suitable default is the stricter end — labels only once
zoomed in past 2x fit (user-decided 2026-09-13). The slider window
extends below it (0.25x) so the default is no longer the floor.
2026-09-13 03:42:15 +03:00
amosblomqvist 5144706fb9 Graph view settings: THE zoom-level control (label threshold)
The label-visibility zoom threshold (was the LABELS_CAMERA_RATIO=1
constant) becomes the fourth persisted setting: labels appear when the
camera ratio is at/below it (1x = fit zoom; slider 0.5x-4x, shown with
an x suffix). Deliberately THE single zoom knob per the cluster design
(vision section 1): cluster labels will fade in over the same span as
node labels fade out, gated inversely on this same setting.

Live-verified: at a zoomed-out camera (labels previously hidden),
raising the threshold to 4x rebuilds and re-shows labels without any
camera movement; resetting restores the fade. 355/355 tests,
typecheck, lint clean.
2026-09-13 03:15:09 +03:00
amosblomqvist d6b229865a Graph view: rendering settings panel (node size, edge width, arrowhead)
Three sliders behind a gear in the search panel (user-decided
2026-09-13), persisted per browser in localStorage with clamped
sanitizing on load. Default node size dropped 8 → 6 for dense graphs.

Mechanics: the mount effect is split — the data effect builds the
graphology graph + one frozen forceAtlas2 pass per snapshot; the render
effect mounts sigma and re-runs on settings change only. Sizes are
stamped onto the same graph instance (layout never recomputed), and the
arrowhead program is constructed per rebuild with ratios solved from
the settings (head px = thickness × lengthToThicknessRatio × 2; wideness
keeps the stock 0.8 shape). The camera position carries across rebuilds
via a ref, so a slider tweak never re-fits or re-lays-out the view;
useDeferredValue smooths slider drags.

Live-verified in the browser: sliders persist to localStorage, the
renderer rebuilds with zero console errors, hover/picking survives the
rebuild, and a zoomed-out camera stays zoomed out across a settings
change (labels-gate opacity as the observable). 355/355 tests,
typecheck, lint, build green.
2026-09-13 02:54:36 +03:00
amosblomqvist 543cf33789 Notes front door: format-agnostic import into the graph pipeline
The same extractor→auditor→merger pipeline now runs over raw personal
notes in arbitrary formats (vision §1, DECIDED 2026-09-13):

- NotesSource records (data/graph/sources.json, deterministic id from
  path hash → idempotent re-imports) play the chatId role in mappings,
  checkpoints, and observation events; usage lands on the source record
  (appendSourceUsage, idempotent per startedAt, kept across upserts).
- mergeFindings is chat-store-free: callers pass MergeSource
  {messages, foldedAt} — sessions pass the chat record, notes pass
  messages:null (no quiz-event rebuild; a deterministic rebuild needs
  structured quiz tool parts).
- Second extractor prompt (NOTES_EXTRACTOR_INSTRUCTIONS) for general-
  purpose extraction; understanding judges the AUTHOR's grasp. Auditor
  wording input-agnostic. extract/audit share one generic runSubmitAgent.
- graphSnapshot.fileSources (srcId→filePath); notes sources render as
  static path entries in the detail card (FileTextIcon, never a link).
- One shared pipeline instance (exported from chat-handler): same
  single-flight registry, live status chips (label = file path), usage
  accounting, and true abort for both front doors.
- Transport: POST /api/graph/import (multipart, 3-wide worker pool) +
  Import notes… button in the graph view; scripts/import-notes.ts for
  batch runs (concurrency 4).
- real-data-extraction/ gitignored (raw import sources never committed).

355/355 tests; typecheck, lint, build green.
2026-09-13 02:08:55 +03:00
amosblomqvist 5f0a1ca30b Labels: disable sigma's label-grid declutter — the zoom gate is the only culling
Some labels (clustered nodes: Flux, The signifier rules the subject,
The body as window onto the thing-in-itself) appeared later in zoom than
others, without fade. Root cause: sigma's label grid — viewport cells
of labelGridCellSize (100px default) compete, only ceil(labelDensity /
ratio^2) winners per cell draw (one at fit with default density 1) —
so clustered nodes' labels were culled until zoom changed the quota,
then popped in with the global fade already complete.

labelDensity: MAX_SAFE_INTEGER turns the quota off. The camera-ratio
gate (LABELS_CAMERA_RATIO + LABELS_FADE_SPAN) is now the ONLY label
culling: every label appears at the same zoom with the same fade —
the wave-1 'all labels at once' intent, finally true.

Verified pixel-level at fit view: all 31 node labels render, including
the three previously-missing ones; no console errors.
2026-09-09 02:06:26 +03:00
amosblomqvist 0c12d53fca Background pipeline status: registry + /api/graph/status + graph-view chips
The background extraction pipeline is invisible on the home surface —
essential observability per the user's directive: the graph view must
show what is currently running (extracting / auditing / merging) and
for which chat.

- extraction.ts: module-level registry of in-flight runs (chatId,
  phase, startedAt), updated at each phase transition in runExtraction
  and cleared in finally (done or failed). extractionStatuses() is the
  read; one process, one registry.
- GET /api/graph/status: thin transport over the registry, chat titles
  joined from the chat store.
- graph-view: polls every 2s and shows one chip per running pipeline
  (spinner + phase + title + elapsed seconds, bottom-left); when a run
  finishes (some -> none) the graph refetches, so merges land live.
  Status is cosmetic — poll errors never surface.
- Unit test drives a mocked pipeline run and observes the registry
  mid-flight: 'extracting' during the extractor's generate, 'auditing'
  during the auditor's, empty after.
2026-09-09 01:26:49 +03:00
amosblomqvist 0fccbbe2ad Detail card: per-session understanding notes next to the session list
The extractor's per-proposition understanding observations (named-word
scale: solid/shaky/accepted/...) were ledger data since the redesign,
with no surface. This exposes them at read time and shows each
session's note as a small chip on its session row in the node detail
card — the per-session confidence history the user asked for, one
observation per (node, chat), latest wins within a chat.

- GraphSnapshot.nodeObservations: nodeId -> chatId -> note, built in
  graphSnapshot() from observation events (pure read-time join).
- Sessions list rows: link + observation chip.
- Session-relative judgments stay session-scoped: they render next to
  their session, never as node state (the state model stays pending).
2026-09-09 01:25:14 +03:00
amosblomqvist 54f88f37e1 Strip the transitional node-state classes (2026-09-08)
The five-class NodeStateClass (taught/known/missed/dont-know/candidate)
was transitional scaffelage from the 2026-09-02 redesign, deriving state
by latest-quiz-wins — exactly what the philosophy rejects: understanding
is inferred at read time from the real state model (still pending
ideation), never encoded as quiz-derived classes.

- contracts: NodeStateClass deleted; GraphSnapshot no longer carries
  nodeStates (objective graph + sessions only).
- core: deriveNodeStates replaced by latestQuizOutcomes — the raw
  event fact (nodeId -> latest quiz kind), consumed ONLY by the
  teacher's graph-as-context selection, never by the home renderer.
- graph-context: hotspot selection re-keyed to outcomes — groups are
  now 'Hotspots (last answer wrong or don-t-know) / Answered correctly /
  Never probed'. Same diagnostic selection, no state taxonomy.
- graph-view: nodes render uniformly (accent color, size 8); the panel
  is search-only; the detail card's state badge is gone. Quiz outcomes
  remain ledger data for the future state model.

Verified live (headless): uniform discs (single color/size, zero state
pixels), search-only panel, card = name + statement + sessions, no
console errors.
2026-09-09 01:24:08 +03:00
amosblomqvist bdd9cac2b7 Node schema pivot: short name (label) + full-precision statement; description dropped
With real extracted data, full-sentence statements as canvas labels proved
unworkable: 10-line label blocks, illegible overprint clusters, bottom-edge
clipping. Per the user's 2026-09-08 decision:

- GraphNode/ExtractedProposition gain `name` — a very short, glanceable
  label (glossary-headword style: "Will to power"), presentational only:
  not unique, not identity, never precise. Set at creation; link-appends
  never change it (first line defines; labels stay stable across sessions).
- `statement` keeps its full precision and becomes the identity anchor;
  the detail card shows it under the name. `description` is dropped.
- Extractor coins the name (freshest session context); auditor carries it
  through on create, rewording only clearly unglanceable names; linked
  propositions keep the existing node's name.
- Renderer: canvas label = name; search matches name + statement;
  detail card = name (bold) + statement (muted).
- Store guard requires name (pre-pivot node lines are skipped as legacy);
  vision.md node-schema bullet + amendments table updated (2026-09-08).
2026-09-09 00:40:02 +03:00