Skip to main content

max / goingson

Compose unification stage 2: CC/BCC on the in-app modal openComposeModal grows CC and BCC inputs hidden behind a Show/Hide toggle, mirroring compose.html's existing CC/BCC row. Auto-expands when a prefill provides cc/bcc (matches compose.html's draft-restore behavior). Autocomplete and address-highlight now attach to all three recipient inputs. The onSubmit handler now reads modal-cc-address and modal-bcc-address through composeForm.collectInput, which already supports them since stage 1 — no contract change required. Visual order (CC/BCC currently render below Body, because openFormModal appends extras after declared fields) is intentionally deferred to stage 3's shared HTML template. docs/ux-audit/compose-migration.md lands here with the six-stage plan, status table, and resumption notes for the next stages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-20 23:30 UTC
Commit: 60bfc7ea9ffd6261f63f5a31014ae88ebd37e69c
Parent: a3b937e
2 files changed, +276 insertions, -6 deletions
@@ -0,0 +1,228 @@
1 + # Compose unification — migration plan
2 +
3 + > Phase 7 Tier 6 #3 (the deferred-but-eventual one). Plans how to collapse
4 + > the two compose surfaces — desktop `compose.html` (its own Tauri window)
5 + > and the mobile compose modal in `js/emails.js` — into one component.
6 + >
7 + > Status as of 2026-05-20: **stage 1 of 6 landed**. Tier 6 #1 (task drawer)
8 + > and #2 (settings drawer) shipped same day. This one's the heaviest of
9 + > the three and touches the email send path, so it's staged across
10 + > releases rather than landed in one drop.
11 +
12 + ## Current state
13 +
14 + | Stage | Status | Notes |
15 + |---|---|---|
16 + | 1 — Data contract | ✓ landed 2026-05-20 | `js/compose-form.js`: caps, `collectInput`, `validate`, `validateForSend`. Both surfaces call in. `to` vs `toAddress` shim deleted. |
17 + | 2 — CC/BCC on modal | ✓ landed 2026-05-20 | `openComposeModal` gains CC/BCC inputs + Show/Hide toggle via `extraContent`. Autocomplete + address-highlight wired to both. Auto-expands on `pf.cc`/`pf.bcc` prefill. Visual order (below Body) deferred to stage 3's shared template. |
18 + | 3 — Shared HTML template | pending | Extract `composeForm.buildFieldsHtml`. Medium risk (DOM-ID dependencies in compose.html's inline JS). |
19 + | 4 — Shared behavior bindings | pending | `composeForm.bindBehaviors`: autocomplete / signature swap / address-highlight / attachment picker. |
20 + | 5 — Desktop-only features to modal | pending | Draft autosave, reply indicator, save-contact prompt promoted into the shared module; modal opts in. |
21 + | 6 — Decide `compose.html`'s fate | open | 6a: keep as thin wrapper. 6b: eliminate, use modal/overlay on desktop too. Decide on creator feedback. |
22 +
23 + **Resuming:** if you're picking this up after a context wipe, read this
24 + file end-to-end, then read `src-tauri/frontend/js/compose-form.js` (the
25 + stage-1 contract) and the CC/BCC block in
26 + `src-tauri/frontend/js/emails.js::openComposeModal` (stage-2 surface).
27 + The next concrete code change is **stage 3**: extract a shared
28 + `composeForm.buildFieldsHtml(opts)` that both `compose.html` and
29 + `openComposeModal` render. Watch the inline-JS DOM-ID dependencies in
30 + `compose.html` (`from-account`, `to-address`, `cc-address`,
31 + `bcc-address`) — preserve them or migrate the inline handlers to
32 + class-based selectors.
33 +
34 + ## Why staged
35 +
36 + The two surfaces have bidirectional drift that makes a one-shot rewrite
37 + unsafe during soft launch:
38 +
39 + | Feature | Desktop window | Mobile modal |
40 + |---|---|---|
41 + | To / Subject / Body | ✓ | ✓ |
42 + | CC / BCC | ✓ | — |
43 + | Account picker | ✓ | ✓ |
44 + | Signature swap on account change | — | ✓ |
45 + | Autocomplete | ✓ (via `address-highlight.js` only) | ✓ |
46 + | Address highlight | ✓ | ✓ |
47 + | Attachment picker | ✓ | ✓ |
48 + | Attachment cap warning | status bar | hard-block toast |
49 + | Reply indicator | ✓ | — (passes refs through) |
50 + | Draft autosave | ✓ | — |
51 + | "Save contact?" prompt | ✓ | — |
52 + | Send-with-delay (undo) | ✓ (via emit to main) | ✓ |
53 + | Payload shape | `{ to, toAddress, ccAddress, bccAddress, ... }` | `{ toAddress, ... }` |
54 +
55 + The `to` vs `toAddress` payload shim in `compose.html` line 451-454 is the
56 + exact kind of bug a unified data contract prevents. A one-shot rewrite
57 + risks shipping a regression in a launch-sensitive flow; staged refactors
58 + let each step ship + bake before the next.
59 +
60 + ## Target end state
61 +
62 + One `GoingsOn.composeForm` module owns:
63 + - The form's HTML template (one shape, both surfaces).
64 + - The send payload contract (one `collectInput` function).
65 + - Validation (one `validate` function).
66 + - Shared behaviors (autocomplete, signature swap, address highlight, attachment picker, attachment cap).
67 +
68 + Each surface (window or modal) is a thin shell that mounts the module:
69 + - `compose.html` becomes a layout-only wrapper that loads the module.
70 + - The mobile modal becomes a layout-only wrapper that loads the module.
71 +
72 + Whether `compose.html` survives as a separate window or gets folded into
73 + an in-app overlay is the final decision (stage 6); deferred until after
74 + the data contract and feature parity are in place.
75 +
76 + ## Stages
77 +
78 + Each stage:
79 + - Ships independently and bakes for at least one release before the next.
80 + - Has its own rollback (revert that PR, the prior stage keeps working).
81 + - Updates `phase-7.md` Tier 6 line item progress.
82 +
83 + ### Stage 1 — Data contract (low risk, ~½ day)
84 +
85 + **What:** New `js/compose-form.js` module exposing:
86 + - `collectInput(form, opts)` → returns the canonical send payload from any form-shape that uses the documented field names.
87 + - `validate(input)` → returns `{ ok: true } | { ok: false, field, message }`.
88 + - `exceedsAttachmentCap(files)` → returns `{ over, warn, totalBytes }`.
89 +
90 + Both surfaces call into these. Per-surface field markup is unchanged.
91 +
92 + **Bug it fixes:** the `to` vs `toAddress` payload shim. Compose.html line 451-454 stops shipping both keys; the module emits the canonical shape.
93 +
94 + **Acceptance criteria:**
95 + - Desktop send and mobile send produce identical wire payloads for the same form input (verify via a trace log or snapshot test in the backend).
96 + - All existing send tests still pass.
97 + - Attachment cap behavior is now uniform: same threshold, same blocking decision.
98 +
99 + **Rollback:** revert the PR; each surface still has its inline payload code (kept until stage 4).
100 +
101 + ### Stage 2 — CC/BCC on the modal (low risk, ~½ day)
102 +
103 + **What:** Add CC and BCC inputs to the mobile compose modal. Use the new
104 + `composeForm.validate` from stage 1 (already handles them). Hide behind a
105 + "Show CC/BCC" toggle, same as `compose.html`.
106 +
107 + **Why now:** brings feature parity to the modal so stage 4 has nothing
108 + new to add when the modal gets the shared template.
109 +
110 + **Acceptance criteria:**
111 + - CC and BCC fields work end-to-end (sent message has the right recipients).
112 + - Address highlight + autocomplete attach to CC and BCC (not just To).
113 + - Modal is no taller than before when CC/BCC are collapsed.
114 +
115 + **Rollback:** revert the PR; modal returns to To-only.
116 +
117 + ### Stage 3 — Shared HTML template (medium risk, ~1 day)
118 +
119 + **What:** Extract the form fields HTML (From / To / CC / BCC / Subject /
120 + Body / Attachments) into `composeForm.buildFieldsHtml(opts)`. Both
121 + surfaces render it; both surfaces' chrome (toolbar in window, modal
122 + header in modal) stays surface-specific.
123 +
124 + **Why this is the medium-risk step:** the desktop window relies on
125 + specific DOM IDs (`from-account`, `to-address`, etc.) for its inline
126 + behaviors (sendEmail, saveDraft, autosaveTimer). The shared template
127 + needs to keep those IDs or migrate the inline JS to class-based
128 + selectors.
129 +
130 + **Acceptance criteria:**
131 + - Desktop window's send / save-draft / autosave / save-contact prompts all work unchanged.
132 + - Mobile modal's signature swap on account change still works.
133 + - One visual regression sweep: side-by-side desktop window vs. mobile modal screenshots before and after.
134 +
135 + **Rollback:** revert the PR; each surface returns to its own inline form HTML. Stages 1 and 2 still apply.
136 +
137 + ### Stage 4 — Shared behavior bindings (medium risk, ~1 day)
138 +
139 + **What:** Extract the shared behaviors into `composeForm.bindBehaviors(rootEl, opts)`:
140 + - Account select → signature swap.
141 + - To/CC/BCC inputs → autocomplete attach.
142 + - To/CC/BCC inputs → address-highlight attach.
143 + - Attach-file button → file picker + attachment row rendering.
144 +
145 + Each surface calls `bindBehaviors` after mounting `buildFieldsHtml`. The
146 + inline binders in each surface are deleted.
147 +
148 + **Why now:** with template (stage 3) and contract (stage 1) shared,
149 + behaviors are the last drift surface. Once shared, any future
150 + compose-form change automatically applies to both surfaces.
151 +
152 + **Acceptance criteria:**
153 + - All shared behaviors work on both surfaces.
154 + - No console errors on either surface.
155 + - Backend send-test suite still green.
156 +
157 + **Rollback:** revert the PR; each surface returns to its own inline
158 + behavior bindings.
159 +
160 + ### Stage 5 — Bring desktop-only features to the modal (low risk, ~1 day)
161 +
162 + **What:** Promote draft autosave, reply indicator, and "Save contact?"
163 + prompt from desktop-only into the shared module. Modal opts in via
164 + `composeForm.bindBehaviors({ ..., enableAutosave: true, enableSaveContactPrompt: true })`.
165 +
166 + **Why now:** with everything else shared, parity gaps are visible and
167 + fixable in one place. Mobile gets full feature parity before stage 6
168 + decides whether the desktop window even survives.
169 +
170 + **Acceptance criteria:**
171 + - Modal-composed drafts survive page reload (autosave works).
172 + - Replying from mobile shows the same reply indicator the desktop window does.
173 + - Save-contact bar appears on mobile after sending to an unknown recipient.
174 +
175 + **Rollback:** revert the PR; modal returns to no-autosave / no-save-contact / no-reply-indicator. Other stages unaffected.
176 +
177 + ### Stage 6 — Decide on `compose.html`'s fate (open question, deferred)
178 +
179 + **Two paths:**
180 +
181 + **6a — Keep the window.** `compose.html` stays as a layout-only wrapper
182 + that loads `composeForm`. Multi-window desktop users get their preferred
183 + flow. About 200 lines of compose.html shell remain (vs. the current 939).
184 +
185 + **6b — Eliminate the window.** Replace with an in-app overlay (or full
186 + modal) on desktop too. Single code path. `compose.html` deleted, cross-
187 + window `compose:queue-send` event listener removed from `app.js`.
188 +
189 + **Tradeoff:** 6a preserves muscle memory for users who pop compose out
190 + into its own window (common on macOS / multi-monitor setups). 6b is
191 + ~750 fewer lines of code and removes a whole class of cross-window
192 + state-sync bugs.
193 +
194 + **Recommendation:** decide based on user feedback collected during
195 + stages 1-5. If no one reports "I miss the popup window", do 6b. If
196 + multi-window-on-desktop is a load-bearing flow for the early creator
197 + cohort, do 6a.
198 +
199 + **Acceptance criteria (either path):**
200 + - All five preceding stages have shipped and baked for ≥1 release each.
201 + - A user study or feedback channel pass on "do you ever pop compose into its own window?".
202 +
203 + ## Sequencing
204 +
205 + Stages 1 → 2 → 3 → 4 → 5 → 6. Each independently shippable.
206 +
207 + Suggested cadence: one stage per week during soft launch, then evaluate
208 + stage 6 after creator feedback stabilizes (probably 2026-Q3+).
209 +
210 + ## Open questions
211 +
212 + - **Drafts table schema:** does the current drafts schema already
213 + support CC/BCC? If not, stage 2 needs a migration.
214 + - **Modal sizing:** with CC/BCC + autosave indicator + reply indicator
215 + inside a modal, does the form still fit a single screen on small
216 + desktops? May need to scroll the body field at smaller heights.
217 + - **Window menu integration:** if 6b wins, the desktop "File → New
218 + Email" menu shortcut needs to open the modal instead of spawning a
219 + window. Trivial change but track it.
220 +
221 + ## What's *not* in scope
222 +
223 + - Rich-text editing — body remains plain text. Adding rich-text is a
224 + separate Phase 4-style feature, not a unification step.
225 + - Encrypted-by-default outbound — out of scope; track separately.
226 + - Multiple inline images — same.
227 + - Cross-tab draft sync via SyncKit — separate; would land as part of the
228 + drafts table sync work, not here.
@@ -181,6 +181,29 @@
181 181 const sig = selectedAccount?.emailSignature;
182 182 const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
183 183
184 + // Stage 2 of compose unification — CC/BCC on the modal, behind a
185 + // toggle that mirrors compose.html lines 299-314. Auto-expand when
186 + // a prefill provides cc/bcc (matches compose.html's draft-restore
187 + // behavior). Rendered via extraContent because openFormModal only
188 + // appends extras after declared fields; the resulting visual order
189 + // (CC/BCC below Body) is fixed up by stage 3's shared template.
190 + const ccPrefill = pf.cc || '';
191 + const bccPrefill = pf.bcc || '';
192 + const ccBccExpanded = !!(ccPrefill || bccPrefill);
193 + const ccBccHtml = `
194 + <div id="modal-cc-row" class="form-group" style="display: ${ccBccExpanded ? '' : 'none'};">
195 + <label class="form-label" for="modal-cc-address">CC</label>
196 + <input type="text" class="form-input" id="modal-cc-address" name="modal-cc-address" placeholder="cc@example.com (comma-separated)" autocomplete="off" value="${GoingsOn.utils.escapeAttr(ccPrefill)}">
197 + </div>
198 + <div id="modal-bcc-row" class="form-group" style="display: ${ccBccExpanded ? '' : 'none'};">
199 + <label class="form-label" for="modal-bcc-address">BCC</label>
200 + <input type="text" class="form-input" id="modal-bcc-address" name="modal-bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off" value="${GoingsOn.utils.escapeAttr(bccPrefill)}">
201 + </div>
202 + <div style="margin-bottom: 0.5rem;">
203 + <button type="button" class="btn btn-secondary btn-sm" id="modal-toggle-cc">${ccBccExpanded ? 'Hide CC/BCC' : 'Show CC/BCC'}</button>
204 + </div>
205 + `;
206 +
184 207 GoingsOn.ui.openFormModal({
185 208 title: 'Compose Email',
186 209 entityType: 'email',
@@ -192,6 +215,7 @@
192 215 { name: 'body', type: 'textarea', label: 'Body', rows: 8, value: bodyWithSig },
193 216 ],
194 217 extraContent: `
218 + ${ccBccHtml}
195 219 <div style="margin-bottom: 0.5rem;">
196 220 <button type="button" class="btn btn-secondary btn-sm" onclick="GoingsOn.emails._pickModalAttachment()">Attach File</button>
197 221 <div id="modal-attachments" style="font-size: 0.8125rem; margin-top: 0.25rem;"></div>
@@ -201,11 +225,13 @@
201 225 // Stage 1 of compose unification — collectInput owns the
202 226 // canonical SendEmailInput shape; validateForSend owns the
203 227 // attachment cap. Both surfaces share the contract now.
228 + const ccEl = document.getElementById('modal-cc-address');
229 + const bccEl = document.getElementById('modal-bcc-address');
204 230 const input = GoingsOn.composeForm.collectInput({
205 231 accountId: data.account_id,
206 232 toAddress: data.to,
207 - ccAddress: '',
208 - bccAddress: '',
233 + ccAddress: ccEl ? ccEl.value : '',
234 + bccAddress: bccEl ? bccEl.value : '',
209 235 subject: data.subject,
210 236 body: data.body || '',
211 237 attachedFiles: modalAttachedFiles,
@@ -227,14 +253,30 @@
227 253
228 254 // Attach autocomplete and signature switching after modal renders
229 255 setTimeout(() => {
230 - const toInput = document.querySelector('[name="to"]');
231 - if (toInput) {
232 - GoingsOn.autocomplete.attach(toInput);
256 + const attachAddressInput = (el) => {
257 + if (!el) return;
258 + GoingsOn.autocomplete.attach(el);
233 259 if (GoingsOn.addressHighlight) {
234 - GoingsOn.addressHighlight.attach(toInput, {
260 + GoingsOn.addressHighlight.attach(el, {
235 261 contacts: GoingsOn.autocomplete.getContacts,
236 262 });
237 263 }
264 + };
265 + attachAddressInput(document.querySelector('[name="to"]'));
266 + attachAddressInput(document.getElementById('modal-cc-address'));
267 + attachAddressInput(document.getElementById('modal-bcc-address'));
268 +
269 + // CC/BCC toggle — mirrors compose.html's toggleCcBcc behavior.
270 + const ccRow = document.getElementById('modal-cc-row');
271 + const bccRow = document.getElementById('modal-bcc-row');
272 + const toggleBtn = document.getElementById('modal-toggle-cc');
273 + if (ccRow && bccRow && toggleBtn) {
274 + toggleBtn.addEventListener('click', () => {
275 + const hidden = ccRow.style.display === 'none';
276 + ccRow.style.display = hidden ? '' : 'none';
277 + bccRow.style.display = hidden ? '' : 'none';
278 + toggleBtn.textContent = hidden ? 'Hide CC/BCC' : 'Show CC/BCC';
279 + });
238 280 }
239 281
240 282 // Handle account change → swap signature