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