Commit Graph
444 Commits
Author SHA1 Message Date
amosblomqvist ae749fe27d Chat top fade round 1 (owner): long soft wash instead of a hard 20px bar
The header fade was h-5 (20px) from full background opacity — it
read as a solid bar with a quick blur on the edge. Now a 96px wash:
65% under the title, holding softly through ~25% at the midpoint,
melting to transparent — supports the title, never reads as a bar.
2026-09-14 09:48:33 +03:00
amosblomqvist 0f6e36f85f feat: the sessionless draft view — "+ New session" creates nothing
The sidebar button no longer POSTs a record (no more "Untitled session"
litter): it opens /chat/new, a sessionless draft view visually identical
to a fresh chat (same header + probe chip, the same ConversationEmptyState,
the same composer blob + stats dock). The session comes to exist only
when the learner sends the first message — and that message becomes its
title (startDraftSession: POST /api/chats {title: msg} → PUT the lifted
pre-chat settings → redirect /chat/<id>?prompt=<msg>, whose initialPrompt
path delivers the message as turn 1 — the home composer's proven contract).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- naturalSize: the popup's diagram renders at the viewBox width in px,
  so fit/pan/zoom math holds.
- The popup's fit box is now the PADDED ink box (GRAPH_PAD all around):
  centered strokes and the glow halo never clip at the frame edge.
- fitZoomFor lost its 0.15 artificial floor — for wide graphs it sat
  ABOVE the true fit and broke 'fully zoomed out = the whole graph
  visible, whichever axis binds' (owner's rule). The fit itself is the
  floor; a 0.02 epsilon only guards degenerate viewports.
2026-09-14 06:26:55 +03:00
amosblomqvist fdeacc42f5 Plan-graph refinement round 1 (owner): chat-font zoom cap + stroke-safe padding
In-chat full plan block:
- TEXT_CAP_SCALE (16.5/12.5): node text renders at text-xs in svg units;
  the block now carries maxWidth = extent × cap + mx-auto, so it still
  fills the column whenever the column is narrower than the cap, but
  never scales node text past the chat prose size (--lh-prose-size).
- GRAPH_PAD (8 svg units) around the full-extent viewBox: centered
  strokes (half outside the geometry) and the −4 glow halo were clipping
  at the frame edges — the padded origin (-8,-8) keeps all ink visible.
  Only the full-extent render pads; the plan window's override viewport
  and the popup's fit margins are untouched.
Test pins updated: mx-auto + max-width:232.32px + viewBox='-8 -8 176 152'.
2026-09-14 06:22:26 +03:00
amosblomqvist 251f2518a6 Merge feat/ui-polish: wave 5 — the cosmetic reskin (owner skin, fonts, dock/prose split, pre-chat model selection) 2026-09-14 05:21:41 +03:00
amosblomqvist c0a27916a7 Merge feat/graph-rendering: wave 5 — semantic clusters, regions renderer, persisted render tuning 2026-09-14 05:20:45 +03:00
amosblomqvist 0d67fd2073 Region alpha applies once after the blur: no more overlap stacking
Per-stroke alpha baked into every stroke/disc accumulated through
source-over compositing: one shape 0.35, an orphan disc over anything
1-0.65^2 ~ 0.58, hubs toward 1.0 — a lone node's glow read as a
darker double-counted spot on top of other regions. The pipeline is
now dilate at full opacity -> blur -> scale the whole field by
edgeAlpha once at the end. Painted at 1, the alpha channel is a pure
coverage indicator (overlaps of any count contribute exactly 1.0),
the blur smooths it into a coverage falloff, and the final scaling
gives a constant interior alpha everywhere covered, varying only
across the blur edges. The blur-off path also runs the readback now
(radius 0 = copy) so the end-alpha applies uniformly without blur.

Interaction change (user-flagged, accepted for now): different
clusters' overlapping halos no longer blend hues proportionally —
the later-painted cluster covers the earlier, ownership boundaries
softened only by the blur reach. Verified: old build spread 36k of
55k tinted pixels above alpha 100 (stacking); new build concentrates
at 70-90 (0.35*255=89 interior with falloff), and an orphan disc now
reads no brighter than the skeleton it sits on.
2026-09-14 04:56:15 +03:00
amosblomqvist 2a80707b85 Screen-anchored region blur + cell floor: fixes tight-search blur collapse and the Infinity throw
The stroke width is anchored to kFit (screen px at fit zoom — the
camera persists across searches), but the blur radius was anchored to
cell, which scales with the FILTERED world span: a tight search shrank
cell, and the blur's screen reach (blur*cell*kFit) collapsed with it
while the stroke stayed put — the blur visibly went away for
close-together matches and the knob did nothing (verified: blur 6 and
blur 10 produced identical output under a tight search). The blur now
uses the stroke's anchoring: its world reach is blur*cellRef, cellRef
being the full-graph cell from worldBBox (the same bbox kFit comes
from) — span-independent, and with nothing filtered cell == cellRef
so the unfiltered look is unchanged.

The cell also gains a floor: a single matching node (span 0) zeroed
the cell and blew the bitmap dims to Infinity, throwing the reported
getImageData TypeError. The floor is the padded rect's own reach, so
dims stay finite however tight the search (and the old bitmap
ballooning — ~1040px bitmap for a 3-node search — is gone too).
2026-09-14 03:53:34 +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 f3a3d69401 Region tints follow the search filter: visible-only edge strokes
buildRegions filtered everything by visibility except the
intra-cluster edge strokes: with a search active, edges between
filtered-out nodes were still stroked into the bitmap — which had
already shrunk to the visible members — and segments crossing that
rect cut diagonal hue streaks from other clusters across the
filtered regions. Strokes now require both endpoints visible, same
law as the world rect, member discs, and label anchors. The
no-double-counting rule survives: a member touched only by a
partially-invisible edge gets its disc.
2026-09-14 03:23:20 +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 652a25e382 Orphan discs in regions + orphan exemption from depth anchoring
Two follow-ups on the semantic-cluster wave, both user-reported:

1. Region coloring: edge-only dilation (the anti-double-counting rule
   from the Louvain era) left 114 of 338 cluster members completely
   uncolored — semantic clusters contain members that share no
   intra-cluster edge with anyone (90 edge-orphans + isolated
   micro-topics), and 20 of 45 clusters painted nothing at all. Fix:
   a disc (radius = dilation, same as a round cap) for exactly the
   members no intra-cluster edge stroke reached. Every member is
   painted by its edges' caps OR one disc, never both — the
   no-double-counting rule survives. Verified live: all 74
   orphans-in-clusters sit on tinted region pixels.

2. Depth modes (ORPHAN RULE): a node with no edges has no dependency
   structure, so depth-0 stamping put 90 unconnected propositions on
   the vertical baseline / radial base ring — meaning they don't have.
   Orphans are now exempt from the depth force and get the regular
   view's unprojected forces instead (full center gravity + full
   cluster pull). Structured-node projections unchanged (x-only
   gravity in vertical, none in radial; tangential-only cluster pull
   in radial). Gravity in depth modes is a custom force with EXACTLY
   d3's forceX/forceY math (strength × alpha — the first draft
   forgot the alpha and shifted the settle). Verified live: radial
   orphans follow their topics instead of the base ring; vertical
   orphans spread 1006 world units (6/90 near the baseline band vs
   90/90 before).
2026-09-13 15:05:05 +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 975a78a304 Cluster gravity: per-cluster centroid pull force in the engine
The 'virtual node' realized as a centroid force: two allocation-free
O(n) passes per tick (membership resolved once at sim creation;
Float64Array sums). Measured indistinguishable from force-off at 338
nodes (271ms vs 272ms per 400-tick settle).

- createGraphSimulation: SimOptions.clusterPull + linkStrength +
  velocityDecay (physics controls plumbing); degree-scaled link
  stiffness so linkStrength=1 is exactly d3's default
- settleGraphLayout split into layout-settle.ts (it computes the
  Louvain partition for the pull); layout.ts stays browser-barrel-pure
- barrel no longer re-exports clusters.ts: graphology + louvain are
  fully out of the client bundle (verified against built chunks)
- replay passes the per-batch partition; CLUSTER_PULL_STRENGTH=0.15
  tuned by sweep: cluster spread 63->36 world units, centroid
  separation and total span unchanged (no blob merge, no collapse)
2026-09-13 09:52:12 +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 7eceb58303 Engine unification: d3-force replaces forceAtlas2 everywhere
One engine owns the equilibrium: the merge settle and (next commit)
the live drag both run createGraphSimulation — d3-force link springs,
1/d charge, weak centroid gravity — so a drag's rest state IS the
server's rest state. The FA2-era anchor mechanism survives as a
post-tick POSITION-SPACE re-blend (retention 0.85): d3's forceLink is
a Hooke spring (strength*(d-L)*alpha), which overpowers any force-space
anchor at long range — the re-blend is alpha-independent and always
wins as the sim anneals (the 675-unit drift bug). Gravity retuned for
d3's 1/d many-body falloff (bootstrap 0.2 / incremental 0.12: cluster
spacing lands ~400 world units, the FA2-era density). Rim orphans seed
(and anchor) at 1.7x the CONNECTED core's mean radius — a max-span-based
rim fed back on itself and ran the layout away to 6000 units. Replay:
338 positions in 1.6s, old-node shift within a batch 4.8 (FA2: 23).
2026-09-13 09:17:01 +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