<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>wrkstdy · blog</title>
    <link>https://wrkstdy.com/blog/</link>
    <description>Writing and dev log entries.</description>
    <language>en-US</language>
    <lastBuildDate>Tue, 27 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://wrkstdy.com/blog/feed.xml" rel="self" type="application/rss+xml"></atom:link>
    <item>
      <title>plugh, tracking voice and tone</title>
      <link>https://wrkstdy.com/blog/plugh-tracking-voice-and-tone/</link>
      <guid>https://wrkstdy.com/blog/plugh-tracking-voice-and-tone/</guid>
      <pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate>
      <description>Why I split my writing rules into small chunks, routed by what&#39;s being written, so AI only reads the ones that apply. Plus the checks that catch my usual slips, and where they don&#39;t.</description>
      <content:encoded>&lt;p&gt;Two weeks ago, alongside the first post here, I started a file called &lt;code&gt;notes/style-guide.md&lt;/code&gt;. It was a scratchpad: a handful of rules about dashes, slashes, and the word &amp;ldquo;markdown&amp;rdquo;, and two of them ended with &amp;ldquo;Common slip of mine.&amp;rdquo; It wasn&amp;rsquo;t a style guide yet, just a list of things I either got wrong or disagreed with in how AI wrote them.&lt;/p&gt;&#xA;&lt;p&gt;Admittedly, most of what I write here now involves AI, either drafting from my notes or reviewing what I wrote. The rules matter in both directions: they keep me honest when AI reviews a draft, and they guide it when it writes off of my notes. That&amp;rsquo;s where the scratchpad started to strain. A notes file is easy for me to keep in my head. For AI, it either gets pasted in whole every time, or it doesn&amp;rsquo;t get read at all.&lt;/p&gt;&#xA;&lt;p&gt;Two things pushed it from a notes file to a tool. I knew I&amp;rsquo;d have other projects needing copy soon, each with its own voice and readers, and a scratchpad in this repo wasn&amp;rsquo;t going to follow me there. And I&amp;rsquo;d been reading Little Plains&amp;rsquo; &lt;a href=&#34;https://agentic.littleplains.com/&#34;&gt;Agentic Systems Are the Future of Brands&lt;/a&gt;. It describes a brand kit written for people and agents alike, with a readme that points each job at the files it needs. The line that stuck with me: &amp;ldquo;It doesn&amp;rsquo;t need every file every time, but it does need to know where to start.&amp;rdquo; I wanted a practical way to apply that kind of focused prompting to my own writing, across this blog and whatever came next.&lt;/p&gt;&#xA;&lt;p&gt;So I built &lt;a href=&#34;https://github.com/stevenosloan/plugh&#34;&gt;plugh&lt;/a&gt;: a small tool for keeping voice and tone rules in pieces, and handing AI only the pieces that apply to what it&amp;rsquo;s writing. This post covers why I split the guide up, how the pieces get routed, the checks that catch my usual slips, where those checks still get it wrong, and how a draft gets from AI to done.&lt;/p&gt;&#xA;&lt;h2 id=&#34;one-file-doesnt-fit-every-page&#34;&gt;One file doesn&amp;rsquo;t fit every page&lt;/h2&gt;&#xA;&lt;p&gt;The site has more than one kind of writing. A blog post is for a developer who found it through a search and skims the headings. A recipe is for someone standing at the counter with wet hands who wants succinct steps. A front matter description is one or two sentences on the homepage.&lt;/p&gt;&#xA;&lt;p&gt;Some rules hold for all of them: write as &amp;ldquo;I&amp;rdquo;, say the specific thing, explain the why. But &amp;ldquo;open on a concrete moment&amp;rdquo; is wrong for recipe steps, and &amp;ldquo;one step per line, amounts first&amp;rdquo; is wrong for a post. One long guide means every piece of writing carries rules for every other kind.&lt;/p&gt;&#xA;&lt;p&gt;Splitting it up helps in two ways. For me, it means I can write a rule for the situation it&amp;rsquo;s actually about, instead of hedging it to cover every kind of page. For AI, it keeps the context lean and focused: it reads the rules for what it&amp;rsquo;s writing, and nothing else to weigh against them. A recipe rule can&amp;rsquo;t pull a blog post off course if it never gets loaded.&lt;/p&gt;&#xA;&lt;p&gt;I&amp;rsquo;ll admit I never got as far as the one big file. The scratchpad was still small when I split it up. But between the Little Plains piece and knowing that every rule rides along with every request, paid for in tokens each time, it seemed worth tackling before the guide grew rather than after. (so no dead end here, just a cost I decided not to wait for)&lt;/p&gt;&#xA;&lt;h2 id=&#34;chunks-and-a-router&#34;&gt;Chunks, and a router&lt;/h2&gt;&#xA;&lt;p&gt;In plugh, each rule set is a &lt;em&gt;chunk&lt;/em&gt;: a short markdown file in &lt;code&gt;voice/&lt;/code&gt;, like &lt;code&gt;voice/punctuation.md&lt;/code&gt; or &lt;code&gt;voice/recipe-steps.md&lt;/code&gt;. Each rule in a chunk says the rule, why it exists, and what to do when unsure. The &amp;ldquo;when unsure&amp;rdquo; line ended up being the part I like best, because it&amp;rsquo;s what lets the rule stretch to a case I didn&amp;rsquo;t think of.&lt;/p&gt;&#xA;&lt;p&gt;A &lt;code&gt;voice.yaml&lt;/code&gt; at the project root routes chunks to content by path. Here&amp;rsquo;s the part of mine for blog posts:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;types:&#xA;  blog:&#xA;    match: content/blog/&#xA;    always:&#xA;      - voice-core&#xA;      - punctuation&#xA;      - word-choice&#xA;      - blog-post&#xA;    if_relevant:&#xA;      - photo-captions&#xA;    budget: 1500 # words&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;always&lt;/code&gt; chunks load every time. &lt;code&gt;if_relevant&lt;/code&gt; chunks show up as a one-line summary, and only get loaded when they apply, like the captions chunk for a post that has photos. A type doesn&amp;rsquo;t have to be a folder, either. The front matter description is a type of its own, routed by the job instead of a path.&lt;/p&gt;&#xA;&lt;p&gt;Running &lt;code&gt;go tool plugh context&lt;/code&gt; on this post prints exactly what AI reads before writing it: who&amp;rsquo;s writing, who it&amp;rsquo;s for, and the 4 chunks.&lt;/p&gt;&#xA;&lt;details class=&#34;code-details&#34;&gt;&lt;summary&gt;The full output of go tool plugh context for this post&lt;/summary&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;# Voice for blog (content/blog/)&#xA;&#xA;Follow these chunks. Where they&amp;#39;re silent, follow their &amp;#34;When unsure&amp;#34; lines.&#xA;&#xA;## Writing as me&#xA;&#xA;Steven, a developer writing a personal log. Works things out in public, keeps the dead ends, and would rather be useful than impressive.&#xA;&#xA;## Writing for curious-dev&#xA;&#xA;A developer who found the post through a search or a link. Knows the basics, hasn&amp;#39;t solved this exact problem, and wants the choices and the reasons more than a tutorial. Skims headings first.&#xA;&#xA;&amp;lt;!-- chunk voice-core, from voice/voice-core.md --&amp;gt;&#xA;# Voice core&#xA;&#xA;Everything on wrkstdy is me talking to one reader. These are the defaults every other chunk builds on.&#xA;&#xA;### First person, plain words&#xA;&#xA;Write as &amp;#34;I&amp;#34;, the way I&amp;#39;d explain it to a friend across the table. Prefer the plain word over the clever one. &amp;#34;We&amp;#34; is for me and AI working on something together, and only when that&amp;#39;s who did it.&#xA;&#xA;Why: the site is a personal log, not a product. Polish that hides the person defeats the point. A &amp;#34;we&amp;#34; that means me and AI is honest about how the work happened; any other &amp;#34;we&amp;#34; blurs who&amp;#39;s talking.&#xA;When unsure: read the sentence out loud. If I wouldn&amp;#39;t say it, rewrite it. If &amp;#34;we&amp;#34; could mean the reader too, name who did it.&#xA;&#xA;### Specific over general&#xA;&#xA;Name the thing: the tool, the number, the place, the moment. &amp;#34;5 MB straight off the phone&amp;#34; beats &amp;#34;large files&amp;#34;.&#xA;&#xA;Why: specifics are what make a post mine instead of generic.&#xA;When unsure: ask &amp;#34;which one?&amp;#34; of every general noun, and answer it.&#xA;&#xA;### Explain the why&#xA;&#xA;When I made a choice, say why, including the options I didn&amp;#39;t take.&#xA;&#xA;Why: the reasoning is usually the useful part for a reader, and the part I want to remember later.&#xA;When unsure: if a paragraph only says *what* happened, it&amp;#39;s probably missing a *because*.&#xA;&#xA;### Candid, a little self-deprecating&#xA;&#xA;It&amp;#39;s fine to admit a mistake, a dead end, or that something took longer than it should have. Light humor at my own expense, not at anyone else&amp;#39;s.&#xA;&#xA;Why: honesty about the process is the reason to read a dev log at all.&#xA;When unsure: poke fun at me, never at a person or a team.&#xA;&#xA;### Stay humble&#xA;&#xA;Present what I did as what worked for me, not the one right way. Say where my experience runs out, and leave room for the reader to know something I don&amp;#39;t.&#xA;&#xA;Why: I&amp;#39;d rather be useful than impressive, and a post that sounds like it has every answer is harder to trust.&#xA;When unsure: if a sentence tells the reader what they *should* do, check whether &amp;#34;what I&amp;#39;d do&amp;#34; is closer to the truth.&#xA;&#xA;&amp;lt;!-- chunk punctuation, from voice/punctuation.md --&amp;gt;&#xA;# Punctuation&#xA;&#xA;### Em dashes are a spaced ---&#xA;&#xA;Write `word --- word`. goldmark&amp;#39;s typographer turns `---` into an em dash and `--` into an en dash, and an en dash is for ranges, not breaks in a sentence.&#xA;&#xA;Why: it&amp;#39;s a common slip of mine, and the two look nearly the same in the editor.&#xA;When unsure: if the break could be a comma or parentheses, it probably should be.&#xA;&#xA;### Throwaway asides&#xA;&#xA;All lowercase, no closing punctuation, in parentheses: `(spoiler: this still took just an afternoon, maybe just a longer one)`. If an aside runs to more than one sentence, use normal capitalization inside it. An aside can sit inside a sentence or stand on its own between two, in the same format either way.&#xA;&#xA;Why: a throwaway aside should read like one, quieter than the sentence around it.&#xA;When unsure: if the aside is important, it isn&amp;#39;t an aside. Make it its own sentence.&#xA;&#xA;### Italics for stress&#xA;&#xA;Use *italics* on the one word I&amp;#39;d lean on when speaking. Not bold. Italics also mark a term the first time it&amp;#39;s defined, like *chunk*.&#xA;&#xA;Why: it&amp;#39;s how I emphasize out loud; bold reads as shouting. A new term gets the same lean, once.&#xA;When unsure: one stressed word per paragraph, at most. A term is italic only where it&amp;#39;s defined, not after.&#xA;&#xA;### Oxford commas&#xA;&#xA;In a list of three or more, put a comma before the &amp;#34;and&amp;#34; or &amp;#34;or&amp;#34;: &amp;#34;dashes, slashes, and the word markdown&amp;#34;.&#xA;&#xA;Why: it&amp;#39;s how I punctuate, and it keeps the last two items from reading as a pair.&#xA;When unsure: a pair that&amp;#39;s one thing, like &amp;#34;salt and pepper&amp;#34;, counts as a single item. That&amp;#39;s why this check is a warning.&#xA;&#xA;&amp;lt;!-- chunk word-choice, from voice/word-choice.md --&amp;gt;&#xA;# Word choice&#xA;&#xA;### &amp;#34;Or&amp;#34;, not a slash&#xA;&#xA;&amp;#34;Hugo or 11ty&amp;#34;, not &amp;#34;Hugo/11ty&amp;#34;.&#xA;&#xA;Why: a slash makes the reader decide what it means (and, or, either).&#xA;When unsure: paths, URLs, and fractions are fine; this is about words.&#xA;&#xA;### &amp;#34;markdown&amp;#34; is lowercase&#xA;&#xA;Always, including at the start of a list item. (At the start of a sentence, rephrase.)&#xA;&#xA;Why: markdown is an idea, plain text with a little punctuation for structure, more than any one spec. CommonMark, GitHub&amp;#39;s flavor, and goldmark all disagree on the edges, and I&amp;#39;m writing to the idea.&#xA;&#xA;### Project names keep their casing&#xA;&#xA;A lowercase name like plugh or tributary stays lowercase, even at the start of a sentence or a title.&#xA;&#xA;Why: it&amp;#39;s the name as written; capitalizing it makes a different word.&#xA;When unsure: this is for names. A common word like markdown still gets rephrased.&#xA;&#xA;### Numerals for counts&#xA;&#xA;Anything counted or measured is a numeral: &amp;#34;6 warnings&amp;#34;, &amp;#34;1,500 words&amp;#34;. At the start of a sentence, rephrase.&#xA;&#xA;Why: a numeral is easier to spot when skimming, and mixing &amp;#34;6&amp;#34; and &amp;#34;four&amp;#34; in one paragraph reads as a slip.&#xA;When unsure: if the number isn&amp;#39;t the point (&amp;#34;one or two sentences&amp;#34;, &amp;#34;a thing or two&amp;#34;), words are fine.&#xA;&#xA;### &amp;#34;I have&amp;#34;, not &amp;#34;I&amp;#39;ve got&amp;#34;&#xA;&#xA;Why: a common slip of mine, and &amp;#34;I have&amp;#34; is plainer.&#xA;&#xA;### &amp;#34;AI&amp;#34;, not the tool&amp;#39;s name&#xA;&#xA;When describing how work was done, say &amp;#34;AI&amp;#34;.&#xA;&#xA;Why: the post is about the way of working, not the vendor, and tool names date fast.&#xA;When unsure: naming a tool is fine when the post is *about* that tool. That&amp;#39;s why this check is a warning.&#xA;&#xA;### Repetition on purpose&#xA;&#xA;Deliberate repetition for emphasis is fine (&amp;#34;wasn&amp;#39;t mine, not really&amp;#34;). Don&amp;#39;t edit it out, but question it.&#xA;&#xA;&amp;lt;!-- chunk blog-post, from voice/blog-post.md --&amp;gt;&#xA;# Blog posts&#xA;&#xA;### Open on something concrete&#xA;&#xA;Start with a specific moment or a candid admission (&amp;#34;After a recent layoff...&amp;#34;, &amp;#34;Here&amp;#39;s a little secret&amp;#34;), not a thesis statement or a definition.&#xA;&#xA;Why: it&amp;#39;s how my posts have always started, and it earns the reader&amp;#39;s attention before asking for it.&#xA;When unsure: cut the first paragraph and see if the second one is the real opening.&#xA;&#xA;### Start broad, get specific&#xA;&#xA;After the opening, give a short overview of what the post covers (&amp;#34;This post is a reflection on that, and a list of things I want to keep in mind...&amp;#34;), then cover it, one piece at a time. Each main topic gets a `##` heading; when a topic has parts of its own, break it up with `###` headings underneath.&#xA;&#xA;Why: the reader knows where the post is going before the details start, and can decide which parts they need.&#xA;When unsure: every section should trace back to something in the overview, and everything in the overview should get a section.&#xA;&#xA;### Headings carry the story&#xA;&#xA;Short, plain `##` headings that tell the reader where the post is going (&amp;#34;Keeping a post and its photos together&amp;#34;, &amp;#34;Resizing&amp;#34;).&#xA;&#xA;Why: posts get skimmed; the headings should work as a summary on their own.&#xA;&#xA;### Show the work, including dead ends&#xA;&#xA;When something was tried and dropped, say so and why (&amp;#34;I had a 1920px size at first, but...&amp;#34;).&#xA;&#xA;Why: the dead ends are often the most useful part for someone doing the same thing.&#xA;&#xA;### Callouts for side points&#xA;&#xA;A `&amp;gt; [!NOTE]` is for a side point the reader can skip without losing the thread. A few a post, at most.&#xA;&#xA;Why: it lets a skimmer jump past it, and a post full of callouts has no thread left to follow.&#xA;When unsure: if the post doesn&amp;#39;t make sense without it, it&amp;#39;s a paragraph, not a callout.&#xA;&#xA;### Close on what the reader takes away&#xA;&#xA;End on what I hope the reader does or thinks differently, not on a recap.&#xA;&#xA;Why: a recap repeats the post; a takeaway gives it a point.&#xA;When unsure: if the last paragraph starts with &amp;#34;In summary&amp;#34;, rewrite it.&#xA;&#xA;## Load if relevant&#xA;&#xA;If a summary applies to what you&amp;#39;re writing, run `go tool plugh chunk &amp;lt;id&amp;gt;` and follow it too.&#xA;&#xA;- photo-captions: Alt text vs captions on photos in any section.&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;/details&gt;&#xA;&lt;h3 id=&#34;whos-writing-and-for-whom&#34;&gt;Who&amp;rsquo;s writing, and for whom&lt;/h3&gt;&#xA;&lt;p&gt;The router also names &lt;em&gt;personas&lt;/em&gt;. For now &amp;mdash; mine are &amp;ldquo;me&amp;rdquo;, &amp;ldquo;curious-dev&amp;rdquo;, and &amp;ldquo;home-cook&amp;rdquo;, each a few sentences long. Blog posts are written as me, for curious-dev. Recipes swap the audience to home-cook and keep the rest.&lt;/p&gt;&#xA;&lt;p&gt;Someone reading a post and someone following a recipe want different things from the same site. Naming the reader once, up front, means the rules don&amp;rsquo;t each have to.&lt;/p&gt;&#xA;&lt;h3 id=&#34;a-budget-on-purpose&#34;&gt;A budget on purpose&lt;/h3&gt;&#xA;&lt;p&gt;Each type gets a word budget, and &lt;code&gt;plugh kit&lt;/code&gt; fails if a type goes over. The budget isn&amp;rsquo;t for the post. It&amp;rsquo;s for the rules: the &lt;code&gt;always&lt;/code&gt; chunks plus the persona descriptions, which is everything AI reads before it writes a word. Blog posts get 1,500 words, and right now they use a little over 1,000.&lt;/p&gt;&#xA;&lt;p&gt;Every word of rules is context AI has to hold alongside the actual task, and a guide that grows without limit stops being followed, by people &lt;em&gt;and&lt;/em&gt; by AI. So adding a rule means checking whether an old one still earns its place.&lt;/p&gt;&#xA;&lt;h2 id=&#34;checks-for-the-mechanical-slips&#34;&gt;Checks for the mechanical slips&lt;/h2&gt;&#xA;&lt;p&gt;Some rules are mechanical enough that a regex catches them. A chunk can carry checks in its front matter, and &lt;code&gt;go tool plugh check&lt;/code&gt; runs them against every file a type covers. &lt;code&gt;I&#39;ve got&lt;/code&gt; is an error. &lt;code&gt;Markdown&lt;/code&gt; with a capital M is an error. (the first draft of this post failed both, quoting them as examples) A slash between two words is a warning.&lt;/p&gt;&#xA;&lt;div class=&#34;alert alert-note&#34;&gt;&#xA;&lt;p class=&#34;alert-title&#34;&gt;Note&lt;/p&gt;&#xA;&lt;p&gt;Lowercase &amp;ldquo;markdown&amp;rdquo; is a choice, not a typo. To me, markdown is an idea, plain text with a little punctuation for structure, more than any one spec. CommonMark, GitHub&amp;rsquo;s flavor, and goldmark all disagree on the edges, and I&amp;rsquo;m writing to the idea, not one of them.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;Warnings are questions, not orders. A tool name in a post is a warning because &amp;ldquo;say AI, not the tool&amp;rdquo; is the right default, but this post is &lt;em&gt;about&lt;/em&gt; a tool, so naming it is fine.&lt;/p&gt;&#xA;&lt;h3 id=&#34;where-the-checks-get-it-wrong&#34;&gt;Where the checks get it wrong&lt;/h3&gt;&#xA;&lt;p&gt;Running it against the rest of the site gives 0 errors and 6 warnings, and all 6 are wrong. Of those, 4 are the slash check, catching link text like &lt;code&gt;[tdewolff/minify](https://github.com/tdewolff/minify)&lt;/code&gt;, where the slash is part of a repo name, not a choice between two words. The other 2 are the Oxford comma check, which can&amp;rsquo;t tell &lt;code&gt;a wire rack, salt and pepper them&lt;/code&gt; from a list.&lt;/p&gt;&#xA;&lt;p&gt;Misses like these have had me question the mechanical checks more than once. A regex doesn&amp;rsquo;t know what a sentence means, and AI reviewing against the same rules does. But for rote checks, a regex is instant, costs nothing, and gives the same answer every time, which makes it far more efficient than asking AI. So I&amp;rsquo;m keeping them, as warnings wherever they&amp;rsquo;re guessing. Each wrong warning is another example of where the line actually is, and as they pile up, I expect they&amp;rsquo;ll point me toward something that works better.&lt;/p&gt;&#xA;&lt;h2 id=&#34;one-format-many-projects&#34;&gt;One format, many projects&lt;/h2&gt;&#xA;&lt;p&gt;plugh is a Go module and a Claude Code plugin in &lt;a href=&#34;https://github.com/stevenosloan/plugh&#34;&gt;one repo&lt;/a&gt;. The plugin has two skills: one loads the routed chunks before writing, and one reviews a draft against them without rewriting it.&lt;/p&gt;&#xA;&lt;div class=&#34;alert alert-note&#34;&gt;&#xA;&lt;p class=&#34;alert-title&#34;&gt;Note&lt;/p&gt;&#xA;&lt;p&gt;plugh is written for Claude Code, but the techniques aren&amp;rsquo;t tied to it. Small chunks of rules, routed by what&amp;rsquo;s being written, with checks for the mechanical slips, should carry over to other AI tools. &lt;code&gt;plugh context&lt;/code&gt; just prints text, so anything that can run a command before it writes can read it.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;plugh ships no rules of its own. The repo has a made-up example project to copy from, but the point is that the rules are yours.&lt;/p&gt;&#xA;&lt;p&gt;I&amp;rsquo;m working on other projects with different voices and different readers, and none of this site&amp;rsquo;s rules belong there. What they can share is the format: the same chunks, router, and checks in each project &amp;mdash; so the way I write rules down and hand them to AI carries over even when the rules themselves don&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://tributary-site.fly.dev/&#34;&gt;tributary&lt;/a&gt; is one of them. It shows current conditions on the water for stream fishing in the US, so its readers are people using it, not developers looking to learn something. And even within tributary, a guide and the rest of the copy will want different rules, the same way a post and a recipe do here.&lt;/p&gt;&#xA;&lt;h2 id=&#34;working-through-a-draft-one-point-at-a-time&#34;&gt;Working through a draft, one point at a time&lt;/h2&gt;&#xA;&lt;p&gt;A post starts with the write skill. AI loads the chunks for the type, works from my notes, and writes a first draft. For this post, it left marked gaps wherever a part needed something only I know, like the moment that started it or a dead end I hit, instead of making one up.&lt;/p&gt;&#xA;&lt;p&gt;After that first draft, I run the review skill and work through what it finds one point at a time. I pick a spot, say what&amp;rsquo;s missing or what I&amp;rsquo;d say differently, and we change just that, then run &lt;code&gt;plugh check&lt;/code&gt; before moving on. This post went that way: the point about keeping context lean, the budget, the Little Plains link, and this section were each their own pass.&lt;/p&gt;&#xA;&lt;div class=&#34;alert alert-tip&#34;&gt;&#xA;&lt;p class=&#34;alert-title&#34;&gt;Tip&lt;/p&gt;&#xA;&lt;p&gt;Working through a draft is the perfect time to update the rules. When I make the same fix twice, or the review makes a call no rule covers, that&amp;rsquo;s a rule waiting to be written, and it&amp;rsquo;s easiest to write while I&amp;rsquo;m still looking at the content that needed it.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;That&amp;rsquo;s just how I edit. Unless I&amp;rsquo;m making sweeping changes, I work on specific wording, a sentence or a paragraph at a time, and I didn&amp;rsquo;t want AI to change that.&lt;/p&gt;&#xA;&lt;h2 id=&#34;worth-it-so-far&#34;&gt;Worth it, so far&lt;/h2&gt;&#xA;&lt;p&gt;It&amp;rsquo;s working. Even this post took fewer edits from me after the first draft than I&amp;rsquo;m used to.&lt;/p&gt;&#xA;&lt;p&gt;It also gets better as I go. Along with its findings, the review lists the calls it made that no rule covers yet. Those are candidates, not findings, and I decide which ones become rules. Working through this post added 6: when &amp;ldquo;we&amp;rdquo; is fine, numerals for counts, lowercase project names, italics for a new term, asides that stand on their own, and when a callout earns its place. The next post starts with all of them.&lt;/p&gt;&#xA;&lt;p&gt;It isn&amp;rsquo;t free, though. Every round of my workflow re-checks the draft with plugh, and that spends some tokens each time. Over a post&amp;rsquo;s worth of small passes it adds up, but it&amp;rsquo;s been worth it.&lt;/p&gt;&#xA;&lt;p&gt;If you&amp;rsquo;re writing with AI and the drafts don&amp;rsquo;t sound like you, I&amp;rsquo;d start smaller than a style guide. Keep a list of the things you fix every time. That list is already most of a voice guide, and it&amp;rsquo;s one nobody else can write for you.&lt;/p&gt;&#xA;</content:encoded>
    </item>
    <item>
      <title>The new person&#39;s advantage, and caveats</title>
      <link>https://wrkstdy.com/blog/the-new-persons-advantage-and-caveats/</link>
      <guid>https://wrkstdy.com/blog/the-new-persons-advantage-and-caveats/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
      <description>Being new lets you see what a team has stopped seeing. Context is what helps the team hear you say it.</description>
      <content:encoded>&lt;p&gt;After a recent layoff at #lastjob, a handful of people joined our team from another part of the company. They were smart, they were capable, and they showed up with strong opinions about how things should work.&lt;/p&gt;&#xA;&lt;p&gt;What they didn&amp;rsquo;t show up with was much curiosity about how things &lt;em&gt;already&lt;/em&gt; worked, or more importantly &lt;em&gt;why&lt;/em&gt; they worked the way they did. This created friction that made it difficult to receive, triage, and apply those ideas.&lt;/p&gt;&#xA;&lt;p&gt;I was on the receiving end of this one, and it&amp;rsquo;s stuck with me. It was also on my mind while reading Vikram Singh&amp;rsquo;s &lt;a href=&#34;https://www.vikramsingh.me/increase-your-slope&#34;&gt;Increase your slope&lt;/a&gt;, which is written for new grads, but holds for anyone new to a team. One of their points is to get comfortable having opinions, and to do your homework and check them with a trusted person or two before taking them wider.&lt;/p&gt;&#xA;&lt;p&gt;This post is a reflection on that experience, and a list of things I want to keep in mind the next time I&amp;rsquo;m the new person on a team. Here, I want to look at what it looks like when that homework &amp;mdash; gaining context &amp;mdash; gets skipped, why being new is still an advantage, and what the homework looks like.&lt;/p&gt;&#xA;&lt;h2 id=&#34;simplifying-a-cache-key&#34;&gt;Simplifying a cache key&lt;/h2&gt;&#xA;&lt;p&gt;Here&amp;rsquo;s the example I keep coming back to. We had an edge cache, and the cache key admittedly had a lot of dimensions in it. To someone new, that looks like a smell: too much cardinality, simplify it, an easy win to &lt;em&gt;fix&lt;/em&gt; the hit rate.&lt;/p&gt;&#xA;&lt;p&gt;But those extra dimensions came from features we&amp;rsquo;d introduced over time, like an existing user&amp;rsquo;s ecommerce plan or a visitor&amp;rsquo;s device type. They had widely varying levels of usage, and each one &lt;em&gt;could&lt;/em&gt; change what the origin would send back. Just simplifying the cache key would mean some visitors might end up with a page that was meant for someone else, or a stale variant of the right one. (spoiler: nobody enjoys finding that out in production)&lt;/p&gt;&#xA;&lt;p&gt;Whether every one of those differentiators still &lt;em&gt;mattered&lt;/em&gt; was a fair question, but making that decision would need to involve stakeholders. The fact that they &lt;em&gt;could&lt;/em&gt; matter was the context the newcomers were missing. They could describe the change they wanted, but weren&amp;rsquo;t telling us what the current setup was protecting and how that risk would be handled.&lt;/p&gt;&#xA;&lt;p&gt;So the conversation never started from &amp;ldquo;is this protection still worth it?&amp;rdquo; It started from &amp;ldquo;why is this so complicated?&amp;rdquo;, and we spent our energy re-explaining instead of improving anything. This pattern repeated and got personal and political faster than I&amp;rsquo;d like to admit.&lt;/p&gt;&#xA;&lt;h2 id=&#34;being-new-is-an-advantage&#34;&gt;Being new is an advantage&lt;/h2&gt;&#xA;&lt;p&gt;I don&amp;rsquo;t believe being new means you shouldn&amp;rsquo;t have opinions or share ideas. Being new is a real advantage, and it&amp;rsquo;s worth saying so plainly.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;You don&amp;rsquo;t know the patterns yet, so you don&amp;rsquo;t know which ones are &amp;ldquo;just how it&amp;rsquo;s done&amp;rdquo; and which are stale constraints.&lt;/li&gt;&#xA;&lt;li&gt;You&amp;rsquo;re more willing to ask the obvious question everyone else stopped asking.&lt;/li&gt;&#xA;&lt;li&gt;Teams go blind to their own friction. The new person still feels it.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Sometimes the question is as simple as &amp;ldquo;why is the hero image lazy loaded?&amp;rdquo; It was asked to understand, not to correct, and it was a real catch. The team that built the page had missed some of our requirements and conventions, and it took someone new to notice.&lt;/p&gt;&#xA;&lt;p&gt;Sometimes it&amp;rsquo;s more fraught, like the blocking request for our experiments JS file, which we didn&amp;rsquo;t cache. It was posed as a mistake, but it exposed a lack of trying to understand how the system worked. That file had to vary on criteria our cache didn&amp;rsquo;t know about, down to the individual user. Caching it would have meant serving someone else&amp;rsquo;s experiments.&lt;/p&gt;&#xA;&lt;p&gt;The difference wasn&amp;rsquo;t the question, it was how it was asked. The same lack of familiarity that lets you see the friction also hides the reasons behind it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;context-has-to-be-earned&#34;&gt;Context has to be earned&lt;/h2&gt;&#xA;&lt;p&gt;It helps to start by assuming good intent. Most weird-looking things are weird for a reason: an outage, a customer, a constraint, a person who left. Some of those reasons are still good. Some expired years ago and nobody noticed.&lt;/p&gt;&#xA;&lt;p&gt;From the new seat you can&amp;rsquo;t tell which is which, and proposing a rewrite in week two costs trust even if you turn out to be right. Sometimes the context a decision was made under no longer applies, but you have to seek the context to know that.&lt;/p&gt;&#xA;&lt;h3 id=&#34;back-to-the-cache-key&#34;&gt;Back to the cache key&lt;/h3&gt;&#xA;&lt;p&gt;The stakeholder conversation it needed took context &lt;em&gt;and&lt;/em&gt; clout, and the newcomers had neither yet. So even a good idea couldn&amp;rsquo;t land on its own. Earning the context is also how you earn the standing to have the bigger conversation.&lt;/p&gt;&#xA;&lt;p&gt;Their idea wasn&amp;rsquo;t wrong. A simpler cache key was worth wanting. But it came without the context of who it needed to go to, the stakeholders behind each of those features, or of the effort it would take to implement safely: checking each dimension, and rolling it out without serving anyone a page meant for someone else.&lt;/p&gt;&#xA;&lt;p&gt;So the question was never whether they were right or wrong. It was who needed to be part of the decision, and what it would take to make the change safely. That made it a conversation with stakeholders, not solely an engineering cleanup.&lt;/p&gt;&#xA;&lt;h2 id=&#34;ask-but-ask-to-understand&#34;&gt;Ask, but ask to understand&lt;/h2&gt;&#xA;&lt;p&gt;None of this means sitting on questions. We had a saying to encourage that: &amp;ldquo;there are no bad questions.&amp;rdquo; I want them asked, but framed as &amp;ldquo;help me understand why&amp;rdquo; instead of &amp;ldquo;why don&amp;rsquo;t we just.&amp;rdquo;&lt;/p&gt;&#xA;&lt;div class=&#34;alert alert-note&#34;&gt;&#xA;&lt;p class=&#34;alert-title&#34;&gt;Note&lt;/p&gt;&#xA;&lt;p&gt;&amp;ldquo;Just&amp;rdquo; can generally be considered a four-letter word. It carries a lot, and if you find yourself thinking it, take a step back and consider what it really means.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;For myself, I want to make sure I learn the history before I propose a change. If the reason is still valid, it saves everyone a debate. If it&amp;rsquo;s expired, I&amp;rsquo;ll have the context to make the case and the trust to be heard.&lt;/p&gt;&#xA;&lt;h3 id=&#34;some-pre-work&#34;&gt;Some pre-work&lt;/h3&gt;&#xA;&lt;p&gt;There&amp;rsquo;s a tool I like for checking whether I&amp;rsquo;m ready. George Heilmeier ran DARPA in the 1970s and wrote up a short list of questions for vetting proposed projects, now known as &lt;a href=&#34;https://www.darpa.mil/about/heilmeier-catechism&#34;&gt;the Heilmeier Catechism&lt;/a&gt;. The one I&amp;rsquo;d borrow before suggesting anything new:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;How is it done today, and what are the limits of current practice?&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;If I can&amp;rsquo;t answer that, I&amp;rsquo;m not ready to propose a change yet. Answering the first half &lt;em&gt;is&lt;/em&gt; gaining context. Naming the limits is what turns a pitch into a case other people can weigh. And it&amp;rsquo;s exactly what was missing from the cache key conversations: the newcomers could describe their idea, but not &amp;ldquo;how it&amp;rsquo;s done today.&amp;rdquo;&lt;/p&gt;&#xA;&lt;p&gt;Before I take an idea to anyone else, these are the questions I want to test it against myself:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Assuming good intent, why does this exist? What could it be providing?&lt;/li&gt;&#xA;&lt;li&gt;What would it take for this to change? Who would need to agree, and what would have to be true first?&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;spend-it-on-curiosity&#34;&gt;Spend it on curiosity&lt;/h2&gt;&#xA;&lt;p&gt;Newness is an asset that runs out, and I intend to use it the next time around. I want to be deliberate with it, though: spend it on curiosity, not conclusions. The conclusions will still be there in a few months, and they&amp;rsquo;ll be better than they would be otherwise.&lt;/p&gt;&#xA;</content:encoded>
    </item>
    <item>
      <title>Photos: transcoding and lazy loading</title>
      <link>https://wrkstdy.com/blog/photos-transcoding-and-lazy-loading/</link>
      <guid>https://wrkstdy.com/blog/photos-transcoding-and-lazy-loading/</guid>
      <pubDate>Mon, 19 Oct 2026 00:00:00 +0000</pubDate>
      <description>Teaching the generator to handle photos: page bundles, resizing, AVIF and WebP with a JPEG fallback, keeping location data out, and getting lazy loading and sizes=&#34;auto&#34; to work together.</description>
      <content:encoded>&lt;p&gt;The site has two new sections: a &lt;a href=&#34;https://wrkstdy.com/journal/&#34;&gt;journal&lt;/a&gt;, for photos and notes from trips, and &lt;a href=&#34;https://wrkstdy.com/recipes/&#34;&gt;recipes&lt;/a&gt;, where the first one is &lt;a href=&#34;https://wrkstdy.com/recipes/ssamjang-wings/&#34;&gt;ssamjang wings&lt;/a&gt;. Both lean on photos, but the generator didn&amp;rsquo;t know what a photo was or how to handle them. Straight from the camera or phone they could be 5 MB or more, so dropping them in as-is wasn&amp;rsquo;t an performant option.&lt;/p&gt;&#xA;&lt;p&gt;Going in, I knew it needed two things:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Configurable eager or lazy loading.&lt;/strong&gt; Most photos are further down the page and can wait until you scroll to them, but one at the top shouldn&amp;rsquo;t. Which is which depends on where the photo sits, so it has to be set per photo.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Resizing and transcoding.&lt;/strong&gt; Each photo in a few widths and a few formats, so the browser can pick the best format it supports at the size it needs.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;keeping-a-post-and-its-photos-together&#34;&gt;Keeping a post and its photos together&lt;/h2&gt;&#xA;&lt;p&gt;The first change is structural. A post used to be a single markdown file, and anything it pointed to lived somewhere else. Now a post can be a folder that holds the content and its resources together: the markdown as &lt;code&gt;index.md&lt;/code&gt;, with the photos beside it. The folder takes the place of the filename (date, then slug):&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code&gt;content/recipes/2026-10-10-ssamjang-wings/&#xA;  index.md&#xA;  trail.jpg&#xA;  seasoned.jpg&#xA;  ...&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;In the markdown, a photo is referred to by its filename. A line right under it, with no blank line between, becomes its caption:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;![Seasoned wings on a wire rack](seasoned.jpg)&#xA;Salted, peppered, and drying under a fan while the oven preheats.&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;h2 id=&#34;resizing&#34;&gt;Resizing&lt;/h2&gt;&#xA;&lt;p&gt;Every photo a page uses is resized to 640, 960, and 1280px wide. The text column is 640px, so those cover 1x and 2x screens, and phones at 3x, which are narrower than the column. I had a 1920px size at first, for 3x screens at desktop width, but it came to as much as the other three together, for screens that are rare. Widths wider than the photo are skipped, so nothing gets upscaled.&lt;/p&gt;&#xA;&lt;p&gt;Phone cameras don&amp;rsquo;t rotate the pixels when you hold the phone sideways. They store the rotation in the photo&amp;rsquo;s EXIF data and leave it to whatever displays it. The resized copies don&amp;rsquo;t carry EXIF, so the rotation gets applied before resizing.&lt;/p&gt;&#xA;&lt;h2 id=&#34;transcoding&#34;&gt;Transcoding&lt;/h2&gt;&#xA;&lt;p&gt;Each width is encoded three times: AVIF, WebP, and a JPEG to fall back on. They go in a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;, and the browser takes the first format it supports:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-html&#34;&gt;&amp;lt;picture&amp;gt;&#xA;  &amp;lt;source type=&amp;#34;image/avif&amp;#34; srcset=&amp;#34;seasoned.640w.avif 640w, …&amp;#34; sizes=&amp;#34;…&amp;#34;&amp;gt;&#xA;  &amp;lt;source type=&amp;#34;image/webp&amp;#34; srcset=&amp;#34;seasoned.640w.webp 640w, …&amp;#34; sizes=&amp;#34;…&amp;#34;&amp;gt;&#xA;  &amp;lt;img src=&amp;#34;seasoned.1280w.jpg&amp;#34; srcset=&amp;#34;seasoned.640w.jpg 640w, …&amp;#34; sizes=&amp;#34;…&amp;#34;&#xA;       width=&amp;#34;1280&amp;#34; height=&amp;#34;960&amp;#34; loading=&amp;#34;lazy&amp;#34; alt=&amp;#34;…&amp;#34;&amp;gt;&#xA;&amp;lt;/picture&amp;gt;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Go&amp;rsquo;s standard library can&amp;rsquo;t write WebP or AVIF. &lt;a href=&#34;https://github.com/gen2brain/avif&#34;&gt;gen2brain/avif&lt;/a&gt; and &lt;a href=&#34;https://github.com/gen2brain/webp&#34;&gt;gen2brain/webp&lt;/a&gt; wrap libavif and libwebp compiled to WebAssembly, so the build still needs no C toolchain, locally or on Netlify.&lt;/p&gt;&#xA;&lt;h3 id=&#34;quality-numbers-dont-compare&#34;&gt;Quality numbers don&amp;rsquo;t compare&lt;/h3&gt;&#xA;&lt;p&gt;I started with AVIF at 60 and WebP at 70. On my first test photo, a smooth landscape, AVIF came out bigger. Since browsers pick AVIF first, most visitors would have been getting the larger file.&lt;/p&gt;&#xA;&lt;p&gt;On a busier photo it goes the other way. A quality number only means something within one encoder, so how two formats compare depends on the photo. AVIF at 50 came out smallest on both, so that&amp;rsquo;s where it landed.&lt;/p&gt;&#xA;&lt;p&gt;Here&amp;rsquo;s the &lt;a href=&#34;https://wrkstdy.com/recipes/ssamjang-wings/&#34;&gt;trail photo&lt;/a&gt; from the wings post at 1280px, the size most screens get, with the same 256 by 160 crop from each at full size:&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&lt;thead&gt;&#xA;&lt;tr&gt;&#xA;&lt;th&gt;Format&lt;/th&gt;&#xA;&lt;th&gt;Quality&lt;/th&gt;&#xA;&lt;th&gt;Size&lt;/th&gt;&#xA;&lt;th&gt;Crop&lt;/th&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/thead&gt;&#xA;&lt;tbody&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;original&lt;/td&gt;&#xA;&lt;td&gt;&lt;/td&gt;&#xA;&lt;td&gt;9.4 MB, before resizing&lt;/td&gt;&#xA;&lt;td&gt;&lt;img src=&#34;https://wrkstdy.com/assets/photos/blog/photos-transcoding-and-lazy-loading/crop-original.8775fd44fe03.webp&#34; alt=&#34;A small waterfall over mossy rock, from the photo resized without compression&#34; width=&#34;256&#34; height=&#34;160&#34; loading=&#34;lazy&#34; decoding=&#34;async&#34;&gt;&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;JPEG&lt;/td&gt;&#xA;&lt;td&gt;80&lt;/td&gt;&#xA;&lt;td&gt;888 KB&lt;/td&gt;&#xA;&lt;td&gt;&lt;img src=&#34;https://wrkstdy.com/assets/photos/blog/photos-transcoding-and-lazy-loading/crop-jpeg-80.276d8fac1670.webp&#34; alt=&#34;The same crop as a JPEG at quality 80&#34; width=&#34;256&#34; height=&#34;160&#34; loading=&#34;lazy&#34; decoding=&#34;async&#34;&gt;&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;WebP&lt;/td&gt;&#xA;&lt;td&gt;70&lt;/td&gt;&#xA;&lt;td&gt;710 KB&lt;/td&gt;&#xA;&lt;td&gt;&lt;img src=&#34;https://wrkstdy.com/assets/photos/blog/photos-transcoding-and-lazy-loading/crop-webp-70.b7f04df55c6f.webp&#34; alt=&#34;The same crop as a WebP at quality 70&#34; width=&#34;256&#34; height=&#34;160&#34; loading=&#34;lazy&#34; decoding=&#34;async&#34;&gt;&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;AVIF&lt;/td&gt;&#xA;&lt;td&gt;60&lt;/td&gt;&#xA;&lt;td&gt;554 KB&lt;/td&gt;&#xA;&lt;td&gt;&lt;img src=&#34;https://wrkstdy.com/assets/photos/blog/photos-transcoding-and-lazy-loading/crop-avif-60.85d5b17b5a0d.webp&#34; alt=&#34;The same crop as an AVIF at quality 60&#34; width=&#34;256&#34; height=&#34;160&#34; loading=&#34;lazy&#34; decoding=&#34;async&#34;&gt;&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;AVIF&lt;/td&gt;&#xA;&lt;td&gt;50&lt;/td&gt;&#xA;&lt;td&gt;394 KB&lt;/td&gt;&#xA;&lt;td&gt;&lt;img src=&#34;https://wrkstdy.com/assets/photos/blog/photos-transcoding-and-lazy-loading/crop-avif-50.ef968fe98000.webp&#34; alt=&#34;The same crop as an AVIF at quality 50&#34; width=&#34;256&#34; height=&#34;160&#34; loading=&#34;lazy&#34; decoding=&#34;async&#34;&gt;&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;The crops are saved losslessly, so each one shows exactly what its format did. WebP smooths out the speckle on the rock and lifts the dark shadows, so the rock goes a little gray. AVIF at 50 smooths the speckle a bit and softens the leaves at the top. JPEG at 80 and AVIF at 60 stay closest to the original.&lt;/p&gt;&#xA;&lt;h3 id=&#34;build-artifact-caching&#34;&gt;Build Artifact Caching&lt;/h3&gt;&#xA;&lt;p&gt;Encoding is slow, AVIF most of all. The wings post has eight photos, and encoding them the first time takes about 40 seconds. Every copy goes into &lt;code&gt;.cache/images/&lt;/code&gt;, in a folder named by a hash of the photo and the encoding settings. A rebuild only encodes what changed, and changing a quality setting starts over on its own.&lt;/p&gt;&#xA;&lt;p&gt;That cache lives on my machine, though. Netlify starts every deploy from a clean checkout, so each one would encode every photo again. Netlify build plugins can keep a folder between deploys, and that only takes a small local plugin, in &lt;code&gt;netlify/plugins/photo-cache&lt;/code&gt;:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-js&#34;&gt;const dir = &amp;#34;.cache/images&amp;#34;;&#xA;&#xA;module.exports = {&#xA;  async onPreBuild({ utils }) {&#xA;    await utils.cache.restore(dir);&#xA;  },&#xA;  async onPostBuild({ utils }) {&#xA;    await utils.cache.save(dir);&#xA;  },&#xA;};&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;It restores the cache before the build and saves it after, so a deploy only encodes photos that are new or changed. Since the whole folder is restored and saved every time, it shouldn&amp;rsquo;t grow forever: the build marks each entry it uses, and prunes any it hasn&amp;rsquo;t used in 30 days.&lt;/p&gt;&#xA;&lt;h3 id=&#34;leaving-location-out&#34;&gt;Leaving location out&lt;/h3&gt;&#xA;&lt;p&gt;Photos can record where they were taken, so anything taken in my kitchen could record where I live. Only the encoded copies are published, never the originals, and re-encoding drops EXIF along with that location.&lt;/p&gt;&#xA;&lt;p&gt;That covers the site, but not the repo. A folder post keeps its photos in &lt;code&gt;content/&lt;/code&gt;, and the repo is public. So photos don&amp;rsquo;t get copied in by hand. A mage task brings them in:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-sh&#34;&gt;go tool mage photos content/recipes/2026-10-10-ssamjang-wings ~/Desktop/wings&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;It takes a photo or a folder of them, and writes each one into the post rotated upright, scaled to at most 2560px, and with no metadata at all. That also keeps the repo from filling up with 10 MB originals. It won&amp;rsquo;t overwrite a photo that&amp;rsquo;s already there unless it&amp;rsquo;s run with &lt;code&gt;-replace&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;Dropping all the metadata drops the color profile too, and iPhone photos are in Display P3, a wider color space than sRGB. Without the profile, browsers read P3 colors as sRGB and show them muted, so the task converts them to sRGB first. The build does the same for any photo that didn&amp;rsquo;t come through it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;lazy-loading&#34;&gt;Lazy loading&lt;/h2&gt;&#xA;&lt;p&gt;Most photos are further down the page than anyone has scrolled yet, so they load lazily. That also unlocks &lt;code&gt;sizes=&amp;quot;auto&amp;quot;&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;sizes&lt;/code&gt; tells the browser how wide an image will be drawn, so it can pick from the &lt;code&gt;srcset&lt;/code&gt; before layout has happened. Normally you write that out by hand, and it&amp;rsquo;s easy to get wrong. With &lt;code&gt;auto&lt;/code&gt;, the browser waits for layout and uses the real width. It can only do that for a lazy image, since it has to wait for layout anyway.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;auto&lt;/code&gt; &lt;a href=&#34;https://caniuse.com/wf-sizes-auto&#34;&gt;isn&amp;rsquo;t Baseline yet&lt;/a&gt;, so it can&amp;rsquo;t be the whole value. It goes first, and the hand-written sizes follow as a fallback. A browser that supports &lt;code&gt;auto&lt;/code&gt; uses it and ignores the rest. One that doesn&amp;rsquo;t skips it and reads the list after it:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code&gt;sizes=&amp;#34;auto, (max-width: 42rem) 100vw, 40rem&amp;#34;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The catch is a photo at the top of the page. It&amp;rsquo;s on screen straight away, and making it wait is just slower. My first take was to load the first photo on each page eagerly, automatically. That works until a post opens with a few paragraphs and the first photo is far down.&lt;/p&gt;&#xA;&lt;p&gt;So it&amp;rsquo;s declarative now. Photos are lazy unless marked, with an attribute block right after the image:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;![A friend walking a rocky trail](trail.jpg){loading=eager}&#xA;Out on the trail.&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;goldmark&amp;rsquo;s attribute syntax only covers headings, so this is another small extension. It reads the block, removes it from the text, and gives that photo &lt;code&gt;sizes&lt;/code&gt; without &lt;code&gt;auto&lt;/code&gt;. Anything else in braces fails the build, so &lt;code&gt;{loading=eagre}&lt;/code&gt; doesn&amp;rsquo;t quietly do nothing.&lt;/p&gt;&#xA;&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s next&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Covers for the journal.&lt;/strong&gt; &lt;code&gt;/journal/&lt;/code&gt; is the same list of dates and titles as the blog. A photo per trip would suit it better, I&amp;rsquo;ll tackle that when I get to actually writing an entry there.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</content:encoded>
    </item>
    <item>
      <title>A build step and styling adjustments</title>
      <link>https://wrkstdy.com/blog/a-build-step-and-styling-adjustments/</link>
      <guid>https://wrkstdy.com/blog/a-build-step-and-styling-adjustments/</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 +0000</pubDate>
      <description>Adding an esbuild step to the Go generator, clearing out what was left of the Hugo skeleton, and a round of styling: a type scale, variable fonts, and GitHub-style alerts.</description>
      <content:encoded>&lt;p&gt;The &lt;a href=&#34;https://wrkstdy.com/blog/a-quick-production-readiness-pass/&#34;&gt;production readiness pass&lt;/a&gt; ended with a list of things left to do, and one of them was a real build step for CSS and JavaScript. That&amp;rsquo;s where today started. Once that was in, I finally got to the part I&amp;rsquo;d been putting off: the styles.&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-build-step-with-esbuild&#34;&gt;A build step with esbuild&lt;/h2&gt;&#xA;&lt;p&gt;Up to now, assets went through a small pipeline of my own: hash each file, add an integrity attribute, minify the CSS and JS. That covered cache busting, but Mermaid still loaded from jsDelivr and MicroLighter was a vendored copy checked into &lt;code&gt;static/&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://esbuild.github.io&#34;&gt;esbuild&lt;/a&gt; has a Go API, which made it an easy fit. The generator calls it directly, so there&amp;rsquo;s no Node tool in the loop &amp;mdash; npm only installs the dependencies. Every stylesheet in &lt;code&gt;assets/css&lt;/code&gt; and every script in &lt;code&gt;assets/js&lt;/code&gt; is an entry point:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Mermaid and MicroLighter come from npm.&lt;/strong&gt; Both are code-split and content-hashed, so a page only fetches the chunks it uses. Each MicroLighter grammar is its own chunk.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Fonts go through esbuild too.&lt;/strong&gt; It hashes each font the CSS references and rewrites the &lt;code&gt;url()&lt;/code&gt;, so the preload tags and the stylesheet always point at the same file.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;The old minifier is gone.&lt;/strong&gt; esbuild minifies, so &lt;a href=&#34;https://github.com/tdewolff/minify&#34;&gt;tdewolff/minify&lt;/a&gt; didn&amp;rsquo;t need to stay.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;code&gt;mage serve&lt;/code&gt; gets a dev mode. It keeps an esbuild context between rebuilds, so only what changed gets rebuilt, and it writes unminified files with source maps and stable names. Breakpoints in devtools survive a rebuild that way, and the dev server sends &lt;code&gt;no-store&lt;/code&gt; so the browser doesn&amp;rsquo;t hang on to a stale copy.&lt;/p&gt;&#xA;&lt;h2 id=&#34;finishing-off-the-skeleton&#34;&gt;Finishing off the skeleton&lt;/h2&gt;&#xA;&lt;p&gt;The site started as an AI-generated Hugo skeleton, and I swapped Hugo for my own generator a while ago. What I hadn&amp;rsquo;t noticed is that the Hugo templates never left. &lt;code&gt;layouts/&lt;/code&gt; and &lt;code&gt;hugo.toml&lt;/code&gt; were still sitting there, and the generator reads from &lt;code&gt;templates/&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;I found out the honest way: I edited &lt;code&gt;layouts/single.html&lt;/code&gt;, and nothing changed. Two folders of templates that look nearly the same is an easy trap, so out they went.&lt;/p&gt;&#xA;&lt;h2 id=&#34;styling-adjustments&#34;&gt;Styling adjustments&lt;/h2&gt;&#xA;&lt;p&gt;With the build sorted, I spent the rest of the day on the styles. Most of it is groundwork, not anything you&amp;rsquo;d notice at a glance.&lt;/p&gt;&#xA;&lt;p&gt;This is one of the downsides of starting from a vibe-coded base. The styles work, but they aren&amp;rsquo;t organized the way I&amp;rsquo;d have organized them writing from scratch: one-off values, rules fighting each other, no system tying it together. What follows is a start at restructuring, not the finished version. I&amp;rsquo;ll keep tightening it up over time.&lt;/p&gt;&#xA;&lt;h3 id=&#34;splitting-up-the-stylesheet&#34;&gt;Splitting up the stylesheet&lt;/h3&gt;&#xA;&lt;p&gt;&lt;code&gt;main.css&lt;/code&gt; was one long file with comment banners between sections. It&amp;rsquo;s now a list of imports, with each section in its own file under &lt;code&gt;base/&lt;/code&gt;, &lt;code&gt;layout/&lt;/code&gt;, &lt;code&gt;pages/&lt;/code&gt;, and &lt;code&gt;themes/&lt;/code&gt;. esbuild bundles them back into one stylesheet, so the split costs nothing at runtime.&lt;/p&gt;&#xA;&lt;p&gt;The palette got the same treatment. Each color is a named variable now (&lt;code&gt;--palette-moss&lt;/code&gt;, &lt;code&gt;--palette-bark&lt;/code&gt;, &lt;code&gt;--palette-forest-floor&lt;/code&gt;), and the variables the rest of the CSS actually uses (&lt;code&gt;--accent&lt;/code&gt;, &lt;code&gt;--bg&lt;/code&gt;, &lt;code&gt;--rule&lt;/code&gt;) pick a light and dark value from those with &lt;code&gt;light-dark()&lt;/code&gt;. Components only reach for the semantic names.&lt;/p&gt;&#xA;&lt;h3 id=&#34;a-font-family-variable&#34;&gt;A font-family variable&lt;/h3&gt;&#xA;&lt;p&gt;The site switches between two families, Fira Sans and Fira Code, in a lot of places. Instead of setting &lt;code&gt;font-family&lt;/code&gt; in each one, every element now reads its family from a variable:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-css&#34;&gt;:root { --font-family: var(--sans); }&#xA;* { font-family: var(--font-family); }&#xA;&#xA;code, pre, kbd { --font-family: var(--mono); }&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The variable inherits, so setting it on an element switches everything inside it too. The header, footer, dates, and table headers all switch to mono with one line each.&lt;/p&gt;&#xA;&lt;h3 id=&#34;fewer-font-files-then-variable-ones&#34;&gt;Fewer font files, then variable ones&lt;/h3&gt;&#xA;&lt;p&gt;The site was loading eight static font files. Trimming that down to the weights actually in use got me thinking about variable fonts instead.&lt;/p&gt;&#xA;&lt;p&gt;Fira Code has a variable version on Fontsource. Fira Sans doesn&amp;rsquo;t, but &lt;a href=&#34;https://github.com/hellogreg/firava&#34;&gt;Firava&lt;/a&gt; is a variable build of it. Its character set is much smaller than Fira Sans: about 450 characters instead of 1,700. Everything I write is in English, and checking every post turned up nothing it was missing.&lt;/p&gt;&#xA;&lt;p&gt;Cut down to the latin range and the weights I use (300 to 500), the upright file is 37.6 KB, which is less than the two static weights it replaces. Italic was closer. Static italic files would have been a bit smaller on most pages, but one variable italic file means italic headings don&amp;rsquo;t pull in a new font when you move between pages. I&amp;rsquo;ll take consistency over 14 KB.&lt;/p&gt;&#xA;&lt;p&gt;That&amp;rsquo;s three font files instead of eight. A small script rebuilds them, since the cut-down Firava is a file I made, not one I downloaded.&lt;/p&gt;&#xA;&lt;h3 id=&#34;a-type-scale&#34;&gt;A type scale&lt;/h3&gt;&#xA;&lt;p&gt;Font sizes had drifted into a scatter of one-off values: &lt;code&gt;0.85rem&lt;/code&gt;, &lt;code&gt;0.9em&lt;/code&gt;, &lt;code&gt;0.95rem&lt;/code&gt;, &lt;code&gt;1.1rem&lt;/code&gt;, &lt;code&gt;1.15rem&lt;/code&gt;. They all come from a scale now. The base is 18px, each step up multiplies by 1.25, and one step down covers the small text (dates, meta, footnotes, the nav).&lt;/p&gt;&#xA;&lt;p&gt;Line heights come from the same scale. Body text is 1.25 squared, 1.5625, and headings are 1.25.&lt;/p&gt;&#xA;&lt;p&gt;The one deliberate exception is monospace. Fira Code runs larger than Fira Sans at the same size, so code sits a little under whatever it&amp;rsquo;s set in, not on a step of the scale.&lt;/p&gt;&#xA;&lt;h3 id=&#34;github-style-alerts&#34;&gt;GitHub-style alerts&lt;/h3&gt;&#xA;&lt;p&gt;I wanted callouts, and the markdown already exists for them:&lt;/p&gt;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;&amp;gt; [!NOTE]&#xA;&amp;gt; Worth knowing.&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;goldmark doesn&amp;rsquo;t render these, so I wrote a small extension for it. After parsing, it looks for blockquotes whose first line is just the marker, and swaps them for an alert with a title. Like GitHub, it only knows the five types, and a marker that isn&amp;rsquo;t on its own line stays a normal quote.&lt;/p&gt;&#xA;&lt;div class=&#34;alert alert-tip&#34;&gt;&#xA;&lt;p class=&#34;alert-title&#34;&gt;Tip&lt;/p&gt;&#xA;&lt;p&gt;This is one. They come in note, tip, important, warning, and caution, each with its own color from the palette.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;h3 id=&#34;smaller-fixes&#34;&gt;Smaller fixes&lt;/h3&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Mermaid diagrams are flat.&lt;/strong&gt; Mermaid&amp;rsquo;s newer default look adds drop shadows and gradients. The classic look with plain borders fits the rest of the site better.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;The footer sits at the bottom.&lt;/strong&gt; Short pages used to end with the footer partway up the window. A flex column on the body fixes that, without a fixed minimum height on &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s next&lt;/h2&gt;&#xA;&lt;p&gt;The build step comes off the list from last time. The rest is still there: an accessibility pass, a sitemap and &lt;code&gt;robots.txt&lt;/code&gt;, and git hooks. Styles-wise, the groundwork is in place, so the next round can be about how things look instead of how they&amp;rsquo;re put together.&lt;/p&gt;&#xA;</content:encoded>
    </item>
    <item>
      <title>A quick production readiness pass</title>
      <link>https://wrkstdy.com/blog/a-quick-production-readiness-pass/</link>
      <guid>https://wrkstdy.com/blog/a-quick-production-readiness-pass/</guid>
      <pubDate>Tue, 13 Oct 2026 00:00:00 +0000</pubDate>
      <description>Before polishing the tooling or the styles, a first pass at what this blog needs to be ready for production: networking, builds, the HTML itself, assets, and CI.</description>
      <content:encoded>&lt;p&gt;The blog works. It builds, it deploys, and you&amp;rsquo;re reading it. Before I sink more time into the tooling or the styles, though, I want to step back and check that the basics are in place for something running in production &amp;mdash; even something as small as this.&lt;/p&gt;&#xA;&lt;p&gt;Production readiness review is a real thing with a real checklist. This is the lightweight version: write down what &amp;ldquo;ready&amp;rdquo; means for this site, check each piece, and fix what&amp;rsquo;s missing.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-i-knew-going-in-and-what-i-didnt&#34;&gt;What I knew going in, and what I didn&amp;rsquo;t&lt;/h2&gt;&#xA;&lt;p&gt;Some of this was decided before I wrote a line of code. Netlify was always going to be the build and hosting target. It&amp;rsquo;s what I&amp;rsquo;ve been reaching for with static sites for personal projects, and the skeleton came with a &lt;code&gt;netlify.toml&lt;/code&gt; already in place.&lt;/p&gt;&#xA;&lt;p&gt;Other parts I only figured out while making the list. The big one: I want Cloudflare in front of the site for networking. I&amp;rsquo;ve spent a lot of time with Akamai, and I&amp;rsquo;d like to learn another common edge CDN while the stakes are this low.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-network-at-a-high-level&#34;&gt;The network, at a high level&lt;/h2&gt;&#xA;&lt;p&gt;Here&amp;rsquo;s how a request gets to a page, and how a page gets built:&lt;/p&gt;&#xA;&lt;pre class=&#34;mermaid&#34;&gt;flowchart LR&#xA;  reader([Reader]) --&amp;gt;|wrkstdy.com| cf[Cloudflare&amp;lt;br/&amp;gt;DNS + edge]&#xA;  cf --&amp;gt; netlify[Netlify&amp;lt;br/&amp;gt;hosting]&#xA;  dev([Me]) --&amp;gt;|git push| github[GitHub]&#xA;  github --&amp;gt;|webhook| build[Netlify build&amp;lt;br/&amp;gt;go tool mage build -production]&#xA;  build --&amp;gt;|public/| netlify&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;Two halves, really. On the read side, Cloudflare owns DNS for the domain and sits at the edge, and Netlify serves the files. On the write side, a push to GitHub kicks off a Netlify build, which runs the same Mage task I run locally and publishes &lt;code&gt;public/&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;h3 id=&#34;networking-with-cloudflare&#34;&gt;Networking with Cloudflare&lt;/h3&gt;&#xA;&lt;p&gt;Cloudflare is where most of the new-to-me learning happens. The pieces I needed to cover:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;DNS.&lt;/strong&gt; Moving the domain&amp;rsquo;s nameservers to Cloudflare and pointing the apex at Netlify.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Caching.&lt;/strong&gt; Deciding what the edge caches and for how long, and how that lines up with the headers Netlify already sends.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;DNS was the easy part: the nameservers moved to Cloudflare, and the apex points at Netlify.&lt;/p&gt;&#xA;&lt;p&gt;Caching was the interesting question. Netlify is a CDN too, so putting Cloudflare in front of it means two layers of caching to reason about. I could have spent a while working out how much each one should do, but I&amp;rsquo;m here to learn Cloudflare, not to tune two CDNs against each other. I also plan to proxy other projects on paths under wrkstdy.com, and that routing belongs at the edge anyway. So Cloudflare does the edge caching, and Netlify serves the files.&lt;/p&gt;&#xA;&lt;p&gt;I started with simple defaults. Cloudflare caches HTML at the edge for a day and the hashed assets for a year. Anything with a status of 300 or above gets a much shorter TTL, so a redirect or a 404 doesn&amp;rsquo;t stick around at the edge after I fix it.&lt;/p&gt;&#xA;&lt;p&gt;Behind the edge, Netlify&amp;rsquo;s headers tell browsers how long to keep things. Everything under &lt;code&gt;/assets/&lt;/code&gt; (fonts, CSS, JS, icons) has a hashed name and a year-long &lt;code&gt;immutable&lt;/code&gt; cache header. Their names change whenever their contents do, so there&amp;rsquo;s nothing to invalidate.&lt;/p&gt;&#xA;&lt;p&gt;HTML is different. Netlify sends &lt;code&gt;max-age=0&lt;/code&gt; for pages by default, which I&amp;rsquo;ve loosened to an hour. That means a deploy can take up to an hour to reach someone who already has a page open, which is a fine trade for a blog. A day at the edge is the part I don&amp;rsquo;t want to wait on, so a Netlify build plugin, &lt;a href=&#34;https://github.com/chrism2671/netlify-purge-cloudflare-on-deploy&#34;&gt;netlify-purge-cloudflare-on-deploy&lt;/a&gt;, purges Cloudflare&amp;rsquo;s cache after every production deploy. A new post shows up at the edge right away, not a day later. It&amp;rsquo;s set up in &lt;code&gt;netlify.toml&lt;/code&gt; to run for production only, so pushing to a pull request doesn&amp;rsquo;t empty the cache for the live site.&lt;/p&gt;&#xA;&lt;h3 id=&#34;builds-in-netlify&#34;&gt;Builds in Netlify&lt;/h3&gt;&#xA;&lt;p&gt;The build side is in better shape. &lt;code&gt;netlify.toml&lt;/code&gt; pins the Go version and runs &lt;code&gt;go tool mage build -production&lt;/code&gt;, which runs &lt;code&gt;go vet&lt;/code&gt; and the tests first and fails the deploy if either fails. Deploy previews and branch deploys get the same build with their own base URL, so links work on preview URLs too.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-html-itself&#34;&gt;The HTML itself&lt;/h2&gt;&#xA;&lt;p&gt;Next is the output: is the markup semantic, and is anything missing?&lt;/p&gt;&#xA;&lt;p&gt;A quick look turned up a few gaps right away:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;No favicon.&lt;/strong&gt; Every browser tab showed the default icon, and every page load logged a 404 for &lt;code&gt;/favicon.ico&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;No &lt;code&gt;robots.txt&lt;/code&gt; or sitemap.&lt;/strong&gt; Hugo generated these for free. My generator doesn&amp;rsquo;t, yet.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;No 404 page.&lt;/strong&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The favicon is the wordmark&amp;rsquo;s &lt;code&gt;~/&lt;/code&gt; in Fira Code SemiBold, outlined into an SVG that follows light and dark mode. Next to it there&amp;rsquo;s an &lt;code&gt;apple-touch-icon&lt;/code&gt; and a root &lt;code&gt;favicon.ico&lt;/code&gt; for clients that still ask for one there.&lt;/p&gt;&#xA;&lt;p&gt;The 404 page is a markdown file, &lt;code&gt;content/404.md&lt;/code&gt;, that the generator writes to &lt;code&gt;/404.html&lt;/code&gt;, which is where Netlify looks for a not-found page. It gets a &lt;code&gt;noindex&lt;/code&gt; tag instead of a canonical URL, and a test makes sure it stays that way.&lt;/p&gt;&#xA;&lt;p&gt;The structure itself is in decent shape: a &lt;code&gt;lang&lt;/code&gt; attribute, a single &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;, a labeled &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;, a canonical URL, and a description on every page. I still want to run each page through a validator and an accessibility checker instead of trusting a quick read.&lt;/p&gt;&#xA;&lt;h2 id=&#34;assets-and-what-else-loads&#34;&gt;Assets, and what else loads&lt;/h2&gt;&#xA;&lt;p&gt;Assets already go through a small pipeline. Each file in &lt;code&gt;assets/&lt;/code&gt; is written under a content-hashed name, with a Subresource Integrity hash for the &lt;code&gt;integrity&lt;/code&gt; attribute. That covers cache busting.&lt;/p&gt;&#xA;&lt;p&gt;It didn&amp;rsquo;t minify anything, though. With one stylesheet and one small script, I didn&amp;rsquo;t expect it to matter much, so I measured first. Gzipped, the stylesheet is 2.4 KB and my script is 0.7 KB. Minifying takes those to 2.2 KB and 0.6 KB. That&amp;rsquo;s a few hundred bytes, small but free, so the pipeline now runs CSS and JS through &lt;a href=&#34;https://github.com/tdewolff/minify&#34;&gt;tdewolff/minify&lt;/a&gt; before hashing them. Minifying comes first so the hash and the integrity attribute match the bytes that are actually served.&lt;/p&gt;&#xA;&lt;p&gt;The real weight is somewhere else. Diagrams like the one above load Mermaid from jsDelivr: about 208 KB gzipped, spread over 25-plus files. That&amp;rsquo;s more than everything else on the page combined, and it comes from someone else&amp;rsquo;s server. For now, Mermaid only loads on pages with a diagram.&lt;/p&gt;&#xA;&lt;p&gt;Concatenating and serving Mermaid myself both point to the same thing: a proper build step for CSS and JavaScript. That&amp;rsquo;s a bigger change than this pass, so I&amp;rsquo;m deferring concatenation until I add one, and I&amp;rsquo;ll work out how to deliver Mermaid at the same time.&lt;/p&gt;&#xA;&lt;p&gt;I also checked what loads that I didn&amp;rsquo;t put there. Cloudflare turns on its Real User Monitoring by default, which adds a beacon script to every page. I may want those numbers later, but for now it&amp;rsquo;s off.&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-first-pass-at-ci&#34;&gt;A first pass at CI&lt;/h2&gt;&#xA;&lt;p&gt;Until this pass, the only automated check was the one Netlify runs on deploy. That meant a broken test showed up as a failed deploy, not before it.&lt;/p&gt;&#xA;&lt;p&gt;The first step was a GitHub Actions workflow on pull requests and pushes to &lt;code&gt;main&lt;/code&gt;. It runs &lt;code&gt;go tool mage test&lt;/code&gt; and a build with drafts, on the same Go version Netlify pins, so a failure shows up on the PR instead. Drafts are in that build on purpose: deploy previews include drafts too, so a post can be reviewed on its PR before it&amp;rsquo;s published.&lt;/p&gt;&#xA;&lt;p&gt;Branch protections on &lt;code&gt;main&lt;/code&gt; back that up. Every change goes through a pull request, and both the CI job and Netlify&amp;rsquo;s deploy preview build have to pass before it can merge. It&amp;rsquo;s just me, so there are no required approvals.&lt;/p&gt;&#xA;&lt;h2 id=&#34;things-left-to-do&#34;&gt;Things left to do&lt;/h2&gt;&#xA;&lt;p&gt;I&amp;rsquo;m leaving a few things for another day:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Accessibility.&lt;/strong&gt; Run every page through a validator and an accessibility checker, and fix what they find.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;A sitemap and &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/strong&gt; Hugo generated these for free. My generator doesn&amp;rsquo;t, yet.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Git hooks.&lt;/strong&gt; Run the same Mage checks before a commit or push, not just on the PR.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;A build step for CSS and JS.&lt;/strong&gt; Concatenate assets, and serve Mermaid myself instead of from jsDelivr.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;None of it is exciting, but all of it is the kind of thing that&amp;rsquo;s easy to skip on a side project and annoying to find out about later. With the basics in place, I can get back to the parts I actually want to spend time on: the tooling and the styles.&lt;/p&gt;&#xA;</content:encoded>
    </item>
    <item>
      <title>Building a blog the long way, on purpose</title>
      <link>https://wrkstdy.com/blog/the-long-way-on-purpose/</link>
      <guid>https://wrkstdy.com/blog/the-long-way-on-purpose/</guid>
      <pubDate>Mon, 12 Oct 2026 00:00:00 +0000</pubDate>
      <description>Fresh off Mailchimp, I&#39;m building a deliberately overengineered blog to learn Go and try out new ways of working with AI.</description>
      <content:encoded>&lt;p&gt;I&amp;rsquo;m writing a blog.&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Narrator: you may be wondering how I found myself here.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;After 14+ years at Mailchimp, I have something I haven&amp;rsquo;t had in a long while &amp;mdash; time to poke at things just to see how they work. I want somewhere to keep track of that, so here we are: a blog.&lt;/p&gt;&#xA;&lt;p&gt;A blog generator is a pretty solved problem though. I could have grabbed Hugo or 11ty, dropped in a theme, and been writing this post in an afternoon. Which is exactly why I&amp;rsquo;m not doing that. (spoiler: Based on how I started it, this still took just an afternoon. Maybe just a longer one)&lt;/p&gt;&#xA;&lt;h2 id=&#34;overengineering-on-purpose&#34;&gt;Overengineering on purpose&lt;/h2&gt;&#xA;&lt;p&gt;I&amp;rsquo;m treating this site as a practice field. I already know what a blog needs to do &amp;mdash; turn some markdown into HTML, list some posts, look decent in light and dark &amp;mdash; so I don&amp;rsquo;t have to figure out &lt;em&gt;what&lt;/em&gt; to build. That leaves all my attention for &lt;em&gt;how&lt;/em&gt;, and the how is where I want to learn.&lt;/p&gt;&#xA;&lt;p&gt;Go has been on my radar for a long time. I&amp;rsquo;ve dabbled, but never really had the hands-on time to learn it properly. I like &lt;a href=&#34;https://commandcenter.blogspot.com/2012/06/less-is-exponentially-more.html&#34;&gt;how small the language is&lt;/a&gt; and how that nudges everyone toward the same style &amp;mdash; one formatter, one loop keyword, not a lot of ways to be clever with the language itself.&lt;/p&gt;&#xA;&lt;p&gt;So the site is built by a small generator I&amp;rsquo;m writing myself: &lt;a href=&#34;https://github.com/yuin/goldmark&#34;&gt;goldmark&lt;/a&gt; for markdown, the standard library&amp;rsquo;s &lt;code&gt;html/template&lt;/code&gt;, &lt;code&gt;fsnotify&lt;/code&gt; watching files for the dev server, and &lt;a href=&#34;https://magefile.org&#34;&gt;Mage&lt;/a&gt; to run tasks. None of it is necessary. That&amp;rsquo;s kind of the point.&lt;/p&gt;&#xA;&lt;p&gt;Building a blogging engine isn&amp;rsquo;t really new for me. I have deep experience with static site generators via &lt;a href=&#34;https://middlemanapp.com&#34;&gt;Middleman&lt;/a&gt;, and I&amp;rsquo;ve tried this exact trick before. Digging through my old site, I found a draft from 2014 for &amp;ldquo;Middlewareman&amp;rdquo; (yes, the name was a pun), a static site generator I was going to write in Clojure to learn a bit more of the language. And now twelve years later &amp;mdash; same idea, different language.&lt;/p&gt;&#xA;&lt;h2 id=&#34;starting-from-a-vibe-coded-skeleton&#34;&gt;Starting from a vibe-coded skeleton&lt;/h2&gt;&#xA;&lt;p&gt;I&amp;rsquo;ll be upfront: the first real commit of this site wasn&amp;rsquo;t mine, not really. I described what I wanted (a forest color scheme was obviously a must) and had AI generate a Hugo skeleton &amp;mdash; layouts, syntax highlighting, the works. I didn&amp;rsquo;t want the lack of a build system standing between me and getting started.&lt;/p&gt;&#xA;&lt;p&gt;Since then, I&amp;rsquo;ve made a few &amp;ldquo;critical&amp;rdquo; edits to get closer to what I had in mind:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Swapped Hugo for my own Go generator.&lt;/strong&gt; Hugo does its job well, maybe too well. It&amp;rsquo;s hard to learn Go from a tool that does everything for you.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cut tags, the separate post archive, and RSS.&lt;/strong&gt; The skeleton came with all the things a &amp;ldquo;real&amp;rdquo; blog has. With one post, the home page list is archive enough, and I don&amp;rsquo;t have anything to tag yet, so out they went. They can come back when they earn it.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Added Mage.&lt;/strong&gt; I know Rake well, so I went looking for something similar in Go, and Mage&amp;rsquo;s Rake-style commands were a great fit. Now it&amp;rsquo;s &lt;code&gt;go tool mage serve&lt;/code&gt; instead of remembering a handful of &lt;code&gt;go run&lt;/code&gt; incantations, and Netlify runs the same &lt;code&gt;check&lt;/code&gt; task I do.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;These changes move it more towards what I want than the starting point. I expect that to keep going, and I&amp;rsquo;ll write about it as it does.&lt;/p&gt;&#xA;&lt;h2 id=&#34;working-with-ai-differently&#34;&gt;Working with AI, differently&lt;/h2&gt;&#xA;&lt;p&gt;At Mailchimp I had a solid AI toolset, but it was about &lt;em&gt;doing work&lt;/em&gt; &amp;mdash; getting a known task done faster. Here I want to explore the ways of working themselves: what&amp;rsquo;s worth handing off, what I want to keep my hands on, and where the back-and-forth actually makes the result better.&lt;/p&gt;&#xA;&lt;p&gt;Up to now I&amp;rsquo;ve been heavily invested in a plan → implement → review workflow: agree on the approach first, build it, then look the result over. It works, and I want to see what else is out there. Next I&amp;rsquo;d like to try playbooks and loops, where the steps are written down once and run repeatedly, instead of me walking each task through the same stages by hand.&lt;/p&gt;&#xA;&lt;p&gt;This post is a small example of where things stand. I outlined my points and notes, then had AI help fill them in with prose, and steered it from there. The voice isn&amp;rsquo;t fully mine yet &amp;mdash; putting together a solid voice and tone guide is on my to-do list.&lt;/p&gt;&#xA;&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s next&lt;/h2&gt;&#xA;&lt;p&gt;First, more adjustments to the blog itself, including a pass to check it&amp;rsquo;s ready for production. Beyond that, I have a few projects in mind that I&amp;rsquo;ll share here once they&amp;rsquo;re started, along with how I worked through them &amp;mdash; dead ends included. Two I&amp;rsquo;m eyeing: a fishing notes app that ties your notes to a location and its watershed, and a CSS themer for raw markdown. No promises on either yet.&lt;/p&gt;&#xA;</content:encoded>
    </item>
  </channel>
</rss>