With a steer coalesced into a quiz-answer continuation, the steer sits AFTER
the assistant message being continued, so the SDK's onFinish list carries
the input copy AND the extended response copy as two entries with the SAME
id — persistTurn saved both, the jobs poller's syncFromServer imported the
dup, and React keyed a single message twice (the user's live test hit it).
withMessageIds now dedupes by id keeping the LAST occurrence — the SDK's
final merge state (a superset of the earlier copy) — which also yields the
right semantic shape: [user, steer, assistant(extended)].
Out-of-lane surgical touch (flagged for master review): turn-service.ts.
Squashed the live session's already-corrupted record by hand (same dedupe).
The bug (seen live): a teacher that ends every turn with a new pending quiz
keeps the wake pipeline's idle gates permanently closed — the steer sits in
'delivering result' forever while the learner answers check after check.
- claimSteers (server/jobs.ts): the chat-handler claims every queued,
undelivered steer the instant a client turn wins the turn registry, and
appends it after the learner's answer — conversion-safe, the answered
quiz carries its output. Every answer flushes pending steers immediately.
- The pump keeps its post-idle duplicate-guard (re-check steerDelivered
before firing), so a claim racing the pump converges to exactly one
delivery either way.
- The stale-job sweep now skips chats with a turn in flight (a healthy but
slow wake no longer gets falsely re-queued as 'recovering').
- Out-of-lane surgical touch (flagged for master review): the 3-line
handleChatRequest seam (claim after beginTurn, persist+stream 'outgoing').
- Verified live end-to-end on the stuck session: answering the pending quiz
fired the steer + wake turn within a second; job flipped done/delivered.
- plan_init is now the ONLY plan tool: a mermaid 'graph TD' block parsed by a
constrained-subset parser (mermaid-plan.ts) with line-precise errors; the
seven mutation/tracking tools are gone by design (2026-09-03 design pass).
- Re-call plan_init = sanctioned wholesale rewrite; slugs are permanent.
- derivePlanStatuses (contracts/plan.ts): done = passed check tagged with a
quiz nodeId; current = open check's node else first topo-ready node — the
tracking the teacher no longer declares by hand.
- questionToolsFor(ctx) (tools.ts): the quiz schema chains a dynamic plan-
nodeId validation against the LIVE record — an invalid nodeId is rejected
at the call boundary and the teacher retries mid-turn, before any UI.
Static questionTools stay validator-less for the default path.
- Prompts rewritten for mermaid authoring + derived position.
- Out-of-lane surgical touches (flagged for master review):
src/core/question-core.ts (quizInputObject export + nodeId description),
src/core/contracts/plan.ts (derivePlanStatuses + doc).
Key-convention policy, settled: every field accepts ⌘/Ctrl+Enter
(metaKey || ctrlKey — Mac gets ⌘↵, Windows/Linux Ctrl↵, the Gmail
convention); plain Enter stays matched to the field's species —
sends in message fields (chat composer, free-text answers — the
universal chat convention, Shift+Enter still a newline), newline in
the thinking field (composition, for dumping).
- The learner's own typed Other answer now renders INSIDE the options
container in the answered card, styled exactly like its selected
siblings (border-primary bg-accent) — it is an option, not a loose
quote floating outside the list. Quotes keep the learner's words.
- The thinking field submits with ⌘↵ on macOS, Ctrl↵ elsewhere
(metaKey || ctrlKey — the Gmail convention, same handler on every
platform). Plain Enter stays a newline: the field is for dumping.
A quiet "⌘/Ctrl + ↵ submits" hint sits next to Submit for
discoverability (text mode keeps Enter-to-submit, no hint).
The extractor's per-session understanding notes were free-form strings;
the model freely appended em-dash justifications ('shaky — the learner
could state the headline distinction but calls the machinery behind it
foggy'), which overflowed the detail card's session rows and squeezed
the session names.
The note becomes a CLOSED named-word scale — solid / shaky / accepted /
resisted / not yet engaged (DECIDED 2026-09-08, no numbers) — enforced
by the zod enum in propositionSchema: a wrong-shaped note is rejected
at parse time and the agent retries. Justifications are dropped, not
stored (user decision: not necessary). Extractor and auditor
instructions updated to demand exactly one word.
Renderer: the session row keeps the session NAME as the main thing
(flex-1, truncates) and the level chip is bounded (max-w-28,
whitespace-nowrap, truncate) — overflow impossible even for legacy
prose notes in old ledgers.
Vision doc: §1 extractor bullet + event-ledger line updated; state
model line notes the five-class derivation was removed; amendments
table row added.
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.
The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:
- A labeled composer-TWIN field ("Your thinking") between the options
and Submit, docked with the submit button inside it exactly like the
chat composer's send. The placeholder invites dumping ("Why this
answer? What are you unsure about? Say as much as you like — the
teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
that fills on selection + inline input, styled like its siblings — so
picking Other never displaces the thinking field; typed text survives
deselection.
- The answered card promotes the thinking to a quoted first-class
block above the verdict, labeled "Your thinking" — the transcript
reads as a dialogue of thinking, not a buried "Note:" footnote.
The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
Seam from the state-class strip: renderGraphContext tags nodes with raw
latest-quiz outcomes ([miss] / [dont-know] / [correct] / [never probed]);
50-graph-context.md still taught the deleted state classes ([missed] /
[known] / [candidate]). Re-aligned, guidance unchanged.
User-decided after seeing real extracted data (three-commit batch, all in the
graph lane): full-sentence canvas labels were illegible, so nodes gain a
short presentational name (glossary-headword; statement stays the identity
anchor; description dropped); the transitional NodeStateClass vocabulary is
deleted (raw latest-quiz outcomes feed only the teacher-side hotspot
grouping; the home renderer is state-neutral pending the state-model
ideation); per-session understanding observations surface in the detail card
and a background-pipeline registry + /api/graph/status show live extraction
runs on the graph.
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.
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).
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.
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).
The minimal implementation of the 2026-09-02 graph vision (docs/vision.md §1):
- contracts/graph.ts: every node is a TRUE proposition (statement is both
rendering label and identity key; required description; no kind, no name).
Edges are follows (derivable) / supports (grounds without entailing) with
message-granular provenance points {chatId, messageId}. Events reduce to
correct/miss/dont-know plus observation (understanding notes); no taught
events, no misconception kind.
- extraction.ts: extractor is TREE-BLIND (transcript only, no plan tree) and
free-form; auditor sees findings + the current graph, owns faithfulness and
identity (link-or-create verdict per proposition, recorded as linkTo);
transcriptView tags every line with its message id (provenance anchors).
- core/graph.ts merger: applies verdicts deterministically — linkTo appends
a link line (phrasings join aliases, provenance unions; reads merge by id),
absent creates; NO string matching anywhere. Quiz events rebuilt from tool
parts with attribution-only nodeId resolution; understanding notes become
observation events (obs:<sourceId>); mapping lines are the verdict audit
trail and make re-merges idempotent.
- deriveNodeStates: transitional — latest quiz event wins (known/missed/
dont-know, else candidate); taught unreachable until the state-model
ideation lands.
- graph-context.ts + 50-graph-context.md: follows chains, hotspot grouping,
state vocabulary updated for the new event kinds.
- graph-view.tsx: node label is the statement; detail shows statement +
description.
- tests: merger verdicts (link, no-string-match, cross-chat assertions),
tree-blind pipeline end-to-end, follows vocabulary in context tests.
276/276 green.
Records the 2026-09-02 graph philosophy decisions, replacing the
2026-08-22 live-emission construction model (second reversal — back to
post-hoc, now deliberately tree-blind):
- Node theory: every node is a TRUE proposition (no kind field);
statement (minimal, context-relative) + description (required) +
aliases (verbatim phrasings); contradictions coexist — the
surrounding subgraph is the context; misconceptions enter as true
corrections, wrongness lives in the event ledger.
- Edges: follows (derivable) | supports (grounds without entailing);
no scalars, no encoded strength — derived at read time.
- Provenance: message-granular (chat, message) points on nodes and
edges, deep-linkable.
- Construction: free-form extraction fully decoupled from the
session's formalism. Extractor sees ONLY the session transcript
(tree-blind, message ids); auditor sees extractor output + the
global graph (faithfulness net + identity judgment, link-or-create,
aliases as phrasings); merger stays deterministic append-only.
- Event ledger: quiz outcomes deterministic from tool parts; NO
taught events; extractor records per-proposition understanding
notes (named-word scale, no numbers) — treatment TBD.
- Understanding: inferred, never encoded; state model (session-
independent, computed from dependency depth/strength) is pending
ideation — current five-class derivation is transitional.
Also updates: §2 plan tree (session scaffolding only), graph-as-context
(follows-edge subtree, event-derived hotspots), home page (message
provenance, transitional states), roadmap item 5, open questions
#2/#3 resolved, amendments table (+6 rows).
Records the 2026-09-02 graph philosophy decisions, replacing the
2026-08-22 live-emission construction model (second reversal — back to
post-hoc, now deliberately tree-blind):
- Node theory: every node is a TRUE proposition (no kind field);
statement (minimal, context-relative) + description (required) +
aliases (verbatim phrasings); contradictions coexist — the
surrounding subgraph is the context; misconceptions enter as true
corrections, wrongness lives in the event ledger.
- Edges: follows (derivable) | supports (grounds without entailing);
no scalars, no encoded strength — derived at read time.
- Provenance: message-granular (chat, message) points on nodes and
edges, deep-linkable.
- Construction: free-form extraction fully decoupled from the
session's formalism. Extractor sees ONLY the session transcript
(tree-blind, message ids); auditor sees extractor output + the
global graph (faithfulness net + identity judgment, link-or-create,
aliases as phrasings); merger stays deterministic append-only.
- Event ledger: quiz outcomes deterministic from tool parts; NO
taught events; extractor records per-proposition understanding
notes (named-word scale, no numbers) — treatment TBD.
- Understanding: inferred, never encoded; state model (session-
independent, computed from dependency depth/strength) is pending
ideation — current five-class derivation is transitional.
Also updates: §2 plan tree (session scaffolding only), graph-as-context
(follows-edge subtree, event-derived hotspots), home page (message
provenance, transitional states), roadmap item 5, open questions
#2/#3 resolved, amendments table (+6 rows).
The Research-complete one-liner is now a native <details> disclosure:
collapsed it reads exactly as before (Research · complete · N sources);
one click expands the full persisted brief + source links via
ResearchBriefCard. No fetch, no state, no protocol change — the
data-subagent-artifact part in the record is the single source of truth,
which is what made this affordance 'feasible later' back in wave 1.
The brief still never clutters the transcript by default; the teacher's
wake turn remains the primary channel for weaving it into the lesson.
Test updated: the brief + sources now assert PRESENT inside the details
(and collapsed — no open attribute) instead of absent from the DOM.
Gates: typecheck, 279/279 tests, lint, build — all green.
The §5 baseline had per-step lines, starts, drops, kept ratios, and
merge stats — but no durations (a background pipeline's cost profile
was invisible) and no failure line: a mid-pipeline throw left a
hanging 'pipeline start' in the server log with nothing recording
what failed or when. Now the extractor and auditor each log their
elapsed ms on completion, the merged line carries the pipeline total,
and runExtraction logs a failure line (with elapsed) before
rethrowing. Log shape only — no behavior, contract, or control-flow
change.
chat.tsx now uses createChatTransport(chatId): a send racing the wake
pipeline waits for the in-flight turn, merges onto the fresh server
record, and re-POSTs instead of surfacing the 409 banner. Also fixes the
transport's import to the core barrel (shell import law).
The optional "server-ahead heal" line in the jobs poller was considered
and skipped: the racing turn IS the wake turn, whose completion flips the
job to done within a poll tick or two — the existing settle-triggered
syncFromServer already heals the post-merge gap within ~1-2s. The extra
record fetch on every poll tick would double poll traffic for a path the
settle sync already covers.
The top-right overlay doubles as the state legend and the filter
surface: a search box (matches name or statement, case-insensitive)
plus one row per node state in reading order (taught, known, missed,
don't know, candidate) with its color dot, label, and total count.
Clicking a row toggles that state's visibility.
Filtering flows through sigma's official nodeReducer/edgeReducer
settings: hidden nodes drop out of rendering, hover, picking, AND
labels; edges drop when either endpoint is hidden; an open detail card
closes if its node is filtered out. Pure predicate (isNodeVisible) is
exported and covered by 7 tests; the reducers just consume its
verdict. Verified live: search 'Nietzsche' 5457->1687->5457 label px
(exact restore), known toggle drops 13 discs+labels and restores,
nonsense query shows the 'No matching nodes.' hint with zero labels,
counts 0/13/0/0/10 match the ledger.
The 409 fix is a merge-retry, not a bare retry: a 409 means the server
record is AHEAD (wake turn persisted the steer), and the write-ahead
persist makes the request body canonical — a naive re-POST would clobber
steer+wake messages and steerDelivered makes the loss permanent. The
transport waits for the racing turn (500ms poll, 30s cap), refetches the
record, appends the client's unsent messages by id, and re-POSTs. The
integration seam (one line in chat.tsx) follows in the next commit.
- components/chat-transport.ts (NEW): createChatTransport(chatId) — the
chat.tsx seam is now a one-line swap. Its fetch turns a 409 on POST
/api/chat (a learner send racing the wake pipeline's ~1s adoption window)
into: wait out the in-flight turn (poll turnInFlight, 30s cap), refetch
the record, MERGE the outgoing messages onto the fresh server list, re-POST.
A bare retry would clobber the steer + wake messages via the write-ahead
persist (steerDelivered makes that loss permanent) — merging is the whole
point. Unit-tested: pass-through, wait+merge+reorder, dedupe by id,
repeated-409 loop, bounded give-up.
- src/core/pending.ts: findPendingJobCall deleted (dead since wave 1 —
job tools resolve server-side with an ack, nothing pends client-side).
JOB_TOOLS kept (runner.ts still uses it). Header doc rewritten.
- src/server/jobs.ts: GRACE_MS 1500 → 750, with the evidence in the
comment: the race window is only the learner's answer→POST flight
(sendAutomaticallyWhen dispatches synchronously; ~10ms localhost,
<300ms realistic RTT — 750 is 2.5× worst case) and misses self-heal
via the pump's gate recheck, the 409 backoff ramp, and the new
transport merge-retry. POLL_MS=400 kept (sampling granularity only).
- tests/jobs.test.ts: prompt-size guard — every fragment ≤8k chars,
composed prompt ≤60k — the README-leak class (815 bytes ≈ 200 tokens
per teacher turn before wave 1's fix) can't silently recur.
Gates: typecheck, 279/279 tests, lint, build — all green.
The fit zoom was capped at 1×, so any graph smaller than the panel
rendered at natural size, centered, with big empty margins above and
below — the common case for early-session chains in the 384px panel.
The fit now scales up to fill (FIT_ZOOM_MAX 1.5 — a lone node doesn't
become a billboard), floor-clamped as before. Math extracted into
fitZoomFor() and unit-tested (scale-up, cap, width/height-bound,
floor, unmeasured viewport).
Purely mechanical: src/core/contracts/{plan,graph,chat,jobs}.ts carry the
sections verbatim (graph = nodes + ledgers + extraction findings; jobs =
the async ack-and-steer protocol + tool results + demo bridge), and
contracts.ts becomes a re-exporting barrel so every existing import path
(@/src/core and ./contracts.ts) is unchanged.
Wave-2 lane ownership follows the split: the graph stream owns
contracts/graph.ts, the teacher stream owns contracts/jobs.ts — same-file
contract collisions between streams are structurally impossible now;
cross-domain schema changes go through master.
The right sidebar's mode (rail | list | graph) is now shared state: a
new useTreePanelMode() hook lets every sidebar section collapse and
expand together. With sub-agents running in rail mode the fleet shows a
quiet strip — bot icon (click expands the sidebar) + vertical 'N running'
count — mirroring the plan rail's node-count strip below it. The sidebar
now collapses all the way regardless of running jobs.
End-to-end verified through the real flow (dispatch part in the
transcript + running job record, the 1s jobs poll rendering the panel):
rail column exactly w-10 with both rails stacked and aligned (40px), the
fleet rail's expand click transitions to list mode and persists, fleet +
plan panels equal widths in list (256/256) and graph (384/384) modes,
9-node graph intact, console clean.
The sub-agents fleet panel was a w-64 fixed aside with no left edge —
aligned with the plan panel only by coincidence in list mode, floating
detached in rail (w-10) and graph (w-96) modes. It now stretches to the
sidebar column's current width and shares the panel's border-l, so the
sidebar is one continuous panel with SUB-AGENTS above PLAN (thin border-b
divider). Verified headlessly in list and graph mode: identical edges and
widths (256/256 and 384/384), perfect vertical stack (fleet 0..99.5, tree
99.5..800), no overlap, 9-node graph intact.
The rail-mode-while-jobs-run question (fleet cards would cram into w-10)
is logged as wave-2 UX design work for the UI stream.
The weave had nested the right sidebar inside the transcript column
(below the header), so the phase chip sat above the panel instead of left
of it and the panel was bounded by the scroller area. The column is now
a true sibling of the middle column under flex h-dvh, per the UI stream's
original design; TreePanel's aside gets flex-1 min-h-0 so it shares the
column with the fleet panel instead of overflowing it. The plan-graph
viewport (ResizeObserver-measured) now has real size, so the mermaid
view draws.
Headless-verified at 1280x800: panel spans 0..800 top-to-bottom in both
modes, chip right edge < panel left edge in both modes, graph SVG = 9
boxes + 8 beziers, console clean.
Node names word-wrapped as muted labels below nodes (global zoom gate),
constant-radius static glow in the node's own state color with
enter/leave crossfades, node-anchored detail card, and padded node
picking (+10px pickable radius via a picking-mode-only shader — the
maintainer-endorsed fix for sigma v3's half-resolution hitbox reads;
visible rendering pixel-identical).
chat.tsx woven by hand: UI's session-surface layout (transcript column +
right sidebar as full-height siblings, h-10 header with PhaseChip) wrapped
in sub-agents' JobsContext.Provider; the message map carries both the
steer ternary (steers bypass the Message wrapper — the model-facing glue
text never renders) and UI's PhaseDivider emission (steers are verified
phase-boundary no-ops). Right sidebar = sub-agents' fleet panel column
(SubAgentsPanel above TreePanel, xl-only) carried onto UI's skeleton.
contracts.ts auto-merged (ChatSummary.phase/pendingQuestion + ChatListItem,
additive above the async-protocol section).
Smoke-verified on the merged tree: steer renders bare as its artifact
badge with no learner bubble, no rendered glue text, and no spurious
phase divider at the steer boundary.
README mermaid + layout + protocol paragraph now describe the merged
protocol: server-executed job tools ack mid-turn, runners steer the
teacher later via the wake pipeline (server/jobs.ts), client adopts
wake turns via turnInFlight and renders steers as artifacts.
architecture.md turn-composition seam 3 rewritten; runner dependency
signature updated (deliver seam).
Server-executed job tools resolve mid-turn with an immediate ack; the
runner steers the teacher later via a server-side wake turn (per-chat
serialized queue on globalThis, turn-free + quiz-aware firing, 409
retry, idempotent per jobId, stale-job sweep). Live observability client:
central /api/jobs poll, dispatch cards, steer artifacts (research/visual/
demo renderers), sub-agents sidebar panel, wake-turn adoption via the
existing turnInFlight machinery. persistTurn fills empty message ids
(withMessageIds). prompt composition loads numeric-prefix fragments only
(README.md had been leaking into the system prompt).
composeInstructions loaded EVERY .md in src/agent/prompts — including
README.md, the directory's human documentation, which rode into the
system prompt as its own section (visible in OpenRouter logs, old POC
paths and all).
The loader now enforces the documented convention: only numeric-prefix
fragments (\d+-*.md) compose. README.md stays in the directory as docs
and can never reach the model again. README's rule text updated to
match; composeInstructions exported for tests; regression test asserts
the README never leaks and every numeric fragment composes verbatim.
Figures now always fill the card's width (h-auto keeps the aspect
ratio): narrower svgs scale up instead of leaving a white strip on the
right when they carry their own background color; oversized ones still
shrink to fit. bg-white stays as a neutral backing for svgs without
their own background.
Fixes the hover flicker the maintainers' way (jacomyal/sigma.js#1493):
sigma v3 picks nodes by reading a single pixel from a picking
framebuffer rendered at HALF the CSS resolution, and has no setting
for hit padding. Small nodes get tiny, ragged hit edges, and our glow
halo (9px beyond the disc) was NOT pickable — moving the cursor from
the disc onto its own halo de-hovered it, an oscillation loop.
NodeCircleProgram subclass (registered via nodeProgramClasses)
replaces only VERTEX_SHADER_SOURCE: a verbatim copy of sigma 3.0.3's
circle vertex shader that adds PICKING_PAD_PX (10) of radius under
#ifdef PICKING_MODE alone. The visible pass never takes that branch,
so rendering is pixel-identical (verified headless); the pickable
disc is disc + 10px, covering the whole hover halo, so the halo is
now hoverable and small candidate nodes are comfortable to hit.
sigma/rendering is imported dynamically (its ESM build touches
WebGL globals at module scope; SSR-safe like the sigma import).
This replaces the reverted prototype-patch approach (f12480e).
sigma v4 ships this officially as the nodePickingPadding setting —
noted for a future upgrade.