- roundedRect typo: the first arcTo's second control point used y+width
as the x-coordinate (y + width instead of x + width). Since arcTo
tangent math is in absolute coordinates, the pill outline sprouted a
stray wedge/notch whose size scaled with the pill's on-screen
position (diagnosed live by the tester subagent: ~1000px left spike
on a top-right node, right-side bites near the left edge).
- Labels now fade instead of popping: fully visible at fit zoom or
closer, then the labels canvas layer's opacity is interpolated across
a 0.5 camera-ratio zoom span before renderLabels switches off, with
the setSetting call guarded so pans don't spam refreshes.
labelRenderedSizeThreshold gated each node's label by its own rendered
size, so big (taught) nodes showed names several zoom stops before
small (candidate) ones. Zero the per-node threshold and instead toggle
renderLabels from the camera ratio: every name appears at fit zoom or
closer and they all vanish together when zooming further out. Hover
still reveals any node's name regardless of zoom.
- Custom sigma label drawers replace the defaults: every label renders
the node name word-wrapped under the node (max 170px line, overlong
words hard-broken, never truncated) instead of one long strip to the
right of the node.
- Hover swaps sigma's hardcoded white box for a dark rounded pill using
the app's popover tokens; hover always shows the name, even below the
zoom threshold.
- labelRenderedSizeThreshold 12 -> 6 (names are short now) and labels
use the app's body font at weight 500.
- Click card anchors just below the clicked node's viewport position
(flipping above near the bottom edge, clamped horizontally, measured
in a layout effect so the flip never flashes); any pan/zoom dismisses
it via the camera 'updated' event.
Schema: GraphNode + ExtractedProposition carry 'name' — a short 2-6 word
header, the rendering handle. Node theory made explicit: a node is an
objective world-facing fact; the learner's relationship to it lives
entirely in the event ledger (state labels are derived, never stored).
Extractor + auditor instructions enforce it — misconceptions state the
wrong idea itself as a claim, never 'the learner believes Y'. Legacy
ledger lines without a name derive one from the statement on read.
graph-view: labels use the name; the detail panel shows name as header
and statement as body.
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):
components/** -> "@/src/core" (pure core API, browser-safe)
app/** -> "@/src/core" + "@/src/server" (server entries)
Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.
Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
(the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
/api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
transport layer; GET /api/graph's fold side-effect now lives behind
graphView() instead of inline in the route
Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.
(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)