EditableSurface — the edit-in-place primitive (TipTap)
A new shared primitive at
src/components/shared/editable-surface.tsx: plain text
with no box chrome that edits in place. Hover shows the text I-beam,
click drops the caret in (TipTap contenteditable), blur commits the
text through an onSave callback. It is
provenance-marker aware (canonical / counsel / proposed / locked) and
gates by permission with readOnly. Foundation for the
Matter Brief (EPIC-1990, pramaan-docs ADR-153): every text region of
the Brief composes this surface.
What was built
No Edit buttons, modals, or field chrome. TipTap
(@tiptap/react 3.27 + StarterKit + Placeholder)
renders the value as plain text; an empty region shows token-ink
placeholder copy counsel clicks to type into. Enter commits a
single-line surface, Escape reverts, blur saves only when the
text actually changed.
canonical (ShieldCheck, never editable),
counsel (pencil), locked (Lock, never
editable) render a quiet glyph with the provenance string on
hover via provenanceLabel. proposed
carries a dotted underline, a Proposed tag, and the
✓ (Confirm & lock) / ✗ (Dismiss proposal) ratify affordance
wired to onConfirm / onReject.
readOnly wins over everything: the region renders
contenteditable="false" +
aria-readonly, the I-beam cursor drops to default,
and the ratify affordance stays hidden on a proposed value.
All copy resolves through the EditableSurface
namespace in both messages/en.json and
messages/hi.json. The region announces itself
(role="textbox", required ariaLabel,
aria-multiline, aria-readonly; markers
carry sr-only provenance text). The ratify cluster
rises in and sinks out via AnimatePresence with the
shared MICRO_TRANSITION token.
Relation to existing primitives
-
ui/editable-text.tsxdocumented itself as v1 with "a TipTap upgrade can drop in behind this same prop shape later" — EditableSurface is that upgrade as a sibling shared primitive (blur-commit + provenance, vs EditableText's per-keystroke draft writes). Existing EditableText callers are untouched; converging them onto EditableSurface is follow-up scope. -
Ratify buttons compose
ui/Button(variant="ghost" size="icon") — no hand-rolled buttons; markers use Lucide at 1.5 stroke. -
Tokens only:
text-ink-muted,border-surface-border, hover accents viabrand-primary/destructive; the TipTap placeholder rule inglobals.cssusesvar(--ink-muted). No color literals.
Storybook states (Shared/EditableSurface)
Editable— interactive type → blur → committed value shownEmptyWithPlaceholder— "Add the seat…" placeholder as the affordanceCanonical— shield glyph, read-only, hover provenanceAddedByCounsel— pencil glyph, editableProposed— dotted underline + tag + ✓/✗ ratify affordanceProposedRatifyFlow— ✓ → locked, ✗ → plain editable; demonstrates the marker's enter AND exit motionLocked— lock glyph, read-onlyReadOnlyByPermission— counsel provenance, viewer cannot writeProposedReadOnly— tag shows, ratify hiddenMultilineProse— paragraph editing, blur commits
Tests and scope guards
-
editable-surface.test.tsx(5 tests): editable region announces via role/aria; canonical + locked render read-only with their markers; ratify callbacks fire; ratify hidden underreadOnly; no-change blur never firesonSave. -
Scope held to the shared primitive: no Brief page, no persistence
wiring (pramaan-docs ADR-153 write path is sibling EPIC-1990
work), no changes to
ui/editable-text.tsxcallers. - Vocabulary law respected: no "frame", no agent-taxonomy words, no agent promises anywhere in UI copy — the primitive carries only provenance vocabulary ("Proposed", "Added by counsel", "Locked").
Verification
pnpm lint # passes with one pre-existing add-matter/avatar.tsx no-img-element warning pnpm typecheck # clean pnpm test # 157 files passed, 988 tests passed pnpm storybook:check # story-drift + Storybook production build passed pnpm build # Next.js production build passed
Files changed
9 file(s), +1030 / -0 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +8 | -0 |
| messages/hi.json | Config | +8 | -0 |
| package.json | Config | +5 | -0 |
| pnpm-lock.yaml | Config | +480 | -0 |
| src/app/globals.css | App route | +10 | -0 |
| src/components/shared/editable-surface-marker.tsx | Component | +104 | -0 |
| src/components/shared/editable-surface.stories.tsx | Component | +170 | -0 |
| src/components/shared/editable-surface.test.tsx | Component | +97 | -0 |
| src/components/shared/editable-surface.tsx | Component | +148 | -0 |