PR: #603: feat(brief): issues for determination as a compound EditableList row [Story-2357] by @ankitmaheshwari-com
View PR →

Story-2357: Issues for determination โ€” the first COMPOUND row

๐Ÿ›ฐ๏ธ Satellite โ€” why this one matters more than "one more editable list"

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.

โœˆ๏ธ Airplane โ€” the analogy: a form on a clipboard vs a line on a ledger

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.

The naive fix, detonated. "Just fork 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.

๐Ÿ”ฌ Microscope โ€” the seam, and why it is shaped this way

WHY the primitive stays value-agnostic: three adapters, no issue-shaped code

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.

WHY one 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.

WHY the burden is a Choice and not a radio

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.

WHY a new issue defaults to CLAIMANT

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.

WHY an unknown burden shows a placeholder instead of being "fixed"

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.

The gate: does the write path actually accept this?

Gate check passed โ€” traced through the real backend contract, not assumed.

LinkWhat 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.

What changed

FileChange
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.

How to see it

  1. pnpm storybook โ†’ MatterDetail/IssueList โ€” every state above, no auth, no backend.
  2. In the app: a matter's Brief โ†’ The dispute โ†’ Issues for determination. Click an issue to edit its words. Click its BURDEN ยท โ€ฆ badge to flip the side. "Add an issue" opens a fresh row (burden: Claimant). Clear a row's text to remove it.
Staging seam, named honestly. Storybook proves the component; the live flow is proven only against a real matter on staging. The write path is verified by contract-tracing (table above) and the local build/test suite โ€” not yet by a click-through against 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.

Files changed

9 file(s), +400 / -68 lines

Path Role + -
messages/en.jsonConfig+7-1
messages/hi.jsonConfig+7-1
src/components/matter-detail/brief/issue-list.stories.tsxComponent+188-0
src/components/matter-detail/brief/issue-list.tsxComponent+101-0
src/components/matter-detail/brief/matter-brief-page.tsxComponent+5-3
src/components/matter-detail/brief/matter-brief-sections.tsxComponent+26-44
src/components/matter-detail/brief/presentation.tsComponent+9-2
src/components/shared/editable-list.stories.tsxComponent+1-1
src/components/shared/editable-list.tsxComponent+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.