Skip to content

Feature: Graph authoring - #126

Merged
jhweir merged 37 commits into
devfrom
feat/graph-authoring
Aug 21, 2026
Merged

Feature: Graph authoring#126
jhweir merged 37 commits into
devfrom
feat/graph-authoring

Conversation

@jhweir

@jhweir jhweir commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

The graph route becomes a place you can work

Summary

The graph route could draw a space and do nothing to it. It read its data once at mount, had no
write path of any kind, and its three modes were three ways of looking at records somebody else had
made. This branch makes it a surface you work in: create a record of any model, connect two records
and argue about the connection, arrange things on a board where position is the data, read an
instance without leaving the map, and decide how any of it looks.

The order matters, because the first change is what makes the rest possible. The engine's only way
to react to new data was start(), which is a full reset — it clears the store, the expansion state,
every position, every pin and the selection, deliberately, because "the spec changed" means "this is
a different graph now". Everything downstream needed the other half of that pair: same graph, newer
data.

Three ideas carry most of the weight, and each one is the answer to a question that came up more
than once:

  • A connection is a record, not a link. "These two are related" is a claim people argue with, so
    it has an author, a date, a thread and ratings — which it gets for free by being a WeNode.
  • Position is a fact about a pair. A coordinate belongs to (board, record), not to either one,
    which is why the same note can sit on two boards in two places. Presentation followed the same
    logic once it existed.
  • A local edit should never wait on a round trip. However fast the backend gets, a slider that
    lags one behind the finger reads as broken. Optimism is a design decision, not a workaround.

Changes

The engine

core/src/engine.tsrefresh() re-runs the seed sources and reconciles the result into what
is on screen, dropping only rows the seeds no longer return and only where the seeds were the last
thing holding them. Positions, pins, selection, camera and open nodes survive. It does not fit the
camera (a refresh often arrives from a peer's write while somebody is reading) and auto-expands only
the arrivals — running the rules over the whole store would re-open every node the user had
collapsed, on every refresh.

Seed loading is factored into loadSeeds so start and refresh cannot disagree about what the
seeds are — and that function is also where the engine learns what the seeds read, by handing the
sources a context whose query records the entity and dataset of every read. That is how it knows
what to watch without parsing any seed source's options.

expand takes an optional expander override, which lifts the exhausted-node guard for that call
only: "show its relations" and "show its fields" are different questions about one node, and the
ordinary guard would answer the second with silence.

setDataOverlay lays fields over nodes without touching what the seeds returned — the engine
half of optimistic editing. It re-indexes and re-routes, because drawing is only one of three things
a node's data decides: the spatial index picks by it and the edge router trims to it. It stays
beside the store rather than merged into it, because the host works out that a write has come back
by comparing its patch against the seeded data; merging would report every patch settled the instant
it was applied.

expansion.tsreleaseFrom is the reference-counted counterpart to collapse, cascading the
same way for the same reason.

behaviours.tsconnect-nodes drags a line between two nodes and emits edgeCreate, writing
nothing: what connecting two things means differs completely between a knowledge map, a board and an
outline, and the engine has no write path anyway. Armed by an option rather than a modifier, because
the modifiers are taken or do not travel and a modifier-only gesture is invisible.
node-double-click and canvas-double-click split the same gesture by what is under it.

geometry.ts — an edge stops at its target's edge rather than on a circle drawn around it.
Clearance is per axis for a box; a plain number still means a circle, and that distinction is
load-bearing rather than tidiness: on a 45° approach a circle of radius r is r away and a square of
half-extent r is r√2, so treating round nodes as boxes would push every diagonal arrow 40% too far
out.

style.ts — rule lists flatten one level, so a $map over data can contribute rules alongside
hand-written ones (a schema cannot merge two arrays; $concat joins strings). And FieldRef joins
MetricRef as a second way a style value can be something other than a literal — see Presentation
below.

protocol/ExpanderContext.watch, GraphSpec.live, BehaviourContext.drawConnection, and
the edgeCreate / nodeDoubleClick / canvasDoubleClick / nodeResize events.

Following the data

GraphHost.tsxwatch uses ModelClass.query(...).subscribe(...), the same live path
$query has always used and the graph never took. It ignores the first delivery (the current state,
not a change) and holds a flag for the write that lands before the initial page resolves, which is
the one update that would otherwise be lost.

Deriving a form from a model

EntitySchema.authoring lets a model declare what a person fills in, and recordDraft.ts
derives a form from it: field order, controls, options, validation. A model nobody wrote a form for
still gets one, which is the whole point — a community defining a shape this afternoon can create
instances of it this evening.

recordForm.ts in the template kit renders that draft. The draft is mutated in place while
typing rather than rebuilt, because <For> keys on object identity and a rebuilt row loses focus
after every keystroke.

Relationships

Relationship — a connection somebody drew, in their own words. A WeNode, so it carries
comments, signals and an author by construction; that is the entire argument for reifying it rather
than writing a link.

RelationshipType — the tier between a free-text label and a declared relation, deliberately the
same shape as SignalType. A record, so adding one is not a schema change; identified, so a query
filters on it and an edge style keys on it. directed and inverseName, because "contradicts" is
asymmetric and "related to" is not.

docs/architecture/relations.md writes the convention down: the axis that decides it, five
decision rules, the promotion trigger and its migration cost, worked examples.
packages/models/CONVENTIONS.md points at it from where somebody is about to type @HasOne.

EdgeDetail.ts reads a connection back with commentThread, agentByline, SignalControl and
composerModal, all unchanged — that reuse was the point of modelling a connection as a node. Its
wording is editable in place: a line drawn "blocks" that turns out to be "depends on" used to need
deleting and redrawing, which discards the thread and the ratings underneath, so what was being
corrected was the claim's existence rather than its words. Changing the kind is the substantial
one: it is what the maps colour and arrow by, so it is how a line drawn as free text gets promoted
into the vocabulary a space actually uses.

The board

Placement — a coordinate on the pair, not on either end. x/y on a block meant only
collections could be placed and a record had exactly one position, so the same note pinned to two
boards in two places — the ordinary case — was unrepresentable.

Many small records rather than a map on the board, because a board holding { nodeId: {x,y} } is a
read-modify-write and a shared board is the worst place for one: two people dragging different
cards would clobber each other. MutedAgent made the same call for the same reason.

Ad4mModel, not WeNode, and the contrast with Relationship is the point. A relationship is a
claim — arguable, ratable, authored as a statement. A placement is bookkeeping about a view. It is
still authored, so "one shared arrangement" and "everyone keeps their own" stay a difference of one
where clause.

Membership and ownership are different questions. The seed reads the placements first — they
are the membership — and fetches those records by id, one query per type (where: { id: [...] }, a
SPARQL VALUES pushdown). Containment is left doing the one job it is right for: owning the cards
composed onto the board, which is what makes an unplaced one recoverable in the tray rather than
lost. Without this, a task owned by a call could not be put on a board without being reparented into
it.

A card draws its content. NodeStyle.content names a host-supplied component — WE registers
block, the renderer post cards already use — so a note holding a photo and three paragraphs looks
like one instead of showing sixty characters of its first line. Clipped rather than scrolled, because
a card is a preview and the wheel already means zoom here; contentMinZoom falls back to the label
below the zoom where none of it is legible, which is what stops a hundred documents being a hundred
live component trees.

Making a card is one write. createCardOnBoard batches the record and its placement into a
single commit. Written separately they are two, and anything watching the data layer sees the state
between them — which is exactly what the board did: it drew the new card in the tray and then moved
it.

Connections on a board are the same gesture and the same record as the knowledge map's. The seed
draws only pairs whose two ends are both placed: a line to a record that is not on the board would
leave the canvas and end nowhere, and pulling the far end in to fix that would put things on the
board nobody placed. The filter is client-side because "both ends in this set" is not a where
clause; the query narrows by source, which is the half a backend can do.

A board reads in three rounds, not one query per kind of thing on it. What decides how long a
board takes to appear is the number of sequential rounds — every read is a round trip to a
peer-to-peer data layer — and three is the floor: what is placed, then the records it names, then the
connections between them.

Presentation, per board

Four properties — size, colour, shape, and how large a card's content is drawn — all on Placement,
beside the coordinate and for its reason: they are facts about a pair. Shrinking a post to fit
six of them on a wall is not editing the post, and the same post on somebody else's board must not
change size because of it. It is also what makes them safe to offer without a confirmation anywhere:
each is undone by taking the card off the board.

FieldRef — a style value read off the subject. A rule list is fixed when the template is
written and per-instance presentation is not, so { from: 'data.boardWidth' } joins MetricRef. The
rule that carries the design is what happens when the field is absent: the property is dropped at
merge time
, so it falls through to whatever an earlier rule set rather than to the built-in
default. Without that, a per-card colour would overwrite a per-type colour on every card carrying
none, and the layer behind it would be pointless. Three layers stack on that: the template's own
rules, then the board's key, then the card.

The seed namespaces these on the way out of the placement — boardWidth, not width — because they
land beside the record's own fields, and width on an ImageBlock is the picture's pixel width.

Resizing is eight handles on the selected card, drawn by the renderer rather than run as a
behaviour: every part of the gesture is about the box on screen, which a world-space hit test knows
nothing about. Corners change both dimensions, edges change one, and the edge you are not pulling
does not move — which, since a card is drawn from its centre, means nodeResize carries a position
as well as a size. The drag is drawn locally and emits once on release: one write instead of one per
frame.

Content scale is a multiplier on the content, not a font size and not a zoom: the content is laid
out in a box of 100% / scale and drawn back down, so at 0.5 twice as much of the document fits in a
card whose size on the board has not changed. The document is untouched.

The key

TypeStyle — one colour per (board, type), one record per fact. On the board rather than on
the type, because two boards in a space legitimately disagree: a retro colours by status, a roadmap
by team, and neither is wrong about what a TaskBlock is. A colour stored on the type would make the
last board somebody styled win everywhere, silently.

The panel listing them is a legend that is also a control, because the moment "decisions are amber"
is on screen the next thought is to change it — and because it is the only surface that can say
anything about a type at all: the detail panel is about the card you selected, and "every task on
this board" has no card to select.

The list of types comes from the placements ordered by type, with consecutive repeats suppressed —
the $prev grouping pattern, which is the only way to express "distinct" with the operators there
are. That limitation is written up as §9.4 of the AD4M gaps plan.

Optimistic editing

A board edit is drawn immediately and reconciled when the graph is drawing the real value.
GraphHostBindings.pendingData carries what the host has written and not yet seen come back; it is
applied before the style rules, so an optimistic field is read by { from: 'data.x' } exactly as a
seeded one is and nothing downstream knows the difference.

Letting go of it is the part worth being careful about, and it is pure and tested. Every field must
match rather than any — a node that has caught up on a card's colour but not its size is still a node
the optimistic size is needed for. And it is reported from the drawn node, not from the read that
brought the data: a read landing is not the moment a card is redrawn from it, there is the rest of a
seed in between, and confirming on the read made every edit flash to its new value, snap back, and
arrive again a second later.

Reading an instance

NodeDetail.ts — a panel over one edge of the canvas: the clicked node's type, its scalars, and
what can be done to it. The fields ride on the click payload already, so reading them costs no query
and works for a type nobody wrote a panel for.

It started as a strip along the bottom and the shape was wrong for what it holds. A strip is wide and
shallow and a record's fields are a list, so six of them shared fourteen hundred pixels of width
and one line of height each, and read as a smear. It overlays rather than pushing the canvas, so
selecting a card near the right edge cannot shift what you are looking at.

CardModal.ts — double-clicking a card opens the composer directly. There was a read-only step
in front of it, and it was a copy of the composer with the ability removed: two dialogs deep to fix a
typo, and a non-editing reader saw exactly the same thing either way.

Elsewhere

  • sdnaModels.ts — a newly added model now reaches spaces that predate it. refreshSpaceSdna
    updated shapes that were stale (present in an older form) and nothing installed one that was
    absent, so every query against a new entity failed with "No SHACL shape stored for class X" in
    every space created before the build that added it. The two absences must be answered differently:
    a stored shape with no properties is deliberately read as fresh, because on a freshly-joined
    neighbourhood that is what a shape whose triples have not replicated yet looks like. So the read
    narrows the field and the SubjectClass marker decides.
  • arrayOps.ts — a bare array in $filter's where is set membership, as it already was in
    $query. The default branch was strict equality, so { id: ['a','b'] } matched nothing.
  • composerModal gained onSaved, because it installed its own onSuccess and discarded the
    caller's.
  • RelationshipTypesSection in settings, so a community can name its own kinds of connection.

Known follow-ups

  • Expansion is still not live. Seeds are watched; a node's expanded neighbourhood is not.
  • Creating a card is not optimistic. The overlay patches existing nodes; a new card has no node
    to patch, and a synthetic one needs a minted address and a full node shape.
  • A card's colour does not decide its text colour. Picking a dark swatch leaves the label at the
    shade the template chose for a light one. Wants a contrast rule in the style layer, not a second
    picker.
  • The key lists placed types only, and pages 200 placements to do it. Both go away with a
    distinct-values query — §9.4 of the AD4M gaps plan.
  • Nothing colours by a field yet. TypeStyle is named for the rule it will grow into (colour by
    task status, by author, by recency); today it holds one colour per type.
  • shape: 'template' is declared in the protocol and implemented nowhere.
  • Inline text editing on the canvas stays absent. The argument is weaker now that a card shows
    its content: what remains is text entry in a transformed, zoomable surface, which is its own piece
    of work. Worth revisiting after this has been used.
  • Knowledge and content modes still overlap. Both seed CollectionBlock.
  • Media blocks are not authorable: format: 'file' properties need an upload path in the
    generated form.
  • Durable pins. A board is the degenerate case of a saved view — one where every node happens
    to be placed and the layout is manual. Pinning a node on a knowledge map currently holds it until
    the page reloads, which is the same missing feature. Placement is already the right record; it
    wants something other than a board on the far end of its parent link.
  • Promotion tooling. Nothing counts how often a relationship kind is used, or converts a reified
    relation into a declared one. Both are scripts somebody writes when a space needs them.

Found upstream, tracked in notes/we/August-2026/ad4m-model-gaps-plan.md

§9.1–9.5 were written during this work, each with what it unlocks in WE:

  • @HasOne's generated accessors drop batchId.
  • A scalar handed to a relation field is silently dropped.
  • A subscription cannot tell my own write from a peer's — measured here: every board write costs
    two full re-reads.
  • No way to ask which values a property takes, which is why the key uses a grouping trick.
  • A scalar property cannot be cleared. '', null and undefined are all skipped on update, so
    "no colour of its own" is unwritable. WE now has two independent sentinel workarounds for this
    (PLACEMENT_UNSET, and SpacePreference's two), which is the tell that it belongs upstream.

Test plan

Automated — all green on the final commit:

  • pnpm build — full monorepo, clean.
  • pnpm test — every package. Notably @we/graph-core 176 (up from 123), @we/graph-expanders 53,
    @we/graph-solid 22, @we/app-shell 505, @we/backend-ad4m 224, @we/schema-shared 585.
  • pnpm --filter @we/schema-shared validate — 28 schemas, no issues.
  • npx eslint packages apps — clean.
  • npx tsc --noEmit in the graph-explorer playground — clean (still not covered by pnpm build or
    pnpm test; see the graph engine's own follow-ups).

New tests, each written against a failure that is otherwise silent:

  • Refresh adds a row without disturbing a pinned position; drops a vanished row with its position and
    selection; keeps a vanished row an expansion still holds; does not re-open a node the user
    collapsed; auto-expands arrivals; collapses concurrent refreshes into one extra pass.
  • Watches exactly the types the seeds read; coalesces a burst; watches nothing when live: false;
    starts and stops on toggle; releases on dispose.
  • connect-nodes emits both ends, follows the pointer, says nothing on empty canvas, refuses
    self-connection, claims nothing when disarmed, drops on a lost button, and takes the press before
    drag-node.
  • Behaviour order: select before pan-zoom clears on a background click and after it does not,
    and putting select first does not cost the pan. Ordering reads like a formatting detail and is
    part of the contract.
  • Reified edges with untyped endpoints: type from the row, author's words as the label, nothing drawn
    when the record does not say what it connected.
  • recordDraft: field selection and order, opt-in for core, everything for a shape, blank optionals
    dropped rather than written as '', numbers written as numbers, and a PointerEvent read as "no
    model named" rather than as a name.
  • The board seed: placed cards, unplaced cards, a placed type nobody listed, placements never drawn
    as nodes, nothing loaded before a board is chosen, unknown types skipped rather than queried; a
    connection drawn when both ends are placed and dropped when the far end is not; placement
    presentation namespaced so an ImageBlock's pixel width cannot be mistaken for a card size; the
    unset sentinel read as no value; and the whole board read in three rounds whatever it holds.
  • Style rules: a nested group flattens among hand-written rules and applies in the position it
    occupies; field references defer to the rule above when absent and override it when present; a
    number stored as a string is accepted; an unknown card shape falls back; a content scale that would
    make a card unusable is clamped.
  • Resize geometry: each handle anchors the edge it is not pulling, an edge handle leaves the other
    dimension alone, and a card squashed against the minimum stops moving as well as stops shrinking.
  • Edge clearance: a wide card met on its side, a narrow one met on its side rather than half its
    height away, a diagonal meeting whichever side the ray reaches first, and a plain radius still
    circular.
  • Optimistic writes: every field must match before a patch is dropped; the same object comes back
    when nothing changed; the overlay reaches hit-testing and edge routing, and leaves the seeded node
    alone.
  • SDNA: a class the perspective has never had is reported missing; one whose triples have not
    replicated yet is not; nothing is asked when every shape is already stored.

Not verified by me — this wants manual testing against a running executor, and the parts I could
not exercise are:

  • setSource / setTarget / setNode linking an untyped relation's far end. The decorator adds
    these regardless of typing and the ORM demonstrably skips a relation field handed a plain value at
    create time, so linking afterwards is the only path — but I have reasoned about it rather than run
    it.
  • Seeding relationships, which asks include to hydrate untyped endpoints. Documented as unsupported
    today and due to be fixed by polymorphic HasMany hydration in ad4m; until then it either degrades
    to bare ids and dashed placeholders, or throws. Placement avoids the question by reading bare URIs
    and matching them up itself.
  • where on a relation field (source: [ids]), which the board's connection query is the first use
    of. Reported to push down as VALUES.

Verified by the author during the work — the board was used through several rounds of this, and
the bugs it surfaced are in the branch as fixes: focus loss in the record form, cards landing twice,
a card created on a board that was positioned but not owned, a resize the hit area disagreed with,
panels pinned to the wrong edge, an optimistic edit that flashed and snapped back, and a default
swatch that could not clear anything.

jhweir and others added 30 commits August 21, 2026 00:00
The engine reads its data once, when it mounts. That is right for a map you
explore and wrong the moment the same page can write to what it draws: create a
record and nothing appears, because nothing tells the graph to look again. The
only way to make it look again was `start()`, which is a full reset — it clears
the store, the expansion state, every position, every pin and the selection,
deliberately, because "the spec changed" means "this is a different graph now".

So the playground remounts the whole view on a counter, and a board built that
way would throw away every card position on every write, including a peer's.

`refresh()` is the other half of that pair: same graph, newer data. It re-runs
the seed sources, merges the result into what is already on screen, and drops
only the rows the seeds no longer return — and only where the seeds were the
last thing holding them, since a node the user reached by expanding something
else is theirs, not the query's. Positions, pins, selection, camera and open
nodes all survive.

Two decisions worth naming. It does not fit the camera: a refresh is often not
something the user asked for, and a viewport that jumps whenever a peer writes
makes a shared graph unusable. And it auto-expands only the arrivals — running
the rules over the whole store would re-open every node the user had collapsed,
on every refresh, so the map would keep growing back.

`ExpansionState.releaseFrom` is the reference-counted counterpart to `collapse`,
cascading the same way for the same reason. Seed loading is factored into
`loadSeeds` so `start` and `refresh` cannot disagree about what the seeds are.

`GraphView` exposes it as a `revision` prop — any value, only the change
matters — so a template can bump it from a create action's `onSuccess`.

The test harness's fake layout now honours `previous`. It did not, which is not
a detail: every real layout warm-starts, so a fake that re-derived every
position would have let the engine lose placements no shipped layout loses, and
the test would have passed while the app moved every node on every update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re of one

The graph read its data once and never again. In a system where every space is
shared and other people are writing to it while you look at it, that makes a
knowledge map quietly wrong: it states as fact something that stopped being true
minutes ago, and nothing on screen says so.

The live path already existed and the graph was not on it. `ModelClass` has two
reads — `query()`, which returns a subscription, and `findAll()`, which returns
a promise — and `$query` takes the first, which is why a post appears in the
cards route the moment it is written. `GraphHost` took the second, because the
host port is shaped as a promise and has nowhere for a second delivery to go.

Rather than widen that port, the engine watches *types*: `ExpanderContext` gains
an optional `watch(entity, dataset)`, and the seed sources are handed a context
that records what they read. So the engine learns what to watch from the reads
themselves and never parses a seed source's options — a seed plugin nobody has
written yet becomes live for free, where option-sniffing would work for the
sources that happen to spell it `entity` and fail silently for the rest.

Watching a type rather than a query is deliberate. The signal is "look again",
and the answer is a reconciling refresh, which is idempotent; mirroring each
where-clause would cost a subscription per query for no better an answer and go
stale as soon as a clause referenced anything reactive.

Notifications are debounced: one user action is many writes — composing a post
writes the collection and every block in it — and a graph that re-queried per
link would run a dozen rounds to reach the state the first one already had.

`live: false` is there for a graph that must hold still: a diagram in a
document, a thumbnail, anything being presented. `watch` is optional on the host
for the same reason — a fixture with no change notification omits it, and the
graph stays as loaded rather than waiting for news that never comes.

The host's implementation ignores the first delivery, which is the current state
rather than a change, and holds a flag for the write that lands before the
initial page resolves — the one update that would otherwise be lost is exactly
the one that arrived while the graph was opening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… from a model

WE can already describe a model as data — properties, types, defaults, closed
vocabularies, relations. What it cannot say is which models a *person* creates
by hand and which fields are theirs, and both are needed before any surface can
offer "create one of these" without a hand-written form per model.

`EntitySchema.authoring` says both at once, because they are one fact. Most
entities are not hand-authored at all: `AgentSettings` and `ReadMarker` are
written by the app, `TextBlock` and `DividerBlock` exist only inside a composed
document. And of one that is, only some properties belong to the author —
`version` is bookkeeping, and `EventBlock.occurrence` is a dedup key whose own
comment explains that a hand-made value would collide two events into one. A
form that showed them would ask a person to fill in the implementation.

Naming the fields rather than flagging what to hide also fixes their order,
which a form needs and a property record does not reliably carry. Absent means
"not authored by hand" — an entity gains a form by someone deciding it should.
Community-defined shapes are the other way round: every property of a shape
somebody wrote is theirs by construction, so they never carry a declaration.

`PropertySchema.control` is the sibling of `options`, for the cases one storage
type covers several kinds of value. `TaskBlock.dueDate` and
`EventBlock.startDate` are both `string`, and their interpretation hints each
spend a sentence saying one is a date and the other a datetime — a fact the
schema was carrying only in prose aimed at a language model. Presentation, not
storage; `format` remains about where bytes live.

`options` on TaskBlock's status and priority states the closed vocabulary those
hints have always described. Worth having twice: the hint tells a model when to
pick each value, and a select stops a person typing "urgent" and producing
exactly the unrecognised tag the hint exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A space's vocabulary is open-ended — a community defines its own models through
the shape wizard, and WE ships a handful of its own — so a surface offering
"create one of these" cannot know at authoring time what it will be asked to
create. Writing a form per model is the thing that does not scale: the wizard
exists so a community can add a model without anybody writing code, and a
hand-written form would leave the model authorable and its records not.

`recordDraft.ts` derives the form from the manifest: which fields, in what
order, with which control, starting at which default. Pure and framework-free,
because the mapping from a declaration to a set of controls is the part with
rules in it and is worth testing without mounting anything.

`RecordStore` holds the draft, and is separate from `ShapeStore` on purpose.
That one is about defining a model — an occasional, admin-shaped act by whoever
shapes a community's vocabulary. This is about using one, which is everyday and
everybody's. They share a manifest and nothing else, and a wizard whose draft is
a list of field declarations has little in common with a form whose draft is a
list of values.

The draft lives in a store rather than `$localState` for the reason the shape
wizard's does: `$localState` names are fixed when a template is written, so a
form over a model chosen at runtime has no names to declare and no `$setLocal`
can address a field nobody knew about.

Classified as 'content' in the template surface, not 'space-settings'. Defining
what a "Sighting" is belongs with renaming the space; creating one is writing a
record, the same act as posting, and belongs in the tier every template reaches.

Two details worth their comments. A blank optional field is dropped rather than
written as `''` — the ORM skips an empty string on update, so a written one is a
value that can never afterwards be cleared. And a save failure lands in the form
rather than only in a toast, so the modal stays open holding what was typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The graph route could draw a community's vocabulary and do nothing with it. In
schema mode it shows every model the space carries, and until now the only thing
you could do with a type on that map was look at it.

`recordFormModal` renders the derived form; the route puts a New button beside
the layout picker and the modal above the canvas. Above rather than inside: a
graph is a transformed, zoomable surface and text entry on one is its own piece
of work — the board notes say so twice — and what is being authored is a record,
which has nothing to do with where it will land.

Every control is written out in the fragment rather than compressed behind a
`type` prop, because which prop carries the value and which event returns it is
per-component knowledge: a date is a `we-date-picker`, a closed vocabulary is a
`we-select`, a boolean is a `we-switch`. That table belongs in the layer that
already knows the components — the same argument `field()` makes about not being
a `$field` operator. And it cannot be a `field()` call per row either: `field()`
binds to `$localState`, which is precisely what a form over a runtime-chosen
model cannot use.

The model picker only appears where there is more than one model, since offering
a select with a single option asks a question whose answer is already on screen.

`revision` is a boolean rather than a counter. The schema language has no
arithmetic, so `$toggleLocal` is the whole of "something happened" available to
a template, and the graph only compares the value to the last one it saw. It is
belt and braces beside the live watches: a watch depends on the backend
reporting the write, and this is the one case where the template knows something
changed because it is what changed it. The graph merges either way, so the
update arriving twice costs a query and changes nothing on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… words

Every edge WE could draw came from a schema: a relation declared on a model,
followed forwards or backwards. That renders structure somebody already
committed to, and it cannot express what a knowledge map is for — noticing that
this contradicts that, that a task came out of a call, that two people's notes
are about the same idea. Those are claims made after the fact, by a person,
about a pair of records nobody anticipated relating.

An entity rather than a link, because the connection has to carry things. As a
WeNode it gets comments, signals, mentions and an author for free, and all four
are the point. A claim that two things are related is exactly what people argue
about, and the argument belongs on the claim rather than on either end of it.

There is deliberately no `weight` property. "How strongly?" is a community
judgement, not a number the person who drew the line gets to set — and
`SignalType` already carries mode, range and aggregate, per agent, so a weight
becomes something a space computes. A scalar here would be one member's opinion
wearing the clothes of a fact.

The endpoints are untyped because the whole point is connecting a TaskBlock to a
community's own Sighting to a CollectionBlock. `CollectionBlock.children` is
already untyped, so the shape is not new. `sourceType`/`targetType` are: a graph
address is minted from dataset, type and id, so drawing this as an edge needs
both ends' types and an untyped relation cannot supply them. Storing the names
means an edge can be drawn from the relationship alone, with no round trip to
ask each end what it is.

Both generators grew the untyped to-one case, which neither had met — the class
generator was emitting `@HasOne(() => , …)` with an empty class, and the type
generator `source?: Model` naming a type that does not exist. Both now mirror
the untyped to-many they already handled: a URI, and no `set<Name>` companion,
since that accessor's whole signature is its target type.

A space model rather than a root one: a relationship is a claim made *to* a
community, and one held privately would be a note to self wearing the shape of a
shared statement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gesture half of `Relationship`. `connect-nodes` drags a line from one node
to another and emits `edgeCreate` with both ends; it writes nothing, because
what connecting two things *means* differs completely between a knowledge map, a
board and an outline, and the engine has no write path anyway. The same rule
`we-sortable` follows: emit intent, never mutate.

Armed by an option rather than a modifier key. The modifiers are taken or do not
travel — shift already extends the selection, `PointerInput` carries no
`ctrlKey`, and `metaKey` is Command on a Mac and the window manager's on Linux —
and more importantly a modifier-only gesture is invisible, with no modifiers at
all on a touchscreen. A template arms it from its own control, which also gives
the user somewhere to see that dragging currently means connecting.

The line following the pointer is not decoration: without it, connecting two
nodes means pressing on one, moving across a canvas that looks completely inert,
and hoping. It is engine state rather than the behaviour's because behaviours
never touch the DOM — and deliberately not an edge in the store, since it stands
for nothing yet and every consumer of the store would have to learn to skip it.
It gets its own change reason for the same kind of reason: `positions` re-runs
the node and edge projections, which is right when nodes have moved and absurd
for one straight segment on a settled graph.

The backward pass had a real gap this depends on. It filtered candidate
relations on `relation.target === address.type`, so an untyped relation was
never followed — and a drawn connection is untyped by definition. Untyped
relations are now candidates, but only on a reified class, and that restriction
is load-bearing: reification is a declaration that this class *is* an edge with
these two ends, where `CollectionBlock.children` carries no such statement and
following it here would make every node reverse-scan for every container in the
space, for an answer the `collection` expander already gives.

The test fake now answers a reverse lookup honestly — a row comes back only from
the end it is attached to. Returning every row for both endpoint queries made
one relationship look like two edges, which is the fake lying rather than the
code being wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The template half of Relationship. The knowledge map now seeds relationships
alongside content, arms the connect gesture from a visible toggle, writes what
the gesture produced, and opens a connection back up so it can be read, rated,
replied to and retracted.

Relationships are *seeded*, not only reached by expanding a node. A connection
nobody has gone looking for is still something the community asserted, and a map
that only showed the ones you had already found would hide exactly the ones you
did not know about. They draw as edges rather than nodes, so seeding adds lines.

Drawn connections are styled apart from schema edges — heavier, coloured, and
carrying their author's own words as a label. The distinction that matters on
this map is "a schema says so" against "a person says so", and the second is the
one worth arguing with.

Creating one reuses the record form wholesale: `Relationship` declares its
`authoring` fields like any other model, so `connectNodes` opens the same modal
on the same save path, with the endpoints held in the store rather than in the
draft — they came from a gesture, not from typing, and nothing should offer to
edit them. The form names what is being connected without making it editable,
because "Post → Sighting" above the label box is the difference between filling
in a form and knowing what you are asserting.

One thing worth its comment in the store: the endpoint *types* go in with the
create payload and the endpoints themselves are linked after. They are different
kinds of write — `innerUpdate` skips a relation field holding a plain value, so
`create(p, { source: uri })` typechecks, runs, and writes no link, leaving a
relationship with no ends that is drawn nowhere.

Both graph events now resolve addresses for the template. `onEdgeCreate` hands
over each end's record id, type and label; `onEdgeClick` resolves `reifiedAs`
into `recordId`. A template has no operator that could take a graph address
apart, so the events answer the questions they raise rather than handing over
something the reader cannot decode — and `onEdgeCreate` refuses non-entity nodes
outright, since a property or a literal has no record to connect.

The detail modal is a modal because a thread cannot live in a one-line strip,
and it reuses `commentThread`, `agentByline`, `SignalControl` and `composerModal`
unchanged. That reuse was the point of making a connection a WeNode: none of it
had to be built twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fourth mode, and different in kind from the three before it. Force settles a
knowledge map, tree ranks containment, the schema map is arranged from relations
nobody chose — every one of them derives an arrangement. A board is where
somebody put a thing somewhere because that is what they meant, and the layout's
whole job is to leave it alone.

The playground has proved the recipe for a while: `manual` layout, card-shaped
nodes, `drag-node` with `pin: true`, `lock` rather than `pin` in the controls,
and `onNodeDragEnd` writing the drop back. What it could not prove was where the
positions live, because its fixture invented `x`/`y` on `CollectionBlock` and the
real model had none.

They are on `CollectionBlock` now, and the alternative is worth naming: a
`Placement` record pointing at a card would let anything be placed and let one
record appear on several boards, both real. It also means a board's contents are
not its children, so nothing that already walks a collection would find them,
and every card becomes two records to create, keep in step and clean up
together. A collection is already WE's container and `kind` already anticipated
a board. The general form stays reachable later without moving any of this.

Optional rather than defaulted to zero, deliberately: a card at the origin and a
collection that was never on a board are different things, and defaulting would
put every post at the top-left of every board it ever touched.

A card is a `CollectionBlock` parented through `we://children` — a post that
happens to live on a board. Not a shortcut: it holds composed content, carries
comments and signals, and is found by everything that already walks a
collection. Nothing about it is board-shaped except the two numbers.

Two small enabling changes. The `query` seed takes a `scope`, because a board's
cards are its children and containment is a link with no field to filter on; a
scope whose anchor is not yet chosen loads nothing rather than filling the canvas
with every card in the space. And `onNodeDragEnd` now carries `recordId`, since a
template writing a position back needs the record, not the graph's address for
it.

Inline text editing on the canvas stays absent, for the third time in this
codebase and the same reason: text entry inside a transformed, zoomable surface
is its own piece of work, and faking it would teach the wrong thing about what
exists. Compose in the modal, drag afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A graph draws structure and hides everything else, which is what makes it
readable and also why a node on its own says almost nothing: a dot labelled
"Ship the docs" is a record with six fields and the map shows one of them.

Two questions, answered in the two places they belong. What the record *holds*
is its scalars, listed in the strip — they ride along on the click payload
already, because `rowToNode` puts an entity's scalars in `data`, so reading them
costs no query, no expansion, and works for a type nobody wrote a panel for.
What it *connects to* is a question about the graph, so Relations and Fields
expand the node in place and the answer appears where the question was asked.

`expandRequest` is how a panel asks. Double-click expands with whatever
`expansion.expanders` names, which is one question; "show me its own fields" and
"show me what it relates to" are two, and a map that can only ask one makes an
instance something you look at rather than something you open. Naming the
expanders lifts the exhausted-node guard for that call only, so a repeat
double-click still does nothing — which is what stops a re-expansion looking
like a broken one.

`fields` is derived in the adapter because `data` is a record and a schema has no
way to iterate one; `$each` takes an array, so this is the only place it could be
done. Nulls and empty strings are dropped rather than rendered, since an absent
property is absent and listing it states something the record does not.

Two corrections to earlier work in this branch. The schema layer *does* have
arithmetic — `{ $setLocal: 'x', by: 1 }` reads the current value and adds — so
`revision` is a counter again and the claim that a template cannot increment one
is gone from the prop's documentation. And `$setLocal`'s `value` is a literal
that is never resolved, which is why the expand request is composed from a
stored *kind* rather than written whole by a button. Both are now documented in
the AI context, where neither was: `by` existed and was invisible, which is how
I got it wrong in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…obody knew

Three things a schema author — human or otherwise — could not have known.

`recordStore` was registered and every member of it read `unknown`, which is the
"136 store members are valid in schemas but undocumented" figure with a new
entry. A store whose whole point is that it serves models nobody wrote a form
for is a poor one to leave undescribed.

`{ $setLocal: 'x', by: 20 }` exists, adds to a number field, and appears in no
fragment. I asserted twice in this branch that the schema layer has no
arithmetic and built around the absence; the operator was there the whole time,
in the resolver, with a comment explaining exactly why.

And `$setLocal`'s `value` is a literal that is never resolved — a token object
inside it is stored as the object rather than as what it would resolve to. That
one is worth stating because it fails silently and looks like a binding that did
not fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el name

Clicking New on the graph route raised `No model named "[object PointerEvent]"
in this space`.

`$action` with no `args` does not call the method with no arguments — it
forwards the handler's own, so a click handler passes the DOM event as the first
parameter. That is deliberate and load-bearing: it is how
`{ "onChange": { "$action": "store.method" } }` passes a value straight through.
What it means is that any store method with an *optional* leading parameter can
be handed an event by a template written the obvious way, and
`openRecordForm(entity?)` is one. `args: []` does not help either, since an empty
list is read as "no args given".

Fixed in three places, because the trap is worth closing at each. The call site
passes `args: ['']` and says why. The store coerces through `asEntityName`, since
there will be more call sites than there are stores. And the AI context now
documents the forwarding behaviour, which appeared nowhere — the `$action` entry
said "optionally with arguments" and left the omitted case to be discovered.

The guard is a named function in the pure layer rather than an inline `typeof`
so the rule is testable without mounting anything, and so the next store method
with an optional leading string has somewhere to reach for.

Also hides New in board mode. `boardBar` already offers Card there, and a record
created from a board would be a real record that simply did not appear on the
board it was made from — the same lesson as an empty menu, by a slower route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reports from the board, both fair.

**A card did not appear until you navigated away and back.** `composerModal`
takes only `$action` and `args` from its `saveAction` and installs its own
lifecycle — closing on success and clearing the in-flight flag are what the
handshake is for, and a caller's `onSuccess` would silently replace both. The
side effect was that nothing could react to a save at all, so the board's card
composer had no way to tell the canvas to re-read.

`onSaved` runs alongside the fragment's own rather than instead of it, and the
board uses it to bump `revision`. The engine also watches `CollectionBlock` and
should have got there by itself; that path is unverified against a running node,
and either way waiting on a notification for something you just did is how a
board comes to feel broken.

**New was gone in board mode.** I hid it last commit because a record created
there would not appear on the board. That was the wrong call: this is the only
way to create a model instance anywhere in the app, and removing the sole entry
point to a capability because one view cannot draw the result leaves somebody
with no way to do it at all. It is back in every mode.

What a board can *hold* is the real question underneath, and it is not answered
here: positions live on `CollectionBlock`, so a board can only show collections,
and the seed queries one entity type rather than opening the board's children of
any type. Both are changeable and neither is a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`x`/`y` on `CollectionBlock` was wrong twice over. It meant only collections
could be placed, so a board could never hold a task or one of a community's own
models; and it meant a record had one position, so the same note pinned to two
boards in two places — the ordinary case — was unrepresentable. The file's own
comment even says kind-specific state does not belong there, which I then put
there anyway.

A position is a fact about a **pair**: this board, this node. `Placement` is that
pair, with the coordinate on it.

Many small records rather than a map on the board, because a board holding
`{ nodeId: {x,y} }` is a read-modify-write and a shared board is the worst place
for one — two people dragging two *different* cards would clobber each other,
and the loser would watch their card snap back with no explanation. `MutedAgent`
made the same call for the same reason. Two people dragging the *same* card is a
real conflict and last-write-wins answers it; two people dragging different cards
is not a conflict at all and must not be turned into one.

`Ad4mModel`, not `WeNode` — the contrast with `Relationship` is the point. A
relationship is a claim: arguable, ratable, authored as a statement, which is
exactly why being a node matters for it. A placement is bookkeeping about a view;
nobody wants to argue with a coordinate, and a comment about a sticky note goes
on the note. It is still authored, because every expression is, so "one shared
arrangement" and "everyone keeps their own" stay a difference of one `where`
clause rather than a migration.

Membership stays containment and only position moves. That is what keeps the
composer path untouched, keeps a board findable by everything that already walks
a collection, and — the practical part — avoids needing to fetch placed records
by id, which the `where` vocabulary cannot express: there is no positive array
membership, only `not`.

The `board` seed reads placements first, because they decide what else is worth
loading. `contains` is what a template could anticipate; the placements name
everything anybody actually put there, which is how a board holds a `Sighting`
nobody had heard of when the seed was written. It draws no edges: containment is
how a board holds things, not what it is about, and edges would make it a
hub-and-spoke diagram around a parent that is not even on the canvas.

`collectionExpander` gains `exclude`, plus an unconditional refusal to open a
collection into its placements — a dot per card, saying nothing and doubling the
node count. A denylist rather than an omission from `children`, because the two
answer different questions: one is what a template wants, the other is what is
never worth drawing whatever anybody wants.

`createOnBoard` is the counterpart to `connectNodes`: the same form and the same
save path, with an intent held beside it, so a record created from a board lands
on it. That is what the New button should have done instead of being hidden.

Not settled, and deliberately: the container is a board today and should be a
*saved view* eventually — a board being the degenerate case where every node
happens to be placed and the layout is manual. The evidence is already in the
engine, where pinning a node on a knowledge map holds it exactly until reload.
When that becomes durable it wants this record with something else on the other
end of the parent link, which costs a query rather than a model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A connection could live in two places: a free-text label on a `Relationship`, or
a relation declared on a model class. The gap between them is most of the useful
ground.

Free text is where a vocabulary is *discovered*, and it is right while nobody
knows yet what kinds of connection a community makes — which is the state every
new space is in. What it cannot do is be queried or drawn: "contradicts",
"Contradicts" and "contradicts?" are three different relations to a `where`
clause, so a template cannot filter on one, count them, or give a kind a colour.

A declared relation is the other end. Full query surface — `include` hydration,
ordering by a related property, count projections — and it can carry nothing
about the connection itself. No author, no date, nothing to comment on or rate.

`RelationshipType` sits between them, and is deliberately the same shape as
`SignalType`: a community names its own vocabulary, as data, in its own space,
and instances reference it by id. A record, so adding one is not a schema change
and any member can propose it; identified, so a query filters on it and an edge
style keys on it. `directed` and `inverseName` are here because "contradicts" is
asymmetric and "related to" is not, and an arrowhead on the second asserts
something nobody meant.

`Relationship.label` is no longer required. A connection needs a kind or a
label, which the form enforces because the schema cannot express "one of these
two"; once kinds exist the label becomes the qualifier on top of one —
"contradicts, *specifically about the timeline*".

Style rules can now be built from data, which is what makes the tier visible
rather than merely queryable. A schema cannot merge two arrays — `$concat` joins
strings — so a template wanting a base rule plus one rule per row had no way to
write the combined list, and styling driven by a community's own vocabulary was
unreachable. Rule lists flatten one level, so a `$map` over the named kinds
contributes rules beside the hand-written ones, in the position it occupies.
`referencedMetrics` flattens too: a metric named only by a mapped rule would
otherwise resolve to its fallback, and the graph would draw plainly for no
visible reason.

`docs/architecture/relations.md` writes the whole thing down — the axis that
decides it (a fact about the *type* versus a claim about a *pair*), five
decision rules, the promotion trigger and its migration cost, and worked
examples. `packages/models/CONVENTIONS.md` points at it from where somebody is
about to type `@HasOne`, and the architecture fragment carries the short version
into the generated context.

Worth noticing that `Signal`/`SignalType` arrived at this same three-tier shape
independently, before any of it was written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A warning telling you an expander failed is a message you want to copy, and it
could not be selected: a press on one gave you the grab cursor and panned the
board behind it instead.

Two things were in the way, both correct for a canvas and wrong here. The root
sets `user-select: none`, because a drag on a direct-manipulation surface moves
the camera rather than selecting text. And the status strip is
`pointer-events: none`, so a press fell straight through to the surface beneath.

Only the message boxes take the pointer back — the container stays transparent,
so the canvas is still pannable everywhere the messages are not. `user-select`
inherits into shadow DOM, so setting it on the alert host covers the alert's own
text without reaching inside it.

Warnings are deduped in the engine, so the strip is bounded by the number of
distinct messages rather than growing over a session. That is what makes it safe
for it to hold the pointer at all — a strip that grew without bound would end up
covering a corner of the canvas nobody could pan through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, and the second is why it survived the drag that should have answered it.

**The condition was wrong.** `manual` warned whenever no node carried `x`/`y`,
which is every board before anybody has dragged a card — so it fired as a matter
of course on a board working exactly as intended.

The failure actually worth reporting is choosing `manual` for a graph that stores
no positions: every node keeps where the previous layout left it, so on screen it
is indistinguishable from a layout that ran and decided nothing needed moving,
and "it silently does nothing" is the conclusion people reach. A fresh board is
not that case — its nodes get parked into a grid, which is a visible arrangement
and the whole reason `unplaced` exists. So the test is now what *happened*
rather than what was read: nothing from data, nothing parked, and something
reused means the layout was a genuine no-op.

**And it could not retire itself.** Warnings accumulate and clear only on
`start()`, so a complaint true of an empty board stayed on screen after the first
drag made it false. A layout warning describes the arrangement *as it is now*, so
a later arrangement supersedes it — otherwise a reader has no way to tell a live
warning from a spent one, which is worse than the thing being reported.

Expander and seed warnings deliberately keep accumulating. Those describe an
event — a query that failed, a scan that truncated — and an event does not stop
having happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hing

Typing in the create-record form lost focus after one character.

`$each` renders rows with Solid's `<For>`, which keys on **object identity**, and
`setRecordField` rebuilt the draft on every keystroke — new array, new field
objects — so every control was torn down and remade under the cursor.

The shape wizard already solved this and its comment says so: typed fields are
mutated without touching the draft signal "so inputs keep focus". I read that,
wrote a comment dismissing it as a cost the wizard paid for reasons that did not
apply here, and shipped the bug it describes. The reasoning was beside the point
— `<For>` does not care what a value is *for*, only whether the object holding it
is the same one as last time.

Mutating is safe here rather than merely expedient, which is the part worth
keeping straight: which control a row renders comes from `field.control`,
validation runs at save, and the typed text is already in the DOM. The wizard
needs its `commitDraft` because its rows genuinely derive things from what is
typed; this form has nothing to keep in step.

The second bug was in the same modal and would have been the next report. The
kind picker wrote `relationshipTypeId` through `setRecordField`, and that
property is deliberately absent from `Relationship.authoring.fields` — so it
found no field and silently did nothing. Choosing a kind did nothing at all. The
chosen kind is now held beside the draft, like the endpoints and the target
board, and merged into the create payload only when one was picked: an empty
string would write a reference to a kind that does not exist, and the ORM cannot
later clear it.

`writeFieldValue` is a named function in the pure layer so the invariant is
testable without mounting anything. What the tests pin is not the value — that
part is obvious — but that the array and the field objects come back as *the same
objects*, which is the whole of the fix and the part a later tidy-up would
otherwise quietly undo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d on it

Creating a CodeBlock from a board wrote its placement and left the record loose
in the space. It existed, it had a coordinate, and it was invisible — turning up
in the cards route, which asks the *space*, and nowhere on the board, which asks
the *board's children*. That difference is exactly the shape of the bug from the
outside, and it is what the report described.

A board holds things by containment and positions them by placement. Those are
two facts and it needs both: the design note said membership stays containment,
and then `createOnBoard` wrote only the position. Records created on a board are
now parented to it as well.

`DEFAULT_CONTAINS` drops to `['CollectionBlock']` while I am here. There is
exactly one way onto a board that leaves no placement behind — a card composed
straight onto it, which is always a collection — and everything else arrives
*placed*, so its type is already named by the placement and already queried. The
old list named `TaskBlock`, `EventBlock` and `ImageBlock` on the reasonable
grounds that a board might hold them; it might, and when it does they have a
placement, so those were three drill-down queries per load and per refresh
looking for what cannot be there.

That also improves the failure mode of the one thing on this branch I have never
been able to run. If `setNode` does not link a placement to its record, the type
is still named, the record is still a child, and it appears unpositioned — where
before it would simply have been missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five things, all downstream of one: a card that could only show a label was a
card you had to open to learn anything from.

**Cards draw their real content.** `NodeStyle.content` names a component the host
supplies through a new `nodeContent` binding; WE registers `block`, which mounts
`BlockRenderer`. So a note holding a photo, a task and three paragraphs looks
like one — where before it showed sixty characters of its first line, with
nothing to say the rest existed.

The seam is the point. `BlockRenderer` drags in the block system, the design
system and a Lexical tree, and a graph package that imported any of that would
stop being portable. Named from a template, supplied by the host, falling back to
the label when a deployment has no such component — the same ladder as expanders,
layouts and behaviours. This is what `NodeVisual.shape: 'template'` was reserved
for, and what `scalars()` meant by "anything else belongs behind a node
template"; it arrives as `content` on a card rather than a shape of its own,
because a card's shape is still a card and only its inside varies.

`contentMinZoom` is the thing that decides whether this scales: a hundred
documents rendered at once is a hundred component trees, and at the zoom where a
board reads as coloured rectangles none of them is legible anyway.

**And every composed card read "CollectionBlock".** A post has no `title` — the
composer writes `editorState` and a flattened `textContent`, which exists "for
search and preview" and was missing from the label candidates, so the label fell
through to the entity name. The 60-character cap went with it: that was tuned for
a caption beside a dot, and a postit stopping mid-sentence looks like truncated
data rather than a truncated caption. Both cases already ellipsise in CSS.

**Nothing invents a coordinate any more.** `createOnBoard` wrote `(0,0)` when
nobody had chosen a point, which is the world origin — reliably wherever the
reader is not, which is why a new card appeared off screen. A record with no
placement is now genuinely *unplaced*, and `manual` parks unplaced nodes in a row
inside the visible rectangle: a tray of things that are on the board and nowhere
in particular. That needed the camera, so `LayoutInput` gained `visible` — a
layout that *puts* something rather than deriving where it goes has to know where
the reader is looking.

**Double-click empty canvas to make something there.** `canvas-double-click`
emits the world point and writes nothing; the board opens the composer and places
the card where the click landed. Position first, then content — forced by the
composer being a modal that covers the canvas, and the better order anyway: you
know where a note goes before you know what it says. It claims only the
background, so it composes with `expand-on-double-click` rather than competing.

**Double-click a card to open it.** The full post, with Edit and Delete. Editing
reconciles through `updatePost`, so a card keeps its comments, its signals and
its placement. `createPost` now returns the new id — `createBlocks` always
returned the model and it was being thrown away — which is what lets `$result`
place a card a schema has just made.

In-place editing stays deferred, and the reasoning has changed rather than
repeated: once the content is *visible* on the card, what typing in place buys is
small next to what it costs. Worth revisiting after this has been used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is in $query

I claimed the where vocabulary had no positive array membership, and used that
to justify a design decision. It was wrong, and checking it turned up a real bug
underneath.

`$query` has always had it. `queryCompiler`'s `fieldCondition` compiles a bare
array to the IR's `in`; `ad4mAdapter` declares `in`/`nin` native; the executor's
`WhereCondition::StringArray` *is* the IN operator and pushes down to a SPARQL
`VALUES` clause. So `where: { id: [a, b, c] }` has always worked, and been
index-friendly rather than a scan.

`$filter` did not. Its default branch was strict equality, so a bare array was
compared against the array *object* — matching nothing, silently. Exactly the
divergence the anchored matchers beside it were added to close, and for the same
reason: the docs promise one operator set across `$filter` and `$query`, so every
operator living on only one side is a clause that works until somebody moves it.
Nothing is lost by treating an array as membership, since strict equality against
one could never have matched.

The root cause is the documentation. CLAUDE.md lists `not` with an array
("excludes multiple values") and never mentions the positive form, so the
operator was invisible to anyone reading the reference rather than the compiler —
which is how it cost a design decision. The fragment now names it, says it is
native and pushed down, and says an empty array matches nothing.

The board design does not change. Membership stays containment, but on its own
reason rather than a borrowed one: a card born on a board belongs to it, so a
collection walk finds it and deleting the board takes it with it. What the
operator *does* unlock is placing a record on a board without reparenting it —
worth having when there is a gesture that needs it, which there is not yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**Every card was empty.** A file-backed property resolves to a
`data:…;base64,…` blob rather than to JSON — that is what `decodeFileAsString`
exists for — and `BlockCard` ran `JSON.parse` on it, threw on every card, and
rendered nothing. `BlockRenderer` already decodes a string it is handed, so the
fix is to hand it over untouched and stop doing its job badly.

Worth noting the failure was silent by construction: the parse was in a `try`
that returned `undefined`, and an undefined state renders nothing, so a hundred
broken cards looked exactly like a hundred empty ones.

**And a placed card appeared twice.** Creating a card at a point is two writes —
the card, then its placement — and `revision` was bumped as soon as the *card*
existed. So the canvas re-read, found a card with no placement, and drew it in
the tray; the placement landed a moment later and the card jumped to where it had
been put.

The refresh belongs after both writes, so it now hangs off `placeOnBoard`'s own
`onSuccess`. Sequencing through the action's lifecycle is what makes "the write
finished" mean the whole write rather than the first half of it.

A residual flash is still possible if the placement is slow enough that the
engine's own watch fires between the two writes. The robust answer is to stop
containment being what puts a card on a board — placement alone would mean an
unplaced card is invisible rather than tray-bound, and fetching placed records by
id is expressible now that the bare-array `in` is known to work. That changes
what deleting a board does to the cards on it, so it is a conversation rather
than a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three facts were doing duty as one, and separating them fixes two problems at
once — but only after noticing they were two problems.

**Ownership** is containment: where a record lives, and what a delete cascades
to. **Membership** is a placement's existence: that a record appears on this
board. **Position** is that placement's coordinates. Letting containment carry
ownership *and* membership is what made "a note born here" and "a task brought
here" indistinguishable — putting an existing record on a board would have
reparented it out of whatever it came from.

So placed records are fetched by id, from the placements — one query per type,
`where: { id: [...] }`, native on AD4M and pushed down as a SPARQL `VALUES`
clause. Containment is still read, but only for what a board *owns* and nobody
positioned: the tray, which is a recovery surface rather than a third kind of
membership. A task owned by a call now belongs on a board without being moved
into it.

**And the flash was never a model problem.** Creating a card at a point is two
writes, and anything watching the data layer sees the state between them —
changing what membership meant would only have swapped one intermediate for
another. The fix is atomicity: `runModelTransaction` gains `join`, so a write
group can run inside one that is already open; `createBlocks` accepts a batch
rather than always opening its own; and `createCardOnBoard` composes the card and
records its position in a single commit.

The node reference goes in as a one-element array, which is what makes it part of
that commit. The ORM skips a relation field handed a plain value — the trap
`Relationship`'s endpoints hit — and the generated `setNode` accessor takes no
batch, so linking afterwards would commit separately and reintroduce exactly the
intermediate state being removed. An array routes through `setRelationValues`
*with* the batch, which is the documented path and the only one that composes.

The board seed's test fake had to learn the by-id path. One that only answered
drill-downs would make a placed-but-unowned record look unreachable, which is
precisely the case the split exists for — the fake would have been asserting the
old model while the code implemented the new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Double-clicking a card did nothing. `nodeDoubleClick` was declared in the
protocol, routed by the Solid adapter, exposed as `onNodeDoubleClick`, and
emitted by **no behaviour at all** — so a template binding the prop got silence.

Declared-and-unimplemented is the quietest kind of gap: everything typechecks,
the wiring reads as complete end to end, and the gesture is simply inert. Same
class as `shape: 'template'`, which sat declared and undrawn until a card needed
it. Worth watching for elsewhere in this protocol.

`node-double-click` emits it, and pairs with `canvas-double-click`: they divide
the gesture by where it landed rather than competing for it, so a template lists
both and exactly one fires. Kept separate from `expand-on-double-click` because
that claims the same gesture to do something else — an explorer opens a node's
neighbours, a board opens the node.

The payload gained `recordId`/`recordType` too. Opening a node means opening the
record it stands for, and a template has no operator that could take a
`we-graph://` address apart to find it — so even once emitted, the board's
`$event.recordId` would have been undefined and the modal would have opened on
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The selected node's details were a row along the bottom, and the shape was wrong
for what it holds. A strip is wide and shallow; a record's fields are a list. Six
of them got one line of height each and a share of fourteen hundred pixels of
width, so they read as a smear. A column affords two lines per field — the name
above the value — which is what makes them scannable at all.

It overlays the canvas rather than taking a slice of the route. Pushing would
resize the graph surface, which changes the world rectangle on screen: selecting
a card near the right edge would shift what you are looking at, and the node
being described could slide underneath the thing describing it.

Replacing the strip improves every mode rather than the board, since all four
shared it — a knowledge-map node's fields were equally cramped.

**Two surfaces, two questions.** The panel answers *what is this* — type, fields,
Relations and Fields. The modal answers *read and edit this*. A post in a
three-hundred-pixel column is worse than a post in a modal, and metadata in a
modal is worse than metadata in a panel: it demands dismissal for something you
wanted at a glance. So the panel describes and offers Open, and the card modal
moved out of the board to the route, because a `CollectionBlock` is a composed
document wherever it was found.

Dismissing the panel leaves the node selected. Deselecting too would be easier to
write and wrong: you dismiss a description because you have read it, not because
you are done with the thing — and the selection is what the expand buttons and
the graph's highlight are keyed on.

**`removeFromBoard`** is the payoff of placement being membership, and completes
the pair with dragging something on. It deletes the placement and nothing else: a
task removed from a board is still owned by the call it came out of, and a card
the board owns survives as an unplaced one in the tray.

Two details worth their comments. The modal binds to the *selection* rather than
to a copied id — there is nowhere to copy an id to, since `$setLocal`'s `value` is
a literal and its `from` reads the event, and binding to the selection cannot
drift from it anyway. And the content tree deliberately keeps
`expand-on-double-click` where the other modes take `node-double-click`: drilling
in is that mode, only one behaviour ever sees the gesture, and an open handler
there would be config that could never fire.

Not done: the panel is a fixed 320px. I said resizing would be "nearly free" and
it is not — `we-resize-handle` reports a delta from the drag's start, and
computing a width from it needs to read one local and write another, which
`$setLocal` cannot do. It wants a store action, like the editor's panels have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an read

Four things from using it.

**Opening a card went through a read-only modal first.** That modal rendered the
card and offered an Edit button which opened a *second* modal to change it — so
it was a copy of the composer with the ability removed, and fixing a typo was two
dialogs deep. The composer already displays a document properly, and a reader who
never edits sees the same thing either way, so reading and editing are not
different enough here to be different surfaces. Double-click now goes straight to
the composer. Closing without saving changes nothing, which is what makes it safe
to be the only door.

Delete moved to the detail panel, which is where it belonged anyway: a delete
button beside a save button is a delete button somebody will hit.

**Relations and Fields did nothing.** Two reasons, and the first is the one that
bit: the board's graph carries no `expandRequest` at all, so on a board the
buttons set a request nothing was listening for. They are hidden there now — a
board draws what is *placed* on it, so a node's relations are not part of that map
and have nowhere to appear.

The second would have bitten next. An explicit request repeated the auto-expansion
— outward, already drawn — so it fetched the same neighbours, merged them, and
changed nothing on screen. Asking now looks `both` ways, because what has not been
fetched is what points *at* the node, which is half of what "Relations" means.

**The panel had no edge.** It is `neutral-0`, the canvas was unset and inherited
the same colour. The canvas now takes the page colour so the panel reads as a
surface raised off it, which is the convention every card in this codebase
follows.

**And `editorState` filled the panel.** A file-backed property resolves to
`data:<mime>;base64,…` — a whole document encoded, with no character a line can
break on — so one field became tens of thousands of unbreakable characters and
the panel scrolled sideways forever. Blobs are dropped: a card's editor state is
not a field, it is the thing the card *is*. Anything else very long is capped, and
values wrap on any character, which covers the URIs and ids that have no spaces
to break at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`neutral-25`, a shade off the canvas rather than a shade above it — because there
is no shade above it. `GraphView` paints its own background and defaults it to
`neutral-0`, so a panel cannot be lighter than what it sits on, and "raised
surface = neutral-0 on a neutral-50 page" — the convention every card here
follows — has nothing to work with.

That default is also why the previous attempt did nothing. It set `bg` on the
container *behind* the graph, which the graph then painted over. Dead config that
reads as meaningful is worse than none, so it is gone and the reason is written
where the next person will put one: a template that wants a different canvas
colour sets the graph's own `bg` prop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes to the board, from using it.

**Clicking empty canvas deselects.** `select` already cleared on a background click and
reported it; nothing was listening, so the detail panel stayed open describing a node that
was no longer highlighted and the only way out was to select something else.
`clearOnEmptySelection` is bound on all three graphs rather than only where a background
click is likely — a selection emptied by shift-clicking the last node has the same problem.

**Connections can be drawn on a board.** The board seed gains a `connections` option: the
reified relation entity to draw as lines between the cards. Only pairs whose two ends are
both placed are drawn — a line to a record that is not on the board would leave the canvas
and end nowhere, and pulling the far end in to fix that would put things on the board
nobody placed. The filter is client-side because "both ends in this set" is not a
where-clause; the query narrows by source, which is the half a backend can do.

The template arms it the way the knowledge map does — `connect-nodes` before `drag-node`,
toggled from a visible Connect control rather than a modifier key — and routes
`onEdgeCreate` to the same `recordStore.connectNodes`. Connecting two things means the same
thing wherever the line was drawn, so both end in the same form, and both get the same
per-kind edge styling. Each line carries the record it stands for, so clicking one opens
the claim rather than dead-ending.

**One create button on a board, not two.** The route header's New and the board bar's
Record opened the same form. Hiding New answers the earlier objection that there was no way
to create a model instance rather than overruling it: Record is that way, and it also
places what it makes, which New does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cards were one fixed size in one fixed colour, and there was no way to change either. All four of
the properties that answers — size, colour, shape, and how large the content inside is drawn — go
on `Placement`, beside the coordinate and for the same reason: they are facts about a **pair**.
Shrinking a post to fit six of them on a wall is not editing the post, and the same post on
somebody else's board must not change size because of it. It is also what makes them safe to offer
without a confirmation anywhere: every one of them is undone by taking the card off the board, and
none can damage the thing being displayed.

**`FieldRef` — a style value read off the subject.** A rule list is fixed when the template is
written and per-instance presentation is not, so `{ from: 'data.boardWidth' }` joins `MetricRef` as
the second way a style value can be something other than a literal. The rule that matters is what
happens when the field is absent: the property is dropped **at merge time**, so it falls through to
whatever an earlier rule set rather than to the built-in default. Without that, a per-card colour
rule would overwrite a per-type colour on every card that carries none, and the layer behind it
would be pointless.

The board seed namespaces these on their way out of the placement — `boardWidth`, not `width` —
because they land beside the record's own fields, and `width` on an `ImageBlock` is the picture's
pixel width. A card silently sized by its image, on the one board where nobody had chosen a size,
is exactly the kind of bug that gets diagnosed as "the board is broken".

**Resizing is a corner on the selected card**, drawn by the renderer rather than run as a
behaviour: both halves of the gesture are about the box on screen, which a world-space hit test
knows nothing about, and the handle sizes in inverse zoom so it stays the same number of screen
pixels to aim at. The drag is drawn locally and emits `nodeResize` once, on release — one write
instead of one per frame. Binding `onNodeResize` is what puts the handle there at all, so a graph
whose sizes are stored nowhere never shows a corner that would move and change nothing.

**Content scale is a multiplier on the content, not a font size and not a zoom.** The content is
laid out in a box of `100% / scale` and drawn back down, so at 0.5 twice as much of the document
fits in a card whose size on the board has not changed — which is the thing people actually want on
a wall of notes. The document is untouched.

Colour, shape and scale are in the detail panel, which already knows the board and the selection.
Colours are tokens rather than hex so a card keeps meaning the same thing when the theme changes,
and the empty token is a swatch you can pick rather than a Reset button — it has to be reachable,
because it is what puts a card back under the board's own rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A board's cards were coloured by rules written into the template — a note is amber, a task is blue —
and a community whose vocabulary is its own had no say in it. The key is where that becomes theirs:
a panel listing what kinds of thing are on the board, with the colour each one is drawn in, and the
colour is *set* there rather than described there.

A legend that is also a control, because the moment "decisions are amber" is on screen the next
thought is to change it — and because it is the only surface that can say anything about a type at
all. The detail panel is about the card you selected, and "every task on this board" has no card to
select.

**`TypeStyle` holds one colour per (board, type)**, one record per fact, for the reasons `Placement`
is one record per card: a map in a field is a read-modify-write, and two people colouring two
different types on a shared board would clobber each other. On the *board* rather than on the type
because two boards in the same space legitimately disagree — a retro colours by status, a roadmap by
team, and neither is wrong about what a `TaskBlock` is. A colour stored on the type would make the
last board somebody styled win everywhere, silently.

It sits between the template's rules and the card's own colour in the cascade, which the `FieldRef`
deferral makes expressible: three layers, each contributing only where it has something to say.

**The list of types comes from the placements**, ordered by type with consecutive repeats
suppressed — the `$prev` grouping pattern, which is the only way to express "distinct" with the
operators there are. The alternatives were worse in ways that show: listing the space's models names
kinds that are not on this board, and a store accessor would have to be told which board it was
being asked about.

Toggleable rather than fixed, remembered per device: a board with three kinds of thing on it does
not need a key, and a panel explaining what you can already see is a panel over what you are looking
at. It is a preference and not view state, so a link's recipient sees the board rather than
somebody else's chrome.

The palette is shared with the card's own colour picker, since they are the same choice about
different things — a palette that drifted would let a card be a shade no type could ever be given,
which the key could then never account for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhweir and others added 7 commits August 21, 2026 19:48
`refreshSpaceSdna` runs on every switch into a WE space and brought stored shapes up to date with
the models this build declares — but only shapes that were *stale*, meaning present in an older
form. A model added outright is not stale, it is absent, and nothing installed it: every query
against the new entity failed with "No SHACL shape stored for class X" in every space created
before the build that added it. `TypeStyle` is where it surfaced; `Placement` and `Relationship`
had the same hole and were only ever used in spaces made after they existed.

The two absences have to be answered differently, and the reason is the one `shapeIsStale` already
documents: a stored shape with no properties is deliberately read as *fresh*, because on a
freshly-joined neighbourhood that is exactly what a shape whose triples have not replicated yet
looks like. So the stored-shape read cannot tell "never installed" from "not arrived yet", and
treating its silence as absence would reinstall shapes already on their way — which is how a space
ends up needing `cleanupSpaceSdna`.

`missingModels` therefore uses the read to narrow the field and the SubjectClass marker — the
reliable answer — to decide. In the ordinary case nothing is a candidate and it costs no round
trips at all; a build that adds a model pays one link query for it, per space, once.

This is the third instance of the same shape of bug: module shapes not reaching existing spaces,
then a property added to an existing model, now a model added outright. All three were silent, and
all three surfaced somewhere other than the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things from using the board.

**Deselect on empty canvas did not work there.** The handler went onto the three graphs declared in
the route and the board's own graph lives in `Board.ts`, so it was the one mode that never got it —
and the one where it matters most, since it is the mode you click around in.

**Resizing now anchors the edge you are not pulling.** Eight handles: four corners that change both
dimensions and four edges that change one. The rule is one line — the edge the handle is not pulling
does not move — but a card is drawn from its *centre*, so holding an edge still means moving that
centre by half the change. `nodeResize` therefore carries a position as well as a size, and a
consumer that stores only the size will watch its cards drift sideways.

That geometry is now a pure function with tests, because "which edge is anchored" is exactly what
was wrong: one corner handle that grew the card in all four directions at once, so pulling the right
edge dragged the left edge with it. Clamping lives inside it too — a card squashed against the
minimum has to stop moving as well as stop shrinking, and clamping afterwards would leave it
crawling sideways for the rest of the drag.

The corners are marked and the edges are not. An edge announces itself by the cursor changing, which
is what every canvas tool does and what keeps a selected card from being ringed with furniture.

**A card no longer snaps back after a resize.** Two causes, both fixed at their own level: the
engine still held the old centre, so the card jumped back on the next refresh — it is now pinned
where the drag left it, exactly as dropping one does — and the new size was not drawn until a
re-seed brought it round.

That second cause is the general one, and `GraphHostBindings.pendingData` is the general fix: the
host says what it has written and not yet seen come back, and the graph draws it as though it had.
Applied before the style rules, so an optimistic field is read by `{ from: 'data.x' }` exactly as a
seeded one is and nothing downstream knows the difference. Named through the board seed's own
mapping rather than a second copy of it — the copy that fell behind would write a key nothing reads,
and the card would sit unchanged with no sign of why.

Letting go of it is the part worth being careful about, so it is pure and tested: every field must
match rather than any (a read that has caught up on the colour but not the size is still a read the
size is needed for), and it compares values rather than trusting a write to have landed, since a
read issued before a write can be answered after it and the reverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What decides how long a board takes to appear is the number of *sequential* rounds, not the number
of queries: every read is a round trip to a peer-to-peer data layer. The seed asked for each thing
in turn — placements, then the key, then one query per kind of record on the board, then the
connections — so a board holding five kinds of thing was seven queries deep before anything was
drawn, and every refresh paid it again.

Three rounds is the floor, and each genuinely waits on the one before: what is placed, then the
records it names, then the connections between them. Placements and the key go together because
neither needs the other; the record queries go together and are consumed in order, so the dedup
below them still means what it says — the placed pass wins over the owned one.

The test measures rounds rather than queries, by answering on a macrotask: everything a `Promise.all`
issues lands before the first answer does, so a burst closes exactly when the seed next has to wait.
A sequential seed and a batched one look identical to a fake that answers immediately, which is the
whole difference being measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**Clicking empty canvas deselects.** `pan-zoom` claims a press on the background and dispatch stops
at the first behaviour that claims, so `select` never saw a background press begin and had nothing
to compare the release against. Every template listed `pan-zoom` first — copied from the catalog's
own example, which contradicted the description sitting beside it ("list it last — it is the
fallback"). Fixed in the templates, the playground, and the example that taught it; the ordering is
now pinned by a test that runs both orders, because it reads like a formatting detail and is a real
part of the contract.

**A resize no longer flashes, snaps back, and arrives again.** The optimistic value was confirmed
where the data was *read*, and a read landing is not the moment a card is redrawn from it — there is
the rest of a seed in between, and dropping the patch there put the old value back for all of it. It
is now reported from the drawn node: when a node's own data already says what the patch says, the
patch can go with nothing moving on screen. `GraphHostBindings.confirmPending` is that report.

**The content-size slider previews while it moves.** It reports continuously and a write per frame
would be absurd, but waiting for the release to see the result means choosing blind — which for "how
much of this document fits" is the whole question. `previewCardStyle` holds the same pending patch
without writing, so the drag previews, the release writes, and the card never jumps between them.

**The default swatch clears an override.** It did nothing, because an empty string cannot be
*stored*: `Ad4mModel`'s update skips `''` exactly as it skips `undefined`. So a card could be given a
colour of its own and never have it taken away — a one-way door, and the way back to being coloured
by its type. `PLACEMENT_UNSET` is a named value the board seed drops as though the field were
absent, which is the trick `SpacePreference` already uses for its own two sentinels.

**The key sits opposite the detail panel.** It was pinned against the box of the animation wrapper a
transitioning `$if` puts around its content, rather than against the canvas. It now docks in a
container that is always mounted and always where it says it is, which takes the question away from
the wrapper — inert while closed, so a column down the left of the board does not eat clicks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**A resized card is now picked and pointed at at its new size.** The optimistic overlay was applied
where the graph is *drawn*, and drawing is only one of three things a node's data decides: the
spatial index picks by it and the edge router trims to it, both resolved from the same style rules.
So a card drew at its new size while it was still clicked at its old one and had arrows landing
where it used to end — a graph disagreeing with itself until something forced a re-read.

The overlay now goes to the engine (`setDataOverlay`), which re-indexes and re-routes from it, and
the renderer reads it back rather than applying its own. It stays *beside* the store rather than
merged into it, because the host works out that a write has come back by comparing its patch against
the seeded data — merging would report every patch settled the instant it was applied.

**The detail panel is docked right.** It could not position itself: a transitioning `$if` wraps its
content in an animation container positioned from that content, so `right: 0` pinned to the
wrapper's edge rather than the canvas's, and the wrapper sat at its own static position — which put
a panel that reads as docked right at the left of the board, on top of the key. Same dock the key
got, and the same reason.

**Hover marks on cards and on edges.** A ring rather than a colour change, since a board lets people
choose a card's colour and feedback made of that colour is invisible on the card that matches it.
Edges get more out of it than cards do: a node is a shape with a visible boundary, an edge is a
two-pixel line whose clickable width is a tolerance nobody can see, so without a mark the only way
to find out whether you are on the line is to click and see what opens. Drawn as a second, wider,
faint path under the real one, so the line itself never changes shape as the pointer nears it.

While doing this I found four rules I had dropped from the stylesheet in the resize commit —
the hover and selection rings on non-card nodes, the pinned outline, and the unresolved-node
placeholder. Restored.

**A connection's wording can be corrected.** A line drawn "blocks" that turns out to be "depends on"
had to be deleted and redrawn, which discards the thread and the ratings underneath it — so what was
being corrected was the claim's existence rather than its wording. The label, the reason and the kind
are now editable in place, seeded from the record and discarded on cancel. Changing the kind is the
substantial one: it is what the maps colour and arrow by, so it is how a line drawn as free text gets
promoted into the vocabulary a space actually uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**An edge now stops at its target's edge rather than on a circle drawn round it.** The clearance was
a radius, and a node's `size` is half its *largest* dimension — so a card was treated as a circle
enclosing it: right on the long side, well outside the shape on the short one. Narrowing a wide card
left every arrow stopping where the old width had been, with a gap no re-read closed, because the
geometry was doing exactly what it was told.

Clearance is now per axis for a box, and the scalar form still means a circle. That distinction is
load-bearing rather than a convenience: on a 45° approach a circle of radius r is r away and a square
of half-extent r is r√2, so passing a round node's radius as a box would push every diagonal arrow
40% too far out. A curve keeps attaching on the axis it arrives along — it just measures the side it
actually meets.

**The detail panel moves to the left and the key to the right.** The module rail runs down the right
edge of the app and overlaps whatever a route puts there; a route cannot see the chrome around it, so
the rule is that the far edge is not a route's to use. Of the two panels the key is the one that can
afford to lose a strip — it is read at a glance, where the detail panel is worked in.

**Both are as tall as the canvas again.** They asked for `height: 100%`, which needs an unbroken
chain of resolved heights above it; where that chain breaks the panel is merely short, with its
border stopping in mid-air — which is what "an outline on its side but not its bottom" was. An
absolute box pinned to `top` *and* `bottom` is the canvas's height whatever resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A broad `prettier --write` early in this branch reformatted every markdown file it could reach, not
only the ones being edited: emphasis markers rewritten from `*` to `_`, and table pipes re-padded.
Nine documents this branch has nothing to do with — the seed system, the routing guide, four AD4M
plan docs — carried ~430 lines of that into the diff, which is exactly the noise that makes a review
skim.

Reverted to `dev`. The only prose here that is actually this branch's is `relations.md`, which is
new, and the two CONVENTIONS files that point at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 21, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 298d645
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a88ce4579db3c000777aad4
😎 Deploy Preview https://deploy-preview-126--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit 82e3d7f into dev Aug 21, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant