Skip to content

Direct-Send Composer — Shared-Component Audit

Direct outcome of the PDEV-970 design iteration (purchase-order-ui.md) and its Email Order sibling (email-order-ui.md). Both composers landed as separate React mocks with almost identical structure — this document lists the overlap concretely so PDEV-1019 — Harmonize Email Order UX with PO-Lite starts with a factored plan rather than “make them look the same.”

The audit covers the two design mocks in this documentation site — not (yet) the production components in arda-frontend-app:

Mock (docs)Production analog (arda-frontend-app)
mocks/_PoComposerMock.tsxOrderSidebar — full PO order-sheet + planned composer view
../../order-queue/direct-email-send/mocks/_EmailOrderMock.tsxEmailOrderPanel — shipped in PR #932

The mocks are the design source of truth for the shape both production surfaces should converge on. Extraction findings apply to both.

Line counts and roles:

FileLinesRole
_PoComposerMock.tsx970Order-sheet view + composer view (attachment card + Edit-back)
_EmailOrderMock.tsx850Composer view with items table embedded in the Tiptap body
_PoComposerMock.module.css820Panel chrome + PO-specific (attachment, order-sheet skeleton)
_EmailOrderMock.module.css680Panel chrome + Email-specific (items table Node View, taxable toggle)

CSS class overlap (via diff of top-level rule selectors): ~93 classes are shared verbatim between the two modules; ~25 classes are PO-only (attachment card, order-sheet skeleton); ~3 classes are Email-only (badgeWrap, itemsTableNode, taxableToggle). Roughly 90% of the CSS is duplicated.

React building blocks: identical or near-identical implementations exist in both files for:

Building blockApprox LOCDelta between mocks
avatarColor, initial helpers12Identical
RecipientField (Gmail-style chip input)65Identical
EditableText (single-click-to-edit + revert badge)95Identical
SplitPrimary (main action + caret menu)80PO uses a menu: SplitMenuItem[] prop; Email uses a fixed one-item href prop. PO’s shape is a strict superset.
Signature toggle switch (checkbox behind styled track/thumb)20 (JSX) + 40 (CSS)Identical CSS; PO extracted to SignatureToggle component, Email inlined
Recipient undo stack (historyRef + updateField + undo)15Identical
Confirmation card (sent state → check + title + reset)25Identical shape; different SentKind union
Toast (showToast + .toast container)15 (JSX) + 20 (CSS)Identical

Panel structural JSX is identical in shape:

<div className={s.panel}>
<div className={s.header}>
<div className={s.title}>…</div>
<div className={s.subtitle}>…</div>
<span className={s.close}>✕</span>
</div>
<div className={s.body}>
{ addresses / subject / attachments / body slot }
</div>
<div className={s.footer}>
<div className={s.footerLeft}>
<button>Cancel</button>
{isDirty && <button>Revert all</button>}
</div>
<div className={s.footerRight}>
<button>Copy</button>
<SplitPrimary label="Send" … />
</div>
</div>
{toast && <div className={s.toast}>…</div>}
</div>

The only meaningful structural differences are:

  • PO adds a two-view state (order-sheet | composer). The order-sheet view is a PO-only skeleton with its own footer (Send via email split with Download / Start order menu).
  • PO composer has an attachment row below the body (PDF chip with paperclip inside + Edit-return ghost button, plus the restricted-mode Download PO PDF affordance and hint).
  • Email composer has the items table embedded in the Tiptap document via a custom itemsTable Node View (React context passes row state into the NodeView), plus a click-to-toggle taxable status cell.

Everything else — chips, editable text, subject row, cover-note prose, badge toggle, footer actions — is duplicated.

Concretely, factoring the shared surface into one component set would:

  1. Cut ~1300 lines of duplicated code across the two mocks (and the two production panels once they follow).
  2. Guarantee visual parity — any future tweak to a chip, a button, or a toggle switch changes both flows at once.
  3. Make the difference visible — each mock becomes a thin wrapper that only declares what’s genuinely PO- or Email-specific (the attachment card, the order-sheet skeleton, the items-table Node View, the Copy tooltip).
  4. Give PDEV-1019 a real deliverable — the ticket becomes “replace the EmailPanel-local editable-text and chip components with the shared composer” rather than “make it look like PO-Lite,” which is much easier to scope.

A single DirectSendComposer (name TBD) with three shapes of extension point:

1. A shared components package (either colocated in a _shared/ folder or promoted to @arda-cards/design-system/canary — see Placement):

  • RecipientField — the Gmail chip input, plus the historyRef undo hook.
  • EditableText — click-to-edit with revert badge.
  • SplitPrimary — the generalized menu: SplitMenuItem[] version from the PO mock (Email’s simpler shape is a subset).
  • SignatureToggle — the switch + label row.
  • Toast — the transient status confirmation.
  • SentConfirmation — the “panel closes” reset card.

2. A shared panel shellDirectSendComposer:

  • Renders the header (title/subtitle/close), body scaffold (addresses row → subject → body slot → attachment slot → toggle), and footer (Cancel + Revert-all | Copy + Send split).
  • Owns: recipient state + undo, subject state, body-dirty tracking, badge toggle, sending in-flight state, sent-state confirmation.
  • Accepts (via props / render slots) everything that varies:
    • bodyEditor: JSX.Element — the Tiptap EditorContent (with the flow’s extensions) plus optional overlays like the Powered-by-Arda badge inside the bodyCard.
    • attachment?: JSX.Element — the PO PDF card + paperclip + Edit ghost button (PO only; omit for Email).
    • copyTooltip?: string — PO passes “Don’t forget to attach the PO”.
    • onSendPlainText, onSendHtml, mailtoHref — data-flow contract for the Send split.
    • sendLabel, sentTitle — “Purchase order sent” vs “Email sent”.

3. Flow-specific bodies — each mock declares its own body content:

  • PO: a Tiptap editor with StarterKit and a two-state view wrapper that toggles between the order-sheet skeleton and the composer. The composer body is the cover-note prose + a bodyCard-inner Powered-by-Arda badge.
  • Email: a Tiptap editor with StarterKit plus the custom itemsTable Node View extension. Body content interleaves prose paragraphs and the items table node.

Two viable homes for the shared bits:

OptionProsCons
Colocated _shared/ under roadmap/procurement/direct-email-send/No new package boundary; matches how the mocks already ship. Immediate.Two mocks reach across order-queue/ and purchase-orders/ sibling trees — path shape is awkward.
Promote to @arda-cards/design-system/canary (ux-prototype)Real destination for production too. One place for storybook / VRT.Larger PR; requires deciding on public API early; needs design-system review.

Recommendation: stage in _shared/ for the mock consolidation first, then promote when the production migration for PDEV-1019 starts. The shared code has had zero real-world iteration outside these two mocks, and moving it into the design system before the production callers stabilize risks locking in the wrong API.

Ordered by risk and reversibility. Each step is independently reviewable.

  1. Create _shared/ and extract the pure components. Move avatarColor/initial, RecipientField, EditableText, SplitPrimary (PO’s generalized form), SignatureToggle, Toast. No behavioral change, just imports. Delete the duplicates from both mocks.
  2. Extract the panel-shell CSS. New _shared/composer.module.css owns the ~93 shared classes. Both mocks import it and only carry their own flow-specific classes. Verify visually: the mocks should render identically.
  3. Extract the DirectSendComposer React component. Accept the slots described above. Both mocks become thin wrappers — a few dozen lines each declaring their body/attachment content.
  4. Extract the itemsTable Node View into its own file so it can move into the shared bundle later if the PO flow ever wants to embed a table too. The NodeView reads its data from React context, so the extraction is clean.
  5. (Optional) Promote to the design system. Once step 3 has soaked with both mocks passing, move the shared package to @arda-cards/design-system/canary and update imports.
  6. Wire the production callers. In arda-frontend-app, EmailOrderPanel and the planned PO composer both consume the shared DirectSendComposer, passing their flow-specific slots. Retires email-order-panel/editable-text.tsx and email-order-panel/recipient-chips.tsx (the workarounds PDEV-1019 already calls out) in the same pass.

Docs-only work (steps 1 – 4 above):

StepEstimate
Extract pure components + CSS2 – 3 hours
Extract DirectSendComposer3 – 4 hours (careful about the slot contracts)
Extract itemsTable Node View1 hour
Storybook stories + browser verification2 hours
Total (docs consolidation)~1 – 1.5 dev-days

Promoting to the design system + migrating both production callers (steps 5 – 6) is a separate ticket — realistically 3 – 5 days, most of it in the production migration of EmailOrderPanel (chip / editable-text callers, tests, VRT). That’s what PDEV-1019 tracks.

  • Panel width and viewport behavior. The Email panel is 640 px wide; the PO order sheet is 720 px. If the composer view stays at the same width as the order-sheet page, PO widens to 720 px; Email stays at 640. Do we want a fixed width across flows?
  • Envelope-sender surfacing. Email’s addresses hint mentions the configured EmailConfiguration sender; PO mirrors it. Should this be a shared prop or a static string from a tenant-config hook?
  • Attachment abstraction. PO is the only flow with an attachment today. If future flows (bulk-print PDFs, receipts) need attachments, is the attachment slot generic ({ chip, actions?, hint? }) or PO-specific?
  • Node View re-entry. The itemsTable Node View reads state through React context. That’s fine for a single embedded table but doesn’t scale to multi-table documents. If we ever need multiple embeddable data blocks (headers, terms), we should consider Tiptap attributes instead of context.

[!note] Authored by Claude Opus 4.7 for nail60