Structures the engine leans on

Skinning — part 6 of 9

Three rules in site.css are not decoration -- other things are built on top of them. A skin may change all three, but it should know what it is paying.

The bar at z-index 5, and the switch beneath it at 3

.wrap > nav is position: sticky with z-index: 5; the back-to-top button sits at 10 and the lightbox at 100, and the theme switch on the banner sits at 3.

Unstick the bar -- a perfectly reasonable thing for a skin to want -- and the theme switch can stop working while still looking fine. .wrap is a grid, and on a grid item z-index takes effect without position. The bar keeps its layer, its transparent padding still covers the corner where the switch sits, and every click lands on the bar instead. Nothing errors; the button simply does nothing.

If your skin moves the bar, give the switch a layer above it:

#theme-toggle { z-index: 6; }

This exact bug shipped, and it was reported by somebody else. It survived because the switch had been exercised by setting the theme directly rather than by clicking it -- see Checking a skin below.

The corner of the banner holds two controls, not one

On a site that publishes more than one language (site.locales), the banner's top right corner is a row, .banner-tools, holding the language switcher and the appearance button side by side. They are the same kind of thing -- a choice about this visit rather than a place to go -- so they are styled as one pair: two chips of the same height, the row stretching the shorter one so no number has to be kept in step.

.banner-tools          the row (absolute, top/right)
  a.lang-switch        the chip
    .lang-switch__item   a language; .is-current is the one being read
  #theme-toggle        the appearance button

A site with one language has no row: #theme-toggle stands in the corner on its own, absolute, exactly as before 1.9 -- so a skin that places it with top, right or a transform keeps working untouched.

Inside the row the ROW places the button, and the engine resets its top, right, bottom, left and transform there, at a weight a bare #theme-toggle { ... } in a skin does not outrank. Numbers written for the corner would otherwise move the button off its place beside the chip. So when a skin that places the button meets a site with languages, place .banner-tools with the same numbers instead -- and leave room for it: the row is wider than the button alone, and a menu that ran up to the button will run under the chip.

The chip is ONE link, not a link per language: a click anywhere on it moves to the next language the page is in and wraps around at the end, the way the button beside it cycles light → dark → system (a language that has nothing of the page is passed over, and carries .is-elsewhere). The codes inside it are spans, so style .lang-switch__item for how a language looks and a.lang-switch for how the control behaves.

The row deliberately has no z-index: with one it would become a stacking context, and #theme-toggle { z-index: 6 } -- which the section above tells you to write when your skin unsticks the bar -- would stop lifting the button above it. Both children carry z-index: 3 themselves instead. If your skin gives the row a layer, give the children one too.

Recolour the switcher with three custom properties rather than by restating the selectors; each falls back to what the button beside it uses, so setting none of them keeps the pair in step:

:root {
  --lang-switch-bg: var(--accent);            /* the chip */
  --lang-switch-text: #ffffff;                /* the language being read */
  --lang-switch-dim: rgba(255, 255, 255, .65); /* the others, and the / */
}

The chip is a <nav>, so the menu's own rules reach it -- gap, minimum height, borders. Its rule says all of them again; if you restyle it from scratch, say them again too, or it will quietly grow into a menu bar.

A header without its picture is the same header

With banner.show_image: false the markup is the banner's with the <img> taken out and nothing else moved:

.banner-wrap.banner-wrap--plain
  a.banner-link        empty, named by aria-label; laid over the whole header
  .banner-overlay      as before
    .banner-title
    .banner-claim
  .banner-tools / #theme-toggle

The stylesheet's own rules for it all hang on :where(.banner-wrap--plain), which weighs nothing: each is exactly as heavy as the rule it replaces, and your .banner-overlay, .banner-title or .banner-claim wins as it always has. The scrims are excluded the same way, so .banner-wrap:has(.banner-claim)::after { content: none; } in a skin still switches one off on a site that shows its picture.

A skin that already draws its own header -- .banner { display: none; }, its own layout for the overlay -- loses nothing by the setting and has nothing to change: the site looks the same, and its readers stop downloading a picture nobody showed them. If your skin leaves the header to the engine, .banner-wrap--plain is the class to hang a plain header's look on.

The excerpt is a positioning context

.content.excerpt is max-height: 500px; overflow: hidden; position: relative, with a gradient in ::after that fades the cut edge.

The position: relative is load-bearing for the engine, and inconvenient for a skin: an absolutely positioned figure inside a listing card anchors itself to the excerpt, not to the card. If your cards put an image at an edge, take the context off the excerpt in your own rules and turn the gradient off with it -- a hard cut with no fade is honest, and a fade that belongs to a box you no longer use is not.

max-width on a flex item is not a cap on its contents

Putting max-width on a flex item that also has flex-basis: 100% does not narrow the content -- it shrinks the item's hypothetical width, which can let the line fit beside the item before it. In one skin the article body ended up sitting next to its own meta line for exactly this reason.

Cap the children instead:

.content > * { max-width: 62ch; }

Something sticky is as wide as its column, and letters are not

A position: sticky heading with a background covers what scrolls under it -- across its own width, which is the column's. Type does not stay inside the column: the hook of a "j", an italic's lean, a hanging quote all reach a pixel or two past the edge, and those pixels scroll by beside the bar, uncovered. It reads as a rendering fault and is not one.

Carry the background past the column instead of trimming the text:

.archive-section h2 {
  position: sticky; top: 0;
  background: var(--card-bg);
  margin-inline: -0.5rem; padding-inline: 0.5rem;
}

Making a row one target usually means stretching its link over it: position: absolute; inset: 0 on the link, position: relative on the row. In a row that is laid out as a grid this covers one cell, not the row. An absolutely positioned child of a grid container that is placed in a grid area takes that AREA as its containing block, and a link you put in the title column is placed in one.

Take the placement off the stretched box:

.archive-list li { display: grid; position: relative; }
.archive-list li a::after { content: ""; position: absolute; inset: 0; grid-area: auto; }