Tagskinning9

What a skin is, and what it is not

Two sites in the wild wear a look the engine never shipped: one dressed as a Hugo theme, one as Ghost. Neither forked the engine, neither edited a template, and both survive git pull untouched. This guide is what those two cost to build -- the mechanism first, then the three places where a skin and the engine can quietly disagree, and last the checks that would have caught every mistake made along the way.

It is not a CSS tutorial. Everything here is specific to this engine.

A skin is one stylesheet of yours, loaded after the engine's own, that repaints what is already on the page.

Before writing one, spend ten minutes with ./style.sh. A surprising amount of what looks like skinning is a setting: the colour palette (seven shipped, or your own), the banner and what is written over it, the menu, the sidebar and its widgets, whether a post opens with a lead image. A site that only wants different colours does not want a skin at all -- it wants a palette, and the palette keeps working when the engine changes.

Read more

Where the skin lives

site.extra_css in config/site.yml -- one path, or a list of them, loaded in order after colors.css and site.css:

site:
  extra_css:
    - "/assets/css/skin.css"

Local paths only. Every page carries style-src 'self', so a stylesheet on another host would be dropped by the browser with no error you would ever see -- the page would simply render undressed. The build refuses a remote path out loud instead of letting that happen quietly.

The file is yours, in your working copy, and the engine never writes to it. That is the whole reason this key exists: an edited site.css means a merge conflict on every update, and the first conflict you resolve badly is the one that silently drops a rule the engine added.

Read more

The one rule: your stylesheet loads last

Everything else in this guide follows from it. Loading last means your rules win at equal specificity -- including the rules the engine wrote for narrow screens.

That is the trap, and it does not look like one, because on the machine where the skin is written the phone is not on screen.

A worked example, from a real skin. The engine floats a table of contents beside the article and, under 700px, stops floating it: the chapters go back to being a block above the text, because at that width there is no room beside anything. A skin gave the contents a percentage width to match its own measure. On the desktop it was too wide and overhung the article. The fix -- a fixed width and a mirrored gutter -- made the desktop right and the phone absurd: the article came out 155px wide on a 390px screen, because the skin's percentage was still winning below 700px, where the engine had already said width: auto and meant it.

Wrap an override in the range where it applies

@media (min-width: 861px) {
  .toc { width: 350px; }
}
Read more

Styling one page of a listing

CSS cannot read an address, so for a stylesheet the front page and /page/2/ used to be the same document -- which is why the two skins here both wanted a lead card or a profile block on the first page and neither could have one.

Since 1.3.2 a listing's <body> says which it is:

.page-first .post-list-item:first-child { /* the lead card */ }
.page-cont  .archive-note { /* only on the continuations */ }

page-first is the page that lives at the listing's own address, page-cont is everything under /page/N/, and both are emitted for every listing -- the front page, a tag, a series, a content type. A post page carries a bare <body>, so a rule scoped to either class cannot leak onto one.

Two of them exist rather than one on purpose: a single mark on the continuations would force you to write the first page's look unconditionally and then take it back property by property, and a rule that undoes another is the kind a later change quietly stops undoing. Scope positively instead.

Read more

What the markup marks for you

A stylesheet can only style what it can select. Each of these was one run of text, or one element among identical ones, until a skin needed to tell the parts apart -- so each is marked. Most look exactly as they did until a rule of yours says otherwise; the page dates and the count are new words on the page, and site.css styles those two.

The parts of the series sentence. "Nový Sean.cz — part 3 of 19" is three spans: .series-note__name, .series-note__sep and .series-note__part. On the series' own listing every card repeats the name the heading has just said:

.listing-heading--series ~ .post-list-item .series-note__name,
.listing-heading--series ~ .post-list-item .series-note__sep { display: none; }

The joiner is a span of its own so that hiding the name does not leave a dash opening the line.

The year of an archive page. Its heading says a kind and a value, as a tag's and a series' do: .listing-heading__kind is the word "Archive", .listing-heading__value is the year, and the year alone is the link back to the map of years. Whatever sets the value apart on .listing-heading--tag now works on .listing-heading--archive too.

Read more

Structures the engine leans on

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.

Read more

Checking a skin

The failures above have one thing in common: each was invisible in the way the skin was being looked at.

Click every control at least once, for real. The theme switch, the menu button on a phone width, an image that opens the lightbox, the search field, the back-to-top button, and the share row under a post -- its Mastodon button opens a question with an input and an error line, and three of the controls are hidden until a script shows them. Setting state directly -- flipping a data attribute to photograph the dark theme -- proves the CSS and proves nothing about whether the control is reachable.

Measure at three widths, not one. A wide desktop, something around the breakpoint, and a phone. The engine's own reflow happens at 700px, so a skin that has never been seen between 700px and its own breakpoint has an untested range.

Look at both schemes. A skin that only defines colours for one of them inherits the other from the palette, which is usually fine and occasionally a white-on-white surprise.

Three traps in browser tooling, all of which have cost an afternoon here:

Read more

Two skins that exist

One engine, two skins
Read more

When a skin is the wrong tool

If you find yourself replacing structure rather than appearance -- moving regions, adding a block the engine does not build, needing markup that does not exist -- a stylesheet has stopped being the cheap option. At that point editing the template is more honest, and you should know the price you have just agreed to: every engine update can conflict there, and docs/decisions.md explains what the templates promise and what they do not.

The other honest answer is that the engine may simply be missing a setting. If a skin has to fight a region into hiding, say so -- a switch for a region is a reasonable thing to ask for, and cheaper for everyone than the CSS that fights it.