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