<?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</title>
    <link>https://loupekit.com/blog/</link>
    <atom:link href="https://loupekit.com/blog/rss.xml" rel="self" type="application/rss+xml" />
    <description>How pages give away their construction, why layout and typography bugs hide where they do, and the decisions behind an inspector that asks for no website permissions.</description>
    <language>en</language>
    <lastBuildDate>Mon, 24 Aug 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Why z-index: 9999 does not put your element on top</title>
      <link>https://loupekit.com/blog/z-index-9999/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/z-index-9999/</guid>
      <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
      <category>layout</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>The number is almost never the problem. z-index: 9999 loses to z-index: 2 every day of the week, and it loses for a reason that is nowhere near the element you were editing. (layout, 6 min)</description>
      <content:encoded><![CDATA[<p><strong>The number is almost never the problem. <code class="font-mono text-[0.92em]">z-index: 9999</code> loses to <code class="font-mono text-[0.92em]">z-index: 2</code> every day of the week, and it loses for a reason that is <strong>nowhere near the element you were editing</strong>.</strong></p><p>Before anything else, one check that costs five seconds: <code class="font-mono text-[0.92em]">z-index</code> does nothing at all on an element whose <code class="font-mono text-[0.92em]">position</code> is <code class="font-mono text-[0.92em]">static</code>. That is the default. If the property is not being read, no value of it is going to help.</p><blockquote><p>The exception is a flex or grid child, where <code class="font-mono text-[0.92em]">z-index</code> applies <strong>without</strong> positioning. It is the one place the property works on a <code class="font-mono text-[0.92em]">static</code> element, and it is a pleasant surprise exactly once.</p></blockquote><p>Past that, raising the number stops helping, and it stops helping abruptly rather than gradually. The reason is that <code class="font-mono text-[0.92em]">z-index</code> does not sort an element against the page. It sorts it against its siblings, inside a box called a <mark class="bg-accent-light text-text">stacking context</mark>.</p><h2>The stacking context is the whole answer</h2><p>A stacking context is a self-contained painting order. Everything inside one paints together, as a unit, at whatever position its root occupies in the <em>parent</em> context. Nothing inside can paint above something outside it.</p><p>Which produces the rule that explains almost every one of these bugs: <strong>a child of a context with <code class="font-mono text-[0.92em]">z-index: 1</code> sits below a sibling of that context with <code class="font-mono text-[0.92em]">z-index: 2</code></strong> — at 9999, at 2147483647, at any number you can type.</p><p><em>the tooltip never wins, and the number is irrelevant</em></p><pre><code>.card    { position: relative; z-index: 1; }
.overlay { position: relative; z-index: 2; }

.card .tooltip { position: absolute; z-index: 9999; }

/* .tooltip paints inside .card, so it paints under .overlay.
   The only number that matters here is the 1. */</code></pre><p><em>The same tooltip at z-index: 9999, twice. The only thing changing is opacity on its parent - and opacity below 1 opens a stacking context, which puts the tooltip under an overlay it used to sit above. — <a href="https://loupekit.com/blog/z-index-9999/">runs on the page itself</a>.</em></p><p>So the element you are editing is rarely the one deciding. The question is which ancestor opened a context, and where <strong>that</strong> ancestor sits among its own siblings.</p><p>It is worth being precise about what a context does to the numbers inside it, because the mental model people carry is usually <em>a bigger number wins</em> rather than the true one, which is closer to a version scheme. <code class="font-mono text-[0.92em]">1.9999</code> is still less than <code class="font-mono text-[0.92em]">2</code>. The ancestor is the integer part.</p><h2>What creates one is a longer list than anybody remembers</h2><p>The familiar case is a positioned element with a <code class="font-mono text-[0.92em]">z-index</code> other than <code class="font-mono text-[0.92em]">auto</code>. The rest is where the surprises live:</p><dl><dt>opacity</dt><dd>Any value below 1. A hover state at <code class="font-mono text-[0.92em]">0.98</code> opens a context for the whole subtree.</dd><dt>transform</dt><dd>Any value other than <code class="font-mono text-[0.92em]">none</code> — including <code class="font-mono text-[0.92em]">translateZ(0)</code>, added for the compositor by someone not thinking about paint order at all.</dd><dt>filter, backdrop-filter</dt><dd>Any value. A blur behind a sticky header is the usual arrival route.</dd><dt>mix-blend-mode</dt><dd>Anything other than <code class="font-mono text-[0.92em]">normal</code>.</dd><dt>isolation: isolate</dt><dd>The only one on this list whose entire purpose is to do it, and therefore the only one that is never a surprise.</dd><dt>contain</dt><dd><code class="font-mono text-[0.92em]">paint</code>, <code class="font-mono text-[0.92em]">layout</code>, <code class="font-mono text-[0.92em]">strict</code> or <code class="font-mono text-[0.92em]">content</code>.</dd><dt>will-change</dt><dd>Naming any property above. A hint about performance that changes correctness.</dd><dt>position: fixed</dt><dd>On its own, with no <code class="font-mono text-[0.92em]">z-index</code> involved at all.</dd></dl><blockquote><p>This is why the bug so often arrives with a commit that has nothing to do with layering. A card gets a hover opacity, a section gets a transform, and a dropdown three levels down that worked yesterday is trapped. <mark class="bg-accent-light text-text">Nothing about the dropdown changed.</mark></p></blockquote><h2>The move that actually finds it</h2><p>Do not start at the element. Start above it and work down:</p><ol><li>Walk from the trapped element up to the root, and at every ancestor ask the one question: does this open a stacking context?</li><li>The first ancestor that does is your ceiling. Nothing below it can ever paint above something outside it.</li><li>Find where that ceiling sits among its own siblings. That comparison — not the one you were editing — is the one deciding.</li><li>Fix it there: raise the ceiling, or move the element out from under it, and put the child back to a number a person can read.</li></ol><p>Doing that by hand means reading computed styles for eight properties on every ancestor, which is precisely why it turns into raising the number instead. It is worth doing properly once, because the answer is nearly always <strong>a single ancestor with a property nobody associates with layering</strong>.</p><h2>The four fixes, worst to best</h2><dl><dt>Raise the number</dt><dd>Works only when the two elements are already in the same context, which is the case you did not have. <strong>It is the fix that produced 9999 in the first place</strong> - each round of it raises the ceiling nobody found.</dd><dt>Raise the ancestor</dt><dd>Correct, and usually a one-line change: put the <code class="font-mono text-[0.92em]">z-index</code> on the ceiling instead of on the child. The risk is that the ceiling now beats things it should not, so it moves the problem up a level rather than removing it.</dd><dt>Move the element out</dt><dd>Render the overlay as a sibling of the ceiling rather than a descendant. In a component framework that means a portal, and the reason portals exist is precisely this - not styling, <mark class="bg-accent-light text-text">paint order</mark>.</dd><dt>Use the top layer</dt><dd>For anything modal, this is the answer, and it is the only one that cannot regress. See below.</dd></dl><blockquote><p>There is a fifth thing people try - <code class="font-mono text-[0.92em]">position: fixed</code> on the trapped element - and it is worth naming because it <em>seems</em> to work. A fixed element escapes scrolling ancestors, not stacking contexts. <strong>If any ancestor has a <code class="font-mono text-[0.92em]">transform</code>, <code class="font-mono text-[0.92em]">filter</code> or <code class="font-mono text-[0.92em]">will-change</code>, the fixed element is positioned against that ancestor instead of the viewport</strong>, and the bug becomes two bugs.</p></blockquote><h2>isolation: isolate, and the one time you want more of this</h2><p>Everything above treats a stacking context as an accident. <code class="font-mono text-[0.92em]">isolation: isolate</code> is the property that creates one on purpose, and it is the right tool in one specific situation: a component whose internal layering must not leak into the page it is dropped into.</p><p><em>a component that cannot fight with the page around it</em></p><pre><code>.widget {
  isolation: isolate;
}

/* Everything inside .widget now sorts among itself.
   The page sorts .widget as one thing.
   Nothing inside can reach past it, in either direction. */</code></pre><p>That is the same mechanism causing every bug in this article, aimed deliberately. <strong>A stacking context is not a hazard; an accidental one is.</strong> The difference is whether you knew you were opening it.</p><h2>The top layer ignores all of it</h2><p>A <code class="font-mono text-[0.92em]">&lt;dialog&gt;</code> opened with <code class="font-mono text-[0.92em]">showModal()</code>, an element with the <code class="font-mono text-[0.92em]">popover</code> attribute, and anything in fullscreen do not participate in this system at all. They paint in the <mark class="bg-accent-light text-text">top layer</mark>: above every stacking context on the page, in the order they were opened.</p><p><em>no z-index anywhere, and nothing can cover it</em></p><pre><code>&lt;div popover id=&quot;menu&quot;&gt;…&lt;/div&gt;
&lt;button popovertarget=&quot;menu&quot;&gt;Open&lt;/button&gt;</code></pre><p>No <code class="font-mono text-[0.92em]">z-index</code> competes with the top layer and none is needed on it. Which makes it the real fix for the case that produces 9999 in the first place — a modal, a tooltip, a menu that has to escape whatever it is nested in. <strong>Not a bigger number. A different layer.</strong></p>]]></content:encoded>
    </item>
    <item>
      <title>What makes a page scroll sideways</title>
      <link>https://loupekit.com/blog/sideways-scroll/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/sideways-scroll/</guid>
      <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
      <category>layout</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>overflow-x: hidden on body is not a fix. It is a way of not knowing which element is forty pixels too wide — and it quietly breaks position: sticky on everything inside it on the way past. (layout, 6 min)</description>
      <content:encoded><![CDATA[<p><strong><code class="font-mono text-[0.92em]">overflow-x: hidden</code> on <code class="font-mono text-[0.92em]">body</code> is not a fix. It is a way of <strong>not knowing</strong> which element is forty pixels too wide — and it quietly breaks <code class="font-mono text-[0.92em]">position: sticky</code> on everything inside it on the way past.</strong></p><p>Sideways scroll is one of the few layout bugs with a single cause. Something is wider than the viewport, or starts far enough right that its edge lands outside it. There is no cascade to reason about and no specificity to lose to. <mark class="bg-accent-light text-text">There is one element, and the work is finding it.</mark></p><p>It is also the bug most often closed without being found, because hiding it takes one line and looks identical in the screenshot.</p><h2>The handful of things that actually do it</h2><dl><dt>100vw</dt><dd>On a desktop browser with a classic scrollbar, <code class="font-mono text-[0.92em]">100vw</code> is the viewport <strong>including</strong> the scrollbar; the content box you are comparing it against is not. A full-bleed section overflows by around fifteen pixels — enough to scroll, small enough to read as a rendering fault.</dd><dt>min-width: auto</dt><dd>The default on flex and grid children, and it means <em>do not shrink below your content</em>. A long word, a wide table or an unbroken URL in a grid cell will push the whole track. Responsible for more of these than everything else here combined.</dd><dt>Fixed pixel widths</dt><dd>A <code class="font-mono text-[0.92em]">width: 480px</code> card in a 390px viewport. Obvious in review, invisible on the desktop it was written on.</dd><dt>Negative margins</dt><dd>A full-bleed trick pulling content out past the right edge, usually correct on one breakpoint and not the others.</dd><dt>Absolute positioning</dt><dd><code class="font-mono text-[0.92em]">right: -40px</code> on a decorative shape. The element is out of flow, but not out of the scroll area.</dd><dt>Unbreakable strings</dt><dd>A URL, a hash, a wallet address, a base64 blob. No spaces means no wrap opportunity, unless <code class="font-mono text-[0.92em]">overflow-wrap: anywhere</code> says otherwise.</dd></dl><p><em>Three rows in a frame. Two are sized to the content box; the middle one is sized to 100vw, which on a desktop browser includes the scrollbar the content box does not get. Fifteen pixels, and the page scrolls. — <a href="https://loupekit.com/blog/sideways-scroll/">runs on the page itself</a>.</em></p><blockquote><p>Worth knowing what does <strong>not</strong> do it: <code class="font-mono text-[0.92em]">box-shadow</code> and <code class="font-mono text-[0.92em]">outline</code> paint outside the box without contributing to scroll width. <code class="font-mono text-[0.92em]">transform: translateX</code> does contribute, even though the layout position has not moved.</p></blockquote><h2>Why the outline trick misleads</h2><p><em>the advice everybody gives</em></p><pre><code>* { outline: 1px solid red; }</code></pre><p>It works often enough to stay in circulation, and it is wrong in a specific way: <strong>it marks every element</strong>, so the offender and its dozen innocent descendants all appear to stick out together. You end up staring at the deepest one — a <code class="font-mono text-[0.92em]">&lt;span&gt;</code> inside the thing that is actually too wide — because it is the one whose red box is easiest to see.</p><p>It also cannot see anything already clipped by an ancestor with <code class="font-mono text-[0.92em]">overflow: hidden</code> set for a legitimate reason, which is exactly where these hide.</p><h2>Measure edges instead</h2><p>The reliable test is arithmetic, not colour. Compare every element's right edge against the document width, then take the <mark class="bg-accent-light text-text">shallowest</mark> element that fails:</p><p><em>the whole diagnosis, in five lines</em></p><pre><code>const limit = document.documentElement.clientWidth;

[...document.querySelectorAll('*')]
  .filter((el) =&gt; el.getBoundingClientRect().right &gt; limit)
  .slice(0, 5);

// The first result is the cause.
// Everything after it is a passenger inside the cause.</code></pre><p>The same pass catches the left side, which people forget exists. An element at a negative offset does not always produce scroll — a browser does not scroll into inline-start overflow in a left-to-right document — but <strong>it does in a right-to-left one</strong>. Which is how this ships broken in Arabic and Hebrew after passing every check in English.</p><h2>The two fixes for the two commonest causes</h2><p>Once the element is identified, the repair is nearly always one of two lines. Neither is clever, and both are worth knowing by heart.</p><p><em>full bleed without the scrollbar arithmetic</em></p><pre><code>.bleed {
  width: 100dvw;
  margin-inline: calc(50% - 50dvw);
}

/* Or skip the unit question entirely: */
.bleed-grid {
  grid-column: 1 / -1;
}</code></pre><p><em>letting a flex or grid child actually shrink</em></p><pre><code>.track &gt; * {
  min-width: 0;
}

.track pre,
.track code {
  overflow-wrap: anywhere;
}

/* min-width: auto is the default and it means
   &quot;never shrink below my content&quot;. A table, a long
   URL or a wide &lt;pre&gt; will push the whole track. */</code></pre><blockquote><p><code class="font-mono text-[0.92em]">min-width: 0</code> on flex and grid children is the single highest-value line in responsive CSS, and it is invisible until the day a long word arrives. <mark class="bg-accent-light text-text">Some teams set it globally on grid and flex children and never see this class of bug again.</mark></p></blockquote><h2>Catching it before a person does</h2><p>This bug has an unusual property among layout bugs: <strong>it is trivially detectable in CI</strong>. It is one comparison of two numbers, it needs no screenshots and no visual diffing, and it fails deterministically.</p><p><em>the assertion, at three widths</em></p><pre><code>for (const width of [320, 768, 1280]) {
  await page.setViewportSize({ width, height: 900 });
  const scrolls = await page.evaluate(
    () =&gt; document.documentElement.scrollWidth &gt;
          document.documentElement.clientWidth,
  );
  expect(scrolls, `scrolls sideways at ${width}px`).toBe(false);
}</code></pre><p>320 is the width WCAG asks a page to reflow to, so that row is a conformance check as well as a layout one. Adding those three lines is usually the last time anybody thinks about horizontal overflow in that codebase.</p><h2>When hiding it is the correct answer</h2><p>Sometimes it genuinely is: a decorative shape deliberately drawn past the edge, a carousel whose track is meant to be wider than its frame. In both cases the clip belongs on that element or its immediate container — <mark class="bg-accent-light text-text">never on <code class="font-mono text-[0.92em]">body</code> or <code class="font-mono text-[0.92em]">html</code></mark>.</p><p>The reason is not tidiness. <code class="font-mono text-[0.92em]">overflow</code> on the scroll container is what makes <code class="font-mono text-[0.92em]">position: sticky</code> stop working, and the failure surfaces somewhere else entirely, weeks later, as a header that will not stick. A clip scoped to the thing being clipped costs nothing and takes nothing else down with it.</p><blockquote><p>If you inherit a codebase with <code class="font-mono text-[0.92em]">overflow-x: hidden</code> on <code class="font-mono text-[0.92em]">body</code>, remove it before debugging anything sticky. <strong>You are not looking for a sticky bug. You are looking for the overflow it was hiding.</strong></p></blockquote>]]></content:encoded>
    </item>
    <item>
      <title>Contrast checkers measure a colour you never shipped</title>
      <link>https://loupekit.com/blog/contrast-on-real-backgrounds/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/contrast-on-real-backgrounds/</guid>
      <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
      <category>accessibility</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>Every contrast checker asks for two colours. A real page hands you one of them at best — the other is a gradient beneath a semi-transparent card above a photograph, and not one of those is a hex value anybody typed. (accessibility, 5 min)</description>
      <content:encoded><![CDATA[<p><strong>Every contrast checker asks for two colours. A real page hands you <strong>one of them at best</strong> — the other is a gradient beneath a semi-transparent card above a photograph, and not one of those is a hex value anybody typed.</strong></p><p>The requirement is simple enough, and the arithmetic is not hard:</p><dl><dt>1.4.3 — normal text</dt><dd>4.5:1 against its background.</dd><dt>1.4.3 — large text</dt><dd>3:1. Large means 24px, or 18.66px when bold — computed pixels, not the number in the design file.</dd><dt>1.4.11 — non-text</dt><dd>3:1 for the parts of a control that identify it, and for meaningful graphics.</dd></dl><p>None of that is in dispute. The dispute is over <mark class="bg-accent-light text-text">which two colours go into it</mark>. A checker takes what you type; a browser paints what the page composited. Those agree on a plain card on a plain background, and part company everywhere else — which is most of the interesting parts of most designs.</p><h2>The background is composited, not declared</h2><p>What sits behind a glyph is the result of stacking every painted layer beneath it: the body colour, a section gradient, a card at 92 percent opacity, an inherited <code class="font-mono text-[0.92em]">opacity</code> on an ancestor fading the whole subtree, a <code class="font-mono text-[0.92em]">backdrop-filter</code> blurring whatever is under it. <strong>None of those is a value you can read off one element and paste into a form.</strong></p><p>Text has the same problem from the other side. <code class="font-mono text-[0.92em]">color: rgba(0, 0, 0, 0.72)</code> is not black; it is whatever black becomes over that particular background.</p><p><em>checked as 21:1, painted at 8.8:1</em></p><pre><code>.panel   { background: #f4f5f7; }
.panel p { color: rgba(0, 0, 0, 0.72); }

/* The palette was checked as #000 on #fff: 21:1.
   The browser composites #444545 on #f4f5f7: 8.8:1.
   Still a pass - and the margin is now four times smaller
   than the number anybody wrote down. */</code></pre><p><em>The same sentence, the same declared colour. On the right the alpha is doing what alpha does, and the ratio moves from a comfortable pass to a clear failure without one hex value changing anywhere in the stylesheet. — <a href="https://loupekit.com/blog/contrast-on-real-backgrounds/">runs on the page itself</a>.</em></p><p>The honest way to get the number is to compute the effective background by walking up the tree, compositing each layer as it goes, stopping at the first fully opaque paint, then applying the alpha of the text over that. That is what the browser did. Anything else is a different question with a similar-looking answer.</p><h2>Where it goes wrong in practice</h2><p>Three failures account for nearly all of them, and all three pass a swatch check.</p><ol><li><strong>Dark mode, derived rather than designed.</strong> A palette is checked in light mode and the dark tokens are produced by inverting lightness. Mid-greys that had 5:1 against white land at 3.2:1 against a near-black. Both were checked — as swatches, twice, never as a page.</li><li><strong>The hero image.</strong> Text over a photograph is checked against the photograph's average colour, or against nothing at all. The requirement is not the average: it is met at the <mark class="bg-accent-light text-text">worst pixel behind any glyph</mark>. A scrim works precisely because it makes the background computable again.</li><li><strong>The brand colour used as text.</strong> A colour chosen to be seen as a large filled shape is rarely legible at 14px on white. It passes as a button and fails as a link.</li></ol><blockquote><p>One place a low ratio is <strong>permitted</strong>: WCAG exempts inactive components. Worth knowing before rewriting a palette to fix something that was never a failure.</p></blockquote><h2>The criterion everybody forgets</h2><p>Contrast is not only about text. 1.4.11 asks for 3:1 on input borders, focus indicators, toggle states, icons carrying meaning on their own, and the lines of a chart.</p><p><strong>Focus rings are where this is failed most</strong>, and the failure is invisible to everyone who uses a mouse. A ring in the brand colour on the brand background can sit at 1.4:1 and look entirely intentional. A keyboard user cannot tell where they are on the page.</p><p><em>the two-tone ring that survives any background</em></p><pre><code>:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--ring-halo);
}

/* The halo does the work: whatever the ring fails against,
   the second colour behind it passes. */</code></pre><h2>How the number is actually computed</h2><p>Worth knowing, because the shape of the formula explains most of the surprises. Contrast is a ratio of <mark class="bg-accent-light text-text">relative luminance</mark>, and relative luminance is not brightness as a person perceives it:</p><p><em>the whole of WCAG 1.4.3, in arithmetic</em></p><pre><code>const channel = (v) =&gt; {
  const c = v / 255;
  return c &lt;= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};

const luminance = ([r, g, b]) =&gt;
  0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);

const ratio = (a, b) =&gt; {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) =&gt; y - x);
  return (hi + 0.05) / (lo + 0.05);
};</code></pre><ul><li><strong>Green carries 71 percent of the weight.</strong> Two colours can be equally dark to look at and land far apart, which is why a mid-green and a mid-blue behave nothing alike as text.</li><li><strong>The 0.05 floor compresses the dark end.</strong> Once a background is near-black, making it blacker barely moves the ratio - and dark-mode palettes are tuned in exactly that region.</li><li><strong>The exponent punishes mid-tones.</strong> The gap between #767676 and #808080 is invisible and it is often the gap between 4.5:1 and 4.3:1.</li></ul><blockquote><p>This is also why <mark class="bg-accent-light text-text">a ratio cannot be averaged</mark>. Two paragraphs at 4.6:1 and 4.4:1 are not &quot;about 4.5&quot;. One passes and one does not, and the requirement is per element.</p></blockquote><h2>What to actually do</h2><ul><li>Check the <mark class="bg-accent-light text-text">rendered page</mark>, not the palette. The palette is a set of intentions; the page is the artefact.</li><li>Check both themes. A derived dark palette is a second design, and it has been checked once.</li><li>Check at the smallest size the text is actually used at — captions, labels, legal lines, placeholder text.</li><li>Check the focus ring, on every surface it can appear over.</li></ul><p>Those four cover nearly every contrast failure that ships from a design system which was, on its own terms, checked.</p>]]></content:encoded>
    </item>
    <item>
      <title>The font a page declares is not the font you see</title>
      <link>https://loupekit.com/blog/font-fallback/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/font-fallback/</guid>
      <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
      <category>typography</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>font-family is a wish list and the browser grants the first wish it can. Nearly every typography bug lives in the gap between the name in the stylesheet and the shapes on the screen. (typography, 6 min)</description>
      <content:encoded><![CDATA[<p><strong><code class="font-mono text-[0.92em]">font-family</code> is a wish list and the browser grants the first wish it can. Nearly every typography bug lives in the gap between <strong>the name in the stylesheet</strong> and <strong>the shapes on the screen</strong>.</strong></p><p>Asking what font a page uses sounds like one question. It is three:</p><ol><li>Which family the CSS <strong>asked for</strong> — the <code class="font-mono text-[0.92em]">font-family</code> list, in order.</li><li>Which face the browser <strong>matched</strong> — after <code class="font-mono text-[0.92em]">@font-face</code>, weight, style, stretch and <code class="font-mono text-[0.92em]">unicode-range</code> have all had a say.</li><li>Which one it <strong>actually painted with</strong> — per glyph, after loading succeeded, failed or timed out.</li></ol><p>On a page that ships its own fonts properly, all three agree. On most pages at least one of them does not, and the gap is invisible if you only read the stylesheet — because <mark class="bg-accent-light text-text">the stylesheet is the request, not the outcome</mark>.</p><h2>Four ways the declared font is not the rendered one</h2><dl><dt>The file never arrived</dt><dd>A wrong path, a blocked CDN, a missing CORS header. There is no error a visitor would notice — the browser moves to the next family in the list and the page renders in the fallback, looking almost right.</dd><dt>The glyph was outside unicode-range</dt><dd>A subset face covers part of the character set. A curly quote, an em dash or an accented name falls outside it and is painted by a <strong>different font in the same line of text</strong>.</dd><dt>The weight was never shipped</dt><dd>A stylesheet asking for <code class="font-mono text-[0.92em]">font-weight: 600</code> with only 400 and 700 loaded does not fail. It matches the nearest face, and depending on the engine may thicken it artificially instead.</dd><dt>The face arrived late</dt><dd><code class="font-mono text-[0.92em]">font-display: swap</code> paints the fallback first and swaps when the file lands, which is a layout shift by design. <code class="font-mono text-[0.92em]">block</code> keeps the text invisible until it lands. Both are correct configurations of the same tradeoff.</dd></dl><h2>Synthetic bold is the loudest tell</h2><p>When a browser cannot find a bold face, <mark class="bg-accent-light text-text">it makes one</mark> — by drawing the regular weight a second time at a slight offset. It looks like bold at a glance and wrong at any size above a heading: the counters fill in, the stems thicken unevenly, and the letterfit is still the regular font's.</p><p>Synthetic oblique is the same trick for italic: a slant applied to upright letterforms, with none of the different shapes a real italic has. <strong>The tell is the lowercase <code class="font-mono text-[0.92em]">a</code></strong> — in most true italics it stops being a double-storey letter, and in a synthesised one it never does.</p><blockquote><p>Both are worth catching because both mean a weight is being requested that the page does not ship. That is a <strong>build problem, not a design one</strong>, and it is fixed in the font loading rather than in the CSS that asked.</p></blockquote><p><em>turn the guess off and see what you actually have</em></p><pre><code>body { font-synthesis: none; }

/* Every faux-bold and faux-italic on the page reverts to
   its real weight. What changes is what was never shipped. */</code></pre><h2>The loading order that causes most of the damage</h2><p>A webfont is discovered late by design. The browser parses the HTML, requests the stylesheet, parses that, finds the <code class="font-mono text-[0.92em]">@font-face</code>, and only then learns the font exists — and in most builds it learns which <em>file</em> only once an element using that family is laid out.</p><ol><li>HTML arrives and parsing begins.</li><li>The stylesheet is requested. <strong>Rendering is blocked</strong> until it lands.</li><li><code class="font-mono text-[0.92em]">@font-face</code> is parsed. The file is still not requested.</li><li>Layout finds text wanting that family. <strong>Now</strong> the file is requested.</li><li>It arrives, and depending on <code class="font-mono text-[0.92em]">font-display</code> the text either swaps or appears.</li></ol><p>Steps 2 to 4 are where the round trips accumulate, and they are why <code class="font-mono text-[0.92em]">preload</code> exists — it moves the request to the top of step 1:</p><p><em>the one hint worth spending, and its two easy mistakes</em></p><pre><code>&lt;link rel=&quot;preload&quot; as=&quot;font&quot; type=&quot;font/woff2&quot;
      href=&quot;/fonts/inter-var.woff2&quot; crossorigin&gt;

&lt;!-- crossorigin is required even for a same-origin font:
     fonts are fetched in CORS mode, and a preload without
     it downloads the file twice. --&gt;
&lt;!-- Preload only the faces above the fold. Preloading six
     weights makes every one of them compete with the CSS. --&gt;</code></pre><blockquote><p><strong>A preload that is not used within a few seconds is worse than none at all.</strong> It spent bandwidth during the most contended moment of the page load and the browser will say so in the console — which is the one font warning worth reading.</p></blockquote><h2>How to see what actually rendered</h2><p>The browser exposes the loaded set. <code class="font-mono text-[0.92em]">document.fonts</code> is a <code class="font-mono text-[0.92em]">FontFaceSet</code> you can iterate for every face and its status, and <code class="font-mono text-[0.92em]">check()</code> answers whether a specific request can be met right now:</p><p><em>what is loaded, and whether one request can be met</em></p><pre><code>[...document.fonts].map((f) =&gt; [f.family, f.weight, f.status]);

document.fonts.check('600 16px Inter');
// false means: nothing will paint at 600 - it will be faked.</code></pre><p>That covers what is available. Which face a particular run of text was painted with is harder — the platform decides per glyph — so the practical approach is to compare the computed <code class="font-mono text-[0.92em]">font-family</code> against the loaded set, and look for the synthesis tells above wherever the two disagree.</p><h2>Variable fonts change the arithmetic</h2><p>Most of the failures above come from shipping a set of static weights and asking for one that is not in it. A variable font removes that category entirely: one file carries a continuous weight axis, so <code class="font-mono text-[0.92em]">font-weight: 570</code> is a real instance rather than a synthesis.</p><dl><dt>One file, not six</dt><dd>A variable face at four weights is usually smaller than the four static files it replaces, and it is <strong>one</strong> request rather than four competing ones.</dd><dt>No synthetic anything</dt><dd>Every weight in the axis range exists. There is nothing for the browser to fake.</dd><dt>The catch</dt><dd><code class="font-mono text-[0.92em]">@font-face</code> must declare the range — <code class="font-mono text-[0.92em]">font-weight: 100 900</code> — or the browser treats the file as a single weight and fakes the rest anyway, from a font that contained them all.</dd></dl><p><em>the declaration that unlocks the axis</em></p><pre><code>@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}</code></pre><h2>Fallback metrics, which almost nobody sets</h2><p>If a webfont swaps in and the text reflows, the cause is that the fallback has different metrics: a different x-height and different advance widths, so the same string occupies a different number of lines. <strong>That is a layout shift attributable entirely to typography.</strong></p><p><em>an override face, costing no bytes and no request</em></p><pre><code>@font-face {
  font-family: 'Inter fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
}

body { font-family: 'Inter', 'Inter fallback', sans-serif; }</code></pre><p><em>Two paragraphs, the same words, the same box. The left one swaps between font stacks with different metrics and takes everything below it down the page; the right one has a fallback face adjusted to match, so the shapes change and nothing moves. — <a href="https://loupekit.com/blog/font-fallback/">runs on the page itself</a>.</em></p><p>The local font is made to occupy the same space as the webfont, so the swap becomes a change of shape rather than a change of layout. <mark class="bg-accent-light text-text">It is the cheapest CLS fix on the web and it is almost never applied.</mark></p>]]></content:encoded>
    </item>
    <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>What a page tells you about how it was built</title>
      <link>https://loupekit.com/blog/reading-a-stack/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/reading-a-stack/</guid>
      <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
      <category>builds</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>Every page announces its own construction, in six different ways, with six different levels of honesty. Knowing which kind of evidence you are looking at matters more than the list of names it produces. (builds, 5 min)</description>
      <content:encoded><![CDATA[<p><strong>Every page announces its own construction, in six different ways, with six different levels of honesty. Knowing which kind of evidence you are looking at matters more than the list of names it produces.</strong></p><p>Stack detection looks like pattern matching and mostly is. What separates a useful answer from a confident wrong one is whether the tool tells you <em>how</em> it knew — because the six kinds of evidence a page leaves are <strong>not equally reliable</strong>, and two of them are trivially faked.</p><h2>The six, weakest last</h2><dl><dt>A DOM marker</dt><dd>An attribute or class the framework itself writes - <code class="font-mono text-[0.92em]">data-reactroot</code> and its successors, a hydration marker, a generated class prefix. <strong>Strong</strong>: it is a side effect of the thing running, not a declaration about it.</dd><dt>A page global</dt><dd>A variable the library leaves on <code class="font-mono text-[0.92em]">window</code>. Equally strong, and often carries a version the page did not mean to publish.</dd><dt>A namespaced CSS variable</dt><dd>A custom property whose prefix belongs to one design system. Quietly one of the best signals, because it <mark class="bg-accent-light text-text">survives the minification that erases the others</mark>.</dd><dt>An asset URL</dt><dd>A script or stylesheet whose path names the thing. Good, with a caveat: a bundled copy under a hashed name says nothing, so absence here is not evidence of absence anywhere.</dd><dt>A response header</dt><dd>The server naming its own software. Reliable when present, and routinely removed, rewritten or set to something untrue as a matter of policy.</dd><dt>The generator meta tag</dt><dd>A declaration by the page about itself. <strong>The weakest of the six</strong>: a string somebody typed, surviving long after the thing it names was replaced, and the first thing edited by anyone who does not want to be identified.</dd></dl><p><em>The same six kinds, weighted by how hard each is to fake. The top three are side effects of code running; the bottom one is a sentence in the markup. — <a href="https://loupekit.com/blog/reading-a-stack/">runs on the page itself</a>.</em></p><blockquote><p>The ordering is the useful part. Two tools can report the same name from the top of that list and from the bottom of it, and <mark class="bg-accent-light text-text">those are not the same finding</mark>.</p></blockquote><p><em>the same claim, at both ends of the list</em></p><pre><code>&lt;meta name=&quot;generator&quot; content=&quot;WordPress 5.2&quot;&gt;

&lt;link rel=&quot;stylesheet&quot;
      href=&quot;/wp-content/themes/twentytwenty/style.css?ver=6.5&quot;&gt;

&lt;!-- The first is a string in a template, unchanged since 2019.
     The second is a file the server is really serving,
     and the two disagree by four major versions. --&gt;</code></pre><h2>Why the evidence has to be shown</h2><p><mark class="bg-accent-light text-text">A flat list of names is not checkable.</mark> Told that a page is running a particular framework, you have no way to know whether that came from a hydration marker or from a meta tag somebody left in a template five years ago — and those two facts deserve different amounts of belief.</p><p>So every match here comes with the literal thing that matched and where it was found. It makes the readout longer and it is the difference between a claim and a finding.</p><p>It also makes the detector correctable. When a match is wrong, the evidence says which rule to fix rather than leaving somebody to guess at a heuristic they cannot see.</p><h2>Absence is not evidence</h2><p>The failure that produces confidently wrong readouts is not a bad match. It is treating a missing signal as a negative finding — and on a modern build, <mark class="bg-accent-light text-text">missing is the normal case</mark>.</p><dl><dt>Bundling</dt><dd>A library compiled into <code class="font-mono text-[0.92em]">main.a1b2c3.js</code> leaves no filename to match. It is running; the URL says nothing.</dd><dt>Minification</dt><dd>Class prefixes and global names are renamed. A framework can be present with every one of its usual markers gone.</dd><dt>Server rendering</dt><dd>Markup arrives fully formed. The runtime that produced it may never have reached the browser at all.</dd><dt>A stripped header</dt><dd>Removed as a matter of policy at more or less every serious host. Its absence is a security setting, not a fact about the stack.</dd></dl><p>So the honest output has two states rather than three: <strong>identified, with the evidence</strong> and <strong>not identified</strong>. There is no <em>not present</em>, and a tool offering one is telling you something the page did not say.</p><h2>Versions, and what a version is worth</h2><p>Where a page states a version, it is read and reported. Where it does not, <strong>nothing is invented</strong> — a guessed version is worse than a missing one, because the missing one prompts a question and the guessed one ends it.</p><p>Some versions cannot be told apart from the outside at all. Two major releases of the same tool can declare identical variable names, and a detector claiming to distinguish them is claiming something the page did not say.</p><h2>Detecting yourself is the useful direction</h2><p>The instinct is to point this at somebody else's site. The finding that changes a decision is usually on your own, because a stack list from the outside is <strong>a list of what actually reached a browser</strong> — not what the lockfile says, not what the bundler was configured to split, not what a colleague removed six months ago.</p><ul><li>A library everyone believes was dropped, still shipping in a chunk nothing imports any more.</li><li>Two versions of the same framework, because a widget brought its own.</li><li>A tag manager loading a second analytics vendor that no one on the team chose.</li><li>A consent banner that itself loads three third parties <strong>before</strong> consent is given, which is the most common finding of all and the most expensive one.</li></ul><blockquote><p>That last one is worth checking on any site with a cookie banner. <mark class="bg-accent-light text-text">It is a compliance failure that is invisible from the inside</mark> and takes about four seconds to see from the outside.</p></blockquote><h2>What to do with the answer</h2><p>The immediate use is orientation: opening an unfamiliar codebase's <em>output</em> before opening its source tells you what you are about to read.</p><p>The slower use is the third-party list. Most pages carry more analytics, tag managers, session replay and consent tooling than anybody on the team believes, because each was added once by somebody with a good reason. Seeing them named together, with the evidence for each, is usually the most uncomfortable part of the readout — and the most useful.</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>
    <item>
      <title>Why this extension asks for no website permissions</title>
      <link>https://loupekit.com/blog/no-host-permissions/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/no-host-permissions/</guid>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <category>privacy</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>Every browser extension you install shows you a list before it runs. Most inspection tools ask for access to all your data on all websites, because it is the easiest thing to ask for. It is also the thing that makes people close the tab. (privacy, 5 min)</description>
      <content:encoded><![CDATA[<p><strong>Every browser extension you install shows you a list before it runs. Most inspection tools ask for <em>access to all your data on all websites</em>, because it is the easiest thing to ask for. It is also the thing that makes people close the tab.</strong></p><p>The permission list is the last honest moment between a user and an extension. After the install it is invisible; before it, it is the only thing that describes the code's reach. So the design question is not how to explain a broad permission well. It is whether the extension can be built without one.</p><p>This one is. <code class="font-mono text-[0.92em]">host_permissions</code> is <mark class="bg-accent-light text-text">empty in both the Chrome and the Firefox build</mark>. Four permissions are declared, and none of them names a website:</p><dl><dt>activeTab</dt><dd>One tab, from a click, until it navigates away.</dd><dt>storage</dt><dd>Settings and saved output, on this machine.</dd><dt>scripting</dt><dd>The mechanism that puts the inspection code into that one tab.</dd><dt>contextMenus</dt><dd>The right-click entry that opens it.</dd></dl><h2>What activeTab actually grants</h2><p><code class="font-mono text-[0.92em]">activeTab</code> gives an extension access to one tab, starting from a user gesture, ending when the tab navigates away. <strong>It is not a weaker version of host access. It is a different shape</strong>: host access is standing permission to every matching page whether anybody is looking or not, and <code class="font-mono text-[0.92em]">activeTab</code> is a key that turns once.</p><p>Concretely: nothing runs until you click. The inspection code is injected into the tab you are on, at the moment you ask, and the grant dies with the navigation. There is no content script sitting in every page you open, and there is no background process reading tabs because they happened to be open.</p><p>The trade is real:</p><ul><li>It cannot act on page load.</li><li>It cannot watch a page it was not invited into.</li><li>It has to re-establish itself every single time.</li></ul><p><em>The whole lifetime of the grant. It begins at a click, covers one tab, and ends at the next navigation - at which point the extension has to be invited again. — <a href="https://loupekit.com/blog/no-host-permissions/">runs on the page itself</a>.</em></p><p>Those are features here rather than costs — but they are constraints on what the product can be, and they were <strong>chosen before the code was written</strong> rather than worked around afterwards.</p><h2>The three things that genuinely need more</h2><p>Three settings cannot work under that model: opening the panel automatically on page load, a User-Agent switcher, and a WebRTC address control. Each needs a permission the four above do not cover.</p><p>So each one asks at the moment it is switched on, from the click that switches it, and hands the permission back when it is switched off. An optional permission is a different object from a declared one: it does not appear on the install screen, it is granted by a person who is looking at what they are enabling, and it is revocable without uninstalling.</p><p>A reader who never turns those on never grants them. That is the whole reason to build it this way rather than declaring the broad origin once and being done.</p><h2>What the manifest looks like, and what to compare it against</h2><p>The claim is checkable in about ten seconds, and it is worth knowing how — for this extension and for the next one you install.</p><p><em>the four lines that decide the install screen</em></p><pre><code>{
  &quot;permissions&quot;: [&quot;activeTab&quot;, &quot;storage&quot;, &quot;scripting&quot;, &quot;contextMenus&quot;],
  &quot;host_permissions&quot;: [],
  &quot;optional_permissions&quot;: [&quot;privacy&quot;],
  &quot;optional_host_permissions&quot;: [&quot;*://*/*&quot;]
}</code></pre><p>The distinction that matters is between the second line and the fourth. <code class="font-mono text-[0.92em]">host_permissions</code> is granted at install, silently, forever. <code class="font-mono text-[0.92em]">optional_host_permissions</code> is <strong>an offer the browser will only accept from a click</strong>, it does not appear on the install screen, and it can be handed back without uninstalling anything.</p><dl><dt>Read your browsing history</dt><dd>Means <code class="font-mono text-[0.92em]">tabs</code> or a broad host match. An inspector does not need it: <code class="font-mono text-[0.92em]">activeTab</code> gives the URL of the tab you asked about, and nothing about the others.</dd><dt>Read and change all your data on all websites</dt><dd>Means <code class="font-mono text-[0.92em]">&lt;all_urls&gt;</code> or <code class="font-mono text-[0.92em]">*://*/*</code> in <code class="font-mono text-[0.92em]">host_permissions</code>. <strong>This is the one to refuse.</strong> It is standing access to every page, whether the extension is being used or not.</dd><dt>Read and change your data on sites you visit</dt><dd>The wording Firefox uses for the same thing. Softer sentence, identical grant.</dd><dt>No website permissions listed</dt><dd>What <code class="font-mono text-[0.92em]">activeTab</code> alone looks like on the install screen. It is rare, and it is the thing to look for.</dd></dl><h2>What a page reading actually sends</h2><p>Permissions describe reach; they say nothing about what leaves the machine. Those are separate promises and both have to be kept.</p><p>Inspection, extraction and the live editing tools run entirely in the browser and send nothing. A few readings do ask a server, each behind its own button. An audit sends counts rather than content — the hostname, the heading text and a set of numbers, with email addresses and long identifier strings redacted before anything leaves the page. Vulnerability dating sends detected package names and versions, with no hostname at all. The server-side readings send the page address with its query string removed, because asking a server to fetch a page cannot be done without naming it.</p><p>Input field values and body copy are never read. The field-by-field list is on the privacy page, and it is a list rather than a paragraph on purpose: a promise you cannot check is a slogan.</p><h2>What it costs to build this way</h2><p>Worth saying plainly, because an architecture described only by its benefits is a sales page.</p><ul><li><strong>Nothing can be measured until you ask.</strong> No load-time metrics, no first-paint capture, no watching a page settle — anything that happened before the click did not happen for us.</li><li><strong>Every session starts cold.</strong> The panel re-injects on each invocation and rebuilds its view of the page, which costs milliseconds a resident content script would not spend.</li><li><strong>Some things are simply impossible.</strong> A feature that needs to observe a page it was not invited into cannot exist here, and several proposed ones did not.</li><li><strong>It is harder to explain than to do.</strong> &quot;We only read the tab you asked about&quot; takes a paragraph; &quot;we can read every page&quot; takes none, and most users never read either.</li></ul><blockquote><p>The trade is accepted because the alternative is unfalsifiable. <mark class="bg-accent-light text-text">A promise about what an extension does with broad access cannot be checked by the person granting it.</mark> A permission list can.</p></blockquote><h2>How to check any of this</h2><p>Do not take the claim. The permission list on the store page is generated by the store from the manifest, not written by us, and it is visible before you install. <strong>If an extension's stated architecture and its permission list disagree, the list is the one telling the truth.</strong></p><blockquote><p><mark class="bg-accent-light text-text">That applies to this one as much as to any other.</mark></p></blockquote>]]></content:encoded>
    </item>
  </channel>
</rss>
