The Brief's Key instruments rail was a wall: Vega listed the documents it
found, and counsel could do nothing about it. If the framer missed the supplemental
agreement, the missing instrument stayed missing. Worse — a matter with no
instruments rendered nothing at all (the block was wrapped in
instruments.length > 0 ? … : null), so the first real matter of a new firm
showed a rail with a hole in it and no way to fill it.
This Story builds the list kind of the Editable Surface family
(EditableList, frozen vocabulary §3) and makes Key instruments its first live
consumer: add, edit in place, remove — instantly, with rollback.
The row IS the value. Each item is an EditableSurface
(plain, single-line) — so clicking an instrument drops the caret in it, blur
commits it, and clearing its text removes it, because an empty row is no row. Add, edit, and
remove all hand the caller the whole next array through one onSave.
The ticket made the data contract a hard gate: if the functions write path did not accept
key_instruments, this Story stops and files the gap. It does accept it.
PATCH /app/matters/{id}/frame with
{ fields: { <field>: <value> } }, whose contract is
"FrameOutput flat field names mapped to their replacement values — full-value semantics
per field". In pramaan-functions, key_instruments is a
first-class FrameOutput field (key_instruments: list[str] on the frame mirror,
listed in frame_output.v1.fields.json), and it is not one of
the two meta fields excluded from EDITABLE_FRAME_FIELDS. So a full-array write
of plain strings is exactly what the endpoint wants. No functions child issue needed.
Two consequences fall straight out of the contract:
contentFormat declaration
travels with the write — that map is limited to the four free-prose fields, and sending
key_instruments in it is rejected server-side.
<EditableList
items={string[]} // the committed items (caller-owned, optimistic)
onSave={(next: string[]) => void} // add | edit | remove — ALWAYS the whole next array
ariaLabel="Key instruments" // required: editable regions announce themselves
addLabel={t("addInstrument")} // the CALLER resolves the i18n key — never a literal inside
placeholder={t("instrumentPlaceholder")}
readOnly={false} // permission gate: no add, no remove, rows read-only
itemProvenance={(item, i) => …} // per-item seam → the shared EditableSurfaceMarker
itemIcon={<FileText …/>}
/>
| Action | How counsel does it | What lands on the wire |
|---|---|---|
| Add | "Add an instrument" opens a draft row with the caret already in it; type; Enter or blur. | [...items, "Arbitration Agreement, cl. 14"] |
| Edit | Click the row's text; blur commits (Escape reverts, as on every Editable Surface). | the array with that index replaced |
| Remove | The row's ✕ (hover / keyboard focus) — or clear its text. | the array without that index |
| Nothing | Open the draft row, type nothing, click away (or Escape). | no write. An empty row never becomes an empty item. |
"Click Add, then click again to actually type" is not an affordance, it is a chore. The draft
row must land the caret on mount — so EditableSurface gained a single
autoFocus prop, forwarded to TipTap's own autofocus option. It is
documented as belonging to a surface that only exists because counsel asked to write
— never to a surface that is merely displaying a value, which would steal focus on page load.
AGENTS.md is blunt about this: a write that waits for the round-trip reads as lag. The Brief's
frame edit lives in the TanStack cache (useAppGetMatterBrief), so it takes
mechanism 1 — onMutate patches the cached Brief, onError restores
the snapshot and toasts, onSettled invalidates to reconcile with server truth.
const edit = useAppEditMatterFrame<{ previous?: MatterBriefView }>({
mutation: {
onMutate: async (variables) => {
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<MatterBriefView>(key);
qc.setQueryData(key, { ...previous, frame: { ...previous.frame,
frame: { ...previous.frame.frame, ...variables.data.fields },
contentFormat: { ...previous.frame.contentFormat, ...(variables.data.contentFormat ?? {}) },
}});
return { previous }; // ← the rollback path
},
onError: (_e, _v, context) => { qc.setQueryData(key, context.previous); toast.error(t("saveFailed")); },
onSettled: () => qc.invalidateQueries({ queryKey: key }),
},
});
Note where this went: on the shared edit mutation, not on a
second list-only copy of it. Every frame field routes through that one mutation, so one
onMutate makes them all instant — the root-cause fix, and a smaller diff than a
bespoke optimistic path per caller. (The prose blocks' components are untouched;
they simply inherit the instantness they were already owed. Their contentFormat
declaration is carried into the optimistic patch too, so a Markdown field never flashes as
literal asterisks in the optimistic window.)
EditableSurfaceMarker the rest of
the family uses: proposed shows the dotted tag, the passive states are
announced to screen readers only.
canonical or locked item is not editable (EditableSurface's own
rule) and carries no ✕ — you cannot delete your way around a lock.
readOnly removes the add affordance and every ✕, and leaves the rows plain.
Shared/EditableList — empty (add-first), populated, add flow, remove, read-only,
provenance-marked, rollback-on-error. The add flow was driven in a real browser, not
just eyeballed: clicking "Add an instrument" produces a draft row that reports
[active] in the accessibility tree (autofocus works), typing + Enter commits the
full next array, and the ✕ removes the row. The provenance story renders the dotted
PROPOSED tag on the marked item.
editable-list.test.tsx — rows announce under the group
label; remove commits the WHOLE next array; the add affordance opens a draft row from an empty
list; read-only has no add and no ✕; a canonical item offers no ✕.
pnpm lint (0 errors), pnpm test (1237 passing), pnpm build,
tsc --noEmit, and pnpm storybook:check (drift + build) are all green.
PATCH …/frame round-trip against staging, and the optimistic rollback firing on a
real server rejection — is not proven in this run. The contract gate is proven
by reading the functions source (key_instruments ∈ EDITABLE_FRAME_FIELDS,
typed list[str]), and the UI is proven in Storybook. The end-to-end staging pass on
a real matter (add → reload → the instrument is still there) is the remaining seam, and it is a
staging verification, not a code change.
9 file(s), +550 / -19 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +4 | -0 |
| messages/hi.json | Config | +4 | -0 |
| src/components/matter-detail/brief/matter-brief-page.tsx | Component | +44 | -1 |
| src/components/matter-detail/brief/matter-brief-sections.tsx | Component | +26 | -17 |
| src/components/shared/editable-list.stories.tsx | Component | +195 | -0 |
| src/components/shared/editable-list.test.tsx | Component | +95 | -0 |
| src/components/shared/editable-list.tsx | Component | +159 | -0 |
| src/components/shared/editable-surface.tsx | Component | +8 | -0 |
| tests/setup.ts | Test | +15 | -1 |
| File | What changed |
|---|---|
src/components/shared/editable-list.tsx | new — the list kind |
src/components/shared/editable-list.stories.tsx | new — seven states under Shared/EditableList |
src/components/shared/editable-list.test.tsx | new — the headless contract |
src/components/shared/editable-surface.tsx | autoFocus prop (the draft row's caret) |
src/components/matter-detail/brief/matter-brief-sections.tsx | Key instruments → EditableList; always rendered |
src/components/matter-detail/brief/matter-brief-page.tsx | optimistic frame edit + rollback + toast; saveList |
messages/en.json, messages/hi.json | removeItem, addInstrument, instrumentPlaceholder, saveFailed |