Skip to main content
← Blog

Fixing One Table Means Fixing Three Parsers — Cell Merge and Cell Background in Description Tables

VauDium·

We added cell merging and cell background colors to the tables inside task descriptions. It looked like a desktop-only change, until we remembered that iOS and Android native editors each parse the same document. A story about settling on one format, aligning three parsers, and folding scattered table menus into a single gear.

Fixing One Table Means Fixing Three Parsers — Cell Merge and Cell Background in Description Tables

A task’s description in Fecit is a rich editor. You can put a table in it, drag column widths, and add or remove columns and rows. Two things were missing: merging cells and coloring cells. Spanning a title row across a meeting agenda, tinting a finished cell a soft color: the basics that make a table behave like a table. Today we added both.

1. Desktop alone wasn’t an option

The first thought was simple. The desktop editor is built on ProseMirror, so add merge and background attributes to the table extension and call it done. But a description document is read in three places: the JavaScript parser on desktop, the Swift parser on iOS, and the Kotlin parser on Android. The mobile editors are native, and each one parses the same document format and renders it on its own.

What happens if only desktop saves merges? The moment mobile opens that document, ignores the unknown attributes, and saves it back, the merge is gone. That fixed the order of work. Settle the format first, change all three parsers together, and ship mobile to the stores first. The same constraint we hit when adding language tags to code blocks.

2. The format — no free-form colors

A cell looks like this: column width, horizontal span, vertical span, background. Attribute order is fixed, and spans are written only when greater than one. All three parsers must emit the identical string for round-trip saves to be safe.

Background colors don’t accept arbitrary hex. Fecit already has a point-color palette. Star colors on tasks, label colors, project colors all come from that dozen-or-so of named colors. Cell backgrounds accept only one of those names. What gets stored is a key like “lemon”, and each platform resolves the actual color for light or dark theme. Open the document in dark mode and you won’t find a light-mode yellow baked in. The soft 0.3 alpha is the same on all three platforms.

Row arrays hold only owning cells. Cells covered by a merge don’t exist in the document. That’s how HTML tables have always worked, and the native side computes an occupancy grid to draw them.

3. One menu instead of many

The controls took longer than the feature. Desktop tables already had column handles and row handles, each opening its own column or row menu. Adding merge, split, and cell color on top meant a cell action pill appearing on selection, then an inline color picker on top of that. Layers stacking up. We built it, then took it apart.

The final shape: select cells and the column and row handles hide, leaving a single gear at the top right of the selection box. One menu from that gear holds everything: cell (merge, split, cell color), column (header column, add column left or right, delete column), row (header row, add row above or below, delete row), and when the whole table is selected, delete table. Every item is icon plus name. Cell color opens a color panel one step out beside the menu when you click it, flying left if there’s no room on the right. Clicking a handle still selects and opens the menu in one go, as before.

One small fix along the way. The table library’s default does nothing when you Shift+click the same cell; it only extends to other cells. To color exactly one cell you need that gesture, so Shift+click on the same cell now selects that one cell. The grips that resize a selection appear only on the middle of edges that can grow within the table’s bounds, or shrink because the axis is two or more cells wide, and a drag changes one axis at a time. Column boundaries come from the table map, not from counting the first row’s DOM, since a merged cell in the first row throws the DOM count off.

4. Mobile displays and preserves

On mobile you cannot operate merges or colors. Mobile displays them and preserves them. That’s the whole reason for aligning three parsers. A cell merged on desktop renders as one box on iOS and Android, and a vertical span running past the end of the table stops at the last row. Add or remove rows and columns on mobile and the spans grow and shrink to match, so the document opens on desktop exactly as it was. Colors are passed to native with the per-theme palette and painted at the same alpha.

Leaving the merge menu out of mobile is less about screen size than about priorities. Designing a table’s structure is a large-screen job; the small screen is for reading and checking. We didn’t cross that line today.

What’s left

This commit hasn’t been run hard at runtime. Type checks for desktop and mobile JavaScript pass, Swift parsing on iOS passes, and Android hasn’t been compiled yet. The release order has to hold: mobile stores first, desktop after. The next time the table format grows, it goes in the same order as today. Format, three parsers, native layout, and then the stores.