<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>appearance – ./blog.sh</title>
    <link>https://blogsh.app/tag/appearance/</link>
    <atom:link href="https://blogsh.app/tag/appearance/rss.xml" rel="self" type="application/rss+xml" />
    <description>Posts tagged appearance on Daniel Šnor&#39;s personal site.</description>
    <language>en</language>
    <lastBuildDate>Sat, 19 Sep 2026 09:49:45 +0200</lastBuildDate>
    <item>
  <title>Whoever loads last wins</title>
  <link>https://blogsh.app/posts/2026/whoever-loads-last-wins/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/whoever-loads-last-wins/</guid>
  <pubDate>Sat, 19 Sep 2026 09:49:45 +0200</pubDate>
  <description><![CDATA[<p>Two real sites wear a look this engine never shipped. sh.cynicky.blog is dressed as Ghost — a full-width lead card, a dotted frame, tiles instead of a plain list. arch-linux.cz is dressed as a Hugo theme, Blowfish specifically — a compact bar, a profile block, cards laid out like someone else&#39;s homepage entirely. Neither forked the engine. Neither edited a template. Both still take a plain <code>git pull</code>.</p>
<h2 id="one-stylesheet-loaded-last">One stylesheet, loaded last</h2>
<p><code>site.extra_css</code> in <code>config/site.yml</code> names a path, or a list of them, loaded after the engine&#39;s own <code>colors.css</code> and <code>site.css</code>. That&#39;s the entire mechanism — a skin is one file of yours that repaints what&#39;s already on the page, and the engine never writes to it, so an update can never conflict with it.</p>
<p>Local paths only: every page carries <code>style-src &#39;self&#39;</code>, and a stylesheet on another host would just be dropped by the browser with no error anyone would ever see — the build refuses a remote path out loud instead, before that silence has a chance to happen.</p>
<p>Whether that file belongs in git is a separate decision — commit it and a second machine already has your look, exclude it and a public repository never carries a design that wasn&#39;t meant to be public. The engine&#39;s own <code>.gitignore</code> doesn&#39;t decide this for you either way.</p>
<h2 id="the-rule-everything-else-follows-from">The rule everything else follows from</h2>
<p>Loading last means your rules win at equal specificity — including the rules the engine wrote for narrow screens, which is not the part anyone expects to be winning against.</p>
<p>sh.cynicky.blog&#39;s skin floats a table of contents beside the article and gave it a percentage width to match its own measure. On a desktop that looked right. Below 700px, the engine has its own reflow: the contents drop above the text and the width rule becomes <code>auto</code>, because there&#39;s no room beside anything at that size any more. The skin&#39;s percentage didn&#39;t know that and kept applying anyway — loading last means winning always, not just when it&#39;s convenient to. The article came out 155 pixels wide on a 390-pixel phone screen.</p>
<p>The fix wraps the override in the range where it&#39;s actually meant to apply:</p>
<pre class="code-block"><code class="language-css">@media (min-width: 861px) {
  .toc { width: 350px; }
}</code></pre>
<p>Below that width, the skin says nothing, and the engine&#39;s own reflow is left to do the one job it was already doing correctly.</p>
<h2 id="what-the-engine-leans-on-without-saying-so">What the engine leans on without saying so</h2>
<p>Two smaller traps came out of the other skin, arch-linux.cz&#39;s, and both share the same shape: a rule that looks purely decorative turns out to be load-bearing somewhere else.</p>
<p>Unsticking the engine&#39;s nav bar is a reasonable thing for a skin to want. On arch-linux.cz it also made the theme-switch button stop responding to clicks — not because the button broke, but because the bar sat in a CSS grid, where <code>z-index</code> takes effect without needing <code>position</code> at all, and the unstuck bar kept its higher layer sitting invisibly on top of the button. The site&#39;s own admin found it by actually clicking the switch; nothing in a screenshot would ever have shown it.</p>
<p>A second, smaller one: <code>max-width</code> on a flex item with <code>flex-basis: 100%</code> shrinks the item&#39;s hypothetical width rather than capping its contents — which is how an article body once ended up sitting beside its own meta line instead of below it. The cap belongs on the children, not the box around them.</p>
<h2 id="one-page-of-a-listing-not-the-whole-feed">One page of a listing, not the whole feed</h2>
<p>A stylesheet can&#39;t read an address, so the front page and <code>/page/2/</code> used to be indistinguishable to CSS — which mattered the moment either skin wanted a lead card on the first page only. Since 1.3.2 every listing&#39;s <code>&lt;body&gt;</code> says which kind it is:</p>
<pre class="code-block"><code class="language-css">.page-first .post-list-item:first-child { /* the lead card */ }
.page-cont  .archive-note { /* only on the continuations */ }</code></pre>
<p>Two classes rather than one, on purpose — a single mark on the continuations would mean writing the first page&#39;s look unconditionally and then undoing it property by property, and a rule that undoes another rule is the kind a later edit quietly stops undoing.</p>
<figure><img src="https://blogsh.app/posts/2026/whoever-loads-last-wins/01.png" width="1280" height="1100" alt="sh.cynicky.blog" loading="lazy" decoding="async"></figure>
<p>That&#39;s sh.cynicky.blog above — full-width lead card, dotted frame, tiles instead of a plain list, and not one edited engine file behind any of it. Its search stays a field in the engine&#39;s own bar rather than the modal Ghost itself would give it, because rebuilding it as one would mean owning behaviour the engine never promised. Neither skin, this one or arch-linux.cz&#39;s, ever tried for a pixel-perfect copy of what it imitates. Fidelity was never the goal. Belonging to the family was.</p>
<p>A skin stops being the cheap tool the moment it needs markup the engine doesn&#39;t build — at that point editing the template is the honest choice, not a stylesheet fighting to fake one. Short of that line, though, none of this needed a theme system: no hooks, no registry, no hierarchy of overrides to keep straight. One fact carries the whole feature. Your file loads last.</p>
<p>—</p>
<p>P.S. — sh.cynicky.blog isn&#39;t where cynicky.blog itself lives. The real site is still on Ghost, updated most days; sh.cynicky.blog is the same look, kept as a working installation of its own. Its author looked at a real migration and chose to stay where he already was — worth saying plainly, rather than letting a domain name imply otherwise.</p>]]></description>
  <category>appearance</category><category>philosophy</category>
</item>
<item>
  <title>One line of colour</title>
  <link>https://blogsh.app/posts/2026/one-line-of-colour/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/one-line-of-colour/</guid>
  <pubDate>Tue, 08 Sep 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>A reader changed one border colour in <code>site.css</code>, ran a rebuild, and waited while the build produced, byte for byte, every page that was already there. He asked why. The answer was one word in one list, and 1.7 takes it out.</p>
<h2 id="what-a-build-writes-down">What a build writes down</h2>
<p>Since 1.6 the build keeps a record of what went into each page it wrote -- the post, the posts around it, the labels, the numbers a page counts. On the next build a page whose record has not moved is not rendered at all. Not rendered and then found identical: not rendered. The summary at the end says how many pages were left alone, the first number to look at when a build is slow.</p>
<p>The record holds only while the engine that wrote it is the same engine, so it hangs off a fingerprint of what the engine is made of -- the files under <code>templates/</code>, <code>lib/</code>, <code>build/</code>, <code>locales/</code> and <code>config/</code>, hashed by contents rather than by timestamp. Change any of it and the record is discarded entire, because any of it can change every page.</p>
<h2 id="why-the-colour-cost-the-archive">Why the colour cost the archive</h2>
<p>Until 1.7 that list also had <code>assets/</code> on it, and <code>assets/</code> is where the stylesheets live.</p>
<p>A page does not contain its stylesheet. It carries a <code>&lt;link&gt;</code> to one, and there is no <code>?v=</code> on that link -- a version baked into the URL would make every page differ on every CSS edit, the same problem wearing a different coat. So the bytes of a rendered page cannot move when <code>site.css</code> moves. The fingerprint was covering a file no page has ever held.</p>
<p>Measured in a copy of the engine, on a test site of 270 posts: a rebuild that changes nothing skips all 378 pages the cache is asked about, and adding a rule to <code>site.css</code> still skips 378. Put <code>assets</code> back into the fingerprint -- the 1.6 behaviour -- and the same edit skips none.</p>
<p>Nothing is given up. Files under <code>assets/</code> are compared with their published copies on every build, by a path that never consulted this record. The edit reaches the site as it always did, and now costs that one file and nothing else.</p>
<h2 id="what-is-still-fingerprinted-and-what-that-costs">What is still fingerprinted, and what that costs</h2>
<p><code>config/</code> stayed on the list, and the palette is one reason it had to.</p>
<p>The colours in <code>config/site.yml</code> are not read out of a stylesheet. They generate one -- <code>assets/css/colors.css</code> is written by the build, not copied from the tree -- and one of them reaches the pages a second way: the light and dark <code>theme-color</code> in the head of every page the layout renders is the palette&#39;s background. Change <code>bg</code> and every one of those pages really is different. Rebuilding them is the record being right.</p>
<p>Change <code>accent</code> and no page moves. It lives only in the generated stylesheet, and the site is rebuilt all the same: the fingerprint is taken over whole trees rather than the keys inside them, so <code>config/site.yml</code> moving throws <code>config/</code> away. On the same test site that edit skipped nothing and rendered all 378. It is the cost this release took off <code>assets/</code>, kept on purpose in a smaller place -- a tree is a coarse thing to hash, and coarse errs towards work. A tree belongs in the fingerprint when a page can hold its contents; linking is not holding.</p>
<h2 id="when-the-record-is-not-believed">When the record is not believed</h2>
<ul><li>The first build after an upgrade. This release changed <code>lib/build_cache.rb</code>, and <code>lib/</code> is in the fingerprint, so the first build on 1.7 renders everything. An engine that worked out which pages a template edit reaches would be wrong eventually, and wrong here means a live site serving stale markup with nothing saying so.</li><li>The facts the build hands to the record by name rather than by hashing a tree: the menu, which content types the archive holds, the address it is published at, and the timezone -- named by the contents of its zoneinfo file, so a system update that rewrites the rules of your zone throws the record away rather than leaving old times on skipped pages.</li><li>Any page whose published file has been deleted, or has moved in size or timestamp since the build wrote it. The record is checked against what is on disk, never only against itself.</li><li>A record that is missing, half-written or unreadable. Deleting <code>.build_cache.json</code> is always safe and costs one slow build.</li><li>A build that died halfway. It writes no record at all, because a record of a site half written would make the next build skip the half that never got written.</li></ul>
<p><code>./blog.sh rebuild --full</code> is the switch for the rest: render everything, compare everything, believe nothing. It is the escape hatch, and it is how the record is tested: a cached build and a full build over the same content must produce byte-identical trees.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>With no version in its URL, getting a changed stylesheet to a reader is the host&#39;s job: it works where files carry an ETag and are revalidated on each load, and a host that caches hard will serve the old one until it expires. And backdating is untouched by this: a post dated 2003 shifts every listing page between the front and where it lands, and those pages really did change.</p>]]></description>
  <category>build</category><category>appearance</category>
</item>
<item>
  <title>A bicycle nobody had to draw</title>
  <link>https://blogsh.app/posts/2026/a-bicycle-nobody-had-to-draw/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-bicycle-nobody-had-to-draw/</guid>
  <pubDate>Mon, 07 Sep 2026 16:12:00 +0200</pubDate>
  <description><![CDATA[<p>The example configuration this engine ships carries a bicycle: a tag called <code>kolo</code>, and under it an <code>&lt;svg&gt;</code> written out by hand. It is there because there was nothing else to put there. <code>tag_icons</code> took the name of an icon the engine ships or a drawing of your own, and the ones it shipped were the eight content types — text, image, video, link, audio, quote, document, chat. A tag about cycling could wear the glyph for &quot;audio&quot;, or somebody could spend an evening with a coordinate grid.</p>
<p>1.7 puts fifty-five drawings in between, and gives each of them a name.</p>
<h2 id="fifty-five-nouns">Fifty-five nouns</h2>
<p>Counted in <code>lib/icons.rb</code>: the eight content types, fifty-five more, sixty-three names a tag may ask for. Grouped by what a blog turns out to be about.</p>
<figure><img src="https://blogsh.app/posts/2026/a-bicycle-nobody-had-to-draw/01.png" width="1000" height="660" alt="A sheet of the fifty-five drawings, each with its name" loading="lazy" decoding="async"><figcaption>The whole set, by name. Drawn on the same grid and stroke as the eight the engine has always shipped.</figcaption></figure>
<ul><li>getting about — <code>bike</code> <code>car</code> <code>train</code> <code>plane</code> <code>boat</code> <code>walk</code></li><li>places and weather — <code>map</code> <code>pin</code> <code>mountain</code> <code>tree</code> <code>sun</code> <code>cloud</code> <code>rain</code> <code>snow</code></li><li>a day — <code>coffee</code> <code>beer</code> <code>food</code> <code>wine</code> <code>clock</code> <code>calendar</code> <code>home</code> <code>heart</code> <code>star</code> <code>gift</code></li><li>making things — <code>pen</code> <code>brush</code> <code>camera</code> <code>film</code> <code>mic</code> <code>music</code> <code>book</code> <code>tools</code> <code>hammer</code></li><li>machines — <code>laptop</code> <code>phone</code> <code>code</code> <code>terminal</code> <code>server</code> <code>bug</code> <code>lock</code> <code>key</code></li><li>living things — <code>paw</code> <code>bird</code> <code>leaf</code> <code>flower</code></li><li>ideas — <code>bulb</code> <code>flag</code> <code>globe</code> <code>eye</code> <code>chart</code> <code>target</code> <code>rocket</code> <code>mail</code> <code>briefcase</code> <code>box</code></li></ul>
<p>Plain English, and the word somebody reaches for first: <code>bike</code>, not <code>bicycle</code>. A footer link under <code>social:</code> can wear one too, where the network marks have nothing to offer: <code>globe</code> for somebody&#39;s other site.</p>
<h2 id="why-they-sit-beside-the-eight">Why they sit beside the eight</h2>
<p>The same 24-unit grid, the same two-unit stroke, the same <code>currentColor</code> — the part that lets one drawing follow a light theme and a dark one without a second copy. All fifty-five carry round ends and round joins, because they are pictures of things rather than diagrams and a mitred corner on a two-unit stroke reads as a spike at twenty pixels. <code>doctor</code> holds a hand-written <code>icon_svg</code> to that same grid.</p>
<h2 id="where-it-turns-up">Where it turns up</h2>
<p>Two places. The heading of the tag&#39;s own <code>/tag/&lt;name&gt;/</code> listing, and the date badge of every post carrying that tag — where it replaces the content type&#39;s icon rather than joining it. A badge is a small tile with a date and one glyph; the only thing that ever adds a second is a pinned post, and a listing stacks ten badges under each other.</p>
<p>A tag is matched by its address rather than its spelling. <code>Sci Fi</code> in <code>site.yml</code> reaches posts tagged <code>sci-fi</code> and posts tagged <code>sci_fi</code>, because those are one page and therefore one tag.</p>
<h2 id="the-order-in-the-list-is-the-priority">The order in the list is the priority</h2>
<p><code>tag_icons</code> is a list, and the order in it decides. Most posts carry more than one tag, and the tag a post was given first is usually whatever an importer put there rather than a subject — so the first entry in the list that a post has is the one it wears, settled once instead of post by post. A post tagged <code>sci-fi, coffee</code> wears coffee if coffee stands higher, whatever order the post wrote them in.</p>
<p>Written as a mapping — <code>kolo: bike</code> indented underneath, the shape half of <code>site.yml</code> is in — it draws nothing, and it always did: until now with no warning from the build, and <code>doctor</code> calling the file sound. Both name the key now, and <code>doctor</code> counts it a problem and exits 1.</p>
<h2 id="what-it-refuses-to-do">What it refuses to do</h2>
<p>It is not a folder to drop drawings into. Everything under <code>assets/</code> is copied onto the site wholesale, so a folder of fifty would publish fifty files to serve a site that uses two — and an icon is written into the page rather than fetched, so nothing would ever ask for them. The set is a Ruby hash for that reason.</p>
<p>The eight content names cannot grow without a content type behind them. And an <code>icon_svg</code> that is not a drawing at all — a filename, an address, an emoji — is refused rather than printed where the glyph goes; the tag falls back to what it would have had with no entry.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>A name the engine does not have draws nothing, and the build does not mention it: configure <code>bicycle</code> instead of <code>bike</code> and the site builds, exits zero, and every post that should have carried a bicycle keeps its content type&#39;s icon. <code>doctor</code> is what says so: it names the tag, the name it asked for, and prints all sixty-three, eight to a row — then exits zero itself, because a tag without a picture is not a broken site.</p>
<p>And the drawing goes into the page. The middling one is a shade over 250 bytes of markup, written out again for every badge that wears it: no request for it, and no way to have it once. The whole set comes to about fifteen kilobytes, and none of it reaches the site — only the drawings actually used.</p>
<p>Most of the drawings in that file are this project&#39;s own. Several are the shape any icon set converges on for an obvious thing — the pen, the spanner, the speech bubble on a chat post — and those are the same paths as Feather Icons, MIT licensed, copyright Cole Bemis. <code>NOTICE</code> says so and carries the licence, which is what a repository owes the people it borrowed from.</p>]]></description>
  <category>appearance</category><category>content</category><category>release</category>
</item>
<item>
  <title>A badge that says what a post is about</title>
  <link>https://blogsh.app/posts/2026/a-badge-that-says-what-a-post-is/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-badge-that-says-what-a-post-is/</guid>
  <pubDate>Sat, 05 Sep 2026 11:06:00 +0200</pubDate>
  <description><![CDATA[<p>Every post wears a date badge with an icon in it, and the icon says what kind of post it is: text, a picture, a video, a quote. On a blog where nearly everything is text, that icon is the same on nearly every card. Somebody running this engine put it plainly in issue #45: one sees the generic icon so often that it becomes invisible, or clutter.</p>
<p>1.6 lets a tag carry an icon instead.</p>
<pre class="code-block"><code class="language-yaml">tag_icons:
  - tag: &quot;build&quot;
    icon_svg: &#39;&lt;svg viewBox=&quot;0 0 24 24&quot; …&gt;…&lt;/svg&gt;&#39;
  - tag: &quot;comments&quot;
    icon: &quot;chat&quot;</code></pre>
<h2 id="it-replaces-rather-than-adds">It replaces rather than adds</h2>
<p>On a post&#39;s date badge the tag&#39;s icon takes the place of the content-type icon; in the heading of <code>/tag/&lt;name&gt;/</code> it takes the place of the generic tag icon. That was the requester&#39;s first point, and the one worth asking about rather than guessing: two icons side by side on a 60-pixel badge is not a badge, it is a puzzle. A tag with no entry changes nothing, so the generic icon is still there for everything you have not named.</p>
<h2 id="the-order-is-the-priority">The order is the priority</h2>
<p>Most posts carry more than one tag — 68% of them on the archive this was measured against — so <code>tag_icons</code> is a list, and the first entry a post matches is the one it wears. That rule was chosen because the tag a post was given first is usually an importer&#39;s rather than a subject: <code>twitter</code> alone opens 1,256 posts on that archive. The order in your config is your order, not the post&#39;s.</p>
<h2 id="eight-by-name-or-your-own">Eight by name, or your own</h2>
<p><code>icon</code> names one of the eight the engine ships — text, quote, chat, image, video, audio, link, document. <code>icon_svg</code> is yours: drawn on the same 24-unit grid, stroked in <code>currentColor</code> so it follows the light and dark themes, with scripts, styles and event handlers stripped before it reaches a page. <code>doctor</code> checks every entry: whether the name is one the engine has, whether a hand-written SVG contains an <code>&lt;svg&gt;</code> at all, and whether it is drawn to the same scale as the rest. An unknown name used to draw an empty space and say nothing.</p>
<h2 id="on-this-site-since-today">On this site, since today</h2>
<p>The seven tags in this site&#39;s menu have icons now — a bulb for Why, a pen for Writing, a box for Archive, a spanner for Engine, the engine&#39;s own chat icon for Comments, an eye for Looks, a package for Releases. Look at the badge on the left of this post, then at the badges on the front page, and you will see the rule at work: a post tagged <code>appearance, content</code> wears the eye, because <code>appearance</code> is named and <code>content</code> is not.</p>
<p>The whole thing was asked for by one person, shaped by his three points, and shipped with the request still open so he could say where it was wrong. He did not have to.</p>]]></description>
  <category>appearance</category><category>content</category>
</item>
<item>
  <title>Where a reader carries a post</title>
  <link>https://blogsh.app/posts/2026/where-a-reader-carries-a-post/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/where-a-reader-carries-a-post/</guid>
  <pubDate>Fri, 04 Sep 2026 16:52:00 +0200</pubDate>
  <description><![CDATA[<p>A post gets read, and then it goes somewhere: into a toot, a message, an email to one person. Until now the reader did that by copying the address out of the bar. 1.6 puts a row of controls under the post for it — off unless <code>share:</code> names what you want, drawn in the order you name it.</p>
<pre class="code-block"><code class="language-yaml">share:
  - mastodon
  - bluesky
  - email
  - copy
  - system</code></pre>
<h2 id="what-is-prefilled-and-what-is-not">What is prefilled, and what is not</h2>
<p>The post&#39;s name and its address, where the target takes both. Facebook and LinkedIn take the address alone and read the name off the page. The reader writes the part that is theirs. <code>bluesky</code>, <code>email</code>, <code>facebook</code>, <code>linkedin</code>, <code>threads</code> and <code>x</code> are plain links, one address each, no script behind them.</p>
<h2 id="three-that-are-not-links">Three that are not links</h2>
<p>The fediverse has as many addresses as it has instances, and a page cannot know which one is the reader&#39;s. So <code>mastodon</code> is a button that asks — in a row that opens under the controls, in the site&#39;s own type — and remembers the answer in that browser afterwards. Nothing about the reader leaves the page; the instance name is kept where they typed it.</p>
<p><code>copy</code> puts the address on the clipboard. <code>system</code> hands the post to the operating system&#39;s own share sheet, which on a phone is Signal and WhatsApp and Telegram at once, and on a desk is whatever the system offers.</p>
<p>Those three appear only where they can work. A browser without a clipboard API gets no copy button, a desk without a share sheet gets no system button, and a block left with nothing to draw hides itself rather than standing a heading over an empty row.</p>
<h2 id="why-there-is-no-pixelfed">Why there is no Pixelfed</h2>
<p>Pixelfed has no address a page can hand a post to. A button that opened nothing would be worse than none, so there is none, and the changelog says so under &quot;not fixed, on purpose&quot; rather than leaving you to wonder.</p>
<h2 id="one-new-file">One new file</h2>
<p><code>assets/js/share.js</code> is a file every page now loads. On a site that never sets <code>share:</code> it does nothing at all; it is still a file, and a deploy that misses it leaves a 404 in the console. <code>git pull</code>, rebuild, deploy — and it is there.</p>
<p>The controls make no request to anyone until the reader presses one. That is the rule for everything the engine puts on a page, and this row is no exception: the share buttons of the web are usually the one thing on a page that phones home before you touch them, and these do not.</p>]]></description>
  <category>appearance</category><category>comments</category>
</item>
<item>
  <title>The card that reads Codeberg</title>
  <link>https://blogsh.app/posts/2026/the-card-that-reads-codeberg/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-card-that-reads-codeberg/</guid>
  <pubDate>Wed, 26 Aug 2026 10:31:00 +0200</pubDate>
  <description><![CDATA[<p>The sidebar has a commits card. Until now it read GitHub, full stop — which is a strange default for an engine whose natural habitat is the Fediverse, where the code lives on Codeberg or on somebody&#39;s own Forgejo, and GitHub is the mirror you keep for one widget&#39;s sake.</p>
<p>One of the first people outside this project to run the engine asked the obvious question, and the answer shipped: <code>widgets.commits.instance</code> takes the server&#39;s address, and that is the whole configuration.</p>
<h2 id="one-key-not-two">One key, not two</h2>
<p>There is no <code>forge_type</code> beside it. An address already answers what kind of host it is — Gitea and Forgejo speak the same API, and a key that must agree with another key is a config mistake waiting for its moment. Leave <code>instance</code> out and the card reads GitHub, exactly as before.</p>
<h2 id="cheaper-than-the-original">Cheaper than the original</h2>
<p>The forge path costs one request where GitHub costs one per commit, because a Gitea activity item carries the commits it is about — message and timestamp included. A card on your own Forgejo is lighter on your own server than the GitHub card ever was on theirs.</p>
<h2 id="the-mistake-is-caught-standing-up">The mistake is caught standing up</h2>
<p>An empty commits card is indistinguishable from &quot;has not pushed lately&quot;, so <code>doctor</code> checks the address before that ambiguity can start: a pasted @handle or a bare host name is refused with a sentence, and an address with a path after it is flagged as worth checking — with the API endpoint that settles it. <code>./style.sh</code> asks the new question too, on configs written before this key existed as well.</p>]]></description>
  <category>appearance</category>
</item>
<item>
  <title>A skin that survives git pull</title>
  <link>https://blogsh.app/posts/2026/a-skin-that-survives-git-pull/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-skin-that-survives-git-pull/</guid>
  <pubDate>Thu, 20 Aug 2026 16:42:00 +0200</pubDate>
  <description><![CDATA[<p>Dressing a site differently used to mean editing a file the engine owns. It worked, right up until the next <code>git pull</code>, which is the moment you find out that customisation and updates were the same file all along.</p>
<p>Four things that needed a modified template are settings now.</p>
<p>Both of these sites are this engine, built from the same commit on the same evening:</p>
<div class="photo-grid"><figure><img src="https://blogsh.app/posts/2026/a-skin-that-survives-git-pull/01.jpg" width="1280" height="1100" alt="blogsh.app, wearing nothing but the defaults" loading="lazy" decoding="async"></figure><figure><img src="https://blogsh.app/posts/2026/a-skin-that-survives-git-pull/02.jpg" width="1280" height="1100" alt="sh.cynicky.blog: a stylesheet of its own, no sidebar, a lead image on every post and a menu it named itself" loading="lazy" decoding="async"></figure></div>
<p>The second one uses all four of the settings below and not one edited engine file — <code>git status</code> in that installation is empty, which is the entire point of the exercise.</p>
<h2 id="your-own-stylesheet">Your own stylesheet</h2>
<p><code>site.extra_css</code> takes a path, or a list of them, loaded after the engine&#39;s own. A skin lives in a file of yours and updates never see it.</p>
<pre class="code-block"><code class="language-yaml">site:
  extra_css: /assets/css/mine.css</code></pre>
<p>Local paths only. Every page carries <code>style-src &#39;self&#39;</code>, so a stylesheet on another host would be discarded by the browser with no error you would ever notice — the page would simply render undressed. The build refuses it out loud instead, and <code>doctor</code> names the line it had to skip.</p>
<h2 id="your-own-menu">Your own menu</h2>
<p>Without <code>nav:</code> the bar is derived from the content types that actually have posts, exactly as before. With it, the site says what belongs there:</p>
<pre class="code-block"><code class="language-yaml">nav:
  - { label: &quot;Home&quot;, url: &quot;/&quot; }
  - { label: &quot;Photographs&quot;, tag: &quot;photo&quot; }</code></pre>
<p>An entry missing either half is skipped rather than rendered as an empty link. An empty list is a decision rather than a mistake — the menu then renders nothing at all, which is also how a site turns the menu off, so there is no second key for that.</p>
<p><code>./style.sh</code> edits it and does the tedious part: it offers your busiest tags and your pages by name, so you pick rather than type slugs. <code>doctor</code> reports an entry whose target has since gone missing, and the <code>url: &quot;about&quot;</code> spelling that works from the front page and nowhere else.</p>
<h2 id="two-regions-on-or-off">Two regions, on or off</h2>
<p><code>layout.sidebar</code> switches the right-hand column off and lets the content take the full width. <code>layout.hero</code> lifts a post&#39;s first usable image out of the text and runs it above the title — off unless you ask, because it reshapes every post page it touches, and a single post can still decide for itself with <code>hero:</code> in its own header. A tracking pixel is never promoted to lead picture.</p>
<p>They are switches for regions, not for how a region looks. What things look like belongs in a stylesheet, and a key per visual property would turn the config into a stylesheet written in YAML.</p>
<h2 id="all-of-it-from-the-wizard">All of it from the wizard</h2>
<p><code>./style.sh</code> grew a Layout section, so palette, banner, fonts, menu, regions and your own stylesheets are one conversation. A site can now be dressed entirely from the wizard, which was the whole point of making these settings rather than documentation about which template to edit. Entering a section and pressing through it changes nothing.</p>
<h2 id="the-one-thing-that-changed-without-being-asked">The one thing that changed without being asked</h2>
<p>The menu bar follows you down the page now. It took over the job of the menu that used to be repeated under the content — which only existed because the bar didn&#39;t — so that one is gone, key and all. Two copies of the same menu on one screen is not a preference.</p>
<p>The line that menu used to draw above the footer is drawn without it, and anything that scrolls a target into view now stops short of the bar rather than under it.</p>]]></description>
  <category>appearance</category>
</item>
<item>
  <title>A palette you can look at first</title>
  <link>https://blogsh.app/posts/2026/a-palette-you-can-look-at-first/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-palette-you-can-look-at-first/</guid>
  <pubDate>Thu, 13 Aug 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p>An earlier post here explains that every colour on this site comes from seven keys in a config file. That part hasn&#39;t changed. What has changed is that you no longer have to imagine what those seven values will look like before you commit to them.</p>
<p>Choosing between fourteen hex values is exactly as blind in a wizard as it is in YAML. So <code>./style.sh</code> shows you.</p>
<h2 id="seven-palettes-one-keystroke">Seven palettes, one keystroke</h2>
<p>Whole palettes now ship in <code>config/palettes.yml</code>, light and dark both:</p>
<ul><li><b>Default blue</b>, <b>warm</b>, <b>monochrome</b> and <b>high contrast</b> — the four from the gallery on this site, whose light modes are exactly what that page showed. Their dark modes are new; the gallery only ever had light homepages.</li><li><b>Sunflower</b>, cream and gold with olive links — the golden yellow stays on the tag pills, because yellow text on cream cannot be read and the accent here is above all a text colour.</li><li><b>Garden</b>, greens and khaki.</li><li><b>Ocean</b>, steel blue over navy.</li></ul>
<p>Those last three come from the TangerineUI Classic family this engine&#39;s own palette grew out of — its bluebird <i>is</i> the default palette here, to the last hex value.</p>
<div class="photo-grid"><figure><img src="https://blogsh.app/posts/2026/a-palette-you-can-look-at-first/01.png" width="1024" height="880" alt="Homepage in the Sunflower palette" loading="lazy" decoding="async"><figcaption>Sunflower — cream and gold, olive links</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/a-palette-you-can-look-at-first/02.png" width="1024" height="880" alt="Homepage in the Garden palette" loading="lazy" decoding="async"><figcaption>Garden — pale green page, khaki nav, grass-green links</figcaption></figure><figure class="span-2"><img src="https://blogsh.app/posts/2026/a-palette-you-can-look-at-first/03.png" width="1024" height="880" alt="Homepage in the Ocean palette" loading="lazy" decoding="async"><figcaption>Ocean — steel blue on cool grey, navy in the dark</figcaption></figure></div>
<p>Adding your own is adding an entry to that file. The wizard lists whatever it finds, and a palette you add needs no translation to show up.</p>
<h2 id="then-it-shows-you-the-site">Then it shows you the site</h2>
<p>Pick one — or finish the fourteen-value custom route — and you are offered a preview: <b>your own built site in the candidate colours</b>, light and dark side by side. On a fresh install with nothing to render yet, a bundled sample post goes through the real builder instead.</p>
<p>On a deployed site the preview travels the way a draft preview does. It is uploaded to your own <code>/palette-preview.html</code>, and the wizard answers with the full address and a QR code — so a palette chosen at an SSH prompt can be judged on the phone in your pocket, on the screen people will actually read it on.</p>
<figure><img src="https://blogsh.app/posts/2026/a-palette-you-can-look-at-first/04.jpg" width="1945" height="2400" alt="A tablet held in one hand shows the palette preview — the same blog in light and dark mode side by side — with the terminal and its QR code on the laptop screen behind." loading="lazy" decoding="async"><figcaption>Chosen at an SSH prompt, judged in the hand</figcaption></figure>
<p>Locally it lands in <code>tmp/palette-preview.html</code> and opens in a browser where there is one.</p>
<p>The colours go through the same code the build uses. That is not a detail: <code>lib/colors_css.rb</code> was extracted for exactly this, so a preview cannot drift from what a rebuild would produce. A preview that lies is worse than no preview.</p>
<p>Nothing is written until the usual confirmation.</p>
<h2 id="the-banner-section-measures-for-you">The banner section measures for you</h2>
<p>Give it the path to an image and it copies the file into place and <b>measures it</b>. <code>banner.width</code> and <code>banner.height</code> exist to reserve layout space before the image loads; they have always been copied by hand, and a stale pair makes every page jump as it loads.</p>
<h2 id="why-this-is-its-own-wizard">Why this is its own wizard</h2>
<p><code>setup.sh</code> and <code>style.sh</code> both write <code>config/site.yml</code>. They are split by lifecycle, not by file: setup asks the things you answer once, this is everything you come back and fiddle with — palette, banner, about text, footer, social icons, sidebar widgets, fonts, analytics.</p>
<p>So it is a menu you dip into, not a form you complete. Which is the honest shape for the part of a site nobody ever finishes adjusting.</p>]]></description>
  <category>appearance</category>
</item>
<item>
  <title>Seven keys</title>
  <link>https://blogsh.app/posts/2026/seven-keys/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/seven-keys/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:29 +0000</pubDate>
  <description><![CDATA[<p>There is not a single colour value in this site&#39;s stylesheet.</p>
<p>Every colour you&#39;re looking at right now — background, text, links, the navigation, the little tag pills — comes from seven keys in <code>config/site.yml</code>. The build compiles them into a <code>colors.css</code> file, and <code>site.css</code> just uses the variables. Change seven lines, rebuild, and the site is someone else entirely.</p>
<p>Here&#39;s a complete light-mode palette:</p>
<pre class="code-block"><code class="language-yaml">colors:
  light:
    bg: &quot;#f5f8fa&quot;
    text: &quot;#444a5a&quot;
    meta_text: &quot;#657784&quot;
    accent: &quot;#1da1f2&quot;
    nav_bg: &quot;#eaf5fd&quot;
    border: &quot;#e1e8ed&quot;
    pill_bg: &quot;#d6ecfc&quot;</code></pre>
<p>And here&#39;s the same homepage in five palettes — nothing changed between these shots except seven lines of YAML and a rebuild:</p>
<div class="photo-grid"><figure><img src="https://blogsh.app/posts/2026/seven-keys/01.png" width="1024" height="880" alt="Homepage in the default blue palette" loading="lazy" decoding="async"><figcaption>Default blue — the palette this site ships with</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/seven-keys/02.png" width="1024" height="880" alt="Homepage in a warm cream and terracotta palette" loading="lazy" decoding="async"><figcaption>Warm — cream, brown, terracotta accent</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/seven-keys/03.png" width="1024" height="880" alt="Homepage in a monochrome palette" loading="lazy" decoding="async"><figcaption>Monochrome — the accent is just a darker grey</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/seven-keys/04.png" width="1024" height="880" alt="Homepage in a high-contrast palette" loading="lazy" decoding="async"><figcaption>High contrast — black on white, hypertext blue</figcaption></figure><figure class="span-2"><img src="https://blogsh.app/posts/2026/seven-keys/05.png" width="1024" height="880" alt="Homepage in a deliberately hideous palette" loading="lazy" decoding="async"><figcaption>Deliberately hideous — lime, purple, magenta, orange</figcaption></figure></div>
<p>The hideous one is the proof, by the way: if the config can make it <i>that</i> ugly, the config really is in charge.</p>
<h2 id="what-you-don-t-configure">What you don&#39;t configure</h2>
<p>Everything else is derived: card backgrounds, link hover states, the search field, code block backgrounds. Those aren&#39;t extra config keys, because in practice they never varied independently — every time I tried a palette, the derived values followed the base ones anyway. Seven keys per mode is the honest number of decisions involved.</p>
<p>Dark mode is the same seven keys again, so light and dark are two palettes, not one palette and a filter.</p>
<h2 id="two-exceptions-on-purpose">Two exceptions, on purpose</h2>
<p>The banner can optionally override its title and claim colours — <code>banner_title</code> and <code>banner_claim</code> — because a banner is an image, and text sitting on an image plays by different rules than text on a flat background.</p>
<p>And one small thing I like too much not to mention: the scrim that keeps banner text readable only darkens the corner the text actually sits in. Turn the overlays off and your banner stays exactly as you drew it, corner to corner.</p>]]></description>
  <category>appearance</category>
</item>

  </channel>
</rss>
