I discovered a page on our customer's site that, at first glance, appeared to be a CSS bug. A hero section on her site had collapsed into a vertical sliver — a fashion-photo strip about 70 pixels wide, running the full height of the page, with her headline text wrapping into a column one word deep. Below it, the same broken hero repeated itself. The service cards that should have been on the page were gone.
It's the kind of screenshot that makes you assume you know the fix before you've looked at anything. A missing breakpoint, probably. Ship a CSS patch, close the ticket, move on.
I was wrong about the scope in almost every direction. By the time I was done, I'd found four separate bugs, two of them in completely different parts of the stack, and one of them entirely unrelated to the customer's report — a bug in the fix itself. This is a write-up of what actually happened, in the order I found it, because the order matters: each bug looked like the whole story until the next one showed up underneath it.
WebDigitize is an AI website builder for Nigerian small businesses — describe your business, get a live, editable, multi-page site in minutes, built on a drag-and-drop editor (Puck) with an AI layer on top. The incident touched the editor, the CSS layout system, the AI onboarding pipeline, and — in the least expected turn — the database ORM.
What I Saw
The customer had onboarded as a business selling physical products, added a handful of items to her catalog, and picked a general-purpose template rather than one of the commerce-flavored ones (it was the best fit for how she described her business). The site generated fine. Interestingly, I launched WebDigitize the previous day, so I was checking to see if there were new onboards. I saw her website on the dashboard and decided to check out each page. That was when I discovered the issue. I followed up with a call, and she confirmed exactly what happened. She went looking for her products on the live site and couldn't find them anywhere — no shop page, no product grid, nothing. So she opened the editor on her phone and started poking at the Services page, trying to see if the products were hiding in a section she hadn't looked at.
That's where the sliver came from. The page's hero component — a two-column "text on the left, full-height photo on the right" layout — had been dragged, on a touchscreen, into a three-column grid meant for service cards. A hero designed to occupy the full page width was now confined to one-third of it, and its internal CSS (a 7fr / 5fr grid split) rendered that third into two even-narrower slivers. The section had also been duplicated somewhere in the process, which is why I saw it twice.
The obvious read: "customer made a drag-and-drop mistake on mobile, CSS broke because nobody designed for a hero being squeezed that small." Both true. Also, not remotely the full story.
Bug One: A Split Hero With No Fallback For Its Own Container
The hero component's CSS looked like this before I touched it:
.hero-split {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
}
@media (max-width: 899px) {
.hero-split {
grid-template-columns: minmax(0, 1fr);
}
}
A perfectly reasonable component, built around one assumption: the only thing that would ever make this hero narrow is a narrow viewport. On a phone, stack the columns. On a desktop, split them 7:5. That assumption is true right up until someone drops the component somewhere that isn't the full page width — inside a grid column, a sidebar, anywhere the component's rendered width is decoupled from the browser's.
@media queries answer "how wide is the viewport?" They cannot answer "how wide is this component, right now, wherever it's been placed?" That second question is what a component with an internal responsive layout actually needs answered, and for a long time, CSS had no good way to ask it.
The fix is a container query — CSS that responds to an element's own size rather than the viewport's:
.hero-section {
container-type: inline-size;
}
@container (max-width: 899px) {
.hero-split {
grid-template-columns: minmax(0, 1fr);
}
}
Same breakpoint, same fallback layout — the difference is what's being measured. Now, if this hero ever ends up somewhere narrow, for any reason, at any nesting depth, it stacks gracefully instead of dividing an already-small box into two useless columns. We didn't design this fix for "what if someone drags a hero into a grid cell" specifically — we designed it for "this component should never assume it owns the full viewport," which is the more durable version of the same idea. The next unanticipated place a hero ends up will handle itself the same way.
If you maintain any component library with a component that has an internal responsive layout, this is worth auditing today: every @media-based internal layout in a component that could ever be nested is a latent version of this bug, just waiting for someone to place the component somewhere you didn't design for.
Bug Two: The Editor Let Her Do This At All
The CSS fix makes the outcome of a hero-in-a-grid drop survivable. It doesn't answer the more basic question: should our drag-and-drop editor have allowed that drop in the first place?
No. A full-width hero, a call-to-action banner, a page section — these are page-level compositional elements. They don't have a sensible meaning inside a three-column card grid, any more than it makes sense to put a <html> tag inside a <div>. The editor had no opinion on this; every block type was a valid child of every container type, and Puck's DropZone component happily accepted the drag.
Puck (and most drag-and-drop editors) probably support allow/disallow lists per drop zone for exactly this reason:
const PAGE_LEVEL_ONLY = ["Section", "EditorialHero", "CTABanner", "StatStrip"];
<DropZone zone="content" disallow={PAGE_LEVEL_ONLY} />;
Every Section/Grid/Flex container's drop zone in our editor now rejects those block types outright. If you drag a hero toward a grid cell, it simply won't drop there — no error message needed, because the interaction itself now communicates the constraint. This is the general lesson: a structural invariant your data model relies on ("heroes live at the page level") should be enforced by the tool that produces the data, not documented and hoped for. I'd implicitly assumed nobody would ever nest a hero in a grid, which was true until the first person who tried.
There's a second layer here, though, that the CSS fix and the drop-zone fix both dodge: this specific mis-drop happened on a phone. Precision drag-and-drop — picking up a small element, dragging it across a cramped viewport, dropping it into the correct one of several adjacent, similarly-sized targets — is a genuinely hard interaction on a touchscreen, even with every guardrail we can add. We could keep discovering edge cases in what happens when a block-based editor gets used with a thumb on a 6-inch screen, or we could accept that this class of editor isn't a good mobile interaction at all and say so plainly:
const isTooNarrow = useMediaQuery("(max-width: 767px)");
if (isTooNarrow) {
return <OpenOnDesktopMessage />;
}
The editor now blocks editing below a viewport-width threshold with a message pointing the owner at a larger screen — your site stays exactly as it is; you just can't drag things around from a phone. This isn't a workaround for the bug we just fixed; it's an acknowledgement that some tools have a form factor they're honestly built for, and pretending otherwise generates support tickets forever.
Bug Three: The Products That Were Never There
Here's where the incident stopped being a UI bug and became something more interesting — and where I have to admit our first fix was aimed at a symptom.
Reread the timeline: the customer didn't set out to break the Services page. She was hunting for products that had never appeared on her site at all, from the moment it was generated, even before she ever touched the editor. The drag-and-drop mishap was a downstream consequence of a completely separate failure: her site was never given anywhere to sell products in the first place.
Our generation pipeline has two paths. The original path calls the AI to generate an entire page's structure from scratch — and it has always included a check: does this business sell products? If yes, does the generated site actually contain a page to sell them on? If not, build one deterministically (a heading plus a full-catalog product grid, no AI needed for something this mechanical) and wire it into navigation.
The second path is newer and exists for cost and speed: rather than asking the AI to invent a page structure from nothing, I clone one of a set of hand-designed "flagship" templates (a fashion boutique layout, a restaurant layout, a general-purpose services layout) and only ask the AI to fill in twenty or thirty text slots — five to ten times cheaper than full generation, and the design quality of a human-built template rather than an LLM's best guess. This is the path our customer went through.
The bug: the "does this business sell products, and if so, does the site have somewhere to sell them" check lived entirely in the first path. The second path never inherited it. Some flagship templates (fashion, food) do include commerce blocks by design. Others — general, services, events, creative — don't, because they're built for businesses that don't sell physical products. Nothing in the pipeline cross-checked "this owner said she sells products" against "this template has zero commerce blocks." Two individually reasonable systems — a store-detection check built for one generation path, a template library built for a different one — had simply never been asked to talk to each other, because the second path didn't exist yet when the first check was written.
Concretely, in pseudocode, the gap looked like this:
def generate_site(brief):
if brief.template_id:
# Fast path: clone template, AI fills text slots. No commerce check.
return generate_from_template(brief.template_id, brief)
else:
# Slow path: full AI generation, WITH a commerce check.
site = generate_full_ai(brief)
if brief.sells_products and not has_shop_page(site):
site = add_shop_page(site)
return site
The fix moves that check to the one place both paths already pass through — right before either result is saved:
def generate_site(brief):
if brief.template_id:
site = generate_from_template(brief.template_id, brief)
else:
site = generate_full_ai(brief)
if brief.sells_products and not has_shop_page(site):
site = add_shop_page(site)
return site
One line moved from inside a branch to after both branches. The lesson generalizes past this one bug: a check that depends on a cross-cutting fact about the business ("does this business sell things?") needs to live at the single point where all content-generation paths converge, not be duplicated — or worse, present in only one of them — per path. Every time you add a new way to produce the same kind of output, audit which invariants the old paths guaranteed and confirm the new one guarantees them too. It's an easy thing to miss precisely because both paths were separately correct in isolation; the bug only exists in the gap between them.
Bug Four: Fixing Production Broke My Fix First
With all three root causes understood, the actual data repair was almost the easy part — pull the mangled hero back out to the page level, drop the duplicate, remove the sections the mishap had gutted, and add the shop page the customer should have had from day one. I wrote it as a script, ran it against a copy of the affected data locally, watched it produce exactly the output we expected, and handed off the command to run in production.
It failed immediately, with a stack trace from deep inside SQLAlchemy that had nothing to do with any of the logic I'd written:
sqlalchemy.exc.InvalidRequestError: When initializing mapper Mapper[User(users)],
expression 'BusinessPaymentAccount' failed to locate a name ('BusinessPaymentAccount').
The script imported exactly two model classes — the Site and Product tables it actually needed. That turned out to be the whole problem. SQLAlchemy declares relationships between models by string, so they can reference each other without circular Python imports:
class User(Base):
payment_account: Mapped["BusinessPaymentAccount"] = relationship(
"BusinessPaymentAccount", back_populates="user"
)
That string only resolves if BusinessPaymentAccount has actually been imported somewhere in the running process — not used, just imported, so the class exists in SQLAlchemy's shared mapper registry. And crucially, the first time any query runs, SQLAlchemy configures every mapper it knows about, not just the one you queried — because relationships can point in any direction, it has to make sure the whole graph resolves before it trusts any of it. The script imported Site and Product. Site has a foreign key relationship into User. User has a relationship string-pointing at BusinessPaymentAccount, which nothing in my script had ever imported. First query, mapper configuration walks the whole graph, hits a name it's never heard of, throws.
The live FastAPI application never hits this because one large router module happens to import nearly every model in the app across its many endpoints — by the time any request is served, every relationship target has been imported as a side effect of something completely unrelated. That's not a design decision anyone made on purpose; it's an accident of the app being big enough that everything gets pulled in eventually. A small, standalone script doesn't get that for free — it only has what it explicitly imports.
Once I knew what to look for, I found the same latent bug already sitting in the Alembic migration environment, which manually lists model imports for exactly this reason — and the list was stale, missing four models added since it was last touched. It hadn't broken yet, only because no migration had happened to touch a relationship pointing at one of the missing four. That's the uncomfortable part of this class of bug: it's not "wrong," it's "correct until the next person changes something unrelated."
The fix, instead of hand-maintaining another list that will drift the same way:
import importlib, pkgutil
import app.models as models_pkg
def import_all_models():
for mod in pkgutil.iter_modules(models_pkg.__path__):
importlib.import_module(f"{models_pkg.__name__}.{mod.name}")
Call it once, before any query, in any standalone script. It doesn't need to know which models matter — it imports the whole package, every time, so the mapper registry is always complete regardless of which relationships happen to exist this month. The lesson: if correctness depends on "everything got imported somewhere," make that automatic and exhaustive, not a manually maintained list. A list like that isn't wrong when it's written; it's a countdown to the next omission.
Writing a Repair Script You Can Trust
One more thing worth calling out, because it's a pattern I'd recommend for any one-off production data fix: I wrote the repair logic as a pure function — data in, corrected data out, no database access inside it at all — and wrote a test first, hand-constructing a data structure that reproduced the exact damage (hero in a grid, duplicated section, orphaned empty containers) before writing a single line of the fix.
def repair_page(puck_data: dict) -> tuple[dict, list[str]]:
"""Pure transform: returns (fixed_data, human_readable_log). No I/O."""
...
def test_repairs_hero_dragged_into_grid():
broken = build_known_broken_fixture()
fixed, log = repair_page(broken)
assert fixed["content"][0]["type"] == "EditorialHero" # hoisted back to top level
assert len(log) > 0
# running it again should be a no-op
again, log2 = repair_page(fixed)
assert again == fixed and not log2
That last assertion — repair twice, expect the second run to do nothing — caught a real bug on the first attempt (an empty container removal wasn't marking its parent section as needing re-evaluation, so a heading-only section survived one pass and was only caught on a second). Cheap to check, and it meant I trusted the script's idempotency before it ever touched a live row. The database-facing wrapper around this pure function only had two responsibilities: fetch the record, and — behind an explicit --apply flag, off by default — write it back. Every run without that flag prints exactly what would change and touches nothing. For a script that runs once, against production data, days after the incident that necessitated it, that's not overkill.
What I Shipped
- CSS: the split-hero layout responds to its own rendered width via a container query, with the viewport-based media query kept as a belt-and-suspenders fallback for browsers without container query support.
- Editor: page-level blocks (heroes, CTA banners, stat strips) are now disallowed as children of Section/Grid/Flex drop zones — the mis-drop can't happen again, on any device.
- Editor, mobile: drag-and-drop editing is blocked below a viewport-width threshold, with a plain-language message pointing the owner to a larger screen.
- Generation pipeline: the "does this business sell products, and does the site have somewhere to sell them" check now runs after both generation paths, not inside only one of them.
- Tooling: a reusable "import every model module" helper for standalone scripts, so the SQLAlchemy mapper-registry gap can't silently resurface in the next one-off script — or, we hope, in the Alembic environment that had the same latent issue.
- Data: the customer's affected page was repaired, and her shop page was added via a pure-function, test-first, dry-run-by-default script.
Takeaways
-
A component with an internal responsive layout should respond to its own rendered size, not the viewport.
@mediaanswers the wrong question the moment a component can be nested anywhere other than the page's full width; container queries answer the right one. - Enforce structural invariants in the tool, not in documentation or hope. If your data model assumes X can never contain Y, make the editor refuse that drop rather than discovering the assumption was implicit.
- Some interactions don't degrade gracefully to a smaller form factor — say so explicitly. Blocking drag-and-drop editing on a phone, with a clear message, beats an editor that technically "works" on mobile but produces this class of damage.
- A cross-cutting business rule needs one home, at the point where all paths converge — not one copy per code path. Every time you add a new way to produce an existing kind of output, audit whether it inherited every invariant the old paths guaranteed.
- If correctness depends on "everything got imported somewhere," make that automatic, not a hand-maintained list. Manually maintained import lists (or any similar "remember to register X" pattern) are correct when written and wrong on a schedule you don't control.
- Write one-off production data repairs as pure functions, test-first, against a fixture that reproduces the actual damage — and make them idempotent and dry-run-by-default. The five extra minutes this takes is cheaper than a second incident caused by the fix for the first one.
None of these four bugs would have been caught by looking only at the screenshot we started with. Each one only became visible by asking "why did this actually happen", one layer deeper than the last — and the layer that mattered most to the customer (her missing products) was the one furthest from what she'd actually reported.
WebDigitize is live. If you're building on top of a drag-and-drop editor or running an AI generation pipeline in production, I'd be curious what invariant-enforcement patterns you've settled on — drop a comment below.
Top comments (0)