Issues for determination are the spine of the fight. Every filing, every hearing, every piece of evidence is ultimately aimed at one of them. Vega drafts them from the pleadings; if it mis-frames an issue, or puts the burden on the wrong side, the Brief quietly lies about who has to prove what โ and counsel had no way to correct it in place. The block was read-only.
Every Brief block promoted before this one (relief, key instruments, governing law, adversary's best shots) was a list of strings. An issue is not a string. It is two facts welded together:
{ "issue": "Whether the Change-in-Law notice was served in time", "burden": "Respondent" }
So this Story is the first time a single EditableList row has to hold
two kinds of the frozen vocabulary at once: an EditableField for
the text and an EditableChoice for the burden. Getting that seam right is what the
Story is actually about โ the issues block is just its first consumer.
The tempting move is to reach for the form world: a little dialog with a text input and a radio
group, an Add button, a Save. That is exactly wrong, and the frozen vocabulary
(docs/design/editable-surface-vocabulary.md ยง1) says why:
Is counsel reading something that already exists (โ Editable Surface), or filling in something new (โ form)?
Counsel is reading the issues Vega already framed. So it is a ledger line, not a clipboard: you click the words and fix the words, you click the burden badge and flip the burden. Each act commits on its own, instantly, with rollback. There is no Save button because there is nothing to save โ the record already moved.
EditableList into an
IssueEditableList." Two lists, two remove buttons, two add flows, two animation
configs โ and the next compound field (parties: name + side) forks a third. The primitive would
rot into a family of near-copies within a month. The rule from AGENTS.md is explicit: extend the
shared primitive; never invent a local variant.
EditableList is now generic over its item type. It never learns what an issue is.
It learns only how to read a row's text, how to write text back onto a row,
and how to make a row from typed text:
itemText?: (item: T) => string // read the editable text itemWithText?: (item: T, text: string) => T // write it back, keeping the rest newItem?: (text: string) => T // build a row from the draft row
Omit all three and the item is its text โ which is every caller that predates the seam
(relief, instruments, governing law, best shots). They did not change a line. A fourth optional
prop, itemMeta(item, index), renders the row's second kind beneath its
text. The primitive puts it in a box; it never asks what is in the box.
onSave for both kinds
The frame edit contract is full value per field: a write sends the whole
issues array, not a patch of one row. So a text edit, a burden flip, an add, and a
remove are all the same act โ "here is the new issues". The burden choice does not
get its own write path; it maps over the array, swaps one row's burden, and hands
the whole thing to the same onSave. One field, one commit seam, one rollback.
Vocabulary ยง3, founder-ruled: "There is no radio kind โ radios belong to the form world."
A two-option in-place value is a two-option EditableChoice. It renders through the
existing "BURDEN ยท CLAIMANT" badge, so promoting the field changed the pixels not at
all โ the badge that used to be a label is now a button. That is the whole point of an editable
surface: it looks like the record until you touch it.
An unset burden is a hole in the record, and a "choose one" prompt on every add is friction on the one act counsel does most (Minimize-user-input doctrine). The claimant proves its own case unless someone says otherwise โ that is the default the law itself uses. One click flips it.
A frame written earlier may carry burden: "claimant" (lowercase) or something the
picker does not know. We fold case-insensitively onto the two canonical wire values; anything
still unmatched renders the choice's placeholder ("Set the burden"). We do not
silently rewrite it to Claimant on display โ a display act must never assert a fact the record
does not carry.
Gate check passed โ traced through the real backend contract, not assumed.
| Link | What it proves |
|---|---|
MatterFrameEditRequest.fields: {[key: string]: unknown} (src/gen/) |
The app-side wire type carries arbitrary JSON per field โ an array of objects is expressible. |
EDITABLE_FRAME_FIELDS (functions frame_contract.py) |
issues is editable โ an edit naming it is not rejected as unknown/non-editable. |
FrameOutput.issues: list[Issue], Issue = {issue: str, burden: str | None}, extra="forbid" (functions matter_frame/dtos.py) |
The exact shape we write โ [{issue, burden}] โ is what the server validates against on _validated_frame. No workaround, no shadow field. |
render.py: f"- {issue['issue']} (burden: {issue['burden']})" |
Downstream FTS/agent-context rendering already reads both keys, so an edited row is indexed the same way a Vega-written one is. |
burden is a free string on the contract, so the two option ids are the
wire vocabulary ("Claimant" / "Respondent" โ what
Vega already writes). The label counsel reads is localized; the value stored is not.
| File | Change |
|---|---|
src/components/shared/editable-list.tsx |
Generic over the item type; adds the itemText / itemWithText / newItem adapters, the itemMeta row slot, and an index-aware itemIcon (the issue number). Compound rows stack; plain rows stay a centred line. Zero issue-specific code. |
src/components/matter-detail/brief/issue-list.tsx (new) |
The compound consumer: issue text + burden EditableChoice, both committing the whole issues array. |
src/components/matter-detail/brief/issue-list.stories.tsx (new) |
Storybook-first: populated, empty/add-first, add flow, burden toggle (incl. an unset burden), clear-to-remove, read-only, provenance, rollback-on-error. |
src/components/matter-detail/brief/matter-brief-sections.tsx |
The read-only issue renderer is deleted; the Dispute section renders the editable block and is now add-first (an issue-less matter shows the add affordance instead of nothing). |
presentation.ts, matter-brief-page.tsx |
BriefIssue type exported; the list commit seam widened from string[] to unknown[] so one saveList serves string lists and compound lists alike. |
messages/en.json, messages/hi.json |
Six new keys in BOTH locales: add label, placeholder, burden aria/placeholder, and the two burden labels. |
pnpm storybook โ MatterDetail/IssueList โ every state above, no auth, no backend.stg.app.pramaan.io. Staging UAT runs post-merge; the zero-issue (fresh matter) case
is the one to exercise first, because that is the case fixtures always flatter.
9 file(s), +400 / -68 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +7 | -1 |
| messages/hi.json | Config | +7 | -1 |
| src/components/matter-detail/brief/issue-list.stories.tsx | Component | +188 | -0 |
| src/components/matter-detail/brief/issue-list.tsx | Component | +101 | -0 |
| src/components/matter-detail/brief/matter-brief-page.tsx | Component | +5 | -3 |
| src/components/matter-detail/brief/matter-brief-sections.tsx | Component | +26 | -44 |
| src/components/matter-detail/brief/presentation.ts | Component | +9 | -2 |
| src/components/shared/editable-list.stories.tsx | Component | +1 | -1 |
| src/components/shared/editable-list.tsx | Component | +56 | -16 |
One sentence to remember: the primitive learned to hold two kinds in one row by learning nothing about issues โ three adapters and a slot, and the burden rides the same full-array commit as the words.