autoLayout.tsfix(graph): give the auto-format real air between cards The gaps were right on paper and wrong on screen, for one reason: dagre spaces ranks by `ranksep + nodeHeight`, and it was being told a card is 72px. That is the card's `minHeight` and the flow canvas's real node size — but a graph card stacks a name and up to four `displayOnCard` field rows and renders around 150px. So `ranksep: 80` bought roughly nothing, and rows came out nearly touching. Telling dagre the real size is most of the fix; the rest is more air. Graph metrics now live in one file: a 200×150 card, 120px between neighbours, 110px between rows, and 200px between the reporting tree and the loose block below it — wider so the split reads as two different things rather than one more rank. `autoLayout` takes the sizes and gaps as options and defaults every one of them to the flow canvas's current numbers, so that caller is byte-for-byte unaffected. Sizing belongs to the caller: only it knows what it is drawing. The two overlap tests were checking against 72px, which is the same assumption that caused this — they measure the real card height now, and two new tests assert the actual clear space between rows and between neighbours, so a future change to the card cannot quietly close the gaps again. 95 graph tests; 4,683 passing across apps/mail. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 1:18 PMfloating-edge.tsfeat(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 PMflow-to-graph.tsfeat(mail): the graph canvas (design: graph-documents phase 5) A graph document renders as an editable node/edge canvas whose nodes open as the documents they point at. ONE tRPC read returns the schema, the nodes, the edges and the layout, because all four live in one Y.Doc and come out of one decode. That is the payoff of keeping nodes inside the document: a board reads its schema from the Y.Doc and its cards from SQL and the two can disagree; a graph cannot. ONE node component, for every graph there will ever be. It renders from the SCHEMA — one row per `displayOnCard` field, typed by the same `TableColumnType` the board and table use, drawn by the same `CardFieldDisplay`. There is no node component per domain, and the day there is, "an org chart is configuration" stops being true. Three things the tests pin that are easy to undo: - The layout runs over ONLY the edges matching `view.layoutEdgeKind`, so which relation is the skeleton is a VIEW property: point it at `influences` and the same people re-rank with nothing written. Adding a non-layout edge moves nobody. - `hydrated ?? derived ?? fields`, in that order. An authored value shows as authored, a bound value shows live once hydration lands, and the cache only fills the gap between open and arrival — so the three can disagree in exactly one direction, toward the document. - Only TWO gestures write: a drag is one `graphLayout` key, a drawn connection is one `connect`. There is deliberately no whole-canvas save, because that is how a derived annotation becomes a persisted field and how one open tab overwrites another's concurrent edit. A computed layout is persisted ONCE, so the next open is stable and any later re-layout is a deliberate act rather than something that happens because someone opened the file. `graph` is registered in all five places a document type has to be — the full-bleed set, FileBrowser, FileArtifactPanel, FilesTab and CompanyExplorer — plus its icon and label. 36 graph tests; 4,588 passing across apps/mail, no regressions. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 14, 2026, 10:04 PMgraph-flow-types.tsfeat(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 PMgraph-to-flow.tsfeat(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 PMGraphEdgeLine.tsxfix(graph): one drop, one bus, one riser — the family-tree line `getSmoothStepPath` turns at the midpoint between ITS OWN two endpoints. That is right for a flow diagram, where each edge is its own object, and wrong for a tree, where the four lines leaving one manager are one drawing. Cards differ in height, so four children produced four horizontal runs a few pixels apart — the zigzag. The turn is now anchored to the PARENT: the bus sits a fixed distance below the upper card's edge, so every child of one parent shares one horizontal line whatever their own cards do. That is the shape every org chart on paper has ever had, and it needs no flag for direction — the upper anchor is always the smaller y, so `min(sy, ty) + offset` is the parent's side whether the edge was drawn junior→senior or a node has since been dragged above the person it reports to. A child sitting directly under its parent now draws ONE straight segment. It was routing two rounded corners around a horizontal run of half a pixel — card centres come off measured DOM boxes and land on fractions — and that kink was the most visible defect on an otherwise tidy chart. One pixel of tolerance decides it. Two degenerate cases the tests pin, because both draw a line THROUGH a card rather than merely looking wrong: a pair dragged almost on top of each other clamps the bus to the lower anchor instead of routing past it, and a short horizontal run shrinks its corners rather than bulging outside the two cards it joins. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 11:41 PMGraphInspector.tsxfeat(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 PMGraphLegend.tsxfix(graph): shadow-xs on the controls, legend and node card Tailwind v4 renamed v3's `shadow-sm` to `shadow-xs`. Unlike the 89 pre-existing uses elsewhere — which want their own sweep rather than a drive-by inside this PR — these three files are NEW on this branch, so they are new violations of the v4 directive and belong with the change that introduced them. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 19, 2026, 6:52 PMGraphNodeCard.tsxfeat(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 PMGraphNodeProfile.tsxfeat(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 PMGraphSchemaEditor.tsxfeat(mail): an icon rail down the canvas, and the schema as a form The "Graph" button sat in the top-left corner — the first place a reader's eye lands on a drawing meant to be looked at — and hid the two things people actually do (add somebody, tidy the layout) two clicks deep behind a word that names the document rather than any action on it. It also carried a read-only "Conventions" list, which is a legend, and there is already a legend. It is a vertically centred rail of three glyphs now: Add node, Auto-format, Schema. Icon-only, so every button carries an aria-label and its tooltip renders that same string — one label, two renderings, so they cannot drift. The tooltip provider is LOCAL: a graph also renders inside the home rail's artifact panel and the company explorer, neither of which descends from the app's provider, and a tooltip without one silently never opens. Schema was previously a JSON edit. It is a form: - **Background** is an OptionPicker, not a select, because "which field paints the cards" is the one question you cannot answer from a list of words — each row carries that field's own palette, built from its `optionColors` in declared option order. A field with no colours shows the dashed "no tint" ring rather than borrowing someone else's. - **Tiers** and **Hierarchy** are short lists of words, so SelectField. - **On the node face** toggles `displayOnCard`, sending the whole field spread with one property flipped: `addFields` merges by key, so an unknown property this form never rendered survives instead of being re-asserted from defaults. A view key pointing at a field or relation the schema has since dropped is offered back as `stance · no longer a field`. Without that the control matches nothing, renders blank, and the first click silently repoints a graph that was tiered by something the form could not name. `FieldPopover` grows a `side` passthrough. Radix defaults to `bottom`, which is right for a row control and wrong for one in a vertical rail: the schema panel dropped over the drawing it edits, and flipped upward whenever the rail sat low on screen. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 3:35 PMGraphSidebar.tsxfeat(mail): an icon rail down the canvas, and the schema as a form The "Graph" button sat in the top-left corner — the first place a reader's eye lands on a drawing meant to be looked at — and hid the two things people actually do (add somebody, tidy the layout) two clicks deep behind a word that names the document rather than any action on it. It also carried a read-only "Conventions" list, which is a legend, and there is already a legend. It is a vertically centred rail of three glyphs now: Add node, Auto-format, Schema. Icon-only, so every button carries an aria-label and its tooltip renders that same string — one label, two renderings, so they cannot drift. The tooltip provider is LOCAL: a graph also renders inside the home rail's artifact panel and the company explorer, neither of which descends from the app's provider, and a tooltip without one silently never opens. Schema was previously a JSON edit. It is a form: - **Background** is an OptionPicker, not a select, because "which field paints the cards" is the one question you cannot answer from a list of words — each row carries that field's own palette, built from its `optionColors` in declared option order. A field with no colours shows the dashed "no tint" ring rather than borrowing someone else's. - **Tiers** and **Hierarchy** are short lists of words, so SelectField. - **On the node face** toggles `displayOnCard`, sending the whole field spread with one property flipped: `addFields` merges by key, so an unknown property this form never rendered survives instead of being re-asserted from defaults. A view key pointing at a field or relation the schema has since dropped is offered back as `stance · no longer a field`. Without that the control matches nothing, renders blank, and the first click silently repoints a graph that was tiered by something the form could not name. `FieldPopover` grows a `side` passthrough. Radix defaults to `bottom`, which is right for a row control and wrong for one in a vertical rail: the schema panel dropped over the drawing it edits, and flipped upward whenever the rail sat low on screen. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 3:35 PMhierarchyLayout.tsfix(graph): give the auto-format real air between cards The gaps were right on paper and wrong on screen, for one reason: dagre spaces ranks by `ranksep + nodeHeight`, and it was being told a card is 72px. That is the card's `minHeight` and the flow canvas's real node size — but a graph card stacks a name and up to four `displayOnCard` field rows and renders around 150px. So `ranksep: 80` bought roughly nothing, and rows came out nearly touching. Telling dagre the real size is most of the fix; the rest is more air. Graph metrics now live in one file: a 200×150 card, 120px between neighbours, 110px between rows, and 200px between the reporting tree and the loose block below it — wider so the split reads as two different things rather than one more rank. `autoLayout` takes the sizes and gaps as options and defaults every one of them to the flow canvas's current numbers, so that caller is byte-for-byte unaffected. Sizing belongs to the caller: only it knows what it is drawing. The two overlap tests were checking against 72px, which is the same assumption that caused this — they measure the real card height now, and two new tests assert the actual clear space between rows and between neighbours, so a future change to the card cannot quietly close the gaps again. 95 graph tests; 4,683 passing across apps/mail. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 1:18 PMindex.tsfeat(mail): an icon rail down the canvas, and the schema as a form The "Graph" button sat in the top-left corner — the first place a reader's eye lands on a drawing meant to be looked at — and hid the two things people actually do (add somebody, tidy the layout) two clicks deep behind a word that names the document rather than any action on it. It also carried a read-only "Conventions" list, which is a legend, and there is already a legend. It is a vertically centred rail of three glyphs now: Add node, Auto-format, Schema. Icon-only, so every button carries an aria-label and its tooltip renders that same string — one label, two renderings, so they cannot drift. The tooltip provider is LOCAL: a graph also renders inside the home rail's artifact panel and the company explorer, neither of which descends from the app's provider, and a tooltip without one silently never opens. Schema was previously a JSON edit. It is a form: - **Background** is an OptionPicker, not a select, because "which field paints the cards" is the one question you cannot answer from a list of words — each row carries that field's own palette, built from its `optionColors` in declared option order. A field with no colours shows the dashed "no tint" ring rather than borrowing someone else's. - **Tiers** and **Hierarchy** are short lists of words, so SelectField. - **On the node face** toggles `displayOnCard`, sending the whole field spread with one property flipped: `addFields` merges by key, so an unknown property this form never rendered survives instead of being re-asserted from defaults. A view key pointing at a field or relation the schema has since dropped is offered back as `stance · no longer a field`. Without that the control matches nothing, renders blank, and the first click silently repoints a graph that was tiered by something the form could not name. `FieldPopover` grows a `side` passthrough. Radix defaults to `bottom`, which is right for a row control and wrong for one in a vertical rail: the schema panel dropped over the drawing it edits, and flipped upward whenever the rail sat low on screen. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 3:35 PMlayout-metrics.tsfix(graph): give the auto-format real air between cards The gaps were right on paper and wrong on screen, for one reason: dagre spaces ranks by `ranksep + nodeHeight`, and it was being told a card is 72px. That is the card's `minHeight` and the flow canvas's real node size — but a graph card stacks a name and up to four `displayOnCard` field rows and renders around 150px. So `ranksep: 80` bought roughly nothing, and rows came out nearly touching. Telling dagre the real size is most of the fix; the rest is more air. Graph metrics now live in one file: a 200×150 card, 120px between neighbours, 110px between rows, and 200px between the reporting tree and the loose block below it — wider so the split reads as two different things rather than one more rank. `autoLayout` takes the sizes and gaps as options and defaults every one of them to the flow canvas's current numbers, so that caller is byte-for-byte unaffected. Sizing belongs to the caller: only it knows what it is drawing. The two overlap tests were checking against 72px, which is the same assumption that caused this — they measure the real card height now, and two new tests assert the actual clear space between rows and between neighbours, so a future change to the card cannot quietly close the gaps again. 95 graph tests; 4,683 passing across apps/mail. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 1:18 PMnode-colors.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 PMprofile-bindings.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 PMtierLayout.tsfix(graph): give the auto-format real air between cards The gaps were right on paper and wrong on screen, for one reason: dagre spaces ranks by `ranksep + nodeHeight`, and it was being told a card is 72px. That is the card's `minHeight` and the flow canvas's real node size — but a graph card stacks a name and up to four `displayOnCard` field rows and renders around 150px. So `ranksep: 80` bought roughly nothing, and rows came out nearly touching. Telling dagre the real size is most of the fix; the rest is more air. Graph metrics now live in one file: a 200×150 card, 120px between neighbours, 110px between rows, and 200px between the reporting tree and the loose block below it — wider so the split reads as two different things rather than one more rank. `autoLayout` takes the sizes and gaps as options and defaults every one of them to the flow canvas's current numbers, so that caller is byte-for-byte unaffected. Sizing belongs to the caller: only it knows what it is drawing. The two overlap tests were checking against 72px, which is the same assumption that caused this — they measure the real card height now, and two new tests assert the actual clear space between rows and between neighbours, so a future change to the card cannot quietly close the gaps again. 95 graph tests; 4,683 passing across apps/mail. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 1:18 PMtree-edge.tsfix(graph): one drop, one bus, one riser — the family-tree line `getSmoothStepPath` turns at the midpoint between ITS OWN two endpoints. That is right for a flow diagram, where each edge is its own object, and wrong for a tree, where the four lines leaving one manager are one drawing. Cards differ in height, so four children produced four horizontal runs a few pixels apart — the zigzag. The turn is now anchored to the PARENT: the bus sits a fixed distance below the upper card's edge, so every child of one parent shares one horizontal line whatever their own cards do. That is the shape every org chart on paper has ever had, and it needs no flag for direction — the upper anchor is always the smaller y, so `min(sy, ty) + offset` is the parent's side whether the edge was drawn junior→senior or a node has since been dragged above the person it reports to. A child sitting directly under its parent now draws ONE straight segment. It was routing two rounded corners around a horizontal run of half a pixel — card centres come off measured DOM boxes and land on fractions — and that kink was the most visible defect on an otherwise tidy chart. One pixel of tolerance decides it. Two degenerate cases the tests pin, because both draw a line THROUGH a card rather than merely looking wrong: a pair dragged almost on top of each other clamps the bus to the lower anchor instead of routing past it, and a short horizontal run shrinks its corners rather than bulging outside the two cards it joins. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 11:41 PMuse-inspector-width.tsfeat(mail): the inspector is the document, at the reader's width Six things, all of them the panel arguing with the person reading it. **It is the real document now.** The body was a read-only markdown render of `doc.content`. It is `<Document />` bound to the same Y.Doc the document page binds, through the same provider registry — so it is not a copy that could drift, it is the document, and typing in the panel and typing on the page are the same keystrokes. No merge to worry about: two views of one CRDT is the case Y.js exists for. **The width is the reader's.** A profile is sometimes the thing you came for and sometimes an aside, and no fixed width is right for both. The left edge is a drag handle, 320–900px, remembered per viewer. Default 480, up from 416. **The title is a heading.** It was `text-sm font-medium` — the same type as the rows under it — so the panel read as a list whose first line happened to be a name. The breadcrumb above it is gone. **No fill behind the properties.** One surface per object: the panel is the surface, and a filled block inside it was a second box drawn around one thing. **Every declared field is a row, value or not.** A schema declares a field because somebody wants that fact recorded, so an empty one is a prompt, not an absence — hiding it means the only way to discover the graph wants a title is to already know. **An array is its values, not its JSON.** Agents write `buying_role` both as `['economic_buyer','finance_approver']` (a tool call) and as `'economic_buyer, finance_approver'` (a markdown round-trip). `toFieldValue` JSON-stringified the first, so the same field rendered as a literal `["economic_buyer","finance_approver"]` in one graph and as two chips in another. Arrays of scalars now take the comma-joined form `splitMultiSelect` already parses, so both shapes render as chips. **A select opens in a popover.** The inline version spliced every option into the form, so opening one field pushed every field below it down the panel and a five-option field was the tallest thing on screen. It is an `OptionPicker` now — filter, ordinals, right-hand tick, keyboard containment, all of it already built. A multi-select stays open across toggles, since picking two values should not be two openings. Also drops the `as any` in the floating-edge test: `getFloatingEdgeParams` takes the two fields it actually reads (`NodeBox`) instead of the whole 20-field `InternalNode`, which a test could only fabricate with a cast — and a cast is what would stop the compiler noticing the day React Flow renames `positionAbsolute`. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 4:13 PM