Commit Graph
14 Commits
Author SHA1 Message Date
amosblomqvist b07c907ffd Graph redesign: true propositions, follows/supports, auditor-owned identity (vision §1 minimal implementation)
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.
2026-09-08 22:19:37 +03:00
amosblomqvist 8f5276040c Padded node picking: +10px pickable radius via a picking-mode-only shader
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.
2026-09-02 03:10:34 +03:00
amosblomqvist f1ce776008 Revert "Fix hover flicker: padded smooth hit-testing + leave-debounce"
This reverts commit f12480e040.
2026-09-02 02:54:51 +03:00
amosblomqvist f12480e040 Fix hover flicker: padded smooth hit-testing + leave-debounce
Root cause: sigma's getNodeAtPosition reads the picking framebuffer,
which is rendered at HALF the device resolution
(pickingDownSizingRatio = 2 * pixelRatio). Small nodes' hit circles
are a couple of device pixels across in that buffer with ragged,
quantized edges — near a node boundary, tiny cursor movements flip
the hover state on/off, and each flip makes sigma clear the hover
layer immediately. That state churn is the flicker; it survived every
animation change because it was never in the animation.

Mitigations:
- patchNodePicking() replaces Sigma.prototype.getNodeAtPosition with
  a smooth distance test (rendered radius + HIT_PAD_PX = 6px) built on
  public renderer APIs only (getGraph, getNodeDisplayData,
  framedGraphToViewport, scaleSize). No ragged edges, and small
  candidate nodes get roughly double the hoverable area.
- Leave-debounce (90ms): when hover drops, hold repainting the
  captured hover paint at the current fade (sigma would otherwise
  clear the layer the instant hoveredNode flips to null), and only
  fade out if the cursor hasn't bounced back within the window. A
  re-enter cancels the fade, so boundary oscillation never visibly
  dims anything.
2026-09-02 02:43:13 +03:00
amosblomqvist 2d9c649f0b Hover text: cross-fade on the same factor as the glow
The instant muted->bright color swap felt abrupt next to the fading
halo. The hover name is again drawn at partial alpha over the muted
resting label, using the SAME transition factor as the halo — text
and glow fade in/out in sync. Because every enter/leave interpolates
from the current fade value, rapid hover toggles at a node's hit
boundary glide instead of snapping (the flicker-damping fix).
2026-09-02 02:34:08 +03:00
amosblomqvist 96da582d35 Hover polish: constant-radius glow (opacity fade only), instant text swap
- The halo no longer grows during the fade: radius is constant at
  size + 9px; only its opacity fades in/out over ~180ms.
- The hover name is drawn opaque in the bright color instead of
  cross-fading over the muted resting label. Partial-alpha text
  oscillated whenever hover toggles rapidly at a small node's hit
  boundary (sigma fires enterNode/leaveNode on every state change) —
  an un-animated color can't flicker.
2026-09-02 02:31:08 +03:00
amosblomqvist 0a8c360546 Hover glow: static (no pulse), fade-in/out transition, flush with disc edge
- The glow no longer pulses: it's a static halo (9px beyond the disc,
  0.45 alpha at the edge fading outward) in the node's state color.
- Hover enter/leave now cross-fades over ~180ms instead of appearing
  instantly: entering schedules sigma renders so drawHover repaints at
  the interpolated fade; leaving draws through a repainter captured on
  the last hover paint (sigma stops calling drawHover once unhovered).
  The text cross-fades too — the brighter hover name is drawn at
  partial alpha over the muted resting label on the layer beneath.
- The halo starts flush at the node disc edge (was size+1, leaving a
  dark gap ring that read as a gray border between node and glow).
- Both fade directions cancel each other and stop on completion, so
  there is no continuous loop — idle CPU stays near zero.
2026-09-02 02:22:18 +03:00
Amos aa14351474 Hover affordance: pulsing node glow + brighter name, drop the dark pill
- defaultDrawNodeHover no longer paints a background box: the hovered
  node now pulses a soft radial-gradient halo ring in its own state
  color (evenodd punch-out keeps the node disc untouched), phase driven
  by performance.now() inside the drawer.
- The hovered label text brightens (muted-foreground -> foreground) at
  identical geometry — drawName is shared by both drawers so nothing
  shifts when hover begins/ends.
- The pulse is animated by a rAF loop that only runs while a node is
  hovered (enterNode starts, leaveNode/leaveStage stop, cleanup
  cancels) — idle CPU stays near zero, preserving run-then-freeze.
- roundedRect removed with the pill (its only remaining consumer).
2026-09-02 01:57:25 +03:00
Amos 08e417134b Fix hover pill geometry; fade labels in/out over a zoom span
- 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.
2026-09-01 19:01:25 +03:00
Amos 42c112f8c5 Labels appear all at once: one global zoom gate, no per-node size gating
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.
2026-09-01 18:25:04 +03:00
Amos 6bfec1555d Graph labels: wrapped names, dark hover pill, node-anchored detail card
- 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.
2026-09-01 17:55:44 +03:00
Amos 2e4e43b8c4 Graph nodes gain a name; nodes are objective facts (no learner/session references)
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.
2026-09-01 03:34:47 +03:00
Amos e92140f993 Shell/core import law: public API barrels + ESLint enforcement (stage 2)
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.)
2026-08-31 19:51:17 +03:00
Amos 8728573253 W4: graph store + emission + home graph view 2026-08-22 10:30:00 +03:00