The round-1 experiment put the braille spinner in the chip's old dot
slot; rejected live: the pill's curve and the square braille cell fought
for space (visually squeezed), and the chat surface already shows
in-flight work (thinking shimmer, the dock) — the chip stating the phase
is its whole job. PhaseChip back to a label-only pill (busy prop
removed, single-child gap dropped); work-state lives in exactly one
place: the sidebar row's spinner. Doc note + test pin the decision so
it doesn't get re-tried blind. Gates: typecheck, lint, 469/469, build.
Nothing exists purely for decoration: phase color-coding is retired
app-wide (phases only advance — every session ends Teaching — so
phase-as-signal was decoration with a one-time function), and what
state genuinely deserved — 'a turn is in flight' — is now carried by
MOTION alone, in the terminal's own glyph language.
- BrailleSpinner (components/braille-spinner.tsx): the canonical
cli-spinner sequence (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) as exact dot bitmaps in a 2×4
braille cell, 80ms/frame (800ms cycle). Pure CSS: one shared keyframes,
frames staggered via animation-delay; no JS timer, no re-render per
frame — cheaper than the old breathing dot. Static contexts (SSR,
prefers-reduced-motion) show frame 0 — a legible glyph, not a blank.
- Sidebar rows: spinner ONLY while a teach turn is in flight; idle and
question-pending render NOTHING (a resting state has no scan value —
the meta line's 'Waiting for your answer' text carries it). The
three-state phase dot (pulse/solid/hollow) and PHASE_FILL/RING maps
are gone.
- Top bar PhaseChip: label-only pill; the old dot slot becomes the
spinner's home while a turn is in flight (busy prop wired from
ChatView's busy = localStream || turnInFlight). Transcript
PhaseDividers drop phase colors for monochrome rules + labels.
- API narrowing (app/api/chats/route.ts + ChatListItem contract):
busy = isTurnRunning ONLY — background jobs no longer count (the
sidebar's Background agents section is their channel; a per-row
spinner would double-signal them).
- globals.css: --phase-* tokens + lh-dot-breathe removed (dead after
the sweep — no remaining users).
Live-verified: idle/pending rows render no indicator, /chat/new chip is
label-only, 10 CSSAnimations at 800ms with correct 80ms stagger, static
frame 0 under reduced motion. Gates: typecheck, lint, 470/470, build.
Lane crossings to flag at merge: app/api/chats/route.ts + the
ChatListItem contract comment (src/core) — the busy-semantics narrowing;
components/chat.tsx one-line busy prop.
The owner's three-part test of the context-injection flow surfaced
timing/visibility bugs, fixed as standing ground rules for ALL background
processes (owner rules, 2026-09-15):
1. THE JOB STARTS AT SEND. triggerGraphContext moves from onFinish to
right after the write-ahead persist in handleChatRequest: its only
input is the first typed message (the topic), just made durable — so
embedding + cosine scan run CONCURRENT with the prober's turn and
the dock blob appears the moment the session starts, not after turn
one finishes. Extraction stays at onFinish (it needs answers
accumulated DURING the turn).
2. DELIVERED CONTENT SHOWS MID-TURN. Steers inserted server-side
(claimed at turn start, persisted write-ahead) used to wait for the
next turn's completion to appear — the full record sync mid-stream
is forbidden (it breaks the SDK's in-place continuation merge), so
adoption was deferred to idle. New append-only seam: core
mergeAdopted() inserts the record's new message ids in canonical
order (in-flight streaming tail kept last) and never rewrites a
local message; any other shape bails to the full-sync-at-idle net.
Wired at three points, no new standing polls:
- turn-start window: three bounded record fetches (400ms/1s/2s)
after a send catch the claimed steer while the turn streams;
self-terminating, survives the submitted->streaming flip.
- jobs-poll settle: mid-turn settles adopt append-only NOW, the
owed full sync still runs at idle.
- wake-adoption poll: each tick adopts persisted wake content, so
steers show at the wake turn's START, not its finish.
The dock stays honest with zero gap: jobs hold "running" while
queued behind a pending question ("delivering context"), then flip
done at claim/delivery — visible from start to delivery, and the
turn-start nudge (useActivity().refresh) clears it within ~150ms.
3. THE MARKER IS CLICKABLE (observability). The knowledge-context
SystemLine becomes GraphContextMarker: quiet line, popover with the
delivered propositions IN DELIVERY ORDER (most similar first — the
order the budget filled), each with its folded understanding level;
Esc / outside pointerdown close. The part data was already the audit
trail — now it's readable at the seam where it landed.
Tests: mergeAdopted unit pins (6), marker render pins (3) — 468/468,
tsc, eslint, build all green; live-smoked end-to-end: blob at send,
marker at t+500ms mid-turn, popover order + levels, instant dock clear.
The continuous 0-4096 dial was precision theater (a 128-token budget is
~2 propositions; nobody wants that granularity). Decided stops (owner,
2026-09-15): 0 / 64 / 256 / 1024 / 4096 / 16384 — the native range walks
positions 0-5 and maps through GRAPH_CONTEXT_TOKENS_STOPS in contracts.
Max rises to 16384 (route range follows); the server still accepts any
integer in range so a stale client cannot brick settings; non-stop
legacy values snap to the nearest stop for the thumb, readout stays
honest.
The original vision, restored (owner, 2026-09-15): pre-chat the session-
data side of the under-composer stats bar is free real estate ("—" only),
so the budget slider mounts exactly where session data will sit once a
session exists — via a new StatsBar leftSlot — vertically centered with
the model + thinking pills by the row's own geometry. Short 96px slider
instead of a full-width one; readout abbreviates "1k tok" so >1k budgets
stay on one line ("4.1k tokens" wrapped). Home and /chat/new both.
One shot per chat, fired at the first turn's onFinish: the session's
topic (its only typed message) is embedded as an EmbeddingGemma retrieval
query; the semantically closest graph propositions are selected under the
session's token budget (pure budget, no similarity threshold — the
decided doctrine) and delivered as a marker-only steer through the wake
pipeline, queuing behind pending questions like every sub-agent result.
- contracts: ChatSettings.graphContextTokens (default 1024, max 4096,
step 128, 0 = off); GraphContextData + graphContextText; the
graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>)
- core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill
under budget, most-recent UnderstandingLevel fold, count cap 64,
parse-don't-cast at the marker seam
- server: embedQuery (role-asymmetric query format, same warm singleton
— vector-space parity by construction); the runner (startGraphContext-
Injection one-shot gates; runGraphContextJob validates ledger parity);
wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs
re-deliver or contextRespawn); abortJob kills context jobs SILENTLY
(nothing was promised in the lesson); turn-service trigger seam
- adapter: data-graph-context flattens to the canonical block (malformed
parts have no model face)
- UI: the budget slider (home dock + /chat/new draft, the session-data
strip); the quiet SystemLine "~537 · 10 propositions"; dock kind
"Knowledge context"; model-picker PUT now spreads settings (a model
pick can no longer clear the sibling override)
Live-smoked end to end on the real embedder + real GLM: seeded ledger
(10 propositions), session on "modal logic: possible worlds and the
necessity box" — job in the dock, queued behind the prober's question,
delivered on the answer, SystemLine in the transcript, wake turn reads
the block and continues the probe. Gates: tsc, lint, 459/459, build.
The old activity dock (fixed bottom-right, every view) was a cross-session
widget parked inside the session view — the same boundary confusion that
got the plan window moved. The sidebar is the app's cross-session surface,
so the agents section lives at its bottom strip now (user-locked design):
- ALWAYS present: empty means empty — the header ('Background agents',
matching the Sessions label language) sits alone; nothing hides and
nothing decorates (the total-cost span only renders when populated).
- Populates and grows when processes run: same blobs (sub-agents +
extraction runs, one poll, inferred cost, elapsed heartbeat), capped by
max-h-[40dvh] + internal scroll so a pile never crowds the session list.
- Per-blob END button (unchanged abort contract); the detail popover
becomes an INLINE expansion — the section scrolls, a floating popover
would clip at its bounds.
- Sidebar-native skin: border-t separator matching the sidebar's edge,
session-row hover language, no floating-card chrome.
- Sidebar closed = the learner opted out; no ambient signal for now
(user decision — invisibility until a clearer observability story).
- Narrow-mode count badge/drawer machinery retired with the dock.
app/layout.tsx: ActivityDock mount removed, ActivityProvider stays (the
sidebar consumes the context). Live-verified: populated rows with inline
detail + session link, abort makes the row vanish, idle renders the
header alone, closed sidebar hides the section with nothing floating.
Gates: typecheck, lint, 423/423 tests.
Three fixes from the owner's resize report:
- Continuous shrink: the marginalia now mounts whenever a question owns
the band and its box width is pure CSS, so it shrinks continuously as
the display narrows. Below MARGIN_MIN_W (220, the owner's floor) it
FADES over the normal 300ms transition instead of hard-unmounting;
widening past the floor fades it back in. No mid-question unmount, no
pop, no invisible limbo.
- Live flank tracking: a ResizeObserver on the dock block replaces the
render-time measurement — the fade floor reacts instantly at any
width (window resize, sidebar toggle, scrollbar changes). The old
measurement went stale during drag-resizes (nothing re-rendered on
macOS until the question rewrapped), so the mount decision popped
late and left a sub-floor sliver visible.
- Phantom-fold mute: resizing rewraps the question text, the browser
re-clamps scrollTop and emits the clamp as a scroll event — the fold
machine read the transient position and flickered the box to the bar
and back. Scroll events within 400ms of a WIDTH change are now
ignored (width-only: stamping the dock's own height morphs
self-muted the machine and swallowed the at-bottom auto re-expand,
leaving the box stuck collapsed — caught in live verification).
Scroll-up/scroll-down fold behavior itself unchanged (verified live:
mute-window scroll ignored, post-mute scroll folds, bottom re-expand
works). Gates: typecheck, lint, 423/423 tests.
Retires the fixed bottom-left plan window (which sat ON the sidebar and
bled into the session). Two placements replace it, per the owner's
lock-in:
- The plan chip in the top bar beside the phase chip — session-level
structure beside session state; opens the full-DAG popup; present at
every width; the access point.
- The plan marginalia: the BARE diagram (no card, no border, no shadow)
in the dock band's right flank — marginalia on the lesson's page, not a
floating widget. The band's reserved height (tall while a quiz is
open) is its canvas; meet-fitted (PlanDiagram's new fill mode) so it
fits both the flank's width and the band's height; live statuses +
the yellow current-node glow; click opens the popup. Mounted only
while a question owns the band — composer mode keeps the reading
view pristine. Folding the dock fades + blurs it out of existence
(300ms), returning to the bottom fades it back in. Rendered only when
the flank holds a real diagram (>= 240px); narrower windows keep the
chip alone.
Files: chat.tsx (chip, DockOverlay marginalia wrapper + flank measure,
PlanWindow removed), plan-window.tsx (PlanWindow → PlanMarginalia),
plan-block.tsx (PlanDiagram fill prop: h-full w-full meet-fit),
tests/plan-window.test.tsx (rewritten for the marginalia).
The rhythm agreed with the owner: the stats/controls row hugs the
composer (8px — reads as the card's metadata, one object), and the
whole cluster floats with deliberate air above the screen bottom
(20px). The 8px straight-edge gap optically matches the 6px pill-to-
pill gap: two rounded pills' corridor reads wider than geometric, so
rounded-rounded gaps run ~2px tighter than straight-edge gaps.
- stats-bar.tsx: pt-4 -> pt-2 (8px under the composer) — shared by
chat and home automatically
- chat.tsx dock block + home.tsx composer wrapper: pb-4 -> pb-5
The dock's reserve auto-measures the block, so the spacer/fold
machinery adapts on its own.
The asymmetry: the stats bar's outside-close ref wrapped the WHOLE
bar, which CONTAINS the model control — so clicking model/thinking
while stats was open counted as 'inside' and nothing closed, while
the reverse direction (model's ref wraps only its own buttons) closed
fine. The ref now anchors to the summary wrapper (button + popover),
matching the model picker's pattern. Both directions close now.
Also centers the stats row vertically in the dock: pt-1.5 -> pt-4,
equal to the block's pb-4 — the row sits midway between the
composer's bottom and the page bottom. (Font size already matched:
both sides compute to 11px JetBrains Mono; the owner chose to keep
the color difference — muted stats vs bright labels.)
Verified live: model->stats, stats->model, stats->thinking all close
the open dropdown and open the new one; row has 16px above (visible
text) and 16px below.
Round 1 centered the popover over the whole bar ROW — the owner meant
the stats bar's own bottom-left content (the context · cost summary
button). The popover now anchors to a relative wrapper around the
button and centers over IT (left-1/2 -translate-x-1/2 of the wrapper),
so it hangs balanced over the trigger, not offset toward the composer
row's center. Outside-click + Esc closing unchanged from round 1.
Same treatment the model/thinking controls got (df4433f): the
per-turn stats popover now closes on any pointerdown outside the
bar and on Escape. Also centered over the stats bar
(left-1/2 -translate-x-1/2) instead of left-aligning with the quiz
box, which looked odd.
Verified live: popover centered over the bar (centers within 2px);
Escape closes; click outside closes; click inside stays open.
border-b border-border — the same 1px border-border line that
separates the left sidebar (border-r on its container), so the top
bar now reads as its own block with the app's standard separator.
Rounds 1–3 chased a soft fade under the title; the owner called the
final overlay look wrong too. Back to the simple picture: the top bar
is its own block — in flow above the scroller, plain page background,
no gradient, no border, no tone change. It reads as a block because
the transcript starts below it. (Also reverts round 3's overlay
header + pt-14 entirely.)
Root cause of the 'solid bar' look: the scroller began BELOW the
header, so the fade zone sat over plain page background — any opacity
there read as a solid bar, no matter the stops. The owner's picture:
content goes under the title bar, easing beneath it.
- The header is now an overlay (absolute top, z-20) over the
scroller, which fills the column to the very top — same floating-
chrome pattern as the home graph view.
- The wash lives INSIDE the header, behind the title content:
60% background at the very top, ramping down to transparent over
48px — never solid, supports the title, content melts beneath it.
- The transcript gets pt-14 (header 40px + its original 16px), so
scrolled-to-top content clears the floating title.
- Header is pointer-events-none except the sidebar toggle, so the
canvas area under the wash stays draggable/selectable.
Verified live: scroller top = 0 under the 40px overlay header; wash
48px ramping from 60%; first message at exactly 56px on scroll-to-top;
the old below-header edge fade is gone.
Round 1 (h-24, 65% hold via 25% mid) read as a faint tail plus an
abruptly solid band and stretched far into the chat. What the owner
wanted: peak ~60% right under the title, ramping down to transparent
a little below — no full opacity anywhere, short reach. Now h-10
from-background/60 to-transparent: a single gentle ramp.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
- 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.
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.
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.
- 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.
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.
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'.
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.
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).
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.
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.
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.