<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>LoupeKit — notes: css</title>
    <link>https://loupekit.com/blog/</link>
    <atom:link href="https://loupekit.com/blog/css/rss.xml" rel="self" type="application/rss+xml" />
    <description>Notes on css, from the blog that https://loupekit.com/blog/rss.xml carries in full.</description>
    <language>en</language>
    <lastBuildDate>Tue, 28 Jul 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Recovering a Tailwind theme from a site you did not build</title>
      <link>https://loupekit.com/blog/recovering-a-tailwind-theme/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/recovering-a-tailwind-theme/</guid>
      <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
      <category>css</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>Nobody ships tailwind.config.js. What reaches the browser is a compiled stylesheet and a great many class attributes — and between those two, most of a design system is still legible. (css, 5 min)</description>
      <content:encoded><![CDATA[<p><strong>Nobody ships <code class="font-mono text-[0.92em]">tailwind.config.js</code>. What reaches the browser is a compiled stylesheet and a great many class attributes — and between those two, <strong>most of a design system is still legible</strong>.</strong></p><p>This is a normal thing to want. You are taking over a codebase whose design decisions were never written down, or matching a component to a system somebody else owns, or checking whether the site you are about to redesign has a scale at all.</p><p>The build is not a black box. It is <mark class="bg-accent-light text-text">a set of decisions, flattened</mark> — and most of them survive the flattening.</p><h2>Version 4 hands it to you</h2><p>Tailwind 4 moved the theme into CSS. A <code class="font-mono text-[0.92em]">@theme</code> block compiles to custom properties on the root element, and custom properties are readable at runtime, in order, with their computed values.</p><p><em>the theme, read off a live page</em></p><pre><code>const root = getComputedStyle(document.documentElement);

[...document.styleSheets]
  .flatMap((s) =&gt; [...s.cssRules])
  .filter((r) =&gt; r.selectorText === ':root')
  .flatMap((r) =&gt; [...r.style])
  .filter((name) =&gt; name.startsWith('--color-'))
  .map((name) =&gt; [name, root.getPropertyValue(name).trim()]);</code></pre><p>So on a version 4 site the theme is not inferred at all. It is <strong>read</strong> — every token the build kept, under the name the author gave it, which is the part that matters:</p><p><em>What comes back off a version 4 build: the token, its name, and the value it resolves to. The name is the half a screenshot of the site could never give you. — <a href="https://loupekit.com/blog/recovering-a-tailwind-theme/">runs on the page itself</a>.</em></p><blockquote><p><code class="font-mono text-[0.92em]">--color-brand-600</code> tells you something that <code class="font-mono text-[0.92em]">#0e7490</code> does not. <mark class="bg-accent-light text-text">The names are the design system.</mark> The values are only its output.</p></blockquote><p>The names are also the fastest way to date a codebase. A palette named for its role — <code class="font-mono text-[0.92em]">surface</code>, <code class="font-mono text-[0.92em]">rule</code>, <code class="font-mono text-[0.92em]">ink</code>, <code class="font-mono text-[0.92em]">danger</code> — is a system somebody maintained. A palette named <code class="font-mono text-[0.92em]">blue-1</code> through <code class="font-mono text-[0.92em]">blue-9</code> is a palette somebody pasted.</p><h2>Version 3 has to be inferred</h2><p>Before the theme lived in CSS it lived in a config file that never shipped. What ships is the generated utilities — and the scale is recoverable from them, because <strong>every generated class is a name-value pair</strong>. <code class="font-mono text-[0.92em]">.text-slate-700</code> carries the value of <code class="font-mono text-[0.92em]">slate-700</code> in its own declaration.</p><ul><li>Collect every rule whose selector is a Tailwind-shaped class.</li><li>Group by the utility prefix — <code class="font-mono text-[0.92em]">text-</code>, <code class="font-mono text-[0.92em]">bg-</code>, <code class="font-mono text-[0.92em]">p-</code>, <code class="font-mono text-[0.92em]">rounded-</code>, <code class="font-mono text-[0.92em]">shadow-</code>.</li><li>Read the declared value out of each rule.</li><li>Take the breakpoints from the <code class="font-mono text-[0.92em]">@media</code> queries the rules are nested inside.</li></ul><p>It is arithmetic on a stylesheet rather than analysis. The gap is that a version 3 build only emits what was used: the config may have carried forty greys, and the stylesheet carries <mark class="bg-accent-light text-text">the nine somebody wrote a class for</mark>.</p><h2>Reading the scale rather than the colours</h2><p>Colour is the part everybody recovers first and the part that matters least — a palette can be lifted from a screenshot. <strong>The spacing scale cannot</strong>, and it is the thing that makes a new component look like it belongs.</p><p><em>every spacing value the page actually uses, ranked</em></p><pre><code>const seen = new Map();

for (const el of document.querySelectorAll('*')) {
  const s = getComputedStyle(el);
  for (const side of ['paddingTop', 'marginBottom', 'gap']) {
    const v = s[side];
    if (v &amp;&amp; v !== '0px' &amp;&amp; v !== 'normal') {
      seen.set(v, (seen.get(v) ?? 0) + 1);
    }
  }
}

[...seen].sort((a, b) =&gt; b[1] - a[1]).slice(0, 12);</code></pre><p>The output tells you two things at once. A tidy result — <code class="font-mono text-[0.92em]">8px</code>, <code class="font-mono text-[0.92em]">16px</code>, <code class="font-mono text-[0.92em]">24px</code>, <code class="font-mono text-[0.92em]">32px</code>, each used hundreds of times — is a scale somebody kept to. A long tail of <code class="font-mono text-[0.92em]">13px</code>, <code class="font-mono text-[0.92em]">19px</code>, <code class="font-mono text-[0.92em]">27px</code> used twice each is <mark class="bg-accent-light text-text">a scale that exists in the config and not in the codebase</mark>, which is a different finding and a more actionable one.</p><dl><dt>Breakpoints</dt><dd>Read from the <code class="font-mono text-[0.92em]">@media</code> queries in the stylesheet, deduplicated. Faster than any documentation, and correct by construction.</dd><dt>Radii and shadows</dt><dd>Small, closed sets. Three radii is a system; eleven is a codebase where every component chose for itself.</dd><dt>Type scale</dt><dd>The computed <code class="font-mono text-[0.92em]">font-size</code> and <code class="font-mono text-[0.92em]">line-height</code> pairs actually in use. The pairing is the part a config file lists and a page proves.</dd></dl><h2>What is gone for good</h2><dl><dt>Anything tree-shaken</dt><dd>A token defined and never used is indistinguishable from a token that never existed. True in both versions.</dd><dt>Plugin configuration</dt><dd>Custom variants, container queries, typography plugin settings. The output is visible; the switch that produced it is not.</dd><dt>The reasoning</dt><dd>You can recover that the spacing scale steps by 4 pixels. You cannot recover that the team agreed to it in a meeting, or that step 6 is <strong>deliberately</strong> absent.</dd><dt>The names, in version 3</dt><dd><code class="font-mono text-[0.92em]">#0e7490</code> is in the stylesheet. Whether the team called it <code class="font-mono text-[0.92em]">accent-deep</code> or <code class="font-mono text-[0.92em]">cyan-700</code> is not — unless the class list happens to say so.</dd></dl><h2>What to do with what you get</h2><p>The recovered theme is a starting file, not an answer. Three things are worth doing with it before it is useful:</p><ol><li><strong>Sort each scale and look at the gaps.</strong> A colour ramp missing its 400 step, or a spacing scale with two values eleven pixels apart, is where the system was patched rather than extended.</li><li><strong>Count the uses.</strong> A token used once is a decision somebody made in a hurry; a token used four hundred times is the system. The count is the difference between a palette and a scale.</li><li><strong>Name what you recovered, if the build could not.</strong> On a version 3 site you are handed values without names, and writing the names down is the act that turns a list of hex codes back into a design system.</li></ol><blockquote><p>One honest caution: a recovered theme is evidence about the <strong>shipped</strong> page and nothing more. It cannot tell you which values were deliberate, and it will happily present an inconsistency as a scale. <mark class="bg-accent-light text-text">Treat it as a survey, not as documentation.</mark></p></blockquote><h2>The used subset is the useful subset</h2><p>This reads like a limitation and mostly is not. If you are matching a component to a site, the values in the shipped stylesheet are exactly the values in use — <strong>the recovered theme is the live one, with the aspirational parts already removed</strong>.</p><p>Where it does matter is auditing, and there it cuts the other way. A recovered scale with eleven greys in it is not proof that eleven greys were designed. It is proof that eleven greys were <mark class="bg-accent-light text-text">shipped</mark> — which is the more useful of the two findings, and the one no config file would have told you.</p>]]></content:encoded>
    </item>
    <item>
      <title>Measuring AI-generated markup without guessing</title>
      <link>https://loupekit.com/blog/measuring-generated-markup/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/measuring-generated-markup/</guid>
      <pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate>
      <category>css</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>You cannot detect that code was written by a model. You can measure a texture that generated code tends to have — and then the hard part is not flagging the careful hand-written page that happens to share it. (css, 4 min)</description>
      <content:encoded><![CDATA[<p><strong>You cannot detect that code was written by a model. You can measure a texture that generated code tends to have — and then the hard part is not flagging the careful hand-written page that happens to share it.</strong></p><p>Start with the honest version: <strong>there is no signature.</strong> A model can produce markup indistinguishable from a person's, and a person can produce markup indistinguishable from a model's. Anybody selling certainty here is selling a coin flip with a progress bar.</p><p>What there is, is texture. Generated pages tend to carry several habits at once, and the habits are measurable even though none of them is wrong on its own.</p><h2>The habits worth counting</h2><dl><dt>Utility-class verbosity</dt><dd>Forty classes on one element, the same spacing scale repeated at three different values, colour utilities that never resolve to a token.</dd><dt>Structural padding</dt><dd>A paragraph wrapped in eleven nested <code class="font-mono text-[0.92em]">div</code> elements, each with one child, none of them doing anything.</dd><dt>Copy patterns</dt><dd>Headings built from the same six adjectives, three-item lists everywhere, a call to action that says nothing.</dd><dt>Accessibility theatre</dt><dd><code class="font-mono text-[0.92em]">aria-label</code> on an element that already has an accessible name, roles restating what the tag already means.</dd><dt>Runtime artifacts</dt><dd>State written on every render, effects with dependency lists that cannot be right, the same fetch issued twice.</dd></dl><blockquote><p><mark class="bg-accent-light text-text">Any one of these is normal.</mark> A design system produces long class strings; a component library nests; a careful engineer in a hurry writes an <code class="font-mono text-[0.92em]">aria-label</code> that was not needed. <strong>The signal is the co-occurrence, not the habit.</strong></p></blockquote><p><em>one element, five habits at once</em></p><pre><code>&lt;div class=&quot;flex flex-col gap-4 space-y-4 p-4 px-4 py-4
            text-base leading-normal font-normal&quot;&gt;
  &lt;div&gt;
    &lt;div&gt;
      &lt;p aria-label=&quot;paragraph&quot; role=&quot;paragraph&quot;&gt;
        Unlock the power of seamless, cutting-edge solutions.
      &lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;!-- gap-4 and space-y-4 do the same job and fight.
     p-4 is overridden twice by its own components.
     Two wrapper divs hold one child each.
     role=&quot;paragraph&quot; is not a role.
     The sentence names no product and no outcome. --&gt;</code></pre><h2>Why the calibration matters more than the rules</h2><p>Any of those, on its own, is normal. A design system produces long class strings. A component library nests. A careful developer writes an <code class="font-mono text-[0.92em]">aria-label</code> because a screen reader needed it.</p><p>So the number that matters is not how many rules can fire, it is how the weighted total behaves on work that is unambiguously hand-written. The target was set the other way round from the obvious one: a tool that flags careful work is worse than no tool, because it teaches the reader to ignore it — and once ignored, it never catches the real thing either.</p><p>That is a harder direction to tune. Catching generated output is easy; leaving good work alone while still catching it is the whole engineering problem.</p><h2>A score has to show its working</h2><p><mark class="bg-accent-light text-text">A number with no method behind it is an opinion wearing a lab coat.</mark> Every finding says what it measured — the count, the element, the threshold it crossed — and offers a concrete change.</p><p>This has a practical consequence: the score is arguable. Somebody who disagrees can point at the rule that fired and say why it is wrong in their case, and sometimes they will be right. That is the design working, not failing. A verdict nobody can interrogate is one nobody should act on.</p><p>It also sets the standard for us. <strong>If a rule cannot be defended when somebody pushes back on it, the rule comes out.</strong></p><h2>What a score cannot tell you</h2><ul><li><strong>Whether a model wrote it.</strong> There is no signature, and any tool claiming otherwise is selling certainty it cannot have.</li><li><strong>Whether the page is bad.</strong> A high score on a landing page that converts is a finding about maintenance cost, not about quality.</li><li><strong>Whether it was reviewed.</strong> Generated code that a person read, understood and kept is not a defect. The score measures texture, and texture survives review.</li><li><strong>Anything about the code you cannot see.</strong> This reads the rendered page. The repository behind it may be immaculate or may not exist.</li></ul><blockquote><p>The right reading of a high score is <mark class="bg-accent-light text-text">&quot;this page has more of the habits than most&quot;</mark> — and then the finding list, which is the part that is actually actionable.</p></blockquote><h2>What it is for</h2><p>Not for judging other people's work — although that is what it will get used for, and pretending otherwise would be silly.</p><p>The use that pays: run it on a page you just shipped fast. The findings are a refactor list ordered by how much they cost, which is the list you were going to write by hand anyway, on the day you had no time to write it.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
