My scoped CSS was correct, the elements were correct, and none of it applied
A tool on this site shipped with every severity dot zero pixels wide and every badge a square block. The stylesheet said otherwise, the markup said otherwise, the page returned 200 and the console was clean. Astro scopes a plain <style> with an attribute it stamps on elements that exist at build time, and everything on that page is built at runtime with innerHTML.
TL;DR · THE FIX
A build-time-scoped CSS system draws its boundary when it builds. Astro compiles .sa-dot into .sa-dot[data-astro-cid-vd5fpwql] and stamps that attribute on the elements it can see at build time; nodes you create later in the browser never carry it, so the rules match nothing, with no error and no warning. Fix is <style is:global>. The wider point is that no check reading your files can see this, because your files are right: assert getComputedStyle on a generated node instead.
The symptom
I shipped a browser tool on this site, and everything was green:
build clean, 134 pages
page answers 200
zero errors in the console
four findings rendered, four dots present in the markup
Every one of those was true. You paste a schema dump, it audits it, the findings are correct. It just looked flat, in a way I could not name at a glance, until I noticed the specific thing: the little coloured severity dot next to each finding was not on the screen at all.
What I tried first
The two obvious checks both passed. The rule was in the file, exactly as written:
.sa-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
display: inline-block;
}
And the elements were on the page, with the right class on every one:
<span class="sa-dot"></span>
<span class="sa-dot"></span>
<span class="sa-dot"></span>
<span class="sa-dot"></span>
Correct rule, correct element, and nothing anywhere saying why they did not connect: no console warning, no build warning, no 404 on a stylesheet. So I stopped reading my own files and measured what the browser had computed, which is one line:
getComputedStyle(document.querySelector('.sa-dot')).width
dot width 0px
dot display inline
dot corners 0px
badge display block
badge corners 0px
width: auto on an empty inline span is zero pixels wide, which is why there was nothing to see. The badge beside it was a plain block with square corners where the rule asked for a rounded inline-flex pill.
What was happening
I had been reading the rule I wrote, and the build ships a different one. Astro scopes a plain <style> block by stamping a data-astro-cid-* attribute onto the elements that exist in the template at build time, and rewriting every selector in that block to require it. Straight out of the built HTML:
.sa-dot[data-astro-cid-vd5fpwql]{width:.5rem;height:.5rem;border-radius:999px;display:inline-block}
That rule asks for a mark nothing on the page will ever carry, because every dot, pill and finding card is created at runtime with innerHTML after the audit runs. Those nodes were not in the template, so they were never stamped, so the selector matches none of them. From the compiler’s point of view it did what it was asked. From the browser’s point of view a selector matched nothing, which happens on every page on the internet constantly. The failure only exists in the gap between the two.
The fix
One word:
-<style>
+<style is:global>
The build stops adding the condition and the shipped selector goes back to the one I wrote:
.sa-dot{width:.5rem;height:.5rem;border-radius:999px;display:inline-block}
If you would rather not make the whole block global, the other route is to set the properties inline from the JavaScript that builds the markup, which keeps the scoping and pays for it in verbosity.
Leave a comment next to the is:global, because it looks removable to the next person:
<!--
is:global is load-bearing. Astro scopes a plain <style> with a data attribute it
stamps on elements that exist at build time. Every pill, dot and card here is
built at runtime with innerHTML, so it never carries that attribute and the
scoped rules silently did not apply.
-->
The measurement
Both sides driven in headless Edge against the same static server, so the only variable is the one word, and measured with getComputedStyle on the generated nodes rather than on a sample in the template:
| Computed on the runtime node | Plain <style> | <style is:global> |
|---|---|---|
.sa-dot width | 0px | 8px |
.sa-dot border-radius | 0px | 999px |
.sa-pill display | block | flex |
.sa-pill border-radius | 0px | 999px |
.sa-card summary list-style | disclosure-open | none |
| shipped selector | .sa-dot[data-astro-cid-vd5fpwql] | .sa-dot |
And the numbers that were identical on both sides:
Plain <style> | <style is:global> | |
|---|---|---|
| HTTP status | 200 | 200 |
| console errors | 0 | 0 |
| dots in the DOM | 4 | 4 |
| build | clean, 134 pages | clean, 134 pages |
What to take from it
No check that reads a file could have caught this, and none of mine did. The markup was right, the stylesheet was right, the page returned 200. A fetch-the-HTML smoke test is green on the broken build and green on the fixed one, because the difference between them exists only in what the browser computed. I found it by eye, by accident, while checking that a different page rendered.
Any build-time-scoped CSS system draws its boundary when it builds. Astro’s data-astro-cid-*, Vue SFC scoped with its data-v-*, and CSS Modules with their hashed class names all rewrite selectors against marks applied at compile time, and anything your code creates after that is on the other side of the line. So if you generate markup in JavaScript, the check worth having is one assertion about the rendered result:
// after the page has built its own DOM
const dot = document.querySelector('.sa-dot');
expect(getComputedStyle(dot).width).not.toBe('0px');
Discussion
Powered by GitHub. Sign in to leave a comment.