Captured: 2026-05-04
PR: #630 — Modernization of bid /setup/ pages
PRE worktree: .worktrees/jus-1204-pre @ origin/main (15d626697d)
POST branch: alexlazarian/JUS-1204-followup-select (HEAD)
13 surfaces enumerated across Launchpad, Bid, and Messaging. Every surface that mounts a rich-text editor was verified PRE (TinyMCE) vs POST (Tiptap) with byte-level DB evidence and side-by-side UI screenshots.
| Count | |
|---|---|
| Surfaces verified | 13 / 13 |
| Findings | 7 |
| Open / blocking | 0 |
| Resolved (real fix) | 1 (HIGH — Invitation::SPACES_REGEXP, commit 375ecea) |
| Resolved (cosmetic accept) | 6 (5 MEDIUM + 1 NOTE — global Tiptap TextAlign attribute) |
Single global fix point would close the 5 MEDIUM cosmetic findings: configure the shared <RichTextEditor> in @justicebid/jb-ui to either disable the TextAlign extension or strip default-aligned attributes on serialization. Decision: defer (cosmetic only, no user-visible impact at current data scale).
| Surface | Severity | Status | URL |
|---|---|---|---|
| FAQ content | — | ✅ parity | /manage_faq |
| AI Prompt | — | ✅ parity | /ai/prompts/new |
| Org description | — | ✅ parity | /organizations/6w4f6w4f4f |
| Attorney overview / pro_bono | — | ✅ parity | attorney 1890 edit |
| Notable records (perf) | — | ✅ parity (160→90 MB) | attorney 1890 edit |
| Bid: Question title + body | MEDIUM | ✓ resolved (cosmetic) | /setup/31233/information_request |
| Bid: Scorecard description | NOTE | ✓ resolved (POST is better) | /setup/35182/evaluation_guide |
| Bid: Negotiation Summary | MEDIUM | ✓ resolved (cosmetic) | /setup/35335/general_settings |
| Bid: Invitation HTML | HIGH | ✓ fixed (commit 375ecea) |
/setup/35335/participation/invitation |
| Bid: Award email body | MEDIUM | ✓ resolved (cosmetic) | /setup/34597/award |
| Bid: Unsuccessful email body | MEDIUM | ✓ resolved (cosmetic) | /setup/34597/award |
| Bid: Bidder text answer | MEDIUM | ✓ resolved (cosmetic) | /bidder_steps/questions/30625 |
| Messaging: new + reply | MEDIUM | ✓ resolved (cosmetic) | messaging / |
File: domains/select/rails/bid/app/models/invitation.rb
Symptom (PRE-fix): Invitation HTML row grew +1135 bytes per save (1808 → 2943) on every UX action because the trailing-empty-paragraph trim regex only matched TinyMCE's literal <p> </p> form. Tiptap emits <p style="text-align: left;"></p> — the regex didn't match → empties accumulated forever.
Fix (commit 375ecea):
SPACES_REGEXP = %r{
(
<p\b[^>]*>
(?:\s| | |<br\b[^>]*/?>)*
</p>
\s*
)+\Z
}xBroadened to match every empty-paragraph form an editor could emit (bare <p></p>, <p><br></p>, <p> </p>, <p style="text-align: left;"></p>, raw U+00A0 variants).
Re-verified: PUT /setup/35335/prepare_invitation with body containing 3 trailing styled empties → DB row stored 122 bytes with 0 trailing empties. Specs added for all four Tiptap variants + mixed PRE/POST trail. The original TinyMCE <p> </p> case still passes.
| PRE — TinyMCE | POST — Tiptap |
|---|---|
![]() |
![]() |
POST byte-trace (post-fix re-verification)
See trace-post-bid-invitation.txt (none — surface has no per-side trace; details inline in finding).
Pattern: Tiptap's TextAlign extension serializes default-left as an explicit inline style="text-align: left;" attribute on every <p>. PRE/TinyMCE emitted bare <p> wrappers (or stripped them entirely on the bidder-text surface). POST/Tiptap stores all paragraphs with the inline attribute, plus 3 trailing styled empties when Enter is pressed.
Per-paragraph cost: ~28 bytes for the style="text-align: left;" attribute + ~14 bytes for each empty paragraph.
Why accepted: Visually identical (left is the browser default); no user-facing impact. Storage scale is small at current data volumes. Single fix point exists if/when ever needed: configure the shared RichTextEditor to skip serializing alignment when it equals the default, or disable TextAlign entirely.
Per-surface details follow.
URL: /setup/31233/information_request
Mutation: click body card to lazy-mount editor → caret-end + Enter ×3 → click outside.
Persists to: question_options.text (TextOption row 449457 holds question 76478 body).
Finding: Both editors persist trailing empty paragraphs (no server-side trim).
| bytes | tail content | |
|---|---|---|
| PRE / TinyMCE | 16 → 49 (+33) | 3 trailing <p>\xC2\xA0</p> (raw U+00A0) |
| POST / Tiptap | 16 → 137 (+121) | 3 trailing <p style="text-align: left;"></p> |
| Delta-of-deltas | POST stores +88 bytes more per save than PRE | inline TextAlign attribute on every paragraph |
Resolution: Cosmetic only — visually identical, no user impact, storage scale is small. Won't fix; revisit if Tiptap config touched for other reasons.
Note: a sibling mount
QuestionForm.tsx(undersetup/common/components/Wizard/Steps/QuestionsStep) wraps the sameRhfRichTextField→ sameRichTextEditor→ same emitted HTML → same column. Verifying this surface covers both.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST)
URL: /setup/35182/evaluation_guide
Mutation: click .formatted-text card → caret-end + Enter ×3 → on PRE also press x + Backspace (RHF dirty-mark) → click outside.
Persists to: scorecard_items.description (varchar(500), id=2468 / neg 35182).
Finding (the rare positive one): POST stores less than PRE on this surface.
| bytes | tail content | |
|---|---|---|
| PRE / TinyMCE | 127 → 157 (+30) | 3 trailing <p>\xC2\xA0</p> joined by \n (one per Enter) |
| POST / Tiptap | 127 → 134 (+7) | 1 trailing bare <p></p> |
POST emits bare <p></p> (no style="text-align: left;"), suggesting the TextAlign extension is NOT registered on RhfRichTextField for scorecard. Tiptap also coalesces consecutive empties on this surface. No regression; opposite direction from the other surfaces.
Resolution: Cosmetic only — accept.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST)
URL: /setup/35335/general_settings — open via the Summary tab.
Mutation: click summary body editor (mounts directly — PRE TinyMCE iframe, POST Tiptap) → caret-end + Enter ×3 → on PRE also x + Backspace → click outside iframe to autosave.
Persists to: summaries.body (text, id=638 / neg 35335).
Finding: Tiptap rewrites every <p> tag in the body with inline style="text-align: left;" on first save, not just trailing empties.
| bytes | tail content | |
|---|---|---|
| PRE / TinyMCE | 1759 → 1788 (+29) | 3 trailing <p> </p> (HTML entity), zero inline styles |
| POST / Tiptap | 1753 → 2109 (+356, +20%) | 3 trailing styled empties PLUS all 13 in-body paragraphs rewritten from bare <p> to <p style="text-align: left;"> (28 bytes × 13 = +364) |
Wrapping <div> is also dropped on POST serialization.
Resolution: Cosmetic only — accept.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST)
URL: /setup/34597/award → Award Email tab.
Mutation: click "Award Email" tab → editor mounts (PRE: TinyMCE iframe, POST: Tiptap) → caret-end + Enter ×3 → on PRE also x + Backspace → click outside.
Persists to: award_emails.body (text, id=93 / neg 34597).
| bytes | tail content | |
|---|---|---|
| PRE / TinyMCE | 378 → 432 (+54) | 3 trailing <p> </p>, zero inline styles |
| POST / Tiptap | 374 → 711 (+337, +90%) | 3 trailing styled empties + all 12 in-body paragraphs rewritten with inline TextAlign |
Body is delivered to bidders via ActionMailer .html_safe — visually identical, but stored HTML doubles on first save. POST is 6× more wasteful per save than PRE on this surface.
Resolution: Cosmetic only — accept.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST)
trace-pre-bid-award-awarded.txt · trace-post-bid-award-awarded.txt
URL: /setup/34597/award → Unsuccessful Email tab.
Mutation: same as Award email body, on the sibling tab.
Persists to: unsuccessful_emails.body (text, id=8 / neg 34597).
| bytes | tail content | |
|---|---|---|
| PRE / TinyMCE | 296 → 354 (+58) | 3 trailing <p> </p>, zero inline styles |
| POST / Tiptap | 313 → 612 (+299, +96%) | 3 trailing styled empties + all 11 in-body paragraphs rewritten |
4th surface confirming the same global Tiptap TextAlign pattern.
Resolution: Cosmetic only — accept.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST)
trace-pre-bid-award-unsuccessful.txt · trace-post-bid-award-unsuccessful.txt
URL: /bidder_steps/questions/30625
Mutation: log in as bidder marketmaker4test+b01@gmail.com (Password1!) via legacy launchpad form → seat current_actor by visiting an org URL of a wizard-complete org → expand category accordion (#header-16224) → click div.formatted-text#answer_text_75450 to mount editor → caret-end + Enter ×3 → click question's Save button.
Persists to: answer_options.value (longtext with serialize :value YAML, id=33472).
Finding: Two compound deltas, biggest delta of any surface.
| bytes | stored content | |
|---|---|---|
| PRE / TinyMCE | 13 → 9 (-4) | YAML-encoded plain text: --- test\n |
| POST / Tiptap | 9 → 139 (+130, +1444%) | YAML-encoded full HTML with inline style="text-align: left;" × 4 |
- Format-contract change: TinyMCE's serializer strips wrapper
<p>when no formatting applied (column held plain text). Tiptap preserves the wrapper. Anything downstream that assumed plain text now gets HTML. - TextAlign injection: same global Tiptap config issue.
Setup notes (durable in the verifyMutation field for the next person):
- Dev OAuth fails for bidders unless their
current_actor's org haswizard_completed_at— set viaOrganization.find(<id>).update!(wizard_completed_at: Time.current). - PRE worktree is missing
public/vendor/jquery.min.js—cpfrommain.
Resolution: Cosmetic only — accept.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST)
trace-pre-bid-bidder-text.txt · trace-post-bid-bidder-text.txt
URL: messaging /
Mutation: click New Message or open a thread for reply → editor mounts inside the modal/footer → caret-end + Enter ×3 → click Send. PRE uses TinyMCE 7 inline mode (<div class="mce-content-body">), not the iframe variant. POST uses Tiptap.
Persists to: messages.body (text column on messaging_development.messages).
Live row-byte trace not captured because the test bidder has zero projects to send a message about. Captured the editor's emitted HTML instead (server path is identical between PRE/POST, so emitted HTML is the diagnostic):
editor output for test\n\n\n |
|
|---|---|
| PRE / TinyMCE 7 inline | <p>test</p><p><br></p><p><br></p><p><br></p> (after data-mce-bogus strip) |
| POST / Tiptap | <p style="text-align: left;">test</p><p style="text-align: left;"></p><p style="text-align: left;"></p><p style="text-align: left;"></p> |
Both new-message and reply forms wrap the same RichTextEditor via MessagingRichTextField.tsx → single fix point covers both.
Resolution: Cosmetic only — accept.
| PRE | POST |
|---|---|
![]() |
![]() |
Byte traces (PRE / POST — editor-output capture)
All five Launchpad surfaces verified visually + behaviorally. PRE/POST screenshots side-by-side; no byte deltas to flag because the global TextAlign issue manifests on round-trip save and these surfaces had stable round-trip parity (toolbar, fonts, lists, links, tables all preserved through Tiptap save).
| PRE | POST |
|---|---|
![]() |
![]() |
| PRE | POST |
|---|---|
![]() |
![]() |
| PRE | POST |
|---|---|
![]() |
![]() |
| PRE — 2 TinyMCE iframes | POST — 2 Tiptap instances |
|---|---|
![]() |
![]() |
| PRE — 26 TinyMCE iframes | POST — 26 Tiptap instances |
|---|---|
![]() |
![]() |
Memory at 26 instances: 160 MB → 90 MB (-44%)
| Surface | PRE editor | POST editor |
|---|---|---|
| FAQ content | 1 TinyMCE iframe | 1 ProseMirror |
| AI Prompt | 1 TinyMCE iframe | 1 ProseMirror |
| Org description | 1 TinyMCE iframe | 1 ProseMirror |
| Attorney overview / pro_bono | 2 TinyMCE iframes | 2 ProseMirror |
| Notable records (FieldArray) | 26 TinyMCE iframes | 26 ProseMirror |
| Bid: Question title + body | TinyMCE 7 iframe | Tiptap (RhfRichTextField) |
| Bid: Scorecard description | TinyMCE 7 iframe | Tiptap (no TextAlign extension) |
| Bid: Negotiation Summary | TinyMCE 7 iframe | Tiptap (RhfRichTextField) |
| Bid: Invitation HTML | TinyMCE 7 iframe | Tiptap (FullHtml) |
| Bid: Award email body | TinyMCE 7 iframe | Tiptap (FullHtml) |
| Bid: Unsuccessful email body | TinyMCE 7 iframe | Tiptap (FullHtml) |
| Bid: Bidder text answer | TinyMCE 7 iframe (CoffeeScript bridge) | Tiptap (text_tiptap_bridge.tsx) |
| Messaging: new + reply | TinyMCE 7 inline mode | Tiptap (MessagingRichTextField) |
| Capability | PRE (TinyMCE) | POST (Tiptap) |
|---|---|---|
| Undo / Redo | — | ✅ added |
| Text style menu (H1–H6) | — | ✅ added |
| Font family | ✅ Helvetica (default) | ✅ added (post-fix) |
| Font size | ✅ 12px (default) | ✅ added (post-fix) |
| Bold / Italic / Underline | ✅ | ✅ |
| Strikethrough | ✅ | ✅ |
| Subscript / Superscript | ✅ | ❌ dropped |
| Text color | ✅ | ✅ |
| Background color (highlight) | ✅ | ✅ (renamed Highlight) |
| Quote | — | ✅ added |
| Bullet / Numbered list | ✅ | ✅ |
| Insert link | ✅ | ✅ |
| Remove link (separate button) | ✅ | ❌ via Insert link |
| Table (insert + cell/table props) | ✅ | ✅ table only (no cell/table props menu) |
| Text alignment | ✅ 4 buttons (L/C/R/J) | ✅ dropdown (L/C/R) |
| Clear formatting | ✅ | ✅ |
Attorney 1890 overview, after Tiptap save:
| Aspect | Verdict |
|---|---|
<table> with inline styles (border, color, width) |
✅ preserved |
<s> strikethrough |
✅ preserved |
| Font family / size styles | ✅ preserved (CSS spaces stripped) |
| Text color, background color | ✅ preserved |
Lists <ol> / <li> |
✅ preserved |
| Links | ✅ preserved (gain tw: Tailwind classes) |
<u> underline tags |
rewritten to <span style="text-decoration: underline;"> (intentional, see SpanUnderline) |
HTML entity |
rewritten to UTF-8 NBSP \xC2\xA0 invitation.rb regex — fixed in 375ecea |
MsoNormal paragraph class |
❌ dropped |
A custom dashboard (migration-mission-control) drives a 2-worktree verification mission:
- PRE worktree runs origin/main with TinyMCE; POST worktree runs the migration branch with Tiptap.
- Only one worktree is active at a time, both bind the same dev ports + the same MySQL/Redis containers (read-mostly).
- For each surface: navigate, mutate (caret-end + Enter ×3), capture byte-level DB state via
docker exec mysql HEX(SUBSTRING(...)), screenshot, swap worktrees, repeat. - Every finding is a structured row with severity, byte trace, screenshots, and a triage state.
The full mission JSON, per-surface byte traces, and all screenshots are attached to this gist as files (search for trace-*.txt and pre-*.png / post-*.png).



















































