Skip to main content
← Blog

A Day Polishing Tables — Half a Pixel, Vanishing Handles, and the Save That Erased Tables

VauDium·

Double-clicking wrapped the last letter, handles vanished the moment you pressed them, and saving on mobile wiped column widths. All fixed today.

A Day Polishing Tables — Half a Pixel, Vanishing Handles, and the Save That Erased Tables

Today started with a single sentence: “let’s polish tables on desktop.” There was no plan — I reported things that had been bugging me as I used the editor, one by one, and Claude Code diagnosed and fixed them. By the end there were over ten commits, and a few of them turned out to be stories worth writing down.

1. Why auto-fit wraps the last letter

Tables in the Fecit editor auto-fit a column to its content when you double-click the column divider — spreadsheet grammar. But fitting to a header named f_by_ratio came up just short, and you got this:

f_by_rati
o

The last letter wraps to the next line, whole. It felt like the column shrank too aggressively, but the actual shortfall was less than one pixel.

The culprit was offsetWidth in the measurement code. offsetWidth returns a value rounded to an integer. If the real text width is 102.4px, it gets recorded as 102, and the table ends up 0.4px short of content space. Normally an error that small is invisible, but the ProseMirror body uses word-wrap: break-word, so a 0.4px shortfall wraps the last letter in one piece. A sub-pixel error amplified into a defect the size of a full character.

The repair is two lines. Measure with getBoundingClientRect().width (fractional, as-is) instead of offsetWidth, and round up with Math.ceil when recording. That guarantees the recorded width is always at least the content width. The measurement clone’s white-space: nowrap also became pre — nowrap collapses consecutive spaces, so it measured narrower than the body (pre-wrap, which preserves them), a secondary defect.

2. The handle that vanishes when you press it

The next report was “the handle is hard to click.” That’s the ⋮⋮ drag handle that appears to the left of a block. Narrowing the observation down surfaced something strange: press it and editor focus drops for an instant; release and it comes back.

That part was actually by design. The ⋮⋮ handle has to start a native HTML5 drag, so its mousedown can’t call preventDefault (that kills the drag). The editor blurring at press time is therefore unavoidable, and code restores focus on release.

The problem was the blur’s chain reaction:

  1. The “hide handles when focus is outside” CSS hides the very handle you’re pressing with visibility: hidden
  2. The CSS that reclaims the handle gutter (24px on the left) kicks in and the entire body jumps 12px to the left
  3. The table control overlay is also focus-gated, so it evaporates too

The handle disappears under your fingertip and the block you were grabbing slides sideways — the “hard to click” feeling was exactly right.

The repair wasn’t to prevent the blur (you can’t) but to mask it. A state flag that’s already raised at press time got exposed as a CSS class on the root, and for the press-to-release window, the handle hiding, gutter reclaim, and border changes are all exempted. The blur still happens; nothing visible moves.

3. The identity of “works sometimes”

A report came in: “double-clicking the table’s right edge border does nothing.” I fixed it, and the reply was “I think it worked before… now it doesn’t.” This is the scariest kind of symptom. It’s not that it won’t reproduce — it reproduces conditionally.

The cause had two layers. First, prosemirror-tables’ resize detection only fires while the pointer is over a cell. An inner border gets a recognition zone of 5px from each neighboring cell — 10px total — but the table’s right edge only has the inner 5px. Aim at the line itself or just outside it and detection is dead. People naturally aim at the line, so “it doesn’t work” was the accurate experience.

Second — and this is the identity of “works sometimes” — my first repair, which reinforced the outer band, was written to work only over the body margin. A table starts out filling 100% of the content width, but once you fit its columns, the table itself gets narrower. Then the area just right of the table is no longer body margin but the table wrapper, the condition misses, and the band dies. Works on full-width tables, fails on freshly-fitted ones — exactly the pattern the user saw.

Lesson: a “sometimes it doesn’t work” report is almost always a bug whose condition splits on state. Find which state worked and which didn’t and you’re halfway done.

4. Handle click = select — but select what?

While at it, a new grammar went in: clicking ⋮⋮ selects that block. The Notion behavior.

The first cut grabbed everything as a NodeSelection (object selection). In practice it felt off. A blue frame around a paragraph reads less like “I grabbed this paragraph” and more like “a box appeared.” Following that feedback, the behavior changed to each node getting its own native selection method:

  • Text blocks = a range selection, as if you’d swept the text with a drag
  • Tables = the table’s own all-cells selection (the gear menu tags along, so “delete table” is one step away)
  • Images = the image’s own selection grammar (border + resize grips)

And this produced my favorite detail of the day. Selecting a checklist item as a text range meant cut took only the letters and left the checkmark behind. A checklist item’s identity isn’t its text — it’s “an item including its checked state” — and text selection can’t carry that. But switching to object selection loses the natural face of a range selection.

The answer already existed in the ProseMirror ecosystem: NodeRangeSelection — looks like a text range selection, but its contents snap to block boundaries. With that, cutting a checklist item carries the checked state to the clipboard whole, while what you see is still a highlight hugging the letters. It was the moment I started thinking of the shell and the contents as separate things.

5. The save that erases tables

The last report was a heavy one. “Mobile must not wreck the tables I carefully built on desktop — does it leave things like sizes alone?”

I checked. It did not. Desktop stores column widths as the cells’ colwidth attribute, but the mobile editor’s cell model was {text, format, chips, header} — nothing else. Width doesn’t exist as a concept. When mobile saves a document, it parses the whole body into its own model and reassembles it into HTML, and any information the model doesn’t know about dies silently in that round trip.

The scary part is when the destruction happens. You don’t even have to touch the table. Edit one sentence above the table and save, and the table gets reassembled as attribute-less <td> elements, wiping every column width. A table you carefully tuned on desktop, reset as the price of fixing a typo on your phone.

The repair is a pass-through. Add a colwidth field to the cell model, capture it at parse time, reattach it as-is at serialization time. Mobile still doesn’t interpret widths — it only carries them. The parser and serializer exist in three copies — TypeScript, Swift, Kotlin — so the same fix landed in all three.

This class of bug deserves a name: “fields the model doesn’t know, the save deletes.” A lossless round trip isn’t free — it’s a contract you have to re-verify every time another platform starts persisting a new attribute.

Today’s lesson

Looking back on the day, the fixes share a common thread. Every one of them was a defect not of missing features but of unfinished finishing.

  • Finishing defects live in the decimals. A 0.4px shortfall wraps a letter; a 5px hit-zone asymmetry feels like “a feature that doesn’t work.”
  • They live in event timing. One blur’s chain reaction becomes “the handle won’t click,” and state read at mouseup can differ from state at press time.
  • And they live in data round trips. The less visible an attribute is on screen, the more quietly a single save erases it.

For a day that started with “let’s polish tables,” it went far. Tomorrow starts with verifying on a real device that mobile saves really do preserve widths.