Feature: Graph engine improvements - #113
Merged
Merged
Conversation
…sign system
Three issues found by actually driving the playground — which is what it is for.
**Content sat in the top-left with nodes off-screen.** `Viewport.fit` cannot frame
into a zero-sized box, and the one fit that matters is requested by `start()`,
which runs before any renderer has measured itself. The request was silently
dropped and the camera stayed at the origin. Force layout half-hid this by
centring on a fallback 800×600; tree, grid and radial place from `0,0` or
negative coordinates, so they showed it plainly.
A fit that cannot run is now remembered and applied on the first real
measurement. `resize` moves onto the engine, since it is the engine that knows
whether a fit is outstanding, and "Fit to view" calls `fit()` rather than
`relayout({fit:true})` — re-running the layout was doing far more work than asked
and could move nodes the user had deliberately placed.
**A dragged node stopped responding to hover and could not be picked up again.**
`pin()` updated positions but never rebuilt the spatial index, so the node
painted where it was dropped and stayed hittable where it started. Invisible
under force layout, which reindexes on the next tick; permanent under any layout
that computes once — which is every deterministic one. Reindexing now has a
single named path that every position change goes through.
Hit radius comes from the node's own style rules too, instead of a hardcoded 18px
that gave a 6px property node an area swallowing its neighbours and a 28px type
node one smaller than it looked.
**The chrome ignored the design system.** Nodes and edges stay raw — they are
painted at arbitrary positions inside a transformed layer and have to survive a
canvas renderer with no elements at all. But the zoom controls, status strip and
empty state are ordinary UI, and hand-rolling them meant the graph's own
furniture answered to a local stylesheet rather than the theme. They are
primitives now, which costs no framework coupling (Lit custom elements) and
follows what `@we/widgets` already does. The playground rail likewise: a harness
that hand-rolls its own buttons stops telling you anything about the real thing.
Both stylesheets are down to layout.
Seven regression tests, each pinning a failure that is silent rather than loud.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt spellings Three cleanups from driving the graph playground, all of the same kind: friction that had been absorbed rather than fixed. **`on:click` now typechecks.** The generated declarations emitted camelCase for native DOM events and `on:<name>` for a component's own custom events. That split is principled — Solid delegates `onClick` at the document, which only works for native bubbling events, while `on:x` attaches a real listener, which a custom event needs. The trap is that having just learned `on:` for custom events, the obvious next move is `on:click`, which **works at runtime** and failed to typecheck. A type error on correct code is a false negative, and this one is easy to lose an afternoon in. Both spellings are declared for Solid now, camelCase first so it still reads as the default. **One convention for pulling in the `we-*` intrinsics.** The repo had two: four packages used an ambient `solid-elements.d.ts`, four used `types` in tsconfig. The tsconfig form has a trap of its own — `types` *replaces* automatic @types inclusion, so every package using it must also remember `node` or `vite/client`, and forgetting surfaces as "Cannot find type definition file for 'node'" a long way from the cause. It caught me in this very branch. Standardised on the ambient file: additive, colocated, and independent of tsconfig shape. **Layout containers are `Column`/`Row`.** The graph's overlays and the playground's rail were divs against a stylesheet; they are design-system props now, and both stylesheets shrank to what the design system genuinely cannot express — the canvas, and one list. The canvas itself stays raw and the reason is written down where it matters: `we-graph__node` exists once per node, so at a 2,000-node budget that is 2,000 component instances on the hottest path, and it has to survive a renderer with no elements at all. `portable-ui-slice`'s one control is a `we-button` too. A harness built to show the design system rendering over a non-AD4M backend, whose own button opted out, was quietly undermining the thing it exists to demonstrate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the review: three chunks of the protocol were unreachable, and two
scenarios advertised behaviour the fixture could not produce.
**Reified edges — the one that changed what the engine can display.** `reifiedAs`
was in the protocol and no expander ever set it, so pointing this at an
interpretation or Flux dataset would have drawn every `SemanticRelationship` as a
*node*: three times the dots and none of the relationships they encode. The
entity expander and the query seed now recognise declared edge classes, resolve
both endpoints in the same round trip, and collapse each instance into the single
edge it stands for — carrying its scalars, and keeping `reifiedAs` so clicking
the edge can still open the record.
Configuration rather than inference, deliberately: a `Membership{agent, space}`
is an edge, a `Comment{author, post}` is a node people want to read, and only
whoever modelled the space knows which. The AD4M and Flux shape ships as the
default so it works out of the box.
**Metrics now exist.** `MetricRef` had a full consumer side — resolvers, colour
scales, a registry slot — and no producer, so every `{metric: …}` silently fell
back. That was the speculative generality I warned about, built anyway. `degree`
answers "make the important things bigger"; `community` (label propagation, ties
broken on node id so a map does not recolour itself on reload) makes cluster maps
reachable, which was one of four advertised graph types that could not be drawn.
The engine computes only what the live style rules reference.
**Budget stopped reporting when it mattered most.** Landing *exactly* on the
ceiling refused further expansion without ever tripping the flag, so the map went
quiet with no explanation — the precise failure the budget exists to prevent.
Refusal now reports, in both the click and auto-expansion paths.
Also: `schemaExpander` honoured no direction, so asking what pointed *at* a type
returned all its instances; the tree layout ignored the containment tree the
protocol hands it and had no crossing reduction, so it was layered in name only
(barycentre sweeps, and children grouped under their parent); `we-alert` had a
file but was never exported from the primitives index, so every alert in the
graph's status strip was an undefined element painting nothing.
The fixture now contains what the engine needs to prove itself: a belief whose
author has not synced (placeholders), thirty utterances against a page size of
eight (paging, and the "+" badge), x/y on collections (manual layout), and four
relationships with a relevance score. Four new scenarios — edges with data,
cluster map, paging a hub, board — and one shared palette instead of four
copy-pasted ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…u grab Three things found by driving the playground. **Edges stopped short of their nodes.** The node was a shrink-to-fit flex column pulled back by half the dot's width — correct only while the dot is the widest thing in it. The moment a label is longer, which is any real sentence, the box widens, `align-items: center` re-centres the dot *inside the box*, and the dot drifts right of its own coordinates by half the overhang. Edges are drawn at the true position, so they appeared to fall short. The worse half was invisible: hit-testing also used the true position, so the thing you clicked was not quite the thing you saw, and every interaction was slightly off wherever a label was long. The node is a zero-size anchor now, with the dot and label absolutely placed around it — no box to be widened, and the dot's centre is the node's coordinates whatever the label does. **Dragging teleported.** The node's position was set *to* the pointer, so grabbing one anywhere but dead centre snapped it under the cursor on the first move. The grab offset is recorded on press and preserved for the drag, so the node moves with your hand. `nodeDragEnd` now reports the node's position rather than the pointer's — a board persisting the pointer would save every drop off by however far from centre you grabbed it. `BehaviourContext` gains `positionOf`, since anything moving a node *relative* to where it already was needs to know where that is. **A theme toggle in the playground.** Worth having beyond convenience: the graph paints design tokens rather than colours, and flipping the theme is the fastest way to catch anything that does not. Nothing does, as it turns out — the one non-flipping token is deliberate, since the "+" badge sits on a saturated dot in both themes. Ten behaviour tests, covering the grab offset, drop reporting, pin-versus-release, and the claim-and-stop dispatch that lets drag-node take precedence over pan-canvas without either knowing the other exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The graph playground's light/dark toggle did nothing, and the reason was not the toggle. A theme in WE is a **parameter set**, not a stylesheet: colours are generated from a hue, a saturation and a lightness ramp, and `multiplier: -1` inverts that ramp — which is what "dark" means. The `@we/themes` CSS files carry only the few rules that cannot be parametric. So importing `@we/themes` and setting `data-we-theme="dark"` looks like it should work and changes almost nothing; the half that matters, writing the parameters as custom properties, lived in `ThemeStore` with the presets in `themeRegistry`, both inside `@we/app-shell`. The design system could not theme itself, and every second host — this playground, an embed, a future non-Solid shell — hits the same wall. That is a layering inversion against the repo's own rule that the design system is host-agnostic. - Presets move to `@we/themes/presets` as data. The design system owns what a theme *is*; the app keeps persistence, editing and per-space scoping, which are genuinely host concerns. - `applyThemeVars(root, overrides)` joins `themeToStyle` in `@we/schema-shared`, carrying the removal bookkeeping with it — it clears exactly the previous theme's variables. The `cssText` shortcut does not, and that is not theoretical: it deleted the shell's own `--we-dock-*` layout state, so chrome positioned against a docked panel snapped to the window edge until something forced a recompute. That fix was a comment in one store; it is shared behaviour with tests now. - `app-shell` consumes both, so the presets have one home and cannot drift. The playground toggle uses the real presets and the real apply function — no numbers copied into a harness to rot the next time a preset changes. Two further findings are recorded in the audit rather than fixed here: the mapping still lives in schema-shared when it is design-system knowledge, and there are no semantic role tokens, which is why dark can only be an *inverted* light theme rather than a designed one (elevation cannot invert, contrast cannot be capped, and `subtractor: '108%'` is a hand-tuned constant papering over both). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**A single click latched the node to the cursor.** On a board the behaviours run `[pan-zoom, select, drag-node]`, and `dispatchPointer` stopped at the first behaviour that claimed an event — including the pointer-up. So a plain click let `select` claim the release, `drag-node` never learned the press had ended, kept its node latched, and the next mouse movement dragged it with no button held. From the outside: click a node once and it sticks to the cursor, with no obvious way to put it down. Claiming answers "who is handling this gesture", which is right while one is in progress and wrong for the event that *ends* one — a behaviour holding state across a gesture has to be told it finished, whether or not something ahead of it also cared. Terminal phases now reach every behaviour; claiming still short- circuits everything else, so drag-node keeps its precedence over pan-canvas on the way down. Two independent guards alongside, because that bug was invisible and confusing: `drag-node` ignores movement with no button held, and `onPointerCancel` is handled and wired, so a gesture the browser interrupts cannot leave a node latched either. **Cards.** `shape: 'card'` puts a node's text *inside* a sized box rather than under a mark — the post-it, where the content is the node rather than a caption attached to it. `size` stops meaning radius and `width`/`height` take over, including for hit-testing, so picking covers the card rather than a dot in the middle of it. Everything else keeps the label underneath, which is what keeps a dense map readable when the marks are 8px across. **An inspector.** A graph shows structure and hides everything else, so a node's content has to be readable somewhere; the engine already hands the whole node to `onNodeClick`, and where that goes is the host's decision. The panel lists the data bag and, for the harness, edits the identity field — writing back into the fixture so the value survives a round trip *through* the seed and expander path rather than being patched into the rendered node. Editing is from the panel rather than inline on the canvas, deliberately. Text editing inside a transformed, zoomable surface is the hard part of the board project, and a fake here would misrepresent what exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A post-it only highlighted and dragged from its centre. The cause was two derivations of the same thing: the renderer sized a card through `nodeVisual`, which understands `width`/`height`, while the engine sized its *hit area* by reading `size` off the raw style rules. A card sets `width` and never `size`, so that path fell through to its default and gave a 170×120 card an 18px grab spot. Hit geometry now comes from `nodeVisual` too — one derivation, so what you can click is what you can see, by construction rather than by both sides agreeing. Cards also pick as rectangles rather than circles. A circle big enough to cover a 170×120 box overshoots it vertically by twenty-odd pixels, and on a board — where cards sit close together — a click landing on the wrong card is worse than one landing on nothing. `SpatialIndex` takes optional half-extents and box-tests those, keeping the circle for ordinary marks. Ordering is still by distance to centre, so between two overlapping cards you get the one you are nearest the middle of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`will-change: transform` on the transformed layer is the obvious optimisation and the wrong one here. It promotes the layer, so the browser rasterises its contents *once* at the current scale and reuses that texture for later transforms — lossless while panning, since translating a bitmap loses nothing, and destructive while zooming, because the bitmap is simply stretched. Everything went soft on zoom in and stayed soft. Removed, with the reasoning written where the property used to be: it looks like free performance, and re-adding it unconditionally would silently make the whole canvas blurry again. The cost is a repaint per transform, which at DOM-node counts is not the bottleneck — and the dense case has its own answer in a canvas renderer, which does not have the trade-off at all. If it ever needs revisiting: promote during a pan gesture only, and drop the hint before any scale change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s down Two halves of the same question: what belongs in a JSON API that an LLM composes from a sentence, and what has to stay inside the renderer. **The option.** `scaleWithZoom` on edges and `scaleLabelWithZoom` on node labels, both defaulting to true. A board wants lines to thicken as you zoom, because the drawing *is* the document; a large network wants constant on-screen width, because hairlines vanish when you zoom out to see the whole thing. Both are defensible, so it is a style property rather than a renderer decision. Edges use SVG's `non-scaling-stroke`; labels counter-scale against a `--graph-zoom` custom property the layer publishes. Only the label, for nodes. A mark always scales: its size is world units and so is its hit area, and letting those disagree is exactly the bug class that has already cost two fixes on this branch. **The conventions.** `packages/graph-system/CONVENTIONS.md` — the test for what gets exposed (could someone describe wanting it in a sentence about how their graph should look?), the three tiers it sorts into, why the escape hatch is a named plugin rather than a flag, a table of every default and the reasoning behind it, and the invariants that have each been broken once already. The distinction it exists to protect: `scaleWithZoom` is intent, `will-change` is mechanism. Exposing mechanism does not give an author power, it gives them a way to be wrong — and an LLM would set `will-change` precisely because it reads like an optimisation. Two rules for defaults are recorded there too: a default must be usable with nothing else set, and absent must never mean off, or a graph ends up with no arrows because nobody thought to mention arrows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The edge surface was a 100000×100000 SVG offset by -50000 with its contents translated back, so curves bowing away from the origin could never be clipped. `overflow: visible` already does that, and the giant box cost more than it bought: every edge and label was drawn at user coordinates around 50,000, which the camera then multiplied — roughly 200,000 device pixels of offset at 4× zoom before any content began. Paths tolerated it, since sub-pixel error in a vector is invisible. Text did not. `text-anchor: middle` re-measures and re-centres the glyph run at each scale, and at those magnitudes the rounding changed frame to frame, so labels jumped while everything around them moved smoothly. The SVG is now the same size as the layer, which makes SVG user space *be* world space: no offset, no counter-translate, ordinary coordinates. Also removes a duplicated copy of the translate rule left behind by an earlier edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second attempt at this, and the first one fixed a real problem that was not the cause. The clue was the timing: the labels *settled* into place after up to three seconds, which is not a rasterisation-precision symptom — it is something re-rendering on a schedule we do not control. The only timer in the system is the layout tick, and the static diagram uses a deterministic layout that never ticks, so the delay was the browser's. SVG `<text>` under a scaled ancestor is rasterised into a cached texture and re-rendered at the new scale on the engine's own schedule. Node labels never had the problem because they are ordinary HTML text, re-laid out with the transform. So edge labels are DOM now too: one text pipeline for the whole graph, rather than a third attempt at persuading the SVG one. Edge *lines* stay SVG, which is what SVG is good at — a retained-mode surface with sub-pixel curves. Only labelled edges produce an element, so an unlabelled graph pays nothing for this. The previous commit's change stands on its own merits: the edge surface no longer being a 100000px box means SVG user space is world space, which is simply correct, and it removed a duplicated rule. It just was not this bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were wired correctly and could never receive a click. The canvas calls `setPointerCapture` on itself for every press, and the controls live *inside* the canvas element — so both pointer-down and pointer-up retargeted to the canvas, and the browser fired `click` there rather than on the button. Nothing was broken about the buttons; the surface underneath was quietly eating their events. Chrome is now marked `data-graph-chrome` and the canvas ignores gestures that start on it — no capture, no pan, no zoom-on-wheel over a status message. The check walks `composedPath` rather than using `closest`, because the buttons are Lit custom elements and the real event target is inside their shadow root, where `closest` cannot see the marker. Pointer-up is deliberately *not* guarded: a drag that begins on the canvas and ends over the controls still has to deliver its terminal event. Same rule as behaviour dispatch — the end of a gesture always lands. Also restores something the earlier `Column` conversion dropped: the status strip and the empty state had `pointer-events: none` in the stylesheet, and lost it when they became components. The empty state covers the whole canvas, so without it an empty graph could not be panned at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
You were right that the marker was a workaround. Both questions turned out to be the same piece of work. **Why the marker existed.** The pointer handlers sat on the graph root, which is the common ancestor of the canvas *and* its chrome, so a press on a button bubbled into the canvas — z-order does not stop propagation. The fix was to mark every overlay so the canvas could learn to ignore it, which meant a new overlay that forgot the marker would silently break the canvas beneath it. The structural answer is a dedicated hit surface: gestures are handled on a sibling that sits under everything, so chrome bubbles to a root that listens to nothing and needs no marking. What blocked it was edges: paths carried `pointer-events: stroke`, so a surface below them lost panning from an edge, and a surface above them lost edge clicks entirely. **So edge picking moved into the engine**, which it should have been from the start — the invariant everywhere else is that the core owns hit-testing, and edges were the one exception. They were also the reason a canvas renderer could not have supported clicking an edge, since there would have been no path to hit. Routing now lives in `@we/graph-core/geometry.ts` and produces *geometry* — control points, not an SVG path string — so the core can measure a curve and any renderer can stroke it. Picking samples the route and measures distance, which is exact enough for something a few pixels wide and works unchanged for straight, orthogonal and self-looping edges. The renderer keeps one function: control points to `d`. A bug fell out of the tests immediately: `pin()` reindexed nodes but never re-routed edges, so a dragged node's edges stayed drawn *and picked* where they used to run. Both consumers of a position now go through one `positionsChanged()`. **Controls are a registry.** They were already engine-provided and toggleable, but as a hardcoded three. A control is now a declaration — icon, title, what it does — so a module can contribute one without shipping framework code, and a template names them like behaviours: `"controls": ["zoom-in", "fit", "relayout"]`, or `[]` for an embedded thumbnail with no chrome. `relayout` ships but is off by default, since it discards every position somebody chose on a board. CONVENTIONS gains both lessons: nothing is picked by the DOM, and one place per consequence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dead region of the canvas — bottom-right, or wherever the camera happened to have moved — where nothing could be dragged or scrolled. The transformed layer is a viewport-sized box with default `pointer-events`, and it sits above the hit surface. The camera *translates* it, so it silently covers whichever region it has been moved over, and gestures there stopped at the layer instead of reaching the surface underneath. Everything inside it was already `pointer-events: none`; the container itself was not. This did not exist before the previous commit: with handlers on the root, an event swallowed by the layer still bubbled to them. Moving gestures onto a sibling *below* the layer is what made the gap reachable — the fix was right, and it had this consequence one layer up. Set inline as well as in the stylesheet, since it is behaviour rather than appearance and a stale CSS build should not be able to reintroduce it. The rule is now written into CONVENTIONS as its own invariant: nothing stands between the pointer and the surface, and the transformed layer is the one that is easy to miss because it moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dient Second candidate for the hover flicker, and correct on its own merits either way. Every button carried a `::before` covering its whole base part, with `opacity: 1` and a 150ms opacity transition to 0 on hover. On the overwhelming majority of buttons it drew nothing — the gradient variable resolves to `none` unless a primary button opts in — yet the transition still ran on every hover. An opacity transition is compositor-driven, so each hover promoted the pseudo-element to its own layer and demoted it afterwards, repainting the button's whole area independently of the background change happening underneath. Two paints of the same region, which is what "the background steps in, then out, then in again" looks like. It also explains why it is intermittent and why it happens on both the button being entered *and* the one being left. The overlay's `content` now comes from a variable the component sets alongside the gradient itself, so the pseudo-element is not generated at all when there is nothing to draw. One source of truth, and no per-hover cost on buttons that never had a gradient. Also removes the previous commit's diagnostic: narrowing the base transition did not fix it, which is what ruled `transition: all` out as the cause here — though it remains worth changing for its own reasons, recorded for the design-system PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root cause of the hover flicker, found by instrumenting rather than reasoning — and the instrument had to be fixed twice before it could see it. The evidence: on every hover, four transitions ran on the same element — `background-color`, `color`, and **`background-position-x` / `background-position-y`**. Nothing about a hover that changes colour should move a background position, and nothing interrupted them: no cancels, no restyles, hover stable throughout. The mechanism: `bg` emitted the **`background` shorthand**, and a shorthand resets every longhand it does not mention. The base rule sets `background-position: var(--we-*-bg-image-position, center)` on every component, so a hover rule using the shorthand reset position from `50% 50%` to `0% 0%`. With the design system's `transition: all` default, that difference *animated* — 150ms of repainting the background area on every hover, on every button in the app. It was a correctness bug as well as a cosmetic one: any component with a `bgImage` and a hover `bg` had its image cleared on hover, since the shorthand reset `background-image` too. Nobody had hit that yet. `bg` now emits `background-color` for a colour and `background-image` for a gradient, so each value lives in the property that owns it and nothing clobbers anything. The `BASE_VISUAL_SPECS` entry moves with it, so the Lit primitives' generated CSS says the same thing as the Solid path. Three tests pin the invariant, including the bgImage case that was silently broken. `transition: all` is still worth narrowing — it is what turned a stray longhand reset into 150ms of visible motion, and it still animates `outline-width`, so focus rings animate too. That is a separate change for the design-system PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`transition: all` was the default on every component's base part, and the properties a state block may set include layout and typography — so `all` animated **geometry**. The instrumented log caught it directly: `border-top-width`, `border-right-width`, `border-bottom-width`, `border-left-width` and `outline-width` all transitioning on ordinary hovers. An element whose box is mid-animation moves under the cursor, which can drop and re-apply `:hover` and repaints its own bounds every frame while it does. I dismissed this early on the strength of a test that could not have shown it. The diagnostic set `transition` on the *scenario buttons only*, via `--we-button-transition`; the layout chips, the theme toggle and the diagnostics button all still had `transition: all`, and moving across the rail crosses all of them. A negative result from a test that only covers part of the surface is not a negative result. The default is now an explicit list — background-color, border-color, outline-color, color, box-shadow, opacity, fill, stroke — every property a state change legitimately animates and nothing that can move a box. `border-color` and `outline-color` cover their longhands; the matching *widths* deliberately do not appear. A component that needs something else still has the `transition` prop. This also keeps the light/dark fade, which was the worry about narrowing it: a theme switch changes colour parameters, and colours are exactly what the list retains. The diagnostics panel also now shows which button each transition belongs to. It was hiding that, which is why two buttons transitioning in sequence read as one button transitioning twice — and sent me looking for a second element that was never there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hovering across a list of buttons produced an intermittent glitch. It turned out to be three independent defects, which is why no single-cause theory ever accounted for all of it. Each was isolated by building variants of the same list differing in one property at a time, and measuring the painted colour every frame rather than reasoning about the CSS. **The departure trailed.** Transitions were symmetric, so a button you had left kept painting a decaying highlight. A pointer crosses one of these in 40-70ms, measured, so one or two ghosts sat lit behind the cursor at all times. Nine variants, no exceptions: every one with a hover-out fade flickered, every one without was clean, and a lone button could not be made to flicker at all — it needed a sequence. A hover indicator exists to say *the pointer is here*, so any persistence after the pointer leaves asserts something false about somewhere it is not. **The arrival outlasted the gesture.** A 150ms fade buys about a third of itself in a 47ms transit, so a quick pass lit a button dimly and then removed it — a partial state that appears and aborts, which reads as a glitch precisely because it never resolved. Not an interruption problem, which is the intuitive answer: back when both directions still faded, 150ms flickered *less* than 50ms, the opposite of what that theory predicts. **Animating text colour, which dominated the rest.** Two columns identical but for whether hover changed `color` as well as the background flickered at roughly a hundred to one; the same fade with no text at all was clean. Every frame of a colour fade re-rasterises every glyph, and glyph rendering is both far more perceptible than the fill behind it and invisible to everything measuring the fade — computed style correct throughout, interpolation exactly on its line, geometry never moving, zero dropped frames. The fix is two transition declarations instead of one, using where each rule already applies rather than any new machinery. The state rules govern animating *into* a state, so they carry the arrival: 50ms, ease-out, and no `color`. The base rule governs animating back *out*, so it carries the departure and resolves to 0s. That base rule is also what cross-fades on a theme change, which is worth keeping and is a different job on a different timescale — and it is where `color` belongs, since there the text colour is the point rather than a side effect. So it reads --we-theme-switch-duration, and applyThemeVars raises it around a switch and lowers it after. A custom property rather than a stylesheet rule because primitives paint inside shadow roots, and a custom property is the one thing that crosses that boundary. The window deliberately does not open on the first application: that is the initial paint, not a switch, and opening it there left every component running a 250ms departure for the first fraction of a second of the page's life. A residual on the order of one to two percent remains. It reproduces on a plain <button> with hand-written CSS and no framework of any kind, so it is the browser's, and there is nothing left in the design system to change in front of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instrument this replaces listened to transitionrun/start/end/cancel and reported that everything was fine through four rounds while the flicker was plainly visible. A transition event describes an animation the browser agreed to run; a flicker can be one frame painted wrong with no animation attached, so that instrument was structurally incapable of seeing the class of bug it was aimed at and its silence carried no information. It also only ever ran inside the graph explorer — Solid, plus the design system, plus a scroll rail — with nothing separating them, so "the design system has a hover bug" was assumed rather than established. This page fixes both. Columns identical in size, spacing and layout differ in one layer each: we-button, we-button with explicit hoverProps, the same shape in bare CSS, a transition-free control, and variants isolating text colour and text presence. Wherever an artefact appears, the layers absent from that column are eliminated — and a flicker on the bare-CSS control means the browser rather than anything WE can fix. There is no framework on the page at all, so reproducing here clears the framework and failing to reproduce here points straight back at it. Measurement is getComputedStyle per frame inside rAF, after style and before paint, in premultiplied alpha — the space CSS interpolates in, and necessarily so here: these buttons rest at `transparent`, so the whole fade lives in the alpha channel and an rgb-only comparison sees a one-frame jump and nothing else. Two detectors: a reversal with no pointer event behind it, and an off-axis excursion, since every legitimate colour mid-fade lies on the line between rest and hover and distance off that line is a number rather than an impression. Pressing space dumps the window with the keypress at 0, which puts the eye in the position of oracle instead of asking someone to find the anomaly in a wall of events. D lists every computed property a hover changes, which is how the text-colour change was found after every other measurement had come back clean. optimizeDeps.exclude covers both playgrounds. The WE packages resolve to dist/, so Vite pre-bundles them into a cache that does not invalidate on rebuild, and a dev server will happily serve a design system older than the one on disk — silently, with nothing in the page or the repository to say so. That cost a full round of testing against a build that was never loaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w through Every edge was drawn as the same bowed arc, which is a reasonable default and a poor only option — it is the shape that keeps two nodes related in both directions from collapsing into one line, and it says nothing useful about a hierarchy or a pipeline. So four, named for what they look like rather than for the maths, because these values are hand-written into templates and picked by a model from a sentence: - straight — a direct line, right when the layout is already doing the talking - arc — bows to one side; the default, and what `bezier` used to mean here - smooth — leaves and arrives along the axis the edge mostly runs on, the flow-chart S - step — right angles, for containment and org charts `bezier` and `orthogonal` still resolve, through one normaliser, so templates written against them keep working. Renaming was worth it: `bezier` described a quadratic with one control point, which is both harder to picture and actively misleading, since in most node editors "bezier" means the S-curve — which is `smooth` here. Which way a step turns first, and which way a smooth curve departs, follow the axis the edge mostly runs along. A hierarchy laid out top-to-bottom should not leave sideways before it starts descending, and deriving that from the endpoints means neither the layout nor the author has to say so. A step therefore turns twice, so `elbow` becomes `elbows` — storing one corner forced every consumer to re-derive the second and to assume horizontal-first. The arrowhead was drawing over its own line. The marker sat with its tip near the path end (refX="9" of a 10-unit viewBox), so the stroke ran underneath almost all of it. Markers scale with stroke width, which makes the line's half-width a constant 0.83 viewBox units whatever the stroke, while the triangle's half-height where the line stopped was 0.5 — the line was wider than the arrow at the point it ended, so its edges showed either side of the tip. Always; zoom only magnified it. The marker's base now sits at the path end and the stroke stops an arrowhead earlier, from one constant so the head and the gap it needs cannot drift apart. The route is untouched: picking still uses the full line, because the part under the arrowhead is still part of the edge as far as clicking is concerned. `arrow: 'both'` also draws a head at the source, which the type has always offered and the renderer never did. Both reach the catalog, since a style option that is not in it cannot be authored, and the explorer gains an edge-shape picker — which of these reads best depends on density and on how much the layout is already saying, and that is not a judgement anyone makes correctly from a name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e parallel Three things, all from driving the picker. **Smooth becomes the default.** It is the shape people expect from a node graph — it reads as direction without insisting on it — and `arc` becomes what it should have been all along: the deliberate choice, for a graph dense enough that lines need telling apart by shape. That was only safe once parallel edges stopped depending on the bow to separate them. Asking for straight edges and getting curves back for the mutual pairs was the wrong trade: the author picked a shape, and separating two relationships does not require abandoning it. Straight edges are now shifted sideways instead, and steps cross at different points — half the offset in both cases, since a quadratic's midpoint deviates half its control distance, so the separation matches what a bow of the same offset achieved. **A smooth edge running right-to-left looped back on itself.** The span was taken as a magnitude, which put both control points behind the source. Invisible on any left-to-right edge, and most edges in a tidy layout run left to right. **Switching edge shape threw away every node position.** Not a layout bug: a host that rebuilds its spec object hands over a new `seeds` each time, and the reload effect tracked reading it rather than comparing its value, so any control outside the graph restarted the graph. It now compares, like the layout and style effects beside it always did. The regression test for that asserts on the query log, not on where nodes ended up. Position is the symptom but a poor probe — several scenarios lay out deterministically, so a restart puts everything back exactly where it was and the test passes whether or not the graph was destroyed and rebuilt. Queries do not lie: `start()` re-seeds, so a restart shows up as the seed query running twice. Verified by mutation; the first version of this test passed with the guard removed, which is how the difference was found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…here the line points Three reports from the picker, and the middle one turned out to be older and wider than it looked. **A restyle computed new routes and never said so.** `refreshHitAreas` already re-routed — node size decides where an edge stops — but unlike `positionsChanged` it never notified, so the geometry sat in the map unpainted until something else happened to bump the signal. Dragging a node did, which is why changing an edge's shape appeared to do nothing and then apply retroactively. **Mutual pairs were never fanned apart at all.** `routeEdge` took its perpendicular from the edge's own direction, which reverses on the return leg; the offsets handed to the two legs are already opposite, so the two sign flips cancelled and the pair stacked exactly on top of each other. This predates the new shapes — `arc` has done it since the beginning, despite separating mutual edges being the stated reason it was the default. What looked like two curves was one curve drawn twice. Fixed geometrically, by pinning the perpendicular to a half-plane, rather than by having the engine flip the sign for the return leg. A caller should be able to ask for `+n` one way and `-n` the other and get two lines; making every caller compensate is how this stays broken for the next one. Verified across all four shapes. **Arrows pointed at the corner of their target.** Where an edge meets a node was decided by trimming along the straight line between the two centres, which is right for a shape that travels along that line and wrong for one that does not. A smooth curve arrives horizontally and a step arrives at a right angle, so the arrowhead landed somewhere the line was never pointing, and slid around the node's rim as it moved. The route now decides where it lands, because the route is what knows how it arrives: axis-aligned shapes attach on the side they approach from. That also explains the jump from a side to an underside as a node crosses the diagonal — it is the same moment the curve changes which axis it travels along, so it is now one visible change rather than two disagreeing ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… middle A step separated its two legs by crossing at different places, which does nothing for the segments running into the nodes: those sat on the centre line at both ends, so a mutual pair was the same line exactly where it is easiest to look at. It also made the gap read as smaller than `straight`, which moves its whole line for the same offset — the difference was not the spacing but where the spacing was. The attachment now moves along the node's face instead — a lane, in the flow-chart sense — so an axis-aligned route is displaced for its whole length. A step gets both: the lane holds its approach segments apart and the crossing holds the segment between them apart, at right angles to each other, so they compose rather than compete. Smooth changes the same way, and drops its control-point offset in favour of it. Displacing only the controls bulged a mutual pair apart in the middle and let it converge at both ends, which is the same complaint one step removed. Two parallel S-curves instead, for the reason `straight` gives two parallel lines. Lanes shift along a fixed axis rather than a perpendicular taken from the edge's direction, so unlike the fanning this needs no canonicalising to survive the return leg. They are clamped to the node they land on, so a wide offset on a small node cannot slide the attachment off its face. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A layout is constructed with its options and holds them, but the live instance was cached
on type alone — so a spec that re-tuned a layout without swapping it reused the old one.
Everything downstream looked correct: the spec updated, `relayout` ran, positions were
recomputed and reapplied. The graph simply did not move, because it had been laid out again
by the same layout with the same numbers.
It surfaced as a picker that worked from every layout except the one the graph was already
using: the `static` scenario declares `{ type: 'tree', options: { direction: 'right',
levelGap: 190 } }`, so its default *is* tree, and asking for plain `tree` changed only the
options. Coming from `force` the type differed, the layout was rebuilt, and it worked —
which is what made it look like a quirk of one button rather than a bug in the engine.
The same fault would have silently swallowed any template tuning `levelGap`, `columns` or
`distance` on its own, with no warning and nothing to see.
Keyed on type and options together now. The warm-instance behaviour it exists to protect is
kept and tested: expansion still reuses a running layout, which is why an expanding map
settles around what is already placed instead of jumping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t say what it could not do **Force ended up in a corner.** The fit was applied once, at `init`, which frames the positions a simulation *starts* from — and it then spends a second or two spreading out from under the camera. The graph was not wandering off; the camera had stopped following it. A fitted relayout now keeps framing until the layout stops reporting itself as running, which costs nothing for the layouts that compute in one pass, since those never report it. **Manual appeared to do nothing.** It does: it reads each node's position from its own data, and for a dataset carrying none it keeps what is already there — on screen, identical to a layout that ran and decided nothing needed to move. Picking it and seeing no change is then indistinguishable from picking it and having it silently fail, and the second is what people conclude. So `LayoutResult` carries `warnings`, and manual uses it to say that no node carried the fields it reads and that a graph without stored positions wants a layout that derives them. A layout that finds nothing to work with still has to return positions, so without somewhere to say so its only options were to fail silently or to invent an arrangement and present it as derived. The engine folds these into the same status warnings expanders already use, so they surface where an author is already looking. Both verified by mutation: the settling test fails when the camera stops following, and the warning test fails when the engine drops what a layout reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manual mode was impossible to evaluate. It reads each node's position from that node's own data — which is what makes a board a board, position being the thing edited rather than something derived — and the fixture carried no coordinates, so a drag lasted until the next reload and the layout looked like it did nothing. Dropping a card now writes its position to the record and the next query reads it back as an ordinary field, with localStorage standing in for the backend. Going through onNodeDragEnd → write → query is the point: a playground that kept a side-map of positions beside the data would demonstrate persistence while testing none of the wiring that has to work for a real board. Only the two coordinate fields persist, so a reload is otherwise a clean slate, and there is a reset button for getting back to an unplaced grid. Also types the edge-shape override against the real curve union rather than `string`. It had been wrong since the picker was added and nothing caught it — the playground's types are checked by neither the build nor the test run, which is worth knowing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n it Every control until now was a momentary action — zoom, fit, re-run the layout — so a control had nothing to *be*, and `GraphControl` had no way to say it. A lock is not that: it has a state, and a toggle that does not show its own state is a switch you have to remember the position of. So controls gain `active` and `enabled`, plus an `activeIcon` and `activeTitle` for the toggles that read better as two states than as one pressed one. The renderer draws them, recomputed against live engine state so a button follows a selection made by clicking a node rather than only by pressing the button. `pin` holds the selected nodes where they are, so the layout stops moving them — the usual way to shape a force graph: put the thing you care about where you want it, hold it, and let the rest settle around it. It acts on the selection rather than inventing a gesture, which keeps it discoverable and reuses a selection people already make. A partly-held selection pins the remainder rather than releasing what is held: the reading that cannot lose work. `lock` blocks moving nodes so a graph cannot be rearranged while it is being read or shown. Deliberately about the user and not the layout — a locked force graph still settles, it just cannot be dragged — and refused at the pointer-down rather than by discarding the result, because a drag that follows the pointer and then snaps back has told you it worked and then taken it away. Panning is untouched: locking is about not rearranging a graph, not about not being able to read it. Neither is on by default, because each is meaningless on some graphs: nothing to hold on a board where every node is placed, nothing to lock where the template never allowed dragging. A held node is ringed. Pinning is state the layout obeys and nothing else shows, so without a mark a graph that refuses to rearrange one node looks broken rather than instructed — and the cause is usually a drag somebody has forgotten making. This also settles what chrome is allowed to do, which `relayout` had already quietly widened: controls act on the scene, never on the data. Nothing a control does is written anywhere, and persisting a position stays the job of `onNodeDragEnd` and the host listening to it. Both invariants are now written down beside the ones they qualify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ring says a node is held where you put it, so the layout will not move it — and on the static diagram, where `drag-node` is configured to pin, it is the drag being remembered. Correct, and wrong in two ways. It never left, because that scenario offered `lock` in its controls and not `pin`, so dragging created a state with no way to undo it. Arranging a diagram left a held node behind on every card you touched. It now offers both. And it fired where it carried no information. A layout that reads positions from the data marks every node fixed by definition, so a board ringed every card — marking the rule rather than the exception, which reads as everything being in some special state rather than nothing being. Layouts now declare whether they work out where nodes go; only `manual` says it does not, and the marker follows that rather than the flag on the position. Both halves of the invariant are written down: a state the layout obeys has to be visible *and* reversible, and only what is exceptional is worth marking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tate in the engine A force simulation re-energises itself when a node is held or released — that is what makes the rest of a graph flow around the one being dragged, and it is the only thing that makes pinning mean anything to look at. But the engine stops polling a layout the moment it reports itself settled, so the reheat went nowhere: the dragged node moved, nothing else responded, and a force layout behaved like a deterministic one. Pinning then looked inert because nothing would have moved the node anyway. Pinning also stopped being the engine's to remember. Every tick replaces the position map wholesale, so `fixed` survived only because the force layout re-derives it from its own `fx` — luck rather than contract, and a hold the *user* asked for should not be something a plugin can forget. The engine keeps the set and re-asserts it over whatever a layout returns, which is also how this surfaced: a test layout that reported plain positions lost the hold on its first tick. Deterministic layouts were already honouring held nodes across a re-layout, so pinning was real there too — it just had nothing to demonstrate it with until a graph moved. The playground gains live force tuning: link distance, repulsion and spacing, applied wherever a force layout is actually running. Deliberately here rather than in the graph's chrome — those are already the layout's options, so what was missing is not a way to express them but a way to find the numbers worth writing down, and a graph shipping a tuning panel would hand a reader controls over a decision its author already made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Graph engine improvements
Summary
This branch takes the graph engine from "the architecture is right" to "you can actually use it",
by driving the playground and fixing what it exposed. Most of the work is edges and layouts: edges
could only be drawn as one bowed shape, mutual pairs silently drew on top of each other, arrowheads
pointed at the corners of their targets, a re-tuned layout was ignored, and a force simulation
stopped responding the moment it settled. None of that was visible from the code — every one of them
was found by looking at a graph and asking why it looked wrong.
A second thread runs through it. An intermittent hover flicker in the design system was reported
early on, survived four rounds of instrumentation that all came back clean, and was eventually
isolated by building a dedicated bisecting probe: nine variants of the same button list differing in
one property each. It turned out to be three independent defects, and the dominant one — animating
text colour — was invisible to every instrument that had been built to look for it. That probe is
kept, because it is the only thing that found it and the technique generalises.
The engine's own contracts moved with the work rather than around it: layouts can now say what they
could not do, controls can have a state, and
CONVENTIONS.mdrecords the invariants each of thesebugs violated so the next contributor does not rediscover them.
Changes
Edges —
graph-system/protocol,graph-system/core/geometry.ts,frameworks/solidFour shapes instead of one. Every edge was a bowed arc, which is a reasonable default and a poor
only option — it says nothing useful about a hierarchy or a pipeline.
straight,arc,smoothand
stepare named for what they look like rather than for the maths, because these values arehand-written into templates and picked by a model from a sentence.
bezierandorthogonalstillresolve through one normaliser, so templates written against them keep working. The rename was worth
it:
bezierdescribed a quadratic with one control point, which is both harder to picture andactively misleading, since in most node editors "bezier" means the S-curve —
smoothhere.smoothis the default. It is the shape people expect from a node graph, andarcbecomes whatit should have been: the deliberate choice for a graph dense enough that lines need telling apart by
shape.
Mutual edges were never fanned apart — in any shape, ever.
routeEdgetook its perpendicularfrom the edge's own direction, which reverses on the return leg; the offsets handed to the two legs
are already opposite, so two sign flips cancelled and the pair stacked exactly. Separating mutual
pairs was the stated justification for
arcbeing the historic default, and it has never worked:what looked like two curves was one curve drawn twice. Fixed geometrically by pinning the
perpendicular to a half-plane, rather than by having the engine compensate — a caller should be able
to ask for
+none way and-nthe other and get two lines.Separation now holds at the nodes, not only in the middle. A step separated its legs by crossing
at different places, which left the segments running into each node on the same centre line — the
two edges were one line exactly where they are easiest to look at, and the gap read as smaller than
straight's. Axis-aligned routes are displaced by a lane along the node's face, clamped so a wideoffset on a small node cannot slide the attachment off it.
smoothdropped its control-point offsetin favour of the same mechanism, since displacing only the controls bulged a pair apart in the middle
and let it converge at both ends.
The route decides where it lands. Where an edge met a node was decided by trimming along the
straight line between two centres, which is right for a shape that travels along it and wrong for one
that does not: a smooth curve arrives horizontally and a step arrives at a right angle, so the
arrowhead landed somewhere the line was never pointing and slid around the rim as the node moved.
The arrowhead was drawing over its own line. The marker sat with its tip near the path end
(
refX="9"of a 10-unit viewBox), running the stroke under almost all of it. Markers scale withstroke width, so the line's half-width is a constant 0.83 viewBox units whatever the stroke, while
the triangle's half-height where the line stopped was 0.5 — the line was wider than the arrow at the
point it ended, and its edges showed either side of the tip. Always; zoom only magnified it. The
marker's base now sits at the path end and the stroke stops an arrowhead earlier, from one constant so
the head and the gap cannot drift apart. Picking still uses the full line.
arrow: 'both'also drawsa head at the source, which the type has always offered and the renderer never did.
Layouts —
graph-system/core/engine.ts,graph-system/layoutsA re-tuned layout was ignored. The live layout instance was cached on type alone, but a layout is
constructed with its options and holds them — so a spec that changed
levelGaporcolumnswithoutswapping the layout reused the old one. Everything downstream looked correct and the graph simply did
not move. It surfaced as a picker that worked from every layout except the one already in use, and it
would have silently swallowed any template tuning a layout on its own. The warm-instance behaviour the
cache exists for is kept and tested: expansion still reuses a running layout, which is why an
expanding map settles around what is already placed instead of jumping.
The camera stopped following a settling layout. A fit ran once at
init, framing the positions asimulation starts from; it then spent a second or two spreading out from under a stationary camera.
The graph was not wandering into a corner — the camera had stopped following it.
A settled layout could not start moving again. A force simulation re-energises itself when a node
is held or released, which is what makes the rest of a graph flow around the one being dragged. But
the engine stops polling once a layout reports itself settled, so the reheat went nowhere: the dragged
node moved and nothing responded, making a force layout behave like a deterministic one and making
pinning look inert.
Layouts can say what they could not do.
LayoutResultcarrieswarnings, folded into the samestatus warnings expanders already use.
manualis the case that forced it: asked to read positionsfrom data that carries none, it keeps what is already there — on screen, identical to a layout that
ran and decided nothing needed moving, so picking it and seeing no change was indistinguishable from
it silently failing. A layout that finds nothing to work with still has to return positions, so
without somewhere to say so its only options were to fail silently or invent an arrangement and
present it as derived.
Chrome —
graph-system/protocol/render.ts,core/controls.tsControls can have a state. Every control was a momentary action — zoom, fit, relayout — so
GraphControlhad no way to express one that is on, and a toggle that does not show its own stateis a switch you have to remember the position of. Controls gain
active,enabled,activeIconandactiveTitle; the renderer recomputes them against live engine state, so a button follows a selectionmade by clicking a node rather than only by pressing the button.
pinholds the selected nodes so the layout stops moving them — the usual way to shape a forcegraph. It acts on the selection rather than inventing a gesture, which keeps it discoverable. A partly
held selection pins the remainder rather than releasing what is held: the reading that cannot lose
work.
lockblocks moving nodes so a graph cannot be rearranged while it is read or shown. About theuser rather than the layout — a locked force graph still settles — and refused at the pointer-down
rather than by discarding the result, because a drag that follows the pointer and then snaps back has
told you it worked and then taken it away. Panning is untouched.
Neither is on by default: each is meaningless on some graphs.
Held nodes are ringed, but only where being held is exceptional. Under a layout that reads
positions from the data every node is placed, so the same mark lands on all of them and says nothing.
Layout.derivesPositionsis how a layout declares which it is.Pinning is the engine's state, not the layout's. Every tick replaces the position map wholesale,
so
fixedsurvived only because the force layout re-derives it from its ownfx— luck rather thancontract, and a hold the user asked for should not be something a plugin can forget.
Earlier engine work —
graph-system/*Consolidated from the first half of the branch: the engine owns edge picking (edges were the one thing
behaviours could only reach through the DOM, which is why a canvas renderer could not have supported
clicking one); chrome is a declarative registry rather than hand-drawn buttons; nodes are centred on
their own coordinates and dragged from where you grab them; a card is picked by its box rather than by
a dot in the middle of it; the canvas stays sharp when zoomed; edge labels are DOM rather than SVG
text, which stops them jittering for seconds after a zoom; and the drawing layer no longer blanks part
of the canvas. Plus reified edges, metrics, and
CONVENTIONS.md.Design system —
3-primitives,design-system/utils,schema-system/sharedThe hover flicker was three independent defects, which is why no single-cause theory accounted for
all of it.
decaying highlight; a pointer crosses one in 40–70ms, measured, so one or two ghosts sat lit behind
the cursor. Nine variants, no exceptions: every one with a hover-out fade flickered, every one
without was clean, and a lone button could not be made to flicker at all.
so a quick pass lit a button dimly and then removed it — a partial state that appears and aborts.
Not interruption, which is the intuitive answer: back when both directions still faded, 150ms
flickered less than 50ms.
column, and a no-text column was clean. Every frame of a colour fade re-rasterises every glyph,
which is far more perceptible than the fill behind it and invisible to computed style,
interpolation and geometry alike.
The fix is two transition declarations instead of one, using where each CSS rule already applies: the
state rules carry the arrival (50ms,
ease-out, nocolor), the base rule carries the departure andresolves to
0s. That base rule is also what cross-fades on a theme change, which is a different jobon a different timescale and where
colorbelongs — so it reads--we-theme-switch-duration, whichapplyThemeVarsraises around a switch and lowers after. A custom property because primitives paintinside shadow roots and that is the one thing crossing the boundary. The window deliberately does not
open on first application: that is the initial paint, not a switch.
Three further design-system fixes surfaced during the hunt and are kept as their own commits, since
each is independently real: the gradient overlay is only created when there is a gradient (it used to
exist on every button, invisible, transitioning its opacity on every hover and promoting the element
to its own layer);
bgno longer clobbers the background longhands; and state transitions no longeranimate geometry.
Also:
@we/themesexposesTHEME_PRESETSvia a new./presetssubpath so a theme can be appliedoutside app-shell, and
applyThemeVarsgained removal bookkeeping and test coverage.Playgrounds
apps/playgrounds/vanilla/flicker-probe(new). The instrument it replaces listened to transitionevents and reported that everything was fine through four rounds while the flicker was plainly
visible — a transition event describes an animation the browser agreed to run, and a flicker can be
one frame painted wrong with no animation attached. It also only ever ran inside the graph explorer,
with Solid, the design system and a scroll rail all mixed together, so "the design system has a hover
bug" was assumed rather than established. This page has no framework at all and bisects by column.
Measurement is
getComputedStyleper frame inside rAF, in premultiplied alpha — necessarily, sincethese buttons rest at
transparentand the whole fade lives in the alpha channel. Space dumps thewindow with your keypress at 0;
dlists every computed property a hover changes, which is how thetext-colour change was found after every other measurement came back clean.
graph-explorergains an edge-shape picker, live force tuning (link distance, repulsion,spacing), a board-position store, and controls on the scenarios that want them. Force tuning is
deliberately here rather than in the engine's chrome: those are already the layout's options, so what
was missing is not a way to express them but a way to find the numbers worth writing down. Board
positions persist through
onNodeDragEnd→ write → query, with localStorage standing in for thebackend — a playground keeping a side-map of positions would demonstrate persistence while testing
none of the wiring that has to work.
optimizeDeps.excludeon both playgrounds. The WE packages resolve todist/, so Vitepre-bundles them into a cache that does not invalidate on rebuild, and a dev server will happily serve
a design system older than the one on disk — silently. That cost a full round of testing against a
build that was never loaded.
Documentation
CONVENTIONS.mdgains the invariants these bugs violated: chrome acts on the scene and never on thedata (which
relayouthad already quietly widened); a state the layout obeys has to be visible andreversible, and only what is exceptional is worth marking.
catalog.tsgainscurve,arrow,pinand
lock, since a plugin that is not in the catalog cannot be authored.CLAUDE.mdand the AIcontext are regenerated.
AUDIT_2026-08-11.mdrecords the design-system findings that belong to alater PR.
Known follow-ups
Deferred to the design-system / audit PR
themeToStyle/applyThemeVarsinto the design system; they sit inschema-system/sharedfor no better reason than history.
ThemeParametersandThemeOverrides, which describe the same thing twice.we-text[variant='heading-md']rule.Graph engine
is its own project, not a flag.
so a second renderer needs no new logic.
questions AD4M does.
scope.direction: 'in'in the QueryIR.GraphHostcurrently does backward traversal with a cappedscan (
REVERSE_SCAN_LIMIT = 200), which is documented and reports when it truncates, but is aworkaround for a missing query capability.
treeis too rigid andforcetoo loose.Known and not worth chasing
<button>withhand-written CSS and no framework of any kind. It is the browser compositing a
background-colortransition; there is nothing left in the design system to change in front of it.
Process
pnpm buildnorpnpm test. A latent type errorin the explorer survived several commits because of it, and was only caught by running
tscbyhand. Worth wiring up.
Test plan
Automated — all run green on the final commit:
pnpm build— full monorepo, clean.pnpm test— every package. Notably@we/graph-core116 tests,@we/graph-solid8,@we/schema-shared523,graph-explorer14,app-shell255.pnpm --filter @we/schema-shared validate— 22 schemas, no issues.npx eslintacrosspackages/graph-system,packages/design-system,packages/module-system/graphand both playgrounds — clean.
npx tsc --noEmiton both playgrounds — clean (not covered by the build; see follow-ups).Mutation-verified — each of these tests was confirmed to fail with its fix reverted, because
several of them passed against the broken code on the first attempt:
applyThemeVarsdoes not open a cross-fade window on first application.asserted on node positions and passed either way, because the scenario it used lays out
deterministically — a restart put everything back exactly where it was. It now asserts on the query
log, since
start()re-seeds.)Manual, in the graph explorer — the source of nearly every fix here:
review ↔ revisepair confirmed visibly separated in all four shapes.the button (the case that exposed the caching bug).
release it and watch it be drawn back in.
lockon the static diagram and board;pinon the knowledge map.Manual, in the flicker probe — nine columns driven by hand over several rounds, with the space-key
capture used to align what was seen against what was measured. The final state of the design system
was confirmed against the columns that had been flickering; what remains is the residual noted above,
present equally on the bare-CSS control.
Not verified: nothing in this branch has been run against a live AD4M backend. The graph engine is
exercised entirely through the in-memory host, and
GraphHost's AD4M binding is unchanged from theprevious PR.