Originally published on hexisteme notes.
Every test passed. The page was in pieces.
I was rebuilding a small internal dashboard — FastAPI, Jinja2 templates, hand-written CSS, no build step — and the layout had come apart. Timeline rows unstacked into a vertical column. Status dots floated free of their rows. Log group labels overlapped. It looked exactly like a page whose stylesheet had failed to load.
The stylesheet had loaded. All 481 tests in the suite were green. And when I grepped the CSS file for the rules that were obviously not being applied, they were sitting right there on disk, correctly written.
The cause was a comment closer. Somewhere in the middle of the file, a /* had been closed with #} — Jinja's comment terminator — instead of */. Muscle memory, from switching back and forth between .html templates and .css. CSS then did precisely what the specification tells it to do: it kept reading. The comment ran on and swallowed the next 15 lines of rules — the timeline-row grid, the feed, the bucket layout, the dot alignment — until it hit the next real */, seventeen lines down.
No error. No console warning. No failing test. The rules were present in the file and absent from the page at the same time.
The typo is the least interesting part. What's worth keeping is why CSS is designed to fail without symptoms, why source-level review cannot see it, and why the fix is a two-line assertion rather than more care.
Why nothing complained: CSS has no fatal errors
The CSS Syntax specification defines comment consumption like this: on seeing /*, consume everything "up to and including the first */, or up to an EOF code point." No notion of a comment being too long, no heuristic about blank lines or braces, no upper bound. First */ wins. A comment closed seventeen lines later than intended is not a malformed comment — it is a well-formed comment that happens to be seventeen lines long. The parser has no way to know you meant something else.
Even if the comment had run to the end of the file you would not get an exception. Reaching EOF inside a comment is flagged as a parse error in the spec — but a parse error in CSS is not a failure. Error handling is defined for every one of them, and CSS 2.1 goes further: user agents "must close all open constructs (for example: blocks, parentheses, brackets, rules, strings, and comments) at the end of the style sheet."
This is deliberate, and the rationale in the spec explains the entire design:
When errors occur in CSS, the parser attempts to recover gracefully, throwing away only the minimum amount of content before returning to parsing as normal. This is because errors aren't always mistakes — new syntax looks like an error to an old parser, and it's useful to be able to add new syntax to the language without worrying about stylesheets that include it being completely broken in older UAs.
That is a good trade — it is why a stylesheet using a new property still renders in an older browser instead of blanking the page. The price is that CSS cannot tell you it failed, because from the parser's point of view nothing failed. "Ignore," in the spec's own words, means the user agent "parses the illegal part (in order to find its beginning and end), but otherwise acts as if it had not been there" — which is exactly the behavior that leaves no trace.
Put languages on a spectrum. JSON, YAML, and JavaScript throw: bad input produces a loud, located error. CSS and HTML recover: bad input produces a silently different document. You have to go look at the output.
The same failure class has other members, and they are all quiet:
-
//used as a comment in plain CSS. It is not a comment. The parser reads it as the start of a selector, keeps consuming until it finds a{...}block, decides the whole qualified rule is invalid, and drops it — so the next rule silently disappears. -
A stray or missing
}. Everything until the braces rebalance is either nested into the wrong rule or thrown away. -
Any comment terminator borrowed from a neighboring language: Jinja's
#}, Handlebars'--}}, a JSX*/}.
Every one of these produces a page that is wrong and a toolchain that is happy.
Why grep lied and the parser told the truth
The diagnostic step that cracked this is the transferable part. I had two facts that could not both be true under my working assumption: the render was broken, and grep showed the rules in the file, intact. If the rules exist and are not applied, then either the browser is not reading this file, or the browser is reading it and not seeing those rules as rules.
The first suspect is caching, and it cost me time: the same project hit a stale-stylesheet problem the same day, so my first theory was that the browser was serving an old CSS file. It was — partly. I busted the cache, some of the layout came back, and the rest was still broken. That eliminated the first suspect.
Second suspect: the rules are in the text but not in the parse. That reframes the search completely. You stop reading rules and start counting delimiters.
grep -c '/\*' style.css # 49
grep -c '\*/' style.css # 48
Forty-nine openers, forty-eight closers. Exactly one comment in that file never closed where it was supposed to. Finding which one took one more grep, fixing it took one edit, and the whole page came back.
Here is a minimal reproduction of the parser boundary. The token that looks like a closer belongs to a template language, not CSS:
/* intended to end here, but closed with #}
.timeline-row { display: grid; }
.feed { display: flex; }
.bucket + .bucket { margin-top: 1rem; }
.dot { align-self: center; }
/* the next real CSS comment */
.footer { display: block; }
The first /* consumes the timeline, feed, bucket, and dot rules through the */ on the later comment. .footer survives because it starts after that real closer. The cheap delimiter test fails on this fixture before the fix. A parser-tier test should also fail because .timeline-row is absent from the parsed rule set even though the selector is present in the source text.
The generalization worth keeping: text presence is not rule presence. grep sees bytes; the browser sees a parsed stylesheet. When those two disagree you have a parser-level problem — a swallowed region, an unbalanced delimiter, an encoding issue — not a logic problem.
Why humans and AI reviewers both miss it
The offending line ends a comment with #}. In a repository full of Jinja templates that is an ordinary-looking token, and nothing about it stands out in a diff. Some editors color the following lines as a comment, but that hint is easy to miss in a long file and absent entirely from a diff view — which shows the lines that changed, not what they did to the fifteen lines below them.
A human reviewing the diff sees one changed line; an AI reviewing the diff sees the same one. Neither is looking at the thing that broke, because the thing that broke is fifteen lines further down and did not change. A diff is a poor representation of an edit whose blast radius is defined by the parser rather than by the edit.
There is an extra wrinkle when an AI agent writes the code. An agent working through a feature routinely edits Python, templates, CSS, and JavaScript in one session, and comment syntax differs in every one: #, {# #}, /* */, //. It is a high-frequency, low-attention token, and syntax bleed scales with language switches per unit of work — which agents do far more than people. I've written before that an AI can't see what it drew; this is the same problem one layer down.
The conclusion is not "review harder" — review is the wrong instrument for a failure that is invisible in the artifact being reviewed.
Guard the failure class, not the typo
The mistake I nearly made was to fix the line, commit, and move on. That fixes one instance of an unbounded class. The next #} in a stylesheet — or the next //, or the next stray } — lands exactly the same way, and the next debugging session starts from zero.
What went into the test suite instead was two assertions:
css = (STATIC_DIR / "style.css").read_text()
# 1. Every comment that opens must close.
assert css.count("/*") == css.count("*/")
# 2. No template-language comment syntax inside a stylesheet.
assert "#}" not in css and "{#" not in css
Two lines, no new dependencies, runs with everything else. It knows nothing about the line that broke; it knows the shape of the failure. That is the general move — write the assertion for the class, not the instance. "Is that one line correct?" is a test with a lifespan of one commit. "Do comment delimiters balance, and is foreign comment syntax absent?" still works after the file has been rewritten twice.
In this project the regression lives in test_aesthetic.py; the narrow run is:
pytest test_aesthetic.py -q -k css_comment_delimiters_balanced
That command proves only the text-level guard. The diagnosis has its own falsifier: if a real CSS parser still returns .timeline-row from the broken fixture above, then the rule was not swallowed by that comment and the investigation has to move to cascade, specificity, or asset selection. A rendered assertion such as “the timeline row computes to display: grid” is the stronger final gate because it observes the surface the user sees.
If you want more than two lines, the guards stack in cost order:
| Tier | Catches | Cost |
|---|---|---|
| Delimiter balance + foreign-token ban | unclosed comments, template syntax bleed | 2 lines, no dependencies |
| Parse the stylesheet, assert critical selectors survive | anything that removes a rule from the parse | one CSS parser dependency |
| Visual regression on key screens | everything, including cascade and specificity bugs | a browser in CI, plus baselines |
The middle tier is the one most projects skip and probably shouldn't. Parse the file with a real CSS parser and assert that the selectors your layout depends on are present in the parsed result. That would have failed loudly here: the swallowed rules were absent from the parse while present in the text — precisely the discrepancy that defines this bug.
Worth noting what would not have caught it: a linter. Stylelint and PostCSS parse this file without complaint, because the comment is closed, just later than intended. There is no rule for "this comment is longer than you meant it to be." Stylelint does ship no-invalid-double-slash-comments, which flags JS-style // comments in CSS — one member of this class, enumerated by hand. Tooling guards instances; you have to guard the class yourself.
Where these guards break
The two-line check is cheap, not perfect, and being clear about its limits is part of shipping it.
-
Delimiter counting has false positives, and balanced counts are not sufficient. A stylesheet containing
content: "/*", or aurl()with those characters in it, fails a naive count. Two typos that cancel out still balance, and order is not checked: a*/before its/*counts the same. -
The foreign-token ban is a blacklist. It knows about
{#and#}because those are the languages in this project. A different stack brings different terminators, and the assertion won't know about them until someone adds them. - Neither assertion catches the sibling failure. A stale stylesheet in the browser cache produces an identical symptom — correct rules on disk, broken page — and no assertion about file contents can see it. That needs a version query on the asset URL, so the URL changes whenever the file does.
Those last two are why "tests green, screen broken" needs both suspects ruled out in order: is the browser reading this file, and is the browser seeing these rules as rules?
The rule I run on now
Absence of an error is not evidence of correctness in a recovery-by-design language. CSS, HTML, and most template languages swallow bad input and keep going, because that is what lets them survive version skew. There, the only ground truth is rendered output. A green suite tells you the markup exists and the routes return 200; it says nothing about whether the cascade produced the layout you asked for. If your verification surface never renders the thing, it is verifying something other than what you think.
Every fix ships with a class-level guard, or it isn't finished. The question at the end of a debugging session is not "is it working now" but "what family of mistakes does this belong to, and what is the cheapest assertion that covers the family?" For a page broken by a comment closer, the answer was two lines in a file I already had. That trade is available for most bugs, if you name the class before you close the tab.
More notes at hexisteme.github.io/notes.
Top comments (0)