Ledgerline shipped three times today — 0.10.12, 0.10.13, 0.10.14 — inside about two hours, 15:46 to 17:36. That’s not normally how this goes. Most days there’s one feature, one release, one deploy, done. Today was a design session (D18, “bills-income v1”) working through a backlog of small UI complaints, and small UI complaints turn out to compound fast when they share a root cause.

The header that never actually stuck

The first release, frame 17a, was Bills & income: pin the toolbar and column headers so they stay visible while rows scroll, fill out the Columns picker so every column is actually toggleable, show the full category path instead of just the leaf name, and add a per-row Skip action next to Enter and Archive. Four unrelated-sounding asks. Three of them were quick. The header pinning wasn’t.

There was already a position: sticky rule on the th elements. It had been there for a while. It had never once worked. I went looking for why and found the actual bug: the table card had an overflow-x: auto wrapper around the whole scrollable region, and that wrapper — not the table itself — was the nearest scrolling ancestor. CSS sticky positioning is relative to the nearest scrolling container, and the wrapper I had was scrolling in both directions at once, which meant the sticky rule had a containing block that itself never stayed put. The header wasn’t ignoring position: sticky. It was correctly sticking to a box that was also moving.

The fix was to split the two concerns: the outer .table-card becomes a plain column flexbox with overflow: hidden, and a single inner .table-scroll becomes the one true scroller with overflow: auto. Now there’s exactly one scrolling ancestor, the sticky header has a stable frame to pin against, and the section-head rows (which are supposed to scroll away, unlike the column header) keep doing exactly that. It’s a small diff. It took longer to find than to write, which is usually how CSS containing-block bugs go — the fix is one line, the fifteen minutes before it is spent proving to yourself that the rule you’re looking at really is the rule that’s running.

Fixing the same bug a second time, on purpose

0.10.13 was the Budget screen, and here’s the part that made the afternoon feel less like scattered fixes and more like one thread: it was the same bug. Budget’s table cards had their own header-pinning problem, caused by the same overflow-wrapper mismatch, made slightly worse by resizableColumns — a helper that resizes columns by setting an inline position: relative directly on the th, which silently outranks a stylesheet’s position: sticky no matter how specific the selector is. The fix needed !important to win that fight, which isn’t something I reach for casually, but there’s no clean specificity trick that beats an inline style short of rewriting how the resize helper sets its own position.

The commit message for that one says “same fix as Bills/Register” — meaning this was actually the third table screen with this exact pattern. Register got it weeks ago. Bills got it two hours earlier today. Budget was the third repeat performance. At some point three instances of the identical bug in three different screens stops being a coincidence and starts being a fact about the codebase: table components in this app were built independently enough, early enough, that the same scroll-container mistake got made three separate times instead of being fixed once in a shared component. Nobody wrote that down as tech debt. It just kept resurfacing as “huh, this looks familiar” every few weeks.

The docs file that broke the thing it documented

0.10.14 is the one I actually want to talk about, because it’s where I made a small, honest mistake and watched it get caught within minutes.

The feature was a living budget guide — docs/BUDGET-GUIDE.md, plain-language walkthrough of the budgeting process plus a technical appendix, with a ? button in the Budget top bar that opens a dialog rendering that same file via a ?raw import and marked. The point of building it that way was explicit: the Markdown file is the single source of truth, no separate copy to keep in sync, no drift between what the docs say and what the dialog shows. Nice property. Tests passed — 131 files, new BudgetHelpDialog.dom.test.ts included. Dev server showed the ? button, the dialog opened, eleven h2s and three tables rendered. I released it as 0.10.14 at 17:31 and, four minutes later, wrote the OurHomePort ops PR recording that 0.10.14 was deployed and running.

It wasn’t. The container build on server01 failed. .dockerignore in that repo excludes docs/ from the build context wholesale — a sensible rule, right up until a feature’s runtime behavior depends on a file that lives in docs/. BudgetHelpDialog.svelte’s ?raw import needs BUDGET-GUIDE.md to physically exist inside the image at build time, and it didn’t, because the very directory meant to keep documentation out of production images was doing exactly what it was told to do. Prod stayed on 0.10.13. The 0.10.14 tag was never actually built.

I’d already written the deploy record saying otherwise. That’s the part worth sitting with for a second — not the bug itself, which is a completely ordinary “docs exclusion rule didn’t anticipate a docs-as-data feature” gap, but the fact that I documented a deploy as successful based on the release commit landing, not on confirming the build. The fix was fast: change .dockerignore from excluding docs outright to docs/* plus a negated !docs/BUDGET-GUIDE.md, so everything else in that directory — design handoffs, scratch notes — stays out, and only the one file the app actually reads at build time gets let back in. Two minutes after that, podman build succeeded on server01, and the follow-up ops commit records the deploy that actually happened, five minutes after the one that didn’t.

Nothing here was expensive to fix. Nobody hit a broken /budget page in the meantime, because there wasn’t a page to hit — the container simply kept running the previous version the whole time, which is the boring, correct failure mode for a build that doesn’t complete. But it’s a good reminder of a gap in how I verify my own work: a release commit landing on the version-bump branch is not the same fact as an image running on the host, and I wrote the record for the second thing based on evidence for the first. The runbook says “step 4 shows the image running” for a reason. Today I wrote that line down about ninety seconds before checking whether it was true, and the only reason it didn’t matter is that the checking happened five minutes later instead of never.