DocumentProperties.tsxfeat(mail): one properties rail, and a tree that reads as a tree
**The properties rail is now literally the task ticket's.** It was a
lookalike — a `grid-cols-[5.5rem_1fr]` with a fixed label column, its own
caption constant, its own row height — which is why it read as a different
UI for the same job. The task rail is the one built against Linear and the
one that reads right, so it was extracted rather than averaged:
`components/ui/property-rows.tsx` owns the row geometry, the hover chrome,
the caption type and the section rhythm, and both surfaces compose it.
`TaskTicketProperties` keeps only what is a fact about a task.
One deliberate difference, and it is not cosmetic: a document's keys are
ARBITRARY. `stance` and `seniority` are both selects, so `propertyIcon`
gives them the same glyph, and a task rail's icon-only row would leave a
column of identical dots beside three bare values. So the field's name rides
in the row, muted, ahead of the value — same height, same type, same hover.
It does not get a column of its own, which was the thing that made the old
rail read as a form.
**Siblings stopped disagreeing.** Floating anchors were picked by comparing
card CENTRES, and two people on one row of a chart have different centres
whenever their cards differ in height. When their shared boss's centre fell
between the two, one child connected upward and the other downward, and a
line looped around a card for no reason a reader could see. A rank is a
shared TOP edge — exactly equal for siblings however tall either card grows
— so tops are what is compared now.
**The axis relation draws orthogonally.** `getSmoothStepPath` with `offset`
at half the layout's own rank gap, so every child of one parent turns on the
same y and they share one horizontal run: the screenshot's shape, and what
makes a tree read as a tree rather than as a fan of curves that happen to
converge. Every other relation stays a bezier, so an `influences` overlay
can never be mistaken for the reporting line it crosses. The `hierarchy`
flag comes off the same predicate that chose `layoutEdges`, so what ranks
the chart and what draws as its skeleton cannot be two different sets.
**The legend moved to the bottom.** It is read once on arrival and then
never again, which is the whole lifecycle a legend should have — and that is
not a reason to put it across the top of the drawing it explains.
Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 5:50 PMindex.tsfeat(mail): the node inspector reads as a document, not a tooltip
The card a node opens was 18rem wide with a 55vh cap, and a contact profile —
the thing people open it to read — arrived four words to a line. It is now
26rem/78vh, with the properties in their own filled card at the head of the
scroll area and the prose running plainly under it: a document page's shape,
rotated into one column. Without that split a long profile buries the four
values the graph is actually asserting about the person.
The subtitle was the raw path. Half of a Cedar path is ids, so a line of hex
sat under every name — and it was the widest thing in the card. It is a
breadcrumb now, with id segments DROPPED rather than resolved: resolving one
costs a fetch per crumb for a line nobody navigates by, and it would render
"Contact > Brian Wood > Profile" directly under the words "Brian Wood".
Three pieces of furniture go with it, all of them explaining a mechanism
instead of showing a value:
- "Resolved elsewhere" as a third group, and the "from person.title" hint
under each of its rows. A resolved value is now a read-only ROW of the
graph's own group. Read-only therefore has to hold per row rather than per
group, which is the one real change under this.
- "This document carries no properties yet." and the per-group "Nothing yet".
An empty group is not drawn, and neither is an empty rail; a caption over
nothing appears exactly when there is least to look at.
- The collapse toggle, and the per-viewer preference behind it. The rail has
one consumer and it is a floating panel that is already dismissable.
Borders on the node cards drop from 400/800 to 200/900. The fill already
carries the category, so the ring was only telling eight people apart that
the colour had already told apart — and it made them read as eight competing
objects rather than as one diagram.
Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 3:31 PMproperty-groups.tsfeat(graph): a node is a person — face, name, title, link
The panel was drawing a person as a list of key/value rows, including the
rows that ARE the person: `Title Director of Sales` under a heading that
said their name, `LinkedIn https://…` as raw text, and `Person
<email>` — plumbing — as a property. That is the same information
the contact panel beside a conversation already renders as a profile, and
there is no reason a reader should meet two renderings of one human.
So a node now draws as a profile header: avatar, name, LinkedIn mark, title,
company — the shape `PersonProfile` uses, in the inspector at `lg` and on
the canvas card at `sm`. The headline sits under it. A person nobody has
enriched is a circle with two initials, which is exactly as much as is known.
**It is keyed off `from`, not off field keys.** A field bound to
`person.photoUrl` IS the avatar, whatever it is called. `from` is a closed
catalog vocabulary the server already validates; a key is free text an agent
minted, so keying on `photo` would mean a graph that called it `avatar` drew
no face with nothing on screen to say why. It also means no new schema
vocabulary: a graph gets a header by binding fields it would have bound
anyway, and a graph of COMPANIES binds none of them and draws as before.
Whatever the header drew is then SUBTRACTED from the rows — including from
the undeclared-key rule, via `hiddenKeys`, because the node's bag really
does still carry `person` and it would otherwise reappear directly under the
header that replaced it.
**The org chart declares `photo` and `headline`**, bound to `person.photoUrl`
and `person.headline`.
**And the agent can finally fill them.** `graphEnrich` has existed in the
service and on both tRPC routes since phase 7, but `GRAPH_ACTIONS` never
listed it — so the one agent whose whole job is this chart could not enrich
anybody on it. `graph.enrich` is now a verb, behind a WRITE grant even for
the free report: the report and the spend are one act from the agent's point
of view, and a grant that let it price something it could not buy would only
teach it to try.
The prompt tells it to use **fiber**: $0.04 a person against crustdata's
$0.10, and the only provider that returns `person.photoUrl` and
`person.headline` — the two fields the faces are made of. Two calls, the
first free, always with a `limit`, and skip the second when the report says
everyone is already enriched.
Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 11:15 PMproperty-icons.tsfeat(mail): the document properties rail (design: graph-documents phase 4)
A general document capability, not a graph one — the same rail draws a node's
fields, an edge's fields, and a plain document's own. The graph is simply its
first consumer.
Lifted from the task ticket rather than written fresh, so one properties idiom
exists in the app instead of two, and its two rules are followed exactly: TWO
COLUMNS CENTRED AS A PAIR WITH NO RULE BETWEEN THEM (a border there cuts the
page in half and makes the rail read as a bolted-on panel), and properties are a
QUIET ICON + VALUE LIST, NEVER A FORM (the value IS the control). That second one
was the wrong first version for tasks; it is not rebuilt here.
Three things worth knowing:
- Both collapse branches render the SAME body, which is what makes "collapsing
loses no row" structural rather than a promise. The animation is on WIDTH with
one shared tween — never a spring, because overshoot bounces the body text
beside it.
- The floating panel is a raised surface and carries a border; the inline rail
does not. One surface per object: inline, the rail and the body are one page.
- An edge's properties needed no new mechanism. A kind's declared `fields` are
the subject's schema and its bag is the subject's fields — which is exactly
what a schema'd document already is.
Undeclared keys render as text rows under a "Not declared" heading, the same
rule `CardFieldList` already applies, reusing its controls rather than growing a
second set. And a bound key is excluded from the graph group's undeclared set,
so a stale cached copy left in `fields` cannot render twice — once editable and
once not.
16 new tests; 325 passing across tests/modules/documents.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 14, 2026, 10:06 PM