<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://madisonjmeredith.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://madisonjmeredith.com/" rel="alternate" type="text/html" /><updated>2026-07-30T04:27:06+00:00</updated><id>https://madisonjmeredith.com/feed.xml</id><title type="html">Madison Meredith</title><subtitle>Personal website of Madison Meredith — portfolio, writing, and more.</subtitle><author><name>Madison Meredith</name></author><entry><title type="html">Write it down once</title><link href="https://madisonjmeredith.com/writing/write-it-down-once/" rel="alternate" type="text/html" title="Write it down once" /><published>2026-07-14T00:00:00+00:00</published><updated>2026-07-14T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/write-it-down-once</id><content type="html" xml:base="https://madisonjmeredith.com/writing/write-it-down-once/"><![CDATA[<p>The question I get about Claude Code is almost always some version of “okay but what do you type.” Which is fair. The interface is a text box, so of course that’s where you look. But the typing is genuinely the least interesting part of my setup, and has been for a while.</p>

<p>The method, if it deserves that word, is one sentence: every time I caught myself explaining the same preference twice, I stopped explaining it and put it in a file the model reads on its own. Everything below is just where I put things.</p>

<h2 id="plan-mode">Plan mode</h2>

<p>Start here, because it’s free and it’s the one thing I’d push on anybody.</p>

<p>Shift+Tab twice and the model can read but not write. It goes off, looks around, and comes back with a plan instead of a diff.</p>

<p>The value isn’t that the plan is better than what it would have done anyway. Sometimes it is, often it’s identical. It’s that a plan is cheap to argue with and a diff isn’t. When I read a plan and the third bullet says it’s adding an abstraction I don’t want, I type “no, put it in the existing helper” and that costs me nine seconds. When I meet that same decision as a 200-line diff across four files, I either accept it or make it A Whole Thing, and now I’m negotiating with sunk cost on both sides of the conversation.</p>

<p>So: plan mode for anything I can’t hold in my head, which is most things, and straight to it for “rename this variable.”</p>

<p>The part I didn’t expect is that it’s also a read on whether I know what I’m asking for. If the plan comes back and I can’t tell whether it’s right, that isn’t a model problem. That’s me not having decided yet, and finding that out before any code exists is kind of a gift.</p>

<figure class="pull-quote">
  <blockquote class="pull-quote__text">A plan is cheap to argue with and a diff isn't.</blockquote>
</figure>

<h2 id="commit-runs-simplify-first">/commit runs /simplify first</h2>

<p>This is my favorite one, and it’s about four sentences of configuration.</p>

<p>I have a <code class="language-plaintext highlighter-rouge">commit</code> skill, because I got tired of relitigating message conventions (imperative subject, body explains why and not what, no British spellings sneaking in). Standard stuff. The interesting bit is what I bolted onto the front of it: before it stages anything or writes a word, it judges whether the diff is substantial enough to warrant a cleanup pass, and if it is, it runs <code class="language-plaintext highlighter-rouge">/simplify</code> and those edits land in the same commit.</p>

<div class="code-block">
  <div class="code-block__filename">~/.claude/skills/commit/SKILL.md</div>
  <pre class="code-block__pre"><code>Use judgment, this is a gate, not a reflex:

- Run simplify when the diff adds or reworks real logic: new functions
  or classes, non-trivial control flow, a multi-file change, or more
  than a couple dozen substantive lines.
- Skip it for trivial or mechanical changes: typos, config bumps,
  lockfiles, pure renames, formatting-only diffs, one-liners.
- When it's borderline, lean toward running it.</code></pre>
  <div class="code-block__language">Markdown</div>
</div>

<p>Two things make this work, and I think the second one is the actual insight.</p>

<p>The first is what simplify catches. A model’s first draft tends to be correct and slightly too much: an extra layer of indirection, a helper that duplicates one three files over, a function sitting a level lower than it needs to. None of those are bugs. That’s exactly why they survive review, mine included, and it’s how a codebase gradually turns into something nobody volunteers to open.</p>

<p>The second is when. I could run simplify whenever I like. I never would. The moment before committing is the only moment I reliably still have the diff loaded in my head and haven’t already started thinking about the next thing. Attention is the scarce resource, not compute, so the cleanup has to be welded to a thing I’m already doing.</p>

<p>And the gate matters as much as the pass. An early version ran it on everything and it was insufferable. You bump a version number and something spends 40 seconds contemplating your <code class="language-plaintext highlighter-rouge">package.json</code>.</p>

<h2 id="a-hook-that-wont-let-me-cheat">A hook that won’t let me cheat</h2>

<p>Here’s the problem with skills: a skill is a document sitting on a disk. The failure mode isn’t that it’s wrong, it’s that in a hurry somebody (the model, or me) just writes a plain <code class="language-plaintext highlighter-rouge">git commit</code> and none of it happens.</p>

<p>So there’s a <code class="language-plaintext highlighter-rouge">PreToolUse</code> hook. Every <code class="language-plaintext highlighter-rouge">git commit</code> gets inspected and blocked unless the command carries a marker.</p>

<div class="code-block">
  <div class="code-block__filename">enforce-commit-skill.sh</div>
  <pre class="code-block__pre"><code># Skill-emitted commit, carries the marker.
if printf '%s' "$command" | grep -qE 'CLAUDE_SKILL=commit([^A-Za-z0-9_-]|$)'; then
  exit 0
fi</code></pre>
  <div class="code-block__language">Bash</div>
</div>

<p>The prefix does nothing. It sets an env var for the git process and never touches the commit itself. It’s a token, and the only thing it proves is that the skill got loaded.</p>

<p>The nice part, which I would love to claim I designed on purpose: the deny message never says what the marker is. So the only route past the fence is to go read the skill, at which point you’ve read the conventions anyway. The fence teaches you the rule while it’s stopping you. Amends and fixups pass straight through, because the history-rewriting skills need them and they aren’t new commits.</p>

<p>There’s a sibling hook for <code class="language-plaintext highlighter-rouge">gh pr create</code>, and one that checks git identity, because I have a work email and a personal one and my projects are emphatically not sorted by directory (work repos live under <code class="language-plaintext highlighter-rouge">~/Herd</code> right next to personal ones). Deciding by path would be wrong maybe a tenth of the time, which is exactly often enough to bite and rare enough that I’d stop checking.</p>

<p>Permissions themselves I leave on auto, and the deny list is two entries, both <code class="language-plaintext highlighter-rouge">docker prune</code>. Make of that what you will.</p>

<h2 id="design-somewhere-else-then-hand-over-the-artifact">Design somewhere else, then hand over the artifact</h2>

<p>The site you’re reading this on was designed in Claude Design and built by Claude Code, and I tried hard to keep them out of each other’s business.</p>

<p>What Claude Design gives you isn’t a picture of a website, it’s a design system: tokens, plus a set of components with real style objects behind them. So the handoff to Claude Code isn’t “here’s a vibe, please match it.” It’s “here is a file, transcribe it.” The token blocks at the top of my <code class="language-plaintext highlighter-rouge">main.css</code> are a verbatim copy of the design system’s token files, and each component’s CSS is a transcription of that component’s style object, one BEM block each. Both rules are written into the project’s CLAUDE.md, along with the one that actually enforces them: read the component, never work from memory.</p>

<p>Why bother, when you could just ask for a nice landing page? Because you’ll get one. And then the next page will be nice in a slightly different way, and by page four you have four color scales and a spacing system that’s really a rumor. Doing the design once, upstream, means each decision gets made a single time and then persists.</p>

<p>It also changes the shape of the failures, which I care about more than I expected to. When the build is wrong now, it’s wrong in a checkable way: this padding doesn’t match that token. That’s a bug with a correct answer, instead of a taste argument I’m having with a computer at 11 at night.</p>

<h2 id="the-claudemd-is-mostly-scar-tissue">The CLAUDE.md is mostly scar tissue</h2>

<p>Everyone’s first CLAUDE.md is preferences, and mine is too. American English. BEM. Alphabetize CSS declarations. A whole little ordering for HTML attributes, with an exception for when the class value is a wall of Tailwind and belongs at the end instead. Useful, boring, saves me re-typing myself.</p>

<p>The part that actually earns its place is the gotchas. Every entry is something that already cost me an hour, written down so it costs zero hours forever. My Jekyll site’s file has a note that <code class="language-plaintext highlighter-rouge">assign</code> inside an include leaks into the caller and survives into the next iteration of the enclosing loop, which is why every project card was quietly inheriting the previous project’s tag color. I would not have found that twice. I barely found it once.</p>

<aside class="callout callout--warning">
  <div class="callout__label">The one that fooled me for an afternoon</div>
  <div class="callout__body">Chrome on macOS clamps its window to 500px wide, so headless <code>--window-size=390</code> silently renders a 485px layout and just <em>crops</em> the screenshot. It reads as horizontal overflow that isn’t there. To test a narrow viewport you have to render the page inside a 390px iframe and measure <code>scrollWidth</code> in there.</div>
</aside>

<p>Same logic covers the voice stuff. I keep one skill that holds my work register and another that holds this one, because “no em-dashes, no ‘the bottom line,’ keep the hedging” is not a thing I want to type into a chat box every time I sit down. (Yes, I’m aware of what that implies about this post. The opinions are mine, the American spellings are not.)</p>

<h2 id="the-peons">The peons</h2>

<p>Not a tip, just true. Every hook event in my setup also plays a Warcraft III peon line. Session start, tool failure, permission request, subagent spawn. “Work work.” “Something need doing?” “Me not that kind of orc.”</p>

<p>It’s completely unserious and it’s staying. An audio channel for “the thing you started 90 seconds ago now wants something from you” is legitimately useful once you’ve context-switched into Slack, and the fact that it’s delivered by a small green man is orthogonal to that. There’s also an exercise-logging mode attached to it that I decline to justify here.</p>

<h2 id="the-transferable-bit">The transferable bit</h2>

<p>None of this is clever. It’s just noticing you’ve repeated yourself and then spending ten minutes. The setup looks deliberate written out like this, but it accumulated over months, one annoyance at a time, and about a third of it exists because I was annoyed at 6 pm on a Friday.</p>

<p>Take the specifics with a grain of salt, obviously. This is sanded to fit my hands, my projects, and my particular neuroses about commit messages. The transferable bit is smaller and duller than the config: when you find yourself typing the same instruction a second time, that’s the signal.</p>

<p>Stop typing it. Go put it in a file.</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="AI" /><category term="Tooling" /><summary type="html"><![CDATA[The question I get about Claude Code is almost always some version of “okay but what do you type.” Which is fair. The interface is a text box, so of course that’s where you look. But the typing is genuinely the least interesting part of my setup, and has been for a while.]]></summary></entry><entry><title type="html">Documentation for the you who forgot</title><link href="https://madisonjmeredith.com/writing/documentation-for-the-you-who-forgot/" rel="alternate" type="text/html" title="Documentation for the you who forgot" /><published>2026-06-23T00:00:00+00:00</published><updated>2026-06-23T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/documentation-for-the-you-who-forgot</id><content type="html" xml:base="https://madisonjmeredith.com/writing/documentation-for-the-you-who-forgot/"><![CDATA[<p>14 months away from a project, and back on it in May for two weeks of changes.</p>

<p>There’s a <code class="language-plaintext highlighter-rouge">NOTES.md</code> in the root that I wrote. Two entries in it did their job perfectly. One told me the deploy needs a flag I’d never have guessed and would have had to reverse-engineer from a CI config. Another told me that a bit of ordering in the checkout looks wrong and is deliberate, with a sentence about why, which stopped me tidying it up and breaking something.</p>

<p>A third told me confidently how the staging environment works. It had been true in 2023. Nobody had touched the file since, including me, and I spent about 90 minutes finding out it was wrong, most of which I spent assuming I’d misunderstood it rather than that it was lying.</p>

<p>Net, that file is well ahead. But the 90 minutes is the interesting part, because it’s the cost that nobody accounts for when they talk about writing things down.</p>

<h2 id="who-youre-actually-writing-for">Who you’re actually writing for</h2>

<p>The useful move, and the one that decides everything else, is being specific about the reader.</p>

<p>Future you, at some distance, is not a beginner and not a stranger. They have every bit of general skill you have now. They know the language, they know the framework, they can read the code faster than they can read your prose about the code.</p>

<p>What they have lost is entirely context. Not “how does a Jekyll collection work,” which they can look up in 30 seconds. Rather: why is it done this way here, what was tried before, and what is going to look like a mistake when they open it.</p>

<p>Once that’s the reader, most of what people put in documentation turns out to be for somebody else, and mostly for a person who doesn’t exist.</p>

<h2 id="three-categories-and-only-one-of-them-pays">Three categories, and only one of them pays</h2>

<p>Things recoverable from the code. What the function does. What the components are. Where the routes live. Don’t write these down. They’re the cheapest thing in the world to obtain, and the moment you write them somewhere else you’ve created a second copy that drifts, and the drift is invisible. That’s the argument I made about commit messages in 2018 from the other direction: a commit message is welded to its diff and cannot lie, and a paragraph in a wiki has nothing holding it to anything.</p>

<p>Things recoverable, but expensively. The deploy sequence. The flag. The one-off command with seven arguments. Which of the four env vars actually has to be set. These are worth writing down and they’re what most people mean by documentation, and the return is real but bounded, because the alternative is 20 minutes of archaeology rather than a day.</p>

<p>Things not recoverable at all. Why the ordering matters. What we tried that didn’t work. What the client insisted on over your objection. Which bit looks wrong and is deliberate. Why the obvious better approach isn’t better here.</p>

<p>That third category is the whole game and it’s the one almost nobody writes, because at the moment you have the information it feels too obvious to be worth recording. It never survives. Six months is enough to lose it, and after two years it isn’t anywhere in the world except one person’s head, and eventually not there either.</p>

<figure class="pull-quote">
  <blockquote class="pull-quote__text">Everything that looks wrong and is deliberate will be tidied up by somebody eventually, and the somebody is usually you.</blockquote>
</figure>

<h2 id="the-two-formats-i-actually-keep">The two formats I actually keep</h2>

<p>Nothing elaborate, and I’ve tried elaborate.</p>

<p>A comment next to the strange line, saying it’s strange on purpose. This is the highest return per second of any writing I do. It’s in the one place guaranteed to be read by whoever is about to break it, more than can be said for a wiki, and it moves with the code when the code moves.</p>

<div class="code-block">
  <div class="code-block__filename">checkout.js</div>
  <pre class="code-block__pre"><code>// Deliberate: clear the session BEFORE the redirect, not after.
// Their payment provider re-enters this handler on the way back and
// the later version double-fired the confirmation email. Sept 2023.
clearCart(session);
redirect(returnUrl);</code></pre>
  <div class="code-block__language">JavaScript</div>
</div>

<p>And a decision log: a dated list, appended to, never reorganized. Three lines an entry: what we decided, what we rejected, why. It takes about 90 seconds at the moment of deciding, the only moment you have the information.</p>

<p>The one that has earned its place most unexpectedly is the rejected half. Knowing that somebody already tried the obvious thing in 2024 and it didn’t work has saved me from re-running the same failed experiment at least three times, and on one of those I’d have lost the better part of a week.</p>

<h2 id="the-rot-problem-which-is-the-real-discipline">The rot problem, which is the real discipline</h2>

<p>Back to the 90 minutes.</p>

<p>A document that’s wrong is worse than no document, because it’s confidently wrong and it comes with your own name on it, so the reader trusts it and troubleshoots themselves instead of it. No document at all makes them go and look, slower and correct.</p>

<p>Which means the discipline is not writing more. It’s writing less and deleting aggressively.</p>

<aside class="callout">
  <div class="callout__label">Date every entry</div>
  <div class="callout__body">A line with "March 2024" next to it tells the reader how much to trust it, something no amount of careful wording can do. It also makes deletion easy later: anything from a version of the stack you no longer run can go without anybody having to adjudicate whether it's still true.</div>
</aside>

<p>The test I use, and it’s the only maintenance ritual that has stuck: whenever I come back to a project after a gap, I do one small task and pay attention to what I have to work out. Whatever I spend more than a minute rediscovering goes in the file. Whatever I find in the file that’s no longer true comes out, right then, while I have the evidence in front of me.</p>

<p>It’s about 15 minutes and it happens at the exact moment I’m most motivated, namely while I’m annoyed.</p>

<h2 id="and-now-something-else-reads-it">And now something else reads it</h2>

<p>The bit I didn’t expect, and the reason I’m writing this now rather than in 2017 when I wrote about READMEs.</p>

<p>These files have a second reader now. Whatever your agent reads at the start of a session (mine’s a <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>) is documentation, written by you, for a reader with perfect attendance and no memory whatsoever, which is a rather precise description of the future-you I’ve been talking about the whole way through.</p>

<p>And it turned out the two are the same document. What makes a file useful to an agent is exactly what makes it useful to somebody returning after a year: specific, current, stated as a rule with a consequence, no aspiration, no “we value clean code.”</p>

<p>The part I found genuinely surprising is which direction the improvement ran. I started writing these files for the machine, and my documentation for people got better, because the machine is unforgiving about vagueness in a way a colleague is far too polite to be. If a line is ambiguous, it does the wrong thing, immediately and visibly, and you go and fix the line. Nobody has ever given me that feedback loop on prose before.</p>

<p>Which is a whole post of its own and I’ll write it properly. For now: the file you’d write for the version of yourself who forgot is the same file, and you were probably going to have to write it anyway.</p>

<h2 id="even-odds">Even odds</h2>

<p>The staging note is fixed. It’s four lines now instead of 11, it has a date on it, and two of the 11 were describing something that no longer exists and are simply gone.</p>

<p>14 months from now I’ll be back on this and I’ll find out whether I did that well. I’d put it at about even odds, honestly, which is the correct amount of confidence to have in a person who has demonstrably done this wrong before.</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="Documentation" /><category term="AI" /><summary type="html"><![CDATA[14 months away from a project, and back on it in May for two weeks of changes.]]></summary></entry><entry><title type="html">srcset, revisited</title><link href="https://madisonjmeredith.com/writing/srcset-revisited/" rel="alternate" type="text/html" title="srcset, revisited" /><published>2026-05-19T00:00:00+00:00</published><updated>2026-05-19T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/srcset-revisited</id><content type="html" xml:base="https://madisonjmeredith.com/writing/srcset-revisited/"><![CDATA[<p>I audited a site in April that was built last year. Responsive images everywhere, generated by the build, four widths per image, AVIF and JPEG, all of it correct.</p>

<p>On a phone, every image on the page downloaded at 1600 pixels wide.</p>

<p>Which is the same bug, exactly, that I wrote a post about in January 2018. Same cause, same symptom, eight years and an entire generation of tooling later. So this seems like a reasonable moment to go back over it.</p>

<h2 id="the-bug-still">The bug, still</h2>

<p><code class="language-plaintext highlighter-rouge">sizes</code> defaults to <code class="language-plaintext highlighter-rouge">100vw</code>. If you don’t write one, the browser assumes every image will be displayed at the full width of the viewport, picks accordingly, and on a narrow screen with a 2× display that reasoning lands on your largest file.</p>

<p>It looks completely fine. The images are sharp, nothing is broken, and unless you open the network panel and look at what was actually fetched, there is no symptom at all. That’s why it survives code review and why it was still there a year after launch.</p>

<p>Everything else about this has improved. That hasn’t moved an inch.</p>

<h2 id="four-things-that-did-change">Four things that did change</h2>

<p><code class="language-plaintext highlighter-rouge">width</code> and <code class="language-plaintext highlighter-rouge">height</code> came back from the dead. In 2018 I’d have told you to strip them, because CSS does the sizing and the attributes are a relic. Then browsers started using them to compute an aspect ratio and reserve the space before the file arrives, and suddenly the two oldest attributes in HTML were the single biggest fix for layout shift. Put them on everything, always, with the intrinsic dimensions of the file. This is the highest-value thing in the post and it has nothing to do with <code class="language-plaintext highlighter-rouge">srcset</code>.</p>

<p><code class="language-plaintext highlighter-rouge">loading="lazy"</code> made the whole question smaller. An image that never gets fetched doesn’t need the right width. It’s one attribute and it’s in everything now, and it did more for the average page than any amount of careful <code class="language-plaintext highlighter-rouge">sizes</code> tuning. Not for anything above the fold, mind, and I wrote a whole post in 2020 about the three places where lazy-loading makes things worse, all of which are still true.</p>

<p><code class="language-plaintext highlighter-rouge">fetchpriority="high"</code> on the one image that matters. The hero, the thing that is almost certainly your largest contentful paint. The browser deprioritizes images relative to stylesheets and scripts by default, which is usually right and is wrong for that specific image. One attribute, and on a couple of sites it’s been worth more than everything else I did that day.</p>

<p>And the generation problem got solved by tooling rather than by the platform. The complaint at the end of the 2018 post was maintenance: four files per image, by hand, regenerated whenever anything changed, and nobody would keep it up. That was correct and it has been comprehensively fixed. Every static site generator and every framework now has an image component or plugin that takes one source file and emits the whole set at build time, and image CDNs do the same thing on the fly. Nobody hand-writes a <code class="language-plaintext highlighter-rouge">srcset</code> any more, which is precisely why nobody notices when <code class="language-plaintext highlighter-rouge">sizes</code> is missing from the template that writes them.</p>

<div class="code-block">
  <div class="code-block__filename">2018 vs now</div>
  <pre class="code-block__pre"><code>&lt;!-- what I was writing in 2018 --&gt;
&lt;img src="hero-800.jpg"
     srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
     sizes="(min-width: 60rem) 40rem, 100vw"
     alt="…"&gt;

&lt;!-- the hero, now --&gt;
&lt;img src="hero-800.jpg"
     srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
     sizes="(min-width: 60rem) 40rem, 100vw"
     width="1600" height="900"
     alt="…"
     loading="eager" decoding="async" fetchpriority="high"&gt;</code></pre>
  <div class="code-block__language">HTML</div>
</div>

<h2 id="what-sizes-actually-is-still">What sizes actually is, still</h2>

<p>The framing from 2018 that I’d keep.</p>

<p><code class="language-plaintext highlighter-rouge">srcset</code> is you listing what you’ve got. <code class="language-plaintext highlighter-rouge">sizes</code> is you telling the browser how wide the thing will end up on the page, so it can choose before it knows, because it has to choose before layout has happened and it would rather guess than wait.</p>

<p>And the reason it’s a bad attribute is that it’s a copy of your CSS, written in a different language, in the markup, kept in sync by hand. Change a container from 40rem to 44rem and every <code class="language-plaintext highlighter-rouge">sizes</code> on the site is now subtly wrong and nothing tells you. It’s the only piece of layout information a modern site is expected to duplicate outside the stylesheet, and it has been that way since the day it shipped.</p>

<h2 id="which-is-finally-being-fixed">Which is finally being fixed</h2>

<p><code class="language-plaintext highlighter-rouge">sizes="auto"</code> lets the browser skip the guessing and use the width the image actually got.</p>

<div class="code-block">
  <pre class="code-block__pre"><code>&lt;img src="thumb-400.avif"
     srcset="thumb-400.avif 400w, thumb-800.avif 800w, thumb-1200.avif 1200w"
     sizes="auto"
     width="1200" height="800"
     alt="…"
     loading="lazy" decoding="async"&gt;</code></pre>
  <div class="code-block__language">HTML</div>
</div>

<p>No media queries, no rem values, nothing to keep in sync. The layout is the source of truth, which it should have been all along.</p>

<p>It works because the image is lazy: by the time the browser gets round to fetching it, layout has happened and it can measure the box instead of predicting it. Which is also the catch, and it’s a good one.</p>

<aside class="callout callout--warning">
  <div class="callout__label">Not on the image that matters most</div>
  <div class="callout__body"><code>sizes="auto"</code> requires <code>loading="lazy"</code>, and the image where choosing the right width matters most is the hero, which must never be lazy. So the one place you'd most like the browser to just look is the one place it still can't, and that image still needs a hand-written <code>sizes</code> and always will.</div>
</aside>

<p>Check support before you lean on it rather than trusting a post you’re reading a year after it was written. It’s been in two engines for a while and the third caught up more recently, and the fallback is harmless anyway: a browser that doesn’t understand <code class="language-plaintext highlighter-rouge">auto</code> treats the value as invalid and falls back to <code class="language-plaintext highlighter-rouge">100vw</code>, which is where you were already.</p>

<h2 id="what-didnt-change-at-all">What didn’t change at all</h2>

<p>Two things, and they’re the two I’d have most liked to report progress on.</p>

<p>The CMS still undoes it. I wrote that post in 2016 and every word of it stands. The pipeline resizes what the developer commits. The client uploads a 4,000-pixel photograph straight off a camera through an admin form, and whether that gets handled depends entirely on whether somebody wired the transformation into the upload path, which is a decision about the CMS and not about HTML.</p>

<p>And none of this is the first thing to check. If an image is the wrong dimensions for its slot, or was exported at quality 100 by somebody who has never opened it since, then no amount of <code class="language-plaintext highlighter-rouge">srcset</code> is going to save it. The attributes decide which of your files gets sent. They have nothing whatsoever to say about whether the files were any good.</p>

<h2 id="the-usual-shape">The usual shape</h2>

<p>Eight years, and the honest summary is that the attribute I wrote 1,500 words about in 2018 has been quietly demoted. <code class="language-plaintext highlighter-rouge">width</code>, <code class="language-plaintext highlighter-rouge">height</code>, <code class="language-plaintext highlighter-rouge">loading</code> and <code class="language-plaintext highlighter-rouge">fetchpriority</code> between them are worth more on a typical page than every hour I’ve spent tuning breakpoint lists, and three of those four are things you set once and never think about again.</p>

<p>Which is the usual shape, isn’t it. The thing that turned out to matter was boring, was already in the language, and I’d spent the previous few years being told to delete it.</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="Images" /><category term="Responsive" /><category term="Performance" /><summary type="html"><![CDATA[I audited a site in April that was built last year. Responsive images everywhere, generated by the build, four widths per image, AVIF and JPEG, all of it correct.]]></summary></entry><entry><title type="html">It said it was working</title><link href="https://madisonjmeredith.com/writing/it-said-it-was-working/" rel="alternate" type="text/html" title="It said it was working" /><published>2026-05-05T00:00:00+00:00</published><updated>2026-05-05T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/it-said-it-was-working</id><content type="html" xml:base="https://madisonjmeredith.com/writing/it-said-it-was-working/"><![CDATA[<p>We have an internal tool at work called cadence-claude, and I want to describe it properly before I spend 2,000 words on its bugs, because it’s the best piece of internal tooling I’ve worked on and the bugs are almost entirely my fault.</p>

<p>The pitch is that it lets you run Claude Code in <code class="language-plaintext highlighter-rouge">--dangerously-skip-permissions</code> mode without handing over the keys to your machine. Three independent layers, and all of them have to say yes. A set of PreToolUse hooks catches intent: they refuse ssh-family commands aimed at hosts you haven’t allowlisted, and they refuse reads of <code class="language-plaintext highlighter-rouge">~/.ssh</code>, <code class="language-plaintext highlighter-rouge">~/.aws</code>, keychains, browser profiles, shell history. A kernel egress filter catches bytes, on Linux by moving the session’s processes into a per-project cgroup filtered by nftables, so a forbidden destination is refused no matter how the connection got opened. And a per-project ssh-agent catches credentials: the key you’d need to authenticate isn’t even loaded unless you explicitly granted that environment.</p>

<p>That composition is the good idea. The hook layer is defeatable in principle, because it’s parsing shell, and shell is an adversarial input format with infinite surface. The kernel layer doesn’t care how clever your quoting was. The agent layer doesn’t care whether you reached the host. You have to beat all three, and they fail in uncorrelated ways.</p>

<p>The build quality around it is the part I didn’t expect. The binary installs root-owned and immutable, <code class="language-plaintext highlighter-rouge">chattr +i</code> on Linux and <code class="language-plaintext highlighter-rouge">chflags uchg</code> on macOS, so the thing governing enforcement can’t be edited by the agent it’s governing. The install is wrapped in an EXIT trap that re-seals on Ctrl-C or any failure path, so a half-finished upgrade can’t leave it writable. There’s a <code class="language-plaintext highlighter-rouge">verify</code> subcommand that walks the whole trusted computing base and asserts ownership and immutability and hook registration. There’s a SessionStart hook whose only job is to re-assert the tool’s own entries in <code class="language-plaintext highlighter-rouge">settings.json</code> in case something stripped them. Every rendered hook carries a version string so an upgrade can detect skew and re-render.</p>

<p>And it shipped with a bypass suite. 118 adversarial cases, written by someone actively trying to defeat their own hooks: quoted command names, <code class="language-plaintext highlighter-rouge">bash -c</code> wrappers, process substitution, <code class="language-plaintext highlighter-rouge">GIT_SSH_COMMAND=</code>, <code class="language-plaintext highlighter-rouge">ext::</code> transports, <code class="language-plaintext highlighter-rouge">-o KnownHostsCommand=</code>. That suite is why this post exists at all. Hold that thought.</p>

<p>A coworker of mine built all of it, and built it entirely on Linux. cgroups, nftables, systemd units, a little Python daemon over a Unix socket. Then I got handed the job of making it work on a Mac, which I estimated at maybe a week, because how different can it be.</p>

<p>Reader, the very first line of the install died. <code class="language-plaintext highlighter-rouge">install -g root</code> fails on macOS with “install: unknown group root,” because the root user’s group is <code class="language-plaintext highlighter-rouge">wheel</code> and there is no root group at all. Six call sites, ten minutes. In hindsight it was the friendliest bug of the entire project, because at least it stopped.</p>

<h2 id="91-of-118">91 of 118</h2>

<p>I ran the bypass suite on my Mac and it reported 91 failures out of 118.</p>

<p>I want to be precise about what that number means, because it reads worse than it was. It doesn’t mean the tool was broken. On Linux, where it had run every day for months, it was green and it was correct. What that number means is that this was a platform the code had never once executed on, and the suite noticed inside of 30 seconds. Most internal tools would have let me ship a Mac port and find out some other way.</p>

<aside class="callout callout--aside">
  <div class="callout__label">Why 3.2, still, in 2026</div>
  <div class="callout__body">macOS ships bash 3.2.57, which came out in 2007. Bash 4.0 relicensed to GPLv3, Apple won’t ship GPLv3, so <code>/bin/bash</code> froze at the last GPLv2 release and has stayed there for 18 years. The default interactive shell is zsh now, but a script with a <code>#!/bin/bash</code> shebang still gets the fossil.</div>
</aside>

<p>Four idioms in the hooks needed bash 4:</p>

<div class="code-block">
  <pre class="code-block__pre"><code>${var,,}                 # lowercasing
"${empty_array[@]}"      # unbound-variable error under set -u
local -A seen            # associative arrays
${dir/#$HOME/\~}         # keeps the backslash literal on 3.2</code></pre>
  <div class="code-block__language">Bash</div>
</div>

<p>Ordinary portability bugs. The part that kept me up was which direction they broke in.</p>

<p><code class="language-plaintext highlighter-rouge">${var,,}</code> aborts <code class="language-plaintext highlighter-rouge">extract_targets</code>, so the segment splitter decides most ssh-family commands aren’t ssh commands and lets them through. The empty-array expansion trips <code class="language-plaintext highlighter-rouge">set -u</code> and skips the entire subshell-walk loop, the one that catches <code class="language-plaintext highlighter-rouge">$(ssh …)</code>, backticked ssh, <code class="language-plaintext highlighter-rouge">bash -c</code>, <code class="language-plaintext highlighter-rouge">nohup</code>, <code class="language-plaintext highlighter-rouge">timeout</code> and process substitution. <code class="language-plaintext highlighter-rouge">local -A</code> is a parse error, so <code class="language-plaintext highlighter-rouge">protect-secrets</code> bails out of its content scan before it has looked at a single token. And the tilde substitution never matching meant <code class="language-plaintext highlighter-rouge">cat ~/.ssh/id_rsa</code> was simply allowed.</p>

<p>None of these crashed the hook. A helper bailed, the script carried on, and it reached the bottom and exited 0. In the hook protocol, exit 0 means allow.</p>

<figure class="pull-quote">
  <blockquote class="pull-quote__text">The default answer of a broken security hook is yes.</blockquote>
</figure>

<p>My favorite one wasn’t even bash. The destructive-command hook used <code class="language-plaintext highlighter-rouge">\b</code> in a sed regex to catch sudo-prefixed payloads. <code class="language-plaintext highlighter-rouge">\b</code> is a GNU extension. BSD sed doesn’t error on it, doesn’t warn, just quietly matches nothing, so every <code class="language-plaintext highlighter-rouge">sudo</code>-wrapped bypass in the suite walked straight past. I replaced it with a POSIX character class and a backreference, and I still think about how many other regexes in how many other scripts are silently no-opping on somebody’s Mac right now.</p>

<p>There was also a genuinely funny one where the tests were wrong too. The harness built a project under <code class="language-plaintext highlighter-rouge">$TEST_TMP</code>, but on macOS <code class="language-plaintext highlighter-rouge">/var/folders/…</code> is a symlink to <code class="language-plaintext highlighter-rouge">/private/var/folders/…</code>, and the hook does its own <code class="language-plaintext highlighter-rouge">cd &amp;&amp; pwd -P</code> before computing the project slug. So the two disagreed about which project they were in, and every case that was supposed to be allowed got denied. That one only surfaced after I’d fixed the real bugs, which means for a while my score went down as things got better.</p>

<p>209 of 209 after all of it, with no behavior change on bash 4 and 5.</p>

<h2 id="the-install-printed-a-checkmark-and-did-nothing">The install printed a checkmark and did nothing</h2>

<p>I bumped a version, re-ran <code class="language-plaintext highlighter-rouge">./install.sh</code> against my existing install, and watched bash print “Permission denied” on every hook write, followed by <code class="language-plaintext highlighter-rouge">✓ installed (v0.5.0)</code>. On disk: still v0.4.0.</p>

<p>The chain, which I want to write out because each step is individually reasonable:</p>

<ol>
  <li>The version check says the hook is stale, so the writer runs.</li>
  <li>We unlock the existing file.</li>
  <li>The file is still <code class="language-plaintext highlighter-rouge">root:wheel</code>, mode 0755, from the previous install.</li>
  <li>The writer is a plain <code class="language-plaintext highlighter-rouge">cat &gt; target</code> running as me, not under sudo, so it
EACCESes on the truncate.</li>
  <li>The post-write sanity check is <code class="language-plaintext highlighter-rouge">[[ ! -s "$target" ]]</code>, and the file is not
empty. It’s full of the old version.</li>
  <li><code class="language-plaintext highlighter-rouge">chown</code>, <code class="language-plaintext highlighter-rouge">chmod</code> and the re-lock all succeed, because they’re operating on an
unchanged file that already looks exactly like that.</li>
  <li>Print success.</li>
</ol>

<p>Every guard on that path was checking something true. None was checking the thing I cared about. And it had never fired before because my version bump was the first time anyone had exercised the upgrade path on an existing install. Its own small lesson about which code paths get tested by ordinary use.</p>

<p>The fix was to <code class="language-plaintext highlighter-rouge">rm -f</code> the prior file before writing (the directory was chowned to us a few lines earlier, so the unlink works regardless of who owns the file) and then re-run the version check afterward, because the version string embedded in the rendered hook is the only ground truth that new content actually landed.</p>

<p>The <code class="language-plaintext highlighter-rouge">uninstall</code> command I wrote later had the same disease from the other end. Its <code class="language-plaintext highlighter-rouge">_safe_remove</code> helper checked <code class="language-plaintext highlighter-rouge">rm</code>’s exit code, in the shape <code class="language-plaintext highlighter-rouge">if ! sudo rm -f "$1" 2&gt;/dev/null</code>. On bash 3.2 that combination swallowed the non-zero exit even while rm was printing “Operation not permitted” to my terminal. So the helper returned 0, the caller printed “✓ removed,” and the function exited clean, which meant the EXIT trap saw rc 0 and short-circuited and never re-sealed anything. Net result of a “successful” uninstall: hook files still on disk with their immutable bit cleared. Not removed, and no longer protected.</p>

<p>I stopped asking <code class="language-plaintext highlighter-rouge">rm</code> whether it had worked and just checked <code class="language-plaintext highlighter-rouge">[[ -e "$1" ]]</code>. Whether the file is gone is the only outcome that matters, and that test doesn’t care what shell you’re running.</p>

<h2 id="verify-was-green-and-the-sandbox-was-never-on">Verify was green and the sandbox was never on</h2>

<p>On macOS the enforcement layer is <code class="language-plaintext highlighter-rouge">sandbox-exec</code>, engaged by a shim: a small script earlier on your <code class="language-plaintext highlighter-rouge">$PATH</code> than the real <code class="language-plaintext highlighter-rouge">claude</code>, which re-execs the real one inside a per-project profile.</p>

<p>Which works exactly as long as the shim really is first on <code class="language-plaintext highlighter-rouge">$PATH</code>. If Claude Code’s own installer has put <code class="language-plaintext highlighter-rouge">~/.local/bin</code> ahead of it, every <code class="language-plaintext highlighter-rouge">claude</code> you type runs unwrapped. And there’s no signal. <code class="language-plaintext highlighter-rouge">which claude</code> returns a real path. <code class="language-plaintext highlighter-rouge">claude --version</code> works. <code class="language-plaintext highlighter-rouge">verify</code> returned green, because it checked that the shim file existed, which it did.</p>

<p>So I added a check that walks <code class="language-plaintext highlighter-rouge">$PATH</code> segment by segment, canonicalizes both the first reachable <code class="language-plaintext highlighter-rouge">claude</code> and the shim with <code class="language-plaintext highlighter-rouge">readlink -f</code>, and fails if they aren’t the same file. And then, because <code class="language-plaintext highlighter-rouge">verify</code> had never actually failed on anyone before, its failure footer turned out to have two bugs nobody had ever seen: the color escape variables sat inside a single-quoted printf format string and printed literally, and the repair hint interpolated <code class="language-plaintext highlighter-rouge">dirname "$0"</code>, which resolves to <code class="language-plaintext highlighter-rouge">/usr/local/bin</code>, so it told you to run <code class="language-plaintext highlighter-rouge">/usr/local/bin/install.sh</code>, a file that has never existed on anybody’s machine. The error path had never run. I’d have bet money it worked.</p>

<p>The last twist there is my favorite bit of the whole project. Our install-time guidance told people to add the shim’s PATH line to the top of <code class="language-plaintext highlighter-rouge">~/.zshrc</code>. Claude Code’s installer appends its own <code class="language-plaintext highlighter-rouge">export PATH="$HOME/.local/bin:$PATH"</code> near the bottom. So you follow the instructions precisely, open a new terminal, and get shadowed by the exact line we were trying to get in front of.</p>

<h2 id="the-profile-that-never-parsed">The profile that never parsed</h2>

<p>This is the one I’d tell you about at a bar, and it took until April to find.</p>

<p>On Linux the egress filter allowlists by (destination IP, port) tuples in nftables, which is a strong property. The macOS profile generator mirrored that shape, emitting one allow clause per resolved endpoint:</p>

<div class="code-block">
  <pre class="code-block__pre"><code>; what the generator emitted, one per granted endpoint
(allow network-outbound (remote ip4 "10.1.2.3:22"))

; what Seatbelt will actually accept: the host must be * or localhost
(allow network-outbound (remote ip "*:22"))</code></pre>
  <div class="code-block__language">SBPL</div>
</div>

<p>Seatbelt cannot filter network traffic by remote IP. The host portion of a <code class="language-plaintext highlighter-rouge">(remote …)</code> address has to be <code class="language-plaintext highlighter-rouge">*</code> or <code class="language-plaintext highlighter-rouge">localhost</code>. You can gate a port. You cannot gate an address. So the profile didn’t fail to enforce, it failed to parse, and since the shim runs <code class="language-plaintext highlighter-rouge">exec sandbox-exec -f &lt;profile&gt; claude</code>, the moment a project had a granted host, <code class="language-plaintext highlighter-rouge">claude</code> wouldn’t launch in that directory at all. That’s how it got reported: as a crash.</p>

<p>The thing that landed for me is that the per-IP allowlist on macOS was never expressible. Not broken by a refactor, not regressed. There was no version of that file that was ever going to work, and the README had been describing per-IP enforcement on macOS the whole time. It stayed dormant only because a project with no profile on disk has nothing to fail to parse, so ungranted projects worked fine and granted ones were the only ones that ever loaded the file.</p>

<p>Now, here is where I think the architecture earns its keep, and I want to give it credit rather than just cataloging the wreck. A whole enforcement layer was inert on that platform, and the damage was still contained, because by April the hook layer had 200-odd adversarial tests behind it and was doing exactly what it was designed to do: refuse ssh to any host outside the project’s allowlist, regardless of what the kernel layer thought. A three-layer design isn’t there to be impressive. It’s there so that discovering one layer was never real on one platform is a bad week rather than an incident.</p>

<p>The fix is a downgrade and I wrote it down as one. Port-gate <code class="language-plaintext highlighter-rouge">tcp:22</code> instead: blanket-deny it, re-open it to any host once an env grants at least one endpoint on 22, and let the ssh-scope hook decide which hosts are actually reachable. Weaker than Linux, genuinely, and ungranted projects keep a real blanket deny, worth something on its own. I corrected the README and the model-facing rule docs rather than leave a claim in there I couldn’t back.</p>

<p>But the fix I actually care about is a <code class="language-plaintext highlighter-rouge">render-profile</code> subcommand and a suite group that renders the profile and pipes it through <code class="language-plaintext highlighter-rouge">sandbox-exec -f</code>. Not “does it enforce.” Just “does it parse.” A few lines of test that would have caught this on day one, and whose absence is the entire reason it shipped.</p>

<h2 id="the-one-that-failed-closed">The one that failed closed</h2>

<p>I should include the bug that went the other way, because it’s instructive about how much easier that direction is, and because it was mine.</p>

<p>I reshaped a regex into a top-level alternation and dropped the parens around it. Every mutator check in the hook concatenates that regex as a suffix onto a prefix that itself contains alternation:</p>

<div class="code-block">
  <pre class="code-block__pre"><code># the suffix, as I reshaped it
_claude_protected_re='A|B|C'

# the composed check
(&gt;+|&amp;&gt;&gt;?|\|&amp;&gt;)[ \t]*[^ \t|;&amp;&lt;]*$_claude_protected_re

# how precedence actually reads it
((&gt;+|&amp;&gt;&gt;?|\|&amp;&gt;)[ \t]*[^ \t|;&amp;&lt;]*A) | B | C</code></pre>
  <div class="code-block__language">Bash</div>
</div>

<p>The prefix binds to the first branch only, so a bare mention of B or C anywhere in a command trips the deny with no redirect, no <code class="language-plaintext highlighter-rouge">tee</code>, no mutator of any kind. In practice <code class="language-plaintext highlighter-rouge">cat</code>, <code class="language-plaintext highlighter-rouge">grep</code>, <code class="language-plaintext highlighter-rouge">head</code>, <code class="language-plaintext highlighter-rouge">less</code> and <code class="language-plaintext highlighter-rouge">diff</code> on a hook file were all refused as mutations, with the message “command appears to mutate,” about as helpful as it sounds. Nine checks broken by one missing pair of parentheses.</p>

<p>I found it in about four minutes, because it happened to me, immediately, the first time I tried to read a file. Every fail-open bug in this post took weeks and usually a report from someone else. That asymmetry is the whole thing, honestly. A tool that’s too strict is annoying and self-reporting. A tool that’s too permissive is indistinguishable from a tool that’s working, which is precisely why the bypass suite matters more than any individual hook in it.</p>

<h2 id="the-part-im-still-not-sure-about">The part I’m still not sure about</h2>

<p>The last chunk of work was softening a rule, and I go back and forth on it.</p>

<p><code class="language-plaintext highlighter-rouge">~/.claude/settings.json</code> was blanket-denied for Edit and Write, correct and also miserable. Adding an MCP server, flipping a model preference, registering one of your own hooks: all of it meant dropping out of the session. So I replaced the blanket deny with a content validator. The hook computes the proposed post-edit content (for Write that’s just <code class="language-plaintext highlighter-rouge">tool_input.content</code>; for Edit it’s <code class="language-plaintext highlighter-rouge">jq --rawfile</code> plus a literal split/join on the old and new strings) and runs one jq filter asserting that every required entry survives. Matchers are compared by set inclusion, so you can broaden one but not narrow it. Anything that doesn’t touch the enforcement surface passes through.</p>

<p>I wrote 18 tests for it and I’m still not certain. A blanket deny has no bugs. A validator has a surface, and the surface is “did I enumerate everything that matters,” which is exactly the kind of question I’ve spent this whole post being wrong about. Project-level <code class="language-plaintext highlighter-rouge">.claude/settings*.json</code> stayed blanket-denied, because it carries the per-project agent socket and that’s a different threat model that deserves its own pass I haven’t done yet.</p>

<p>Ask me in six months, I guess.</p>

<h2 id="replace-a-claim-with-a-check">Replace a claim with a check</h2>

<p>25 commits, about 1,100 lines of a 7,000-line bash file, six weeks of evenings. Exactly one added something a user would call a feature, and that was <code class="language-plaintext highlighter-rouge">uninstall</code>. The rest was making a good tool do on a second platform what it already did correctly on the first one.</p>

<p>What I keep coming back to is that security tooling has a worse relationship with success messages than anything else I work on. A build that silently fails, you notice, because the site doesn’t change. A firewall that silently fails looks precisely like a firewall that’s working, and it will go on looking that way right up until the one day it matters. Which is the reason a tool like this lives or dies on its test suite rather than its hooks, and the reason I got to write a tidy post about 11 bugs instead of a much worse post about one incident. The suite went from 118 cases to 242 over the port, and every group I added is scar tissue from something in here.</p>

<p>Every fix was some version of the same move: replace a claim with a check. Don’t check what <code class="language-plaintext highlighter-rouge">rm</code> said, check whether the file is gone. Don’t check the file’s size, check its version string. Don’t check that the shim exists, check that it’s the one <code class="language-plaintext highlighter-rouge">$PATH</code> finds. Don’t check that the profile got written, check that <code class="language-plaintext highlighter-rouge">sandbox-exec</code> will load it. None of that is clever, and I’m not sure I learned a generalizable lesson beyond “be more suspicious of your own green ticks,” which everyone already knows and nobody, including me, actually does.</p>

<p>Usual caveats. This is one tool, on one platform, in bash, a language that will punish you for a space, so a decent chunk of this is bash-specific misery rather than anything deep. And I introduced roughly as many of these as I found.</p>

<p>But it runs on my Mac now, it’s the thing I actually use every day, and I trust it more for having taken it apart than I did when it just worked. If you maintain something whose entire job is to say no, it might be worth sitting down this week and asking what it does when it can’t tell. Ours said yes, once, on a platform nobody had tried it on. Now there’s a test for that 🙃</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="Security" /><category term="Tooling" /><summary type="html"><![CDATA[We have an internal tool at work called cadence-claude, and I want to describe it properly before I spend 2,000 words on its bugs, because it’s the best piece of internal tooling I’ve worked on and the bugs are almost entirely my fault.]]></summary></entry><entry><title type="html">The junior and the agent</title><link href="https://madisonjmeredith.com/writing/the-junior-and-the-agent/" rel="alternate" type="text/html" title="The junior and the agent" /><published>2026-04-21T00:00:00+00:00</published><updated>2026-04-21T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/the-junior-and-the-agent</id><content type="html" xml:base="https://madisonjmeredith.com/writing/the-junior-and-the-agent/"><![CDATA[<p>A colleague eight months into their first job fixed a caching bug in about 40 minutes. At eight months I would not have fixed it. I’d have escalated it after two days, and somebody would have fixed it in front of me while I watched.</p>

<p>The fix was correct. It was better than my fix would have been. Then I asked why the invalidation had to happen in that order and they said, entirely without defensiveness, that they weren’t sure, that’s what it suggested and the tests went green.</p>

<p>Which is a completely reasonable answer from somebody eight months in, and I want to be careful, because the version of this post where I’m disappointed in a junior developer is a bad post and also not what I think. They did the job. The job got done.</p>

<p>I’ve been chewing on it for two months and I’m still not comfortable, so I’m going to write it out and see where it lands.</p>

<h2 id="what-being-stuck-was-actually-doing">What being stuck was actually doing</h2>

<p>Here’s my honest account of how I learned anything, and it’s not flattering.</p>

<p>I learned by being wrong in a way that cost me something. Not by reading, not by being told, and largely not by being shown. By forming a wrong idea about how the system worked, acting on it, watching it fail in a way the wrong idea couldn’t explain, and having to go and repair the idea.</p>

<p>That process is expensive and slow and it is the thing that builds the model. Every useful intuition I have about caching, or specificity, or why a form breaks on a phone and not on a laptop, came out of some afternoon where I was wrong for long enough to have to account for it.</p>

<p>An agent removes the afternoon.</p>

<h2 id="but-most-of-the-afternoons-were-waste">But most of the afternoons were waste</h2>

<p>And here’s the counterargument I have to make honestly, because the romantic version of the above is a lie told by survivors.</p>

<p>An enormous proportion of what I was stuck on was not educational in any sense. It was a misspelled property. A version mismatch. A tutorial written for a release two behind the one I’d installed. A trailing slash. I once lost the better part of two days to a character encoding problem and I learned precisely nothing from it that I have ever used again, and I am not nostalgic about those two days.</p>

<p>So “struggle builds expertise” is too coarse. Being stuck on “I don’t understand what this system is doing” is where the learning is. Being stuck on “I have made a small mechanical error and cannot see it” is pure loss, and there was a great deal more of the second kind than the first.</p>

<p>Agents are outstanding at deleting the second kind. That’s an unambiguous gain and it is most of what I get from them personally.</p>

<p>They also delete the first kind, and that’s where the problem is, and the two feel identical from the inside while you’re doing it. You cannot tell, at the moment of asking, which sort of stuck you were about to be.</p>

<figure class="pull-quote">
  <blockquote class="pull-quote__text">You can't tell, at the moment you ask, whether you were about to waste an afternoon or build the only thing that would have made you good at this.</blockquote>
</figure>

<h2 id="the-calculator-and-the-one-place-it-breaks">The calculator, and the one place it breaks</h2>

<p>The obvious rebuttal, and I’ve had it put to me twice, is the calculator. Nobody does long division by hand any more and civilization is fine. Nobody hand-writes assembly. I’ve never implemented a sort. Every generation offloads something and the previous generation frets about it and is wrong.</p>

<p>I take that seriously, and I was on the receiving end of it. People said Stack Overflow would produce a generation who couldn’t reason from first principles. I learned a substantial amount from Stack Overflow. I think I’m alright.</p>

<p>But there’s one asymmetry that I can’t argue away.</p>

<p>A calculator is never wrong. The offloaded skill is not needed to check the offloaded work, because there’s nothing to check. Long division is safe to lose because arithmetic has a verifiable answer that arrives correct every time.</p>

<p>An agent is confidently wrong on a regular basis, and detecting that requires exactly the model that would have been built by doing the work yourself. The skill you’re skipping is the skill required to supervise the thing you’re skipping it with. That’s not true of calculators and it wasn’t quite true of Stack Overflow either, because finding the right answer there meant reading four wrong ones, and choosing between them was itself the exercise.</p>

<p>Degree rather than kind, maybe. But the degree has got very large.</p>

<h2 id="which-is-the-same-trap-industrialized">Which is the same trap, industrialized</h2>

<p>I wrote something in 2021 about tutorials, and the phrase I used was competence you borrow and hand back: everything works while you’re following along, nothing goes wrong, and then you sit in front of an empty folder and can’t start.</p>

<p>This is the same shape with the loan extended indefinitely. The borrowed competence doesn’t get handed back at the end of the chapter now. It’s available every day forever, which means you may never find out what you’d have been able to do without it, which is a strange thing to say about a person’s own abilities.</p>

<p>I don’t know how to feel about that. The tutorial version was clearly bad because the loan was called in. If it’s never called in, is it a loan?</p>

<h2 id="the-bit-that-actually-worries-me">The bit that actually worries me</h2>

<p>Not any individual, and not the eight-months colleague, who is quick and careful and will be extremely good.</p>

<p>Seniors exist because they were juniors who did work that is now cheaper to not do. The pipeline that produces people who can tell whether generated code is right runs entirely on people spending years being slow and wrong about code they wrote themselves.</p>

<p>Nobody owns that pipeline. Every individual firm is correct, in isolation, to take the faster path, because the cost of not training anybody lands somewhere else entirely, years later, spread across an industry. It’s an externality with a ten-year lag, and nothing about how our work is organized is capable of pricing that in.</p>

<p>I’d like to say the market will sort it out. My honest expectation is that it will sort it out around 2035, by which point the people who could have taught the judgment have left, and everybody will describe it as a skills shortage that came out of nowhere.</p>

<aside class="callout callout--aside">
  <div class="callout__label">And it's already got a shape</div>
  <div class="callout__body">Graduate and junior roles are the ones being quietly not-opened, because they're the ones whose output an agent most obviously resembles. Which is precisely backwards if what a junior role actually produces is a senior developer in five years rather than tickets this quarter.</div>
</aside>

<h2 id="what-ive-changed-which-isnt-much">What I’ve changed, which isn’t much</h2>

<p>I’m not going to tell anybody not to use these things. It would be hypocritical, since I use them every day, and it would be futile, since they’d use them anyway and just not mention it, which is worse than doing it in the open.</p>

<p>Two things, both small.</p>

<p>Before, I ask what they’d have tried. Not as a test and not before every task, but often enough that the habit of forming a hypothesis first stays alive. That’s the bit I think is genuinely important, and it survives the tooling.</p>

<p>After, I ask why it’s like that, and I want an answer. Not to catch anybody out. But “I don’t know, it suggested it and the tests passed” is a fine answer at eight months and it can’t still be the answer at three years, and the only way it stops being the answer is if somebody keeps asking the question.</p>

<p>The standard I’ve landed on, which I’ve said out loud to the team so it isn’t a secret rule: use whatever you like to produce it, and don’t hand over anything you couldn’t defend if the person receiving it asked you one hard question about it. That’s not new. It’s what code review was always supposed to be. It just used to be satisfied automatically, because you’d typed the thing.</p>

<h2 id="from-the-wrong-end-of-it">From the wrong end of it</h2>

<p>The caching fix went in and it was right and it’s still right.</p>

<p>And I’ve been a junior in a period when everybody senior was certain the new thing would ruin us, and they were wrong, and I remember how it felt to be told that the way I was learning didn’t count. And I’m describing a mechanism I can only observe from the wrong end of, about an industry two years into a change that will take 20 to show up.</p>

<p>If you’re eight months in and reading this: you’re not doing it wrong. Just ask yourself why it’s like that occasionally, before somebody else has to 😬</p>]]></content><author><name>Madison Meredith</name></author><category term="Philosophy" /><category term="AI" /><category term="Careers" /><summary type="html"><![CDATA[A colleague eight months into their first job fixed a caching bug in about 40 minutes. At eight months I would not have fixed it. I’d have escalated it after two days, and somebody would have fixed it in front of me while I watched.]]></summary></entry><entry><title type="html">Stop and ask me</title><link href="https://madisonjmeredith.com/writing/stop-and-ask-me/" rel="alternate" type="text/html" title="Stop and ask me" /><published>2026-04-07T00:00:00+00:00</published><updated>2026-04-07T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/stop-and-ask-me</id><content type="html" xml:base="https://madisonjmeredith.com/writing/stop-and-ask-me/"><![CDATA[<p>We’ve got three skills at work for Shopify client repos, and between them they cover the entire life of one. <code class="language-plaintext highlighter-rouge">shopify-onboard</code> makes a new repo from the template and gets it talking to the store. <code class="language-plaintext highlighter-rouge">shopify-template-sync</code> takes a repo that already follows the workflow but has fallen behind, and drags it forward. <code class="language-plaintext highlighter-rouge">shopify-workflow-upgrade</code> is for the genuinely old stuff: a project that’s been sitting on Bitbucket on a branch called <code class="language-plaintext highlighter-rouge">master</code> since before we did any of this, that needs to be moved and renamed and rewired before it can even be called behind.</p>

<p>Each one is a single Markdown file with about eight lines of front matter on top. I want to say that plainly because “skill” oversells it a little. There’s no code. It’s a runbook, and if you handed it to a new hire instead of to Claude, it would work about as well.</p>

<p>And the steps in them are boring. Genuinely, completely boring. <code class="language-plaintext highlighter-rouge">gh repo create --template</code>, set the store in the toml, set three secrets, pull the live theme, commit, push, run <code class="language-plaintext highlighter-rouge">/init</code>. I could produce most of that from memory on a decent morning. If that were all the file contained I don’t think it would earn the disk space, and I’d probably have deleted it by now out of embarrassment.</p>

<p>What earns its place is the other stuff. The stuff you only know because you were wrong about it once, in a specific way, on a specific afternoon. And having now written three of these, I think that stuff comes in exactly two flavors.</p>

<p>The first flavor is facts that look like redundancy and aren’t.</p>

<p>The store URL lives in <code class="language-plaintext highlighter-rouge">shopify.theme.toml</code>. That was the whole point of a migration I did a while back, getting it out of <code class="language-plaintext highlighter-rouge">package.json</code> so that file could be byte-identical everywhere. Great. So why does every one of these repos also carry a <code class="language-plaintext highlighter-rouge">SHOPIFY_STORE_URL</code> GitHub secret with the same value in it?</p>

<p>Because CI never reads the toml on the live path. It reads <code class="language-plaintext highlighter-rouge">SHOPIFY_FLAG_STORE</code> off the secret. So the store does live in two places, deliberately, and if you’ve just spent a week de-duplicating config you will look at that and feel a very strong urge to fix it. You’d be fixing it into a broken deploy. So the skill just says so, out loud, in a blockquote, in all three files, every time. It’s not clever. It’s a note to a future person who is about to be very confident.</p>

<p>There’s a smaller one in the same family that I like more than it deserves:</p>

<aside class="callout callout--warning">
  <div class="callout__label">The folder is not the repo</div>
  <div class="callout__body">When a step needs the GitHub repo name, derive it from <code>git remote get-url origin</code>, never from the local directory name. They agree right up until the day they don’t, and then you’re running <code>gh secret delete</code> against somebody else’s project.</div>
</aside>

<p>The second flavor is the one I actually think matters, and it’s the places where the runbook is told to stop.</p>

<p>Here’s the one that taught me. All three skills do their work on a branch with the same name, <code class="language-plaintext highlighter-rouge">feature/shopify-workflow-upgrade</code>, which is nice and consistent and also a small trap, because a sync running a year after an upgrade walks straight into a branch of that name that already exists. <code class="language-plaintext highlighter-rouge">git checkout -b</code> errors out. And a thing executing a checklist, on hitting an error, will look for the way around the error, because getting to step 9 is what success looks like from inside step 1. <code class="language-plaintext highlighter-rouge">git branch -D</code> is sitting right there, one word away, and it is a completely reasonable next move if your only goal is to have a branch.</p>

<p>So the skill doesn’t say “make the branch.” It says find out what you’d be destroying first:</p>

<div class="code-block">
  <div class="code-block__filename">shopify-template-sync/SKILL.md</div>
  <pre class="code-block__pre"><code>git branch --list feature/shopify-workflow-upgrade
git ls-remote --heads origin feature/shopify-workflow-upgrade
git log --oneline origin/main..feature/shopify-workflow-upgrade
gh pr list --head feature/shopify-workflow-upgrade --state open</code></pre>
  <div class="code-block__language">Bash</div>
</div>

<p>Then it forks. Fully merged into <code class="language-plaintext highlighter-rouge">main</code>, no unique commits, no open PR? It’s debris, delete it, nothing is lost. Unmerged commits or an open PR? Stop. Surface it to the person and let them decide, because those are three different decisions (reuse it, close the old PR, branch under another name) and none of them belong to a script.</p>

<p>That instruction is the most important line in the file and it’s also the only line in the file that doesn’t make anything happen. Everything else creates, copies, sets, commits. That one just stands there.</p>

<p>Which I think is the general shape of it. A model handed a numbered list will finish the numbered list. You don’t have to ask for diligence, you get diligence for free, sometimes more of it than you wanted. What you have to ask for, explicitly, occasionally in caps like a lunatic, is the doubt. Never auto-merge, leave the PR for the user. Don’t commit automatically, leave the changes staged for review. Run one project at a time, don’t batch-sync three repos in a single pass because it’s the same six commands. None of those are hard to follow. They’re just hard to think to write down, because nothing about the experience of working through a checklist suggests that the correct move might be to put the pen down halfway.</p>

<p>And the amount of doubt isn’t constant either, which surprised me a bit when I noticed it. <code class="language-plaintext highlighter-rouge">shopify-onboard</code> commits and pushes to <code class="language-plaintext highlighter-rouge">main</code> on its own without asking anybody. <code class="language-plaintext highlighter-rouge">shopify-workflow-upgrade</code> says, in bold, do not commit automatically. Same author, same week, opposite instruction. But onboarding happens in a repo that is four minutes old whose entire contents just came down off the store, so the worst case is you delete the folder and run it again, whereas an upgrade has you standing inside somebody’s actual project with years of history under your feet. Trust isn’t a setting you pick once. It’s a function of what’s waiting on the other side of being wrong.</p>

<p>The obvious objection here, and I want to give it its due because I made it to myself for about a month, is that this is just documentation wearing a costume. Write a README. Congratulations, you have invented the README.</p>

<p>And, sure, kind of. Except the failure mode of a README is that it loses to whoever’s in a hurry, and whoever’s in a hurry is always me. I did write the README version of this. It was accurate. I stopped opening it after the third project, because by then I remembered most of it, and “most of it” is precisely the shape of the problem: I remembered the steps, which were never the point, and forgot the store URL thing, which was. The difference isn’t that the skill is better written. It’s that it gets read start to finish on every single run by something that has never done this before and has no ego about re-reading step 3.</p>

<p>Okay, and one thing I should own, because I noticed it while writing this and it made me wince. That <code class="language-plaintext highlighter-rouge">SHOPIFY_STORE_URL</code> warning I was so pleased about? It’s copy-pasted, near verbatim, into all three files. Which is exactly the drift I built the template to kill, faithfully recreated one floor up in the documentation. If CI ever changes how it reads the store, I have three files to update and I will remember two of them. I don’t have a fix. Maybe a shared reference the skills all point at, maybe that’s over-engineering three Markdown files. Filing it under known and slightly embarrassing.</p>

<p>Anyway. If you’re writing one of these, the only real suggestion I’ve got is to draft the happy path fast and without much care, because the happy path is the part you’d have gotten right anyway. Then go back through it step by step and ask, at every single one, what happens if this is already done, or already exists, or is halfway done in a way I can’t see from here. The answers to that are the actual file. Everything else is typing.</p>

<p>Three Markdown files and one platform is the whole of my evidence here. But the question travels further than the files do.</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="AI" /><category term="Shopify" /><summary type="html"><![CDATA[We’ve got three skills at work for Shopify client repos, and between them they cover the entire life of one. shopify-onboard makes a new repo from the template and gets it talking to the store. shopify-template-sync takes a repo that already follows the workflow but has fallen behind, and drags it forward. shopify-workflow-upgrade is for the genuinely old stuff: a project that’s been sitting on Bitbucket on a branch called master since before we did any of this, that needs to be moved and renamed and rewired before it can even be called behind.]]></summary></entry><entry><title type="html">What an RSS feed is for</title><link href="https://madisonjmeredith.com/writing/what-an-rss-feed-is-for/" rel="alternate" type="text/html" title="What an RSS feed is for" /><published>2026-03-24T00:00:00+00:00</published><updated>2026-03-24T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/what-an-rss-feed-is-for</id><content type="html" xml:base="https://madisonjmeredith.com/writing/what-an-rss-feed-is-for/"><![CDATA[<p>A colleague asked me what the orange icon in a site’s footer was for. They’ve been doing this for two years, they’re very good, and they had genuinely never encountered one.</p>

<p>I gave a bad answer. It was largely about Google Reader shutting down in 2013 and how things used to be, which is a thing older people do and which I would like to stop doing. They wanted to know what it was for, now, in the present tense, and I’d answered a question about the past.</p>

<p>So here’s the answer I should have given.</p>

<h2 id="its-a-file">It’s a file</h2>

<p>That’s most of it, and it’s the part that makes everything else make sense.</p>

<p>A feed is a file sitting at a URL, listing the most recent things a site published, each with a title, a date, a link and usually the whole text. When you subscribe, you are giving a piece of software the address of that file. Every so often it fetches it and looks for entries it hasn’t seen.</p>

<div class="code-block">
  <div class="code-block__filename">feed.xml</div>
  <pre class="code-block__pre"><code>&lt;entry&gt;
  &lt;title&gt;What an RSS feed is for&lt;/title&gt;
  &lt;link href="https://example.com/writing/what-an-rss-feed-is-for/"/&gt;
  &lt;updated&gt;2026-03-24T00:00:00+00:00&lt;/updated&gt;
  &lt;content type="html"&gt;...&lt;/content&gt;
&lt;/entry&gt;</code></pre>
  <div class="code-block__language">XML</div>
</div>

<p>There’s no account. No company in the middle. Nobody is running a service that has to stay solvent for this to keep working, because the only two parties are a web server that was already serving the site and a program on your machine that can make an HTTP request.</p>

<p>Which is why it’s still here after 25 years, and why nothing has replaced it. There’s nothing to acquire and nothing to shut down.</p>

<h2 id="two-properties-and-theyre-the-whole-point">Two properties, and they’re the whole point</h2>

<p>It is chronological, complete, and it ends.</p>

<p>Everything from everyone you subscribed to, in the order it was published, and then a bottom of the list. Nothing is promoted, nothing is withheld, nothing is inserted, and if a writer you follow posts twice in a day you get both, and if they post nothing for eight months you get nothing and then you get the thing they posted in the ninth month.</p>

<p>I had forgotten this was unusual until I tried to describe it. Every other reading surface I use is infinite and sorted by something other than time, and the difference in how it feels to reach the end of a list is much larger than it sounds.</p>

<p>And the publisher doesn’t know you’re there.</p>

<p>There’s no follow, no account, no notification when you subscribe. I’ve been reading some of these people for eight years and none of them have any idea.</p>

<p>I should be honest that this cuts both ways and it’s a real reason platforms won. Writing into a feed is writing into silence. You get no follower count, no signal that anybody arrived, and if you want to know whether anyone read a thing, RSS will not tell you. Some people need that number and there is nothing wrong with them for needing it.</p>

<h2 id="it-didnt-die-it-stopped-being-a-product">It didn’t die, it stopped being a product</h2>

<p>The thing I should have led with, and the fact that usually lands.</p>

<p>Every podcast in the world is an RSS feed. That’s not a metaphor or a legacy detail, it’s the actual mechanism: a show publishes a feed, your app polls it, new episode appears. The most successful new media format of the last 15 years is built entirely on the technology everybody describes as dead.</p>

<p>What died in 2013 was one popular reader, and with it the idea that this would be a mainstream consumer thing with a logo. The plumbing never went anywhere. It’s just that most people now touch it through an app that never says the word.</p>

<h2 id="getting-started-and-the-mistake-everybody-makes">Getting started, and the mistake everybody makes</h2>

<p>Pick a reader. There are a dozen, several are free, and it matters far less than the tutorials suggest. I pay about $30 a year for one that syncs between my phone and my laptop and I could not tell you the last time I thought about it.</p>

<p>Then subscribe to fewer things than you want to.</p>

<p>This is the whole difficulty and the reason most people bounce off. The first week you add 40 feeds because adding them is fun, and by the second week there’s an unread count in the hundreds, and it now feels like an inbox, which is to say a pile of obligations you’re behind on. Then you stop opening it.</p>

<aside class="callout">
  <div class="callout__label">It's a river, not an inbox</div>
  <div class="callout__body">Mark everything as read whenever you like, without reading it, without guilt. Nothing is owed to anybody. If a writer is good enough that missing three posts bothers you, you'll notice, and if missing three posts doesn't bother you, that's useful information about the subscription.</div>
</aside>

<p>10 to 20 feeds is a reading habit. A hundred is a second job you have assigned to yourself for no money.</p>

<h2 id="what-its-bad-at">What it’s bad at</h2>

<p>Discovery, entirely. A feed reader will never show you somebody you don’t already follow, which is the exact hole I was writing about last month with blogrolls. The two halves fit together: somebody has to point, and the feed is where the pointing lands.</p>

<p>Conversation, mostly. There are no comments and no replies in a reader, and the discussion around a post happens somewhere you aren’t. I’ve made my peace with that and I know people who can’t.</p>

<p>And it’s noticeably worse for anything of the moment. RSS is good at people who write occasionally and at length. If what you want is the news in the last 20 minutes, this is the wrong tool and I wouldn’t try to sell it to you.</p>

<h2 id="put-a-feed-on-it">Put a feed on it</h2>

<p>If you write anything at all on your own site, please put a feed on it. On this one it’s a plugin and about four lines of configuration, and it’s very likely your static site generator does it already and you’ve never checked. Publish the full text rather than a truncated summary, if you can bear to. Making people click through to see the rest is a small tax on the person who liked you enough to subscribe.</p>

<p>And the icon in the footer, to answer the actual question: it means you can read this without the site knowing, without an account, without an algorithm deciding, and without anybody’s permission, including mine. Which is not a revolution. It’s just about the only place left where that’s true, and it costs a plugin.</p>]]></content><author><name>Madison Meredith</name></author><category term="Culture" /><category term="Culture" /><summary type="html"><![CDATA[A colleague asked me what the orange icon in a site’s footer was for. They’ve been doing this for two years, they’re very good, and they had genuinely never encountered one.]]></summary></entry><entry><title type="html">The repo with no code in it</title><link href="https://madisonjmeredith.com/writing/the-repo-with-no-code-in-it/" rel="alternate" type="text/html" title="The repo with no code in it" /><published>2026-03-10T00:00:00+00:00</published><updated>2026-03-10T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/the-repo-with-no-code-in-it</id><content type="html" xml:base="https://madisonjmeredith.com/writing/the-repo-with-no-code-in-it/"><![CDATA[<p>Every new Shopify project at work used to start the same way, which is to say it started by copying the last one. You’d clone whichever client repo was most recently in okay shape, rip out the theme, nuke the git history, and start typing. And that worked, in the narrow sense that it produced a repo. But you also inherited that project’s specific weirdness: whatever hardcoded store URL somebody left in a script, whatever half-finished workflow file, whatever <code class="language-plaintext highlighter-rouge">npm run</code> alias that only made sense in the context of a ticket from eight months ago. Every project was a slightly corrupted photocopy of the one before it, and nobody could tell you which generation of the copy they were looking at.</p>

<p>So I built a template repo. Extremely boring answer to the problem, and I want to be upfront that the boring part isn’t the interesting part.</p>

<p>The interesting part is that it ships no theme code at all.</p>

<p>That felt wrong when I landed on it. A template with nothing in it? But Shopify themes don’t live in your repo, not really. They live on the store. The actual source of truth for the theme you’re about to work on is the client’s published theme, sitting in their admin, carrying whatever content edits somebody’s marketing team made last Tuesday. So the very first thing you do in a new project is <code class="language-plaintext highlighter-rouge">npm run pull:live</code>, and the entire theme lands: <code class="language-plaintext highlighter-rouge">sections/</code>, <code class="language-plaintext highlighter-rouge">snippets/</code>, <code class="language-plaintext highlighter-rouge">templates/</code>, <code class="language-plaintext highlighter-rouge">layout/</code>, <code class="language-plaintext highlighter-rouge">assets/</code>, <code class="language-plaintext highlighter-rouge">config/</code>, <code class="language-plaintext highlighter-rouge">locales/</code>. There is nothing for a template to seed. Shipping a starter theme would be worse than useless, because step one would be deleting it.</p>

<p>What’s left when you take the theme out is just infrastructure. A <code class="language-plaintext highlighter-rouge">package.json</code> that’s nothing but thin wrappers around the Shopify CLI. A <code class="language-plaintext highlighter-rouge">shopify.theme.toml</code> for the store config. Two GitHub Actions workflow files. An <code class="language-plaintext highlighter-rouge">.mcp.json</code>. A README. <code class="language-plaintext highlighter-rouge">ls</code> on main returns about 11 things, and I think that’s the honest shape of the thing rather than an embarrassment. The template isn’t a head start on the code. It’s a head start on everything around the code.</p>

<p>The first real design decision was where the store URL goes. Originally it lived in <code class="language-plaintext highlighter-rouge">package.json</code>, baked right into the script strings, which meant <code class="language-plaintext highlighter-rouge">package.json</code> was different in every single project. Moving it into <code class="language-plaintext highlighter-rouge">shopify.theme.toml</code> (which the Shopify CLI reads natively, so this isn’t even a clever trick, it’s just using the tool as intended) meant <code class="language-plaintext highlighter-rouge">package.json</code> became byte-for-byte identical everywhere.</p>

<div class="code-block">
  <div class="code-block__filename">shopify.theme.toml</div>
  <pre class="code-block__pre"><code>[environments.default]
store = "STORE_URL"

# [environments.redesign]
# store = "STORE_URL"
# theme = "REDESIGN_THEME_ID"</code></pre>
  <div class="code-block__language">TOML</div>
</div>

<p>I did not appreciate at the time how much that one move would matter. My instinct going in was that a good template is highly configurable, lots of knobs, adapt to anything. It turns out the opposite is closer to true: what makes a template maintainable is pushing as much of it as possible toward being generic, so there’s exactly one file a human is expected to edit and everything else can be treated as identical across every project forever. Configurability is drift with better PR.</p>

<p>The commented block in there is for redesign projects, where the new build lives on an unpublished theme sitting alongside the client’s live one. Those get their own named CLI environment, and there’s a small gotcha I lost 20 minutes to.</p>

<aside class="callout callout--warning">
  <div class="callout__label">Named environments don't inherit</div>
  <div class="callout__body">A <code>[environments.redesign]</code> block does not pick anything up from <code>[environments.default]</code>, so <code>store</code> has to be repeated inside it. Leave it out and the CLI will cheerfully go looking for a store it doesn't have.</div>
</aside>

<p>That populated block does double duty: it’s also the signal our deploy workflow uses to decide whether a merge to <code class="language-plaintext highlighter-rouge">main</code> goes to the live theme or the redesign theme. Which is the kind of thing that feels clever for about a day and then starts to worry you, because now the difference between “deploys to a preview nobody sees” and “deploys to the client’s actual storefront” is whether three lines are commented out. So an uncommented block still holding the placeholder values hard-fails the deploy on purpose. Fill it in or comment it out, no third option. But I’ve written about that side of it already.</p>

<p>Anyway. Here’s the thing I got wrong, and it’s the reason any of this is worth a post.</p>

<p><code class="language-plaintext highlighter-rouge">gh repo create --template</code> is a copy. One time, at birth, and then the connection is severed. There is no upstream. The template isn’t a dependency the client repo tracks, it’s a photograph of the template taken on whatever day that repo happened to get made. So a year in you have ten client repos, each frozen at a different point in the template’s history, and every improvement you make to the template applies to precisely zero of the projects you already have. You’ve done the thing where you hand out photocopies of a form and then quietly change the form.</p>

<p>Which meant the actual work wasn’t the template. It was writing something that could go back into a repo that already exists, months later, and update the parts it’s allowed to update without touching the parts it isn’t.</p>

<p>And you cannot write that until you can answer one question for every file in the repo, out loud, without hedging: who owns this?</p>

<p>Four answers, as it turned out. Some files are template-owned, like the workflow callers and <code class="language-plaintext highlighter-rouge">.mcp.json</code>, and the sync just overwrites them, because a client project has no business having opinions about those. Some are shared, like <code class="language-plaintext highlighter-rouge">package.json</code> and <code class="language-plaintext highlighter-rouge">shopify.theme.toml</code> and the README, where you take the template’s structure and scripts but preserve every value the client filled in. Some are client-owned and get touched under no circumstances whatsoever: all the theme directories, the real store URL, the theme IDs.</p>

<p>And then there’s a fourth category I hadn’t even considered until I went looking, which is files that should not exist in a client repo at all.</p>

<p>Because <code class="language-plaintext highlighter-rouge">gh repo create --template</code> copies everything, and “everything” included the three lifecycle skills that exist to create and maintain client repos. So every client project we’d ever made shipped with tooling for making client projects. Along with the template’s own <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, which described, in loving and confident detail, a repo that isn’t the one you’re in. Anyone opening a client project and reading that file would come away believing their repo contained no theme code, which is a pretty impressive thing to be wrong about while standing inside 200 Liquid files.</p>

<p>The fix for the skills was one <code class="language-plaintext highlighter-rouge">git rm -r</code> during onboarding. The fix for the <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> was more interesting, because my first instinct was to ship a skeleton version with blanks in it, and I’m glad I didn’t. A half-filled-in skeleton is genuinely worse than an empty repo, because it reads as documentation. It has headings. It looks authoritative. It just happens to be describing nothing. So now onboarding pulls the live theme first and then runs <code class="language-plaintext highlighter-rouge">/init</code>, which reads the theme that’s actually there and writes about that: which base theme it is, what version, how the CSS is organized, which third-party integrations are wired in. Documentation generated after the facts exist instead of before.</p>

<p>The most recent addition is smaller and I like it more than it deserves. There’s a hook committed into the repo’s <code class="language-plaintext highlighter-rouge">.claude/</code> that intercepts any Shopify theme push (the <code class="language-plaintext highlighter-rouge">push:*</code> scripts, the raw CLI they wrap, chained commands, all of it) and asks you to confirm before it runs. It asks, it doesn’t block, and every early exit in the script allows the command, so if it breaks it breaks in the direction of letting you work.</p>

<p>The part I care about is that it’s committed rather than sitting in my personal global config. A safety rule that lives on one person’s laptop isn’t a safety rule. It’s a personal habit that happens to have a shell script attached, and it protects exactly one person from exactly one class of mistake while everyone else is out there raw-dogging <code class="language-plaintext highlighter-rouge">shopify theme push</code> on a Friday. Putting it in the repo makes it a property of the project instead of a property of me, which is the only version that survives me being on vacation.</p>

<p>So the thing I set out to build was a starting point, and what I actually ended up with is a claim about ownership. The template’s real content isn’t the 11 files. It’s the assertion that these files belong to us and are safe to overwrite, and those files belong to the client and we don’t get to have feelings about them, and this third pile needs careful hands. The scaffolding is just where that claim happens to be written down.</p>

<p>There’s a second one now, for the projects where we’re putting a Laravel front end over a Magento back end, and building it was much easier because the taxonomy transferred wholesale even though not one of the 11 files did. Which I take as weak evidence that the taxonomy was the actual artifact.</p>

<p>Take all of this with a grain of salt, obviously. This is one agency’s setup for two platforms, and Shopify’s whole deal is that the theme lives somewhere you don’t control, which is a fairly unusual constraint that shapes basically every decision above. If your source of truth is your repo, most of this is solving a problem you don’t have. But if you’ve got a template that ten projects were born from and no way to reach any of them, I’d start with the taxonomy rather than the tooling. The tooling is easy once you know who owns what.</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="Shopify" /><category term="Tooling" /><summary type="html"><![CDATA[Every new Shopify project at work used to start the same way, which is to say it started by copying the last one. You’d clone whichever client repo was most recently in okay shape, rip out the theme, nuke the git history, and start typing. And that worked, in the narrow sense that it produced a repo. But you also inherited that project’s specific weirdness: whatever hardcoded store URL somebody left in a script, whatever half-finished workflow file, whatever npm run alias that only made sense in the context of a ticket from eight months ago. Every project was a slightly corrupted photocopy of the one before it, and nobody could tell you which generation of the copy they were looking at.]]></summary></entry><entry><title type="html">The blogroll</title><link href="https://madisonjmeredith.com/writing/the-blogroll/" rel="alternate" type="text/html" title="The blogroll" /><published>2026-02-24T00:00:00+00:00</published><updated>2026-02-24T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/the-blogroll</id><content type="html" xml:base="https://madisonjmeredith.com/writing/the-blogroll/"><![CDATA[<p>In January I found a blog I’ve since read all the way through, and the route was: a post I was reading linked to it, in the running prose, with a sentence about why.</p>

<p>It took me the rest of the day to work out why that felt strange. It’s not that recommendations have stopped reaching me. I get more of them than I can use. It’s that that one came from a person who had put their own name next to it.</p>

<p>Which is a slightly precious way to describe a hyperlink, and I’ve been chewing on it for a month, so here we are.</p>

<h2 id="where-discovery-went">Where discovery went</h2>

<p>Three things happened, none of them a conspiracy, and between them they closed the door.</p>

<p>Search became answers. If I look up how something works I now get a paragraph, which is genuinely often what I wanted, and I do not get a list of five people who have written about it and one of whom I might have started reading. The paragraph has digested them. It’s a better answer and a worse doorway.</p>

<p>Feeds became ranked. Whatever people post now goes through something that decides who sees it, tuned for a metric that isn’t “did this person find someone worth following for the next decade.” Even when it works, what it produces is the post rather than the writer, and you have to go looking to convert one into the other.</p>

<p>And links themselves got devalued from both ends. For years everybody was told outbound links leak something, and for years everybody wanted you to stay, so the whole web quietly stopped pointing anywhere except at itself.</p>

<p>So the only remaining path from “a person whose taste I trust” to “a person I’d never have found” is a person choosing to point, and the blogroll is the oldest and least sophisticated version of that.</p>

<figure class="pull-quote">
  <blockquote class="pull-quote__text">Recommendation is a machine's job now. Doing it yourself, with your name on it, is a small refusal, and it costs about an hour.</blockquote>
</figure>

<h2 id="two-reasons-we-stopped-one-of-which-nobody-says">Two reasons we stopped, one of which nobody says</h2>

<p>The technical reason is boring and true. The blogroll lived in a sidebar, and the sidebar died in about 2012 when everything went mobile-first, because there was nowhere to put it on a phone. It didn’t get moved. It got dropped and nobody missed it for a decade.</p>

<p>The other reason is social, and I’ve not seen many people say it out loud.</p>

<p>A blogroll is a public list of who you rate. That’s the whole value of it and it’s also the whole problem, because leaving somebody off is a statement whether you meant it as one or not, and people notice. There’s an expectation of reciprocity that nobody agreed to and everybody feels. And once your list has any professional consequence, it stops being “who I read” and starts drifting toward “who it is good for me to be seen reading,” which produces a list that’s no use to anybody.</p>

<p>I think that’s the real reason it withered, honestly, more than the sidebar. Curating in public is uncomfortable and the discomfort compounds.</p>

<h2 id="what-i-actually-did">What I actually did</h2>

<p>I exported my reader as OPML, which took one click, and opened it expecting a tidy list of people I read.</p>

<p>60-odd feeds. I’d been adding to it since about 2016 and subtracting from it essentially never.</p>

<p>A good third hadn’t published anything since 2022. Several of the domains were gone, one now sells something, and a couple redirect to a LinkedIn profile, which is its own small sad genre. Of the ones still going, there was a solid group I skim the titles of and never open, which I had not previously admitted to myself.</p>

<p>The list I could honestly stand behind was about 18 people.</p>

<aside class="callout callout--aside">
  <div class="callout__label">Publish the OPML too</div>
  <div class="callout__body">A page of links is for humans and takes a while to work through. The same list as an OPML file is one import into somebody's reader and they've got all 18 at once. It's an ancient, unglamorous format and it does this one job perfectly, so put both up and let people take whichever they want.</div>
</aside>

<h2 id="the-rule-that-made-it-useful">The rule that made it useful</h2>

<p>Only people I have actually read something by in the last 12 months.</p>

<p>That sounds obvious written down. It removed about half of what I’d have listed from memory, including two or three people whose names I’d have put up mostly because putting their names up says something flattering about me. Those are exactly the ones that make a blogroll worthless, because the reader can’t tell the difference between a recommendation and a signal, and if there are enough signals in there they stop trusting the recommendations.</p>

<p>Two other things I decided and would defend.</p>

<p>I’ve written a line next to each one, saying what they write about and why I keep reading. A bare list of domains is a list of homework. One sentence turns it into somebody telling you about a person, and it’s the difference between a page anybody clicks and a page nobody does.</p>

<p>And I’m not doing reciprocal links. If somebody lists me I’m delighted, genuinely, and it doesn’t get them onto mine, because the moment the list is an exchange it stops describing what I read. That’s going to feel rude at some point and I’ve decided in advance to eat it.</p>

<h2 id="its-not-a-solution-to-anything">It’s not a solution to anything</h2>

<p>Being realistic, because I don’t want to oversell a page of links.</p>

<p>18 links on a personal site is not a discovery mechanism. It’ll reach a few dozen people who were already here. It doesn’t scale, there’s no network effect, and it’ll be out of date within the year unless I put a reminder in the calendar, which I have, for July, and which I fully expect to snooze twice.</p>

<p>But the alternative isn’t a better system, it’s nothing. And it took an hour, and the hour was mostly enjoyable, because reading back through eight years of subscriptions turns out to be a decent record of what I was interested in and when. That was worth it on its own, in the same way that writing a uses page in 2020 was more useful to me than the page has ever been to anybody else.</p>

<p>Mine went up this week. If you’ve got a site, put one up, and put a sentence next to each name. That’s the whole ask. Somebody finds a writer they keep for ten years and neither of you ever finds out, which is a fairly good description of what the web was supposed to be for :)</p>]]></content><author><name>Madison Meredith</name></author><category term="Culture" /><category term="Culture" /><summary type="html"><![CDATA[In January I found a blog I’ve since read all the way through, and the route was: a post I was reading linked to it, in the running prose, with a sentence about why.]]></summary></entry><entry><title type="html">Style queries</title><link href="https://madisonjmeredith.com/writing/style-queries/" rel="alternate" type="text/html" title="Style queries" /><published>2026-01-20T00:00:00+00:00</published><updated>2026-01-20T00:00:00+00:00</updated><id>https://madisonjmeredith.com/writing/style-queries</id><content type="html" xml:base="https://madisonjmeredith.com/writing/style-queries/"><![CDATA[<p>The card on a client’s site had five surface variants. Paper, and four tints. Inside it, four things that needed to know which variant they were sitting in: the tag pills, the meta line, the link, and a rule above the footer.</p>

<p>20 selectors. All of the form “when the parent is this, the child is that.” Every new tint meant four more, in four different parts of the stylesheet, and the person adding the tint had no way of knowing that from looking at the card.</p>

<p><code class="language-plaintext highlighter-rouge">@container style()</code> deletes that grid, and it’s now in every browser I care about, so I’ve spent a couple of weeks actually using it rather than reading about it.</p>

<h2 id="what-it-is-in-one-block">What it is, in one block</h2>

<p>A container query that asks about a custom property instead of a width.</p>

<div class="code-block">
  <div class="code-block__filename">tag.css</div>
  <pre class="code-block__pre"><code>.tag {
  background-color: var(--tag-fill);
  border: 0;
}

@container style(--surface: tinted) {
  .tag {
    background-color: transparent;
    border: 1px solid currentColor;
  }
}</code></pre>
  <div class="code-block__language">CSS</div>
</div>

<p>The card sets <code class="language-plaintext highlighter-rouge">--surface: tinted</code> on itself, and stops there. It doesn’t know a tag exists. The tag doesn’t know the card exists, or how many variants there are, or what they’re called. There’s one token in the middle that both of them agree on, and adding a sixth tint is now one line on the card and nothing at all anywhere else.</p>

<p>Three things about it caught me out, and they’re all in the first hour.</p>

<h2 id="you-dont-declare-a-container">You don’t declare a container</h2>

<p>Size queries need <code class="language-plaintext highlighter-rouge">container-type: inline-size</code> on the parent, and that has real consequences, because it establishes containment and the element stops being sized by its contents.</p>

<p>Style queries need nothing. Every element is already a style container. There is no opt-in, no containment, no layout side effect, and nothing to remember to put on the parent. That took me an embarrassingly long time to believe, because it means the feature costs nothing to adopt, not the usual arrangement with CSS.</p>

<h2 id="it-queries-the-parent-not-itself">It queries the parent, not itself</h2>

<p>This is the one that will get you, and it got me inside about ten minutes.</p>

<p>A style query is evaluated against the nearest ancestor container, and since every element is a container, that’s the parent. Not the element the rule matches.</p>

<p>So this does nothing at all:</p>

<div class="code-block">
  <pre class="code-block__pre"><code>.card { --surface: tinted; }

@container style(--surface: tinted) {
  .card { border-color: var(--edge-strong); }  /* never applies */
}</code></pre>
  <div class="code-block__language">CSS</div>
</div>

<p>The card sets the property and the query looks at the card’s parent, which knows nothing about it. You cannot style an element based on a value it set on itself, which is exactly the thing everybody tries first because it’s the obvious reading of the syntax.</p>

<aside class="callout callout--warning">
  <div class="callout__label">Why it mostly still works</div>
  <div class="callout__body">Custom properties inherit, so any descendant at any depth sees the value, not just direct children. A tag three wrappers down inside the card still matches. The parent-not-self rule only bites on the element doing the setting, which is why it feels broken for ten minutes and then stops mattering.</div>
</aside>

<h2 id="custom-properties-only-and-equality-only">Custom properties only, and equality only</h2>

<p><code class="language-plaintext highlighter-rouge">style(--surface: tinted)</code> works. <code class="language-plaintext highlighter-rouge">style(color: red)</code> does not, in anything shipping today. Standard properties are in the spec and nobody has implemented them, so treat the feature as being about your own tokens.</p>

<p>And there are no ranges. <code class="language-plaintext highlighter-rouge">style(--columns &gt; 3)</code> isn’t a thing. You get equality against a value, which means the tokens you query need to be a small set of names rather than numbers with meaning. In practice that’s fine and arguably good, because it pushes you toward <code class="language-plaintext highlighter-rouge">--density: compact</code> instead of <code class="language-plaintext highlighter-rouge">--density: 3</code>, and the first one is legible to whoever reads it next.</p>

<p>One sharp edge in there: the comparison is on the computed value, and an inherited value counts. If you set <code class="language-plaintext highlighter-rouge">--surface: tinted</code> at the root by accident, every element on the page matches, and there’s no obvious symptom other than everything being wrong.</p>

<h2 id="the-question-id-actually-ask-first">The question I’d actually ask first</h2>

<p>Here’s the part I want to be honest about, because two weeks of using this has left me more careful about it, not less.</p>

<p>Most of the time you don’t need a style query, because a custom property was already the mechanism. If the child’s response to a variant is a value, the child should just read a token and you can go home:</p>

<div class="code-block">
  <pre class="code-block__pre"><code>.card { --tag-fill: var(--wash-paper); }
.card--sage { --tag-fill: var(--wash-sage); }

.tag { background-color: var(--tag-fill); }</code></pre>
  <div class="code-block__language">CSS</div>
</div>

<p>No query, works in every browser going back years, and it’s easier to follow. If that’s your situation and you reach for <code class="language-plaintext highlighter-rouge">@container style()</code>, you’ve added a feature to do a job that assignment was doing.</p>

<p>The query earns its place when the response isn’t a value. When the tag changes from filled to outlined, which is two declarations changing in opposite directions. When something gains a pseudo-element it didn’t have. When a layout goes from a row to a stack. You can technically drive all of that through tokens (<code class="language-plaintext highlighter-rouge">--tag-border-width: 1px</code>, <code class="language-plaintext highlighter-rouge">--tag-background: transparent</code>, and so on), and I have, and what you end up with is a component with 11 custom properties in its API, nine of which exist only so that two of them can be set together.</p>

<p>So the test I’ve settled on: if I’m setting three tokens at once and they’re never meaningfully set apart, that’s not three tokens. It’s one condition wearing three tokens, and it should be a style query.</p>

<h2 id="what-it-doesnt-fix">What it doesn’t fix</h2>

<p>Being fair to the 20 selectors I started with.</p>

<p>They were greppable. <code class="language-plaintext highlighter-rouge">.card--sage .tag</code> tells you, from the tag’s stylesheet, exactly which parent triggers it, and dev tools show you the whole chain. A style query tells you a token had a value, and finding out who set that token means walking up the tree in the inspector checking computed values, which is genuinely more work.</p>

<p>I think it’s a good trade, because the thing I was actually losing time to was the combinatorics rather than the debugging. But it is a trade, and if your component has two variants rather than five I’d probably just write the four selectors and get on with something else.</p>

<h2 id="logic-moving-to-the-right-layer">Logic moving to the right layer</h2>

<p>I’ve been doing a version of this in my templates for years, and that’s what made it click. The card include on this site works out what tone its tags should be and passes it down, in Liquid, at build time, because the CSS couldn’t express it.</p>

<p>Now the CSS can express it, and the template goes back to only knowing about content. Which is a small thing, and it’s the same small thing custom properties gave us in 2016 and container queries gave us in 2023: one more piece of conditional logic moving out of the layer that generates the markup and into the layer that’s supposed to own it.</p>]]></content><author><name>Madison Meredith</name></author><category term="Craft" /><category term="CSS" /><summary type="html"><![CDATA[The card on a client’s site had five surface variants. Paper, and four tints. Inside it, four things that needed to know which variant they were sitting in: the tag pills, the meta line, the link, and a rule above the footer.]]></summary></entry></feed>