PR: #599: feat(brief): value at stake — numeric-validated EditableField + note [Story-2361] by @ankitmaheshwari-com
View PR →

Story-2361: Value at stake becomes editable — and the surface learns to say no

The Brief's Value at stake card was a read-only figure. Vega proposed a number, counsel could only look at it. If the framer read ₹4.2 crore out of a quantum statement that actually said ₹42 crore, the Brief carried a wrong figure — the single most consequential number on the page — with no way to correct it in place.

This Story promotes the card: the amount edits in place under numeric validation, and the note beside it (what the figure covers — "principal only", "excluding interest") edits as a plain line.

The one new idea: validation is a PROP AXIS

The frozen vocabulary (docs/design/editable-surface-vocabulary.md §3, founder-ruled on Spike-2316) settles this before anyone can argue it: "Validation is a prop axis, not a kind or preset." The HTML precedent is exact — <input type="number"> is still an <input>. So an amount is not a fifth kind and not a new preset. It is the same EditableField with two extra props:

// src/components/shared/editable-surface.tsx
validate?: (next: string) => boolean;   // false → reject the commit
onInvalid?: (rejected: string) => void; // the caller toasts
inputMode?: "text" | "decimal" | "numeric";

The whole seam lives in one place — the blur commit:

if (next !== current.value) {
  if (current.validate && !current.validate(next)) {
    instance.commands.setContent(...current.value...);  // revert to the last good value
    current.onInvalid?.(next);                          // caller toasts
  } else {
    current.onSave(next);
  }
}
What this kills. The two failure modes an unvalidated numeric field ships with: (1) persisting garbage — "about 42 crore" landing in an integer column as a 422 or, worse, a coerced 0; and (2) the silent unsaved edit — counsel types, the write is dropped, the field looks committed, and the Brief quietly still says the old number. A rejected commit here is visible: the field snaps back to the last good figure and a toast says why.

The gate that had to pass first

Every editable-surface Story starts the same way: will the write path actually accept this? For Value at stake the answer is unusual — the amount and the note are written through two different endpoints, and getting that wrong would have shipped a field that looks saved and isn't.

ValueWhere the truth livesWrite path
Amount + currency Canonical — matters.value_at_stake_minor / _currency PATCH /app/matters/{id} → useAppUpdateMatter (proxy already exists at /api/internal/matters/[matterId])
Note Frame — value_note on FrameOutput PATCH …/frame → the Brief's existing save("value_note")

The trap is the amount. It is rendered canonical-first — valueAtStake() in presentation.ts reads brief.canonical.valueAtStake and only falls back to the frame's proposal when the canonical column is empty. So writing the edited amount to the frame would have been swallowed whole: the PATCH succeeds, the frame changes, and the Brief keeps rendering the old canonical figure. The edit vanishes with a green network tab. pramaan-functions says the same thing in its own words:

src/domains/matter_frame/frame_contract.py

# `case_ref`, `value_*`, and `relief_sought` are also Category-1 overlaps but their
# accept is lock-only for now (canonical columns stay primary via their own editors)
GATE: PASS — no pramaan-functions change needed. The amount writes canonical through the matter record (the same seam the ribbon's title edit already uses); the note writes the frame field value_note, which is in EDITABLE_FRAME_FIELDS (frozenset(FrameOutput.model_fields) - meta). Both endpoints exist, both proxies exist, nothing was stubbed.

What counsel can type

The field displays the formatted figure — ₹4,20,00,000 — so counsel edits what they read. The validator therefore has to accept the rendering it produced, plus the ways a person actually types an amount:

// src/components/matter-detail/brief/presentation.ts
const AMOUNT_NOISE = /[₹,\s]/g;      // the symbol we rendered, INR groups, spaces
const AMOUNT = /^\d+(\.\d{1,2})?$/;  // what must be left over

isAmountValid("₹4,20,00,000")  → true    amountToMinor(...) → 4_200_000_000
isAmountValid("1,25,000.50")   → true    amountToMinor(...) →    12_500_050
isAmountValid("")              → true  (clearing is a legitimate edit → null)
isAmountValid("about 42 crore")→ false   REJECTED — reverts + toasts
isAmountValid("42cr")          → false
isAmountValid("1.234")         → false  (more precision than paise can hold)

Storage is minor units (paise), matching the column: Math.round(major × 100). Clearing the amount clears the currency with it — the pair is one fact, and a currency with no amount is a value at stake nobody can read.

Instant, and reversible

The write is optimistic per AGENTS.md: onMutate patches canonical.valueAtStake in the Brief's TanStack cache so the new figure renders the moment counsel clicks away; the PATCH runs behind it; onError restores the snapshot and toasts; onSettled invalidates to reconcile with server truth. The note rides the frame-edit mutation that already had this exact shape.

The card is now always rendered, empty included — the placeholder is the empty state, the same add-first law as Relief sought and Key instruments. Previously a matter with no value at stake showed no card at all, which is precisely the matter where counsel most needs to type the number in. A non-monetary matter still reads rather than edits: that flag is set where the matter is configured, not on the Brief.

Files

FileChange
src/components/shared/editable-surface.tsxThe validation axis: validate, onInvalid, inputMode. Reject → revert + notify, never persist.
src/components/shared/editable-surface.stories.tsxNumericValidated story — happy path AND the invalid-input rejection/revert, under Shared/EditableSurface.
src/components/matter-detail/brief/presentation.tsisAmountValid, amountToMinor, valueCurrency.
src/components/matter-detail/brief/matter-brief-sections.tsxThe value card: ValueAmountField (validated) + the note as a plain EditableSurface. Always rendered.
src/components/matter-detail/brief/matter-brief-page.tsxThe canonical amount write — useAppUpdateMatter, optimistic on the Brief cache, rollback + toast.
messages/en.json, messages/hi.jsonPlaceholder, note label/placeholder, and the rejection toast — both locales.

Verification

Named honestly. The reject/revert path is proved by the pure validator's unit tests plus the Storybook interaction; it is not driven headlessly through TipTap (ProseMirror typing is not reliably exercisable in jsdom, which is why no Brief editable-surface Story does it). Staging UAT — edit an amount on a real matter, reload, confirm the figure persisted; type prose into it, confirm the revert + toast — is the run that closes the system boundary.

Files changed

8 file(s), +271 / -21 lines

Path Role + -
messages/en.jsonConfig+4-0
messages/hi.jsonConfig+4-0
src/components/matter-detail/brief/matter-brief-page.tsxComponent+49-0
src/components/matter-detail/brief/matter-brief-sections.tsxComponent+65-12
src/components/matter-detail/brief/presentation.test.tsComponent+40-1
src/components/matter-detail/brief/presentation.tsComponent+32-0
src/components/shared/editable-surface.stories.tsxComponent+43-0
src/components/shared/editable-surface.tsxComponent+34-8