vibescoder

Friday Fixes: The Hyper-Vigilance Tax

·11 min read

Building an app across dozens of disconnected agent sessions accumulates bugs. That's not a failure of the process, it's the process working as advertised. Each session solves the problem in front of it, ships, and moves on. Nobody's holding the whole system in their head across sessions the way one long-tenured engineer might. What slips through the gaps isn't any single session's fault. It's the tax you pay for building this way, and the only way to keep the bill small is staying hyper-vigilant, on two different axes at once, indefinitely, because no session ever hands the watch off to the next one.

The first axis is UX: does the thing actually work the way a person experiences it, not the way a browser's devtools simulates it. The second axis is security: does it hold up against someone trying to make it fail on purpose, not just against someone using it the way it was intended. Vibe coding doesn't get you out of watching both. It just changes who's watching, and how often you have to look.

This stretch of days gave me a clean split between the two. Four small bugs on the UX axis, all of them hiding in a corner some check didn't cover. And, separately, a deliberate pass on the security axis that turned up eight more things, three of them in code that didn't exist the last time anyone looked at the codebase that way. This post is the UX side. Monday's post is the security side, phases and commits and all. Different axis, different post, same underlying discipline: don't assume yesterday's check still covers today's code.

Four bugs this round, none of them dramatic on their own. What connects them is more interesting than any single fix: each one passed a real check and failed on the one dimension that check didn't cover. A layout that looked right in devtools. A relative-time label that did real date math, just the wrong kind. An orphan-detection tool that scans exactly half the places an image can be referenced from. A systemd service that only knew how to survive the kind of change it was written for. Same shape every time — the corner nobody checked is the corner where the bug was hiding.

This is the same territory I keep coming back to in this series: defenses that feel complete but aren't, bugs invisible until a specific condition exposes them, and failures with no error message anywhere in the chain. Different bugs, same lesson repeating itself.

1. The Draft Card That Only Worked in a Wide Window

The problem: Each card on /admin/drafts laid out post metadata and three action buttons (Unschedule / Edit / Publish) in a single flex items-start justify-between row. On an actual iPhone, the buttons didn't have room to share that row with the content, and the layout squished and overflowed.

The fix: Two lines in DraftsList.tsx. Stack the card vertically on mobile, revert to side-by-side above the sm breakpoint:

-<div className="flex items-start justify-between">
+<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">

Plus flex-wrap on the actions container as a second line of defense.

The corner nobody checked: Browser devtools' narrow-viewport mode. It renders at the right width, but it doesn't reproduce real mobile font rendering, tap-target sizing, or how three buttons with real labels actually wrap. The desktop-simulated-as-mobile view looked fine. The actual phone didn't. This is the exact lesson from Mobile First and the Skill That Saved Us two months ago — the skill file has the pixel math now, but pixel math doesn't catch every layout shape, and this one slipped through anyway. Worth a real phone check any time buttons and content share a flex row, full stop.

2. The Label That Used the Wrong Clock

The problem: The drafts page showed a scheduled post as "publishes Jul 13, 2026 (today)" — while it was still July 12 in the browser's local timezone. The date itself was correct. The (today) next to it wasn't.

The cause: relativeTime() in DraftsList.tsx computed the label by diffing raw milliseconds between now and the target, then rounding to the nearest day:

// before
const diffMs = target.getTime() - now.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));

A post scheduled for 4 AM the next day is only 8–12 hours away in the evening. Divide that by 24 hours and round, and you get 0 — "today" — even though the calendar day hasn't turned over yet in the viewer's timezone. Meanwhile formatDate(), rendering the actual date right next to it, was computing calendar fields correctly. Two functions, same UI row, two different definitions of "what day is it."

The fix: Zero both dates to local midnight before diffing, so the comparison is calendar days, not elapsed time:

const startOfDay = (d: Date) =>
  new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const diffDays = Math.round(
  (startOfDay(target) - startOfDay(now)) / (1000 * 60 * 60 * 24),
);

Merged as PR #22.

The corner nobody checked: Anything that only visibly disagrees near a day boundary. relativeTime() and formatDate() had been sitting next to each other rendering the same post for weeks, agreeing with each other every single time, right up until a scheduled post happened to fall in that 8–12 hour evening window. That's the same failure shape as the unquoted-date bug from Friday Fixes #2 — one function handling dates one way, an adjacent one handling them another way, and nothing forcing them to agree until the specific edge case that exposes the gap.

3. The Headshot That Was Deleted as an Orphan

This one's the interesting one, because it wasn't a bug in new code. It was a bug in a cleanup tool, and it sat undetected for 77 days.

The symptom: The About page rendered a broken-image icon where the headshot should be. Not a recent regression — this has apparently been broken since spring.

The cause: Back on April 30, 2026, a housekeeping pass deleted public/images/IMG_9133.jpeg as "orphaned, unreferenced." The admin's orphan-detection logic in src/lib/images.ts only scans subdirectories of public/images/, matching each directory's slug against post slugs and post content — the same 3-tier match (exact / prefix / content) the /admin/images page uses today. IMG_9133.jpeg was a loose file sitting directly at the top level of public/images/, referenced only from a hardcoded <Image src="/images/IMG_9133.jpeg"> in the About page's static page.tsx. It's not in a post. It's not in a slug directory. The detector never had a chance to see it as in-use, in either direction — it wouldn't have shown up in the admin UI to flag as orphaned or as matched. Whatever process ran that April cleanup just saw a stray file nobody's tooling could vouch for and deleted it.

The fix: Recovered the original file intact from git history (2dec8d1^, pre-deletion), moved it into public/images/branding/ alongside the site's other non-post assets — logo, favicons — instead of leaving it as a loose top-level file, and updated the About page's src path to match.

-src="/images/IMG_9133.jpeg"
+src="/images/branding/IMG_9133.jpeg"

The corner nobody checked: Static pages. The entire orphan/match system in images.ts reasons about one thing — MDX post content — and the About page isn't a post, it's a hand-written React component. Any image referenced only from src/app/**/page.tsx is structurally invisible to a detector built around post slugs. I logged this gap in TODO.md rather than just patching around it once: either the detector needs to also grep static .tsx pages for hardcoded /images/... paths, or public/images/ needs a hard rule that it never holds loose top-level files. Until one of those ships, this exact failure mode can recur with any other hardcoded image reference sitting outside the post system — the same blind spot the SEO/AEO audit found in a different tool checking a different half of the site.

4. The Magic Packet Sent to an Interface That No Longer Existed

This one isn't the blog app at all — it's the homelab itself, the workstation these agent sessions run on. Small bug, same shape, worth the four sentences.

The problem: After migrating the homelab to new hardware, Wake on LAN — the SmartThings-to-magic-packet chain from a few months back — stopped waking the machine. Voice command fired, hub still sent the packet, nothing happened.

The cause: The wol-enable.service systemd unit that re-arms WoL on every boot had the old motherboard's interface name, enp8s0, hardcoded. The new NIC enumerates as enp112s0. The service had been failing silently on every boot since the migration — systemctl status showed failed, netlink error: no device matches name, sitting there unread for hours.

The fix: Point the unit at the real interface:

-ExecStart=/sbin/ethtool -s enp8s0 wol g
+ExecStart=/sbin/ethtool -s enp112s0 wol g

Plus the matching manual step the systemd fix can't reach: updating the MAC address in the SmartThings vWOL device's settings, since the hub was faithfully broadcasting a magic packet addressed to a card that no longer exists.

The corner nobody checked: a systemd service that only reports failure in a log nobody was tailing. It existed specifically because I'd been burned by this once before — the original post's whole "make it persist across reboots" step — and it still didn't survive the next kind of change instead: not a reboot, a hardware swap. Built to survive one axis of change, blind to the other.

What Connects Them

All four bugs are the same shape wearing different clothes: a check that covers most of the surface area and misses exactly the part where the failure lives.

The mobile layout was tested at the right width but the wrong fidelity — devtools simulates the viewport, not the device. The relative-time label was tested against the common case, where the elapsed-time math and the calendar-day math happen to agree, which is true right up until the last few hours before midnight. The orphan detector was tested against posts, because posts are the thing the admin UI is built to manage, and a static page sitting one directory away in the same repo simply never entered its field of view. The Wake on LAN service was tested against the one kind of change it was written to survive — a reboot — and simply never had to prove itself against the other kind, a hardware swap that renamed the thing it depended on.

None of these are exotic bugs. They're all the same failure mode I keep writing about in this series: the fix that only covers the layer someone happened to be looking at. The difference this round is how long one of them sat there. A layout bug on an admin-only page gets noticed in a day. A wrong "today" label gets noticed within a week, because someone's staring at the drafts list constantly. A photo on a page nobody but visitors look at can sit broken for 77 days, because the person who'd notice it — me — doesn't visit my own About page.

That's maybe the actual lesson this week: the bugs that survive longest aren't the scary ones. They're the ones on pages you built once and never look at again. That's the UX side of the hyper-vigilance tax, and it's the cheaper of the two to pay, because a broken layout or a wrong label eventually shows up somewhere a human looks. The security side doesn't get that luxury. Nothing renders wrong when an endpoint is missing a rate limit or a dependency has a stacked CORS bypass sitting three layers deep in a transitive dependency. Those don't announce themselves in a screenshot. They just sit there until someone goes looking on purpose, which is the more expensive half of the same bill.

Monday's post is that deliberate look, a fresh security pass against the same categories the last full audit used, covering the surface that's shipped since, three phases, three commits, one repo. Same discipline as this post, pointed at the axis where nobody stumbles into the bug by accident.

By the Numbers

  • 2 lines changed to fix the mobile draft card overflow
  • ~2 minutes to fix the layout once the real device confirmed it
  • ~15 minutes to diagnose and fix the relative-time timezone bug, PR #22
  • 8–12 hours — the evening window where the old elapsed-ms math could mislabel "tomorrow" as "today"
  • 77 days the About page headshot sat broken before anyone noticed (Apr 30 → Jul 16)
  • 1 file recovered intact from git history, zero data lost
  • 1 hardcoded interface name (enp8s0enp112s0) that silently broke Wake on LAN across a hardware migration
  • 3 repos/systems touched across all four fixes (2 code repos, 1 physical machine)
  • 4 bugs, 4 different checks, 1 shared failure mode
  • 0 fodder files left unconsumed after this post

Comments