CSS Doesn't Throw: One Mistyped Comment Closer Silently Ate 15 Lines of My Stylesheet

← hexisteme · notes · 2026-07-17

A layout fell apart in a small internal dashboard while all 481 tests stayed green and the missing rules were sitting in the CSS file, correctly written. A comment had been closed with Jinja's #} instead of */, so the parser kept consuming to the next real */ seventeen lines down and swallowed 15 lines of rules on the way. Nothing errored, because CSS has no fatal parse errors by design. The fix that mattered was not the typo — it was two assertions that guard the entire failure class.

Every test green, the page in pieces

I was rebuilding a small internal dashboard — FastAPI on the back, Jinja2 templates, hand-written CSS, no build step — and the layout had come apart. Timeline rows had unstacked into a vertical column. Status dots floated free of the rows they belonged to. Log group labels overlapped each other. 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.

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." There is 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.

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, requiring that 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 whole 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." Acting as if it had not been there 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. In a recovery-by-design language, the absence of an error signal carries no information whatsoever. The same failure class has other quiet members:

Why grep lied and the parser told the truth

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 on 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 mattered, because it eliminated the first suspect and left only the second: the rules are in the text but not in the parse. Which 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. Every diagnostic you run against the raw file answers a different question from the one you care about, and when those two answers disagree you are looking at 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

Read the offending line as it appeared in the file. It ends a comment with #}. In a repository full of Jinja templates that is a completely ordinary-looking token. Some editors will color the following lines as a comment and give you a visual hint, but that hint is easy to miss in a long file and absent entirely from a diff view, which shows the lines that changed and 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 changed line. 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 the code is written with an AI agent. An agent working through a feature routinely edits Python, templates, CSS, and JavaScript inside one session. Comment syntax is one of the highest-frequency, lowest-attention tokens in any of those files, and it is different in every one: #, {# #}, /* */, //. The probability of syntax bleed rises with the number of language switches per unit of work, and every language switch is an opportunity for one to bleed across. It is the same problem as an AI not being able to see what it drew, one layer down: the model emitted a token that was locally plausible in another file in the same session, and neither it nor I could see the consequence without rendering the page.

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. "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.

TierCatchesCost
Delimiter balance + foreign-token banunclosed comments, template syntax bleed2 lines, no dependencies
Parse the stylesheet, assert critical selectors surviveanything that removes a rule from the parseone CSS parser dependency
Visual regression on key screenseverything, including cascade and specificity bugsa browser in CI, plus baseline maintenance

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 check would have failed loudly here, because 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. Which is the thesis: tooling guards instances, so you have to guard the class yourself.

Where these guards break

The two-line check is cheap, not perfect. Being clear about its limits is part of shipping it.

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 are built to swallow bad input and keep going, because that property is what lets them survive version skew. In those languages the only ground truth is rendered output. A green test 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.

FAQ

Q. Why doesn't CSS report an unclosed comment as an error?
Because CSS has no fatal parse errors by design. The syntax specification says a comment consumes everything up to and including the first */, or up to the end of the file, and CSS 2.1 requires user agents to close all open constructs at the end of a stylesheet. Reaching end of file inside a comment is flagged as a parse error, but every parse error in CSS has defined recovery behavior, so nothing halts and nothing is logged. The spec gives the rationale: new syntax looks like an error to an old parser, so the parser throws away the minimum and keeps going rather than breaking the page.

Q. How do I find a comment that is swallowing my CSS rules?
Count the delimiters. Run grep -c for the opening and closing comment tokens and compare the two numbers. In my case the file had 49 openers and 48 closers, which pinned the problem to exactly one comment. The tell that sends you there is a specific contradiction: the rule is present in the file when you grep for it, and absent from the rendered page. If the text exists and the rule does not apply, you have a parser-level problem, not a logic problem.

Q. Will stylelint or a CSS linter catch this?
Not this one. The comment was closed, just seventeen lines later than intended, so the file parses cleanly and no linter complains. Linters catch specific enumerated instances of this failure family - stylelint ships no-invalid-double-slash-comments for JS-style // comments in CSS, for example - but there is no rule for a comment being longer than you meant it to be. That is why the guard has to be written at the level of the class rather than borrowed from a rule list.

Q. What is the cheapest test that prevents this class of bug?
Two assertions over the stylesheet text: that the count of /* equals the count of */, and that no template-language comment syntax such as {# or #} appears in a CSS file. No new dependencies, and it runs inside a test suite you already have. A stronger tier is to parse the stylesheet with a real CSS parser and assert that the selectors your layout depends on are present in the parsed result, which catches anything that removes a rule from the parse rather than from the file.

Q. Why do code reviews, human or AI, miss this?
Because the damage is not in the changed line. A diff shows one edited comment closer, which looks ordinary in a repository that also contains template files. The fifteen lines that stopped working sit below it and did not change, so they never appear in the review at all. Reviewing source cannot surface a rendering outcome. The risk is higher when an agent writes the code, because one session moves between Python, templates, CSS, and JavaScript, and comment syntax is different in every one of them.

Related notes

← hexisteme · notes · CC-BY 4.0