Skip to main content
Contact us
My Designer Lab platform interface

Project Story

Replacing the text editor in a memorial print product without breaking saved templates

My Designer Lab is where funeral homes lay out the obituaries, prayer cards and memorial folders Messenger prints. We replaced its TinyMCE text boxes with Lotus, a text editor we built that stores text as database rows, and released it behind a toggle so existing documents stay as they were. Before converting anything in bulk, we ran all 2,188,307 stored TinyMCE boxes through the converter against a copy of production.

Client
Messenger
Industry
Funeral services
When
2026
Status
In production for listed funeral homes since October 2026
  • TypeScript
  • React
  • Vite
  • Express
  • PostgreSQL
  • Prisma
  • Puppeteer
  • Playwright
  • sharp
  • Datadog

What TinyMCE was doing to the text

Messenger’s editor stored every text box as a string of HTML from TinyMCE 5. Three surfaces paint each box: the editor canvas, the preview a funeral home checks before ordering, and the PDF the print worker renders in a headless Linux Chrome through Puppeteer.

When text didn’t fit its box, TinyMCE’s autosize loop found every font-size and line-height in that string with a regex and scaled them all by 1%, then measured and went again. A unitless line height already scales with its font, so scaling both compounds. We measured twelve passes taking 12 pt text at a line height of 1.15 down to 10.63 pt at 1.03, the spacing shrinking twice as fast as the letters, and nothing ever scaled it back. The size a page saved also depended on whether its web font had arrived when the loop ran. In one test, holding back a single font file moved glyphs up to 214 px from where a clean load put them.

On a memorial folder, the person who notices that is the family, after the print run. We audited the whole TinyMCE system (every event handler and plugin) and fixed what we could inside it, including making the loop wait for fonts. The further we pushed, the more we were working against an editor that keeps formatting inside an HTML string, so we built our own. It’s called Lotus, and it lives as a package in Messenger’s monorepo.

Text as rows the database can check

A Lotus box holds blocks, which are paragraphs, and a block holds runs. A run is a stretch of characters sharing one look, or a single reference to a case field like the deceased’s middle name. Nothing stores HTML, and nothing stores character offsets. Styling one word splits it into its own run.

We picked runs over one string with styled ranges because a range that’s off by one fails silently: the bold slides a letter over and prints that way. Text also has rival units. A dove emoji on a prayer card is three UTF-16 code units to JavaScript, two code points to Postgres and one character to a person. Runs never measure anything, so there’s nothing for two programs to disagree about.

Every level that can style text points into one table of sparse deltas, editor_text_stylings. Three rules about it live in Postgres rather than in application code, so they hold for every writer, a one-off script included:

  • Each pointer is an id plus a scope column, with the foreign key on the pair. Pointing a paragraph at a character style fails at write time, where otherwise the paragraph would quietly lose its centering.
  • Rows are append-only, enforced by a trigger, because one {bold} row serves every bold run in the system.
  • The database generates each row’s content_hash, so two writers can’t fingerprint “12” and “12.0” differently and pile up duplicates.

Case fields store the reference and never the words, so fixing a misspelled name on the case fixes every product that prints it. The style cascade is resolved in TypeScript, since CSS specificity and source order differed between the editor page and the page the PDF renderer paints. The fit only shrinks, never below 8 pt, and a box’s frame moves only under a person’s hand. A page load never moves it, even when a font arrives late. Each save states the revision it loaded, and a save built on a stale read is refused.

The cost we accepted is collaboration. Runs carry no per-character identity, so editing a box with two people at once would mean redesigning the schema. The product has one editor at a time.

A toggle, and no way back

In August we posted the release plan to Messenger. A toggle in the editor’s Settings panel decides whether a new text box is Lotus or TinyMCE. Once a box is placed it stays what it is. Lotus has to do everything TinyMCE does, and Messenger’s designers sign off before new templates are built with it. Moving existing boxes over comes after all that.

So there are three ways a box becomes Lotus, all of them for Messenger’s internal staff only:

  • Insert a new box with the “Use new text boxes” switch on.
  • Convert one TinyMCE box from its item bar. The converter is a pure function from stored TinyMCE HTML to rows plus a report of what didn’t carry. Case details become live fields, and a detail Lotus has no field for keeps the text it showed and is named in the report.
  • Turn over a whole page set from Settings. The pages and their converted boxes go through one route in one database transaction, and the route refuses all of it if another tab added a page after this one loaded.

Nothing turns a Lotus box back into TinyMCE. Before either kind of conversion writes, the editor saves a version of the order line under a text_box_conversion reason, kept for 90 days with the newest ten kept regardless of age. That version is the way back, and a super admin restores it.

Production got a second gate. Lotus is off except for the funeral homes a super admin lists on an admin page. The list lives in a single-row settings table, and if the row is missing the limit reads as on with nobody listed. The server checks it on every route that can write a Lotus box, and while it’s on no Lotus box can be saved onto a global template. Lotus reached production in October 2026 with one funeral home listed, Messenger’s own demo home. We held that release back from mid-week to test this gate first.

A made-up memorial folder in the editor with the new text boxes switch on and the page set turned over, where every box converts except one the converter refuses, which stays TinyMCE.

Running 2,188,307 boxes through the converter and writing nothing

Before anyone converts saved templates in bulk, we wanted to know what the converter would do to every TinyMCE box Messenger has. So we wrote a dry run. It reads a dated local copy of production and puts each TinyMCE item through the editor’s own converter, counting what comes out.

It refuses any database that isn’t on the same machine and named as a snapshot or test copy, and it stops if Postgres doesn’t give it a read-only session. Sample ids name customer orders, so they stay in a folder outside the repo. Each scope writes a resume cursor, so a stopped run picks up where it left off.

The full run took about 70 minutes:

  • Global template pages: 63,010 boxes
  • Funeral home template pages: 27,765
  • Custom verses: 15,806
  • Global service templates: 13
  • Storefront order pages: 52,762
  • My Designer Lab order pages: 2,028,951, of which the converter refused one

The run exits 1 on any refusal, on any report kind nobody has classified or still waiting on conversion work, and on any change the converter makes without reporting it. The silent-change check found four silent changes. The biggest was no-break spaces folded into ordinary ones, 734,420 times on order pages alone. The first baseline failed, which we expected: on order pages, 7,790 hand-made tables would still come over frozen and 755 box edits like opacity or a border would be dropped. Each fix to the converter now shows its effect by rerunning one scope or one kind.

Comparing prints when a pixel diff couldn't tell good from broken

Counts say what the converter changes. Whether a converted box prints the same needs a picture. Our render match builds each pinned template twice on a local stack, turns one twin over through the Settings action the way staff do, and captures every page on all three surfaces. It cuts each converted box out at the original box’s position and diffs the pair with sharp.

The pinned templates had no box fills or rotated items, so the run adds synthetic ones, along with text under 8 pt and colours spelled four different ways. Five controls break one twin on purpose: a dropped 1 pt border, a missing drop shadow, opacity at 0.33, a missing line, and a line shifted by one space.

The plan was to fail any box past a pixel-difference threshold. That threshold couldn’t separate the two groups. Plain text that converts with nothing lost differed by 9.4 to 12.4% on every surface, and the broken controls by 7.7 to 22%. Aligned to the best whole pixel, they still overlapped. So the harness reports and doesn’t gate. It puts the plain samples beside the controls on every run, so anyone can see how far apart a measure puts them, and lists every box worst first with its crops.

Over 154 painted lines per surface, Lotus lines sit 0.5 px lower and set about 4% narrower, a width ratio of 0.9594. Some template boxes move 20 px or more. We’ll admit those figures are soft. They came from a run that still counted refused boxes as matches, which pulls them toward zero, and it hasn’t been rerun. Why Lotus sets narrower is an open issue, with fit scaling, font metrics and line breaking as the suspects. The gate comes back once some measure can tell the controls apart.

Production has its own check. On every render the PDF worker compares its paint of each box with the preview’s and with the result the editor saved, and logs a mismatch that a Datadog monitor sends to the developers’ channel. Two Chromes lay text out a little differently, so a size within 2% or a frame within 4 px counts as noise. The first night flagged 18 jobs. Five were a bug in the comparer and eight were noise. The rest were real, like a paragraph that wrapped to one line fewer on one surface.

A made-up TinyMCE name box converted into blocks and runs with a report of what changed, then painted on the editor, the preview and the PDF and compared box by box.

A test suite to ship against

Lotus went out alongside a rebuild of the Playwright end-to-end suite, which we’d stopped trusting. We reviewed it spec by spec, from 162 specs to 88. Before a spec went, any claim only it proved moved into a lower test lane or a spec already walking that path, and we checked each move by breaking the product code behind it and watching the new test go red.

Shared helpers now wait on what the app renders instead of fixed sleeps, which took session setup from about 31 seconds to 5. A preflight refuses any run that could write to a production storage bucket. The runner retries only what failed. An eleven-test smoke set walks the critical paths in 108 seconds.

The milestone was named “an e2e suite under an hour,” and honestly we haven’t shown that yet. The only full run so far went on one local clone, because memory left room for one stack, and took about 66 minutes: 187 tests passed and three skipped behind flags. The one failure was a test defect the stricter waits exposed. We haven’t timed it on two clones.

Where it stands

Lotus is live for the funeral homes on the list, and every existing TinyMCE box prints as it did. As of early November 2026, Messenger’s designers are testing conversion on a preview environment with its own copy of the staging database, where the limit is off so every funeral home and template can convert. The bulk migration hasn’t run. The dry run’s pending kinds, frozen tables and dropped box edits mostly, are what’s left before it can.

Questions about this project

HQ, a software design and development agency in Ogden, Utah, replaced TinyMCE in Messenger's My Designer Lab, the editor funeral homes use to lay out printed memorial products. HQ built Lotus, a text editor that stores text as database rows instead of HTML, released it behind a toggle and a per-funeral-home allow list, and built a converter for existing TinyMCE boxes that it ran against every stored box in a copy of production before converting any in bulk.

Lotus stores each text box as rows in PostgreSQL. A box holds blocks (paragraphs), and a block holds runs, each either a stretch of characters with one style or a reference to a case field such as the deceased's name. Styles live in one shared table, editor_text_stylings, whose rows are append-only and deduplicated by a hash the database generates, and every style pointer is a foreign key on an id and scope pair. No HTML and no character offsets are stored, and each save carries a revision number so a save built on a stale read is refused.

HQ used three checks. A read-only dry run put all 2,188,307 TinyMCE boxes in a local copy of Messenger's production database through the converter in about 70 minutes and failed on any refusal or any change the converter made without reporting it. A render match harness built templates twice, converted one copy, and compared each box on the editor canvas, the preview and the PDF. In production, the PDF worker compares every box it paints with the preview and the editor and alerts developers through Datadog when they differ by more than a 2% size or 4 px frame tolerance.

Because in testing no threshold separated faithful conversions from broken ones. Plain text that converted with nothing lost differed by 9.4 to 12.4% from its TinyMCE original on every surface, while deliberately broken controls (a dropped border, a missing shadow, a missing line) differed by 7.7 to 22%. HQ made the harness report offsets, spreads and the worst boxes beside those controls, and plans to restore a gate once a measure tells the two apart.

Existing TinyMCE boxes were left alone. A toggle decides whether a new box is Lotus or TinyMCE, and only Messenger's internal staff can insert, convert or turn over boxes. Nothing converts a Lotus box back; instead each conversion first saves a version of the order line, kept for 90 days, that a super admin can restore. In production Lotus is limited to funeral homes a super admin lists, the limit fails closed if its setting is missing, and the server refuses Lotus boxes on global templates while the limit is on. Lotus reached production in October 2026 for one listed funeral home.

Let's build together

Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.

Contact us