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.tsx | OrderSidebar — full PO order-sheet + planned composer view |
../../order-queue/direct-email-send/mocks/_EmailOrderMock.tsx | EmailOrderPanel — 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.
Overlap today
Section titled “Overlap today”Line counts and roles:
| File | Lines | Role |
|---|---|---|
_PoComposerMock.tsx | 970 | Order-sheet view + composer view (attachment card + Edit-back) |
_EmailOrderMock.tsx | 850 | Composer view with items table embedded in the Tiptap body |
_PoComposerMock.module.css | 820 | Panel chrome + PO-specific (attachment, order-sheet skeleton) |
_EmailOrderMock.module.css | 680 | Panel 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 block | Approx LOC | Delta between mocks |
|---|---|---|
avatarColor, initial helpers | 12 | Identical |
RecipientField (Gmail-style chip input) | 65 | Identical |
EditableText (single-click-to-edit + revert badge) | 95 | Identical |
SplitPrimary (main action + caret menu) | 80 | PO 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) | 15 | Identical |
Confirmation card (sent state → check + title + reset) | 25 | Identical 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 emailsplit withDownload/Start ordermenu). - PO composer has an attachment row below the body (PDF chip with paperclip
inside + Edit-return ghost button, plus the restricted-mode
Download PO PDFaffordance and hint). - Email composer has the items table embedded in the Tiptap document via a
custom
itemsTableNode 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.
What extraction unlocks
Section titled “What extraction unlocks”Concretely, factoring the shared surface into one component set would:
- Cut ~1300 lines of duplicated code across the two mocks (and the two production panels once they follow).
- Guarantee visual parity — any future tweak to a chip, a button, or a toggle switch changes both flows at once.
- 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).
- 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.
Proposed shared foundation
Section titled “Proposed shared foundation”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 thehistoryRefundo hook.EditableText— click-to-edit with revert badge.SplitPrimary— the generalizedmenu: 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 shell — DirectSendComposer:
- 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 TiptapEditorContent(with the flow’s extensions) plus optional overlays like the Powered-by-Arda badge inside thebodyCard.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
StarterKitand a two-stateviewwrapper that toggles between the order-sheet skeleton and the composer. The composer body is the cover-note prose + abodyCard-inner Powered-by-Arda badge. - Email: a Tiptap editor with
StarterKitplus the customitemsTableNode View extension. Body content interleaves prose paragraphs and the items table node.
Placement
Section titled “Placement”Two viable homes for the shared bits:
| Option | Pros | Cons |
|---|---|---|
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.
Migration steps
Section titled “Migration steps”Ordered by risk and reversibility. Each step is independently reviewable.
- Create
_shared/and extract the pure components. MoveavatarColor/initial,RecipientField,EditableText,SplitPrimary(PO’s generalized form),SignatureToggle,Toast. No behavioral change, just imports. Delete the duplicates from both mocks. - Extract the panel-shell CSS. New
_shared/composer.module.cssowns the ~93 shared classes. Both mocks import it and only carry their own flow-specific classes. Verify visually: the mocks should render identically. - Extract the
DirectSendComposerReact component. Accept the slots described above. Both mocks become thin wrappers — a few dozen lines each declaring their body/attachment content. - Extract the
itemsTableNode 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. - (Optional) Promote to the design system. Once step 3 has soaked with
both mocks passing, move the shared package to
@arda-cards/design-system/canaryand update imports. - Wire the production callers. In
arda-frontend-app,EmailOrderPaneland the planned PO composer both consume the sharedDirectSendComposer, passing their flow-specific slots. Retiresemail-order-panel/editable-text.tsxandemail-order-panel/recipient-chips.tsx(the workarounds PDEV-1019 already calls out) in the same pass.
Effort estimate
Section titled “Effort estimate”Docs-only work (steps 1 – 4 above):
| Step | Estimate |
|---|---|
| Extract pure components + CSS | 2 – 3 hours |
Extract DirectSendComposer | 3 – 4 hours (careful about the slot contracts) |
Extract itemsTable Node View | 1 hour |
| Storybook stories + browser verification | 2 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.
Open questions
Section titled “Open questions”- 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
EmailConfigurationsender; 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
itemsTableNode 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.
Related
Section titled “Related”- PDEV-1019 — Harmonize Email Order UX with PO-Lite — the tracking ticket.
- PDEV-969 — Enable Direct email sending for Email Orders — Email flow (shipped).
- PDEV-970 — Enable Direct email sending for PO-Line orders — PO flow (this design workspace).
- Purchase Order UI — Direct Send Design
- Email Order UI — Direct Send Design
[!note] Authored by Claude Opus 4.7 for nail60
Copyright: © Arda Systems 2025-2026, All rights reserved