{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Dustin Edwards blog: accessibility",
  "home_page_url": "https://dustinedwards.info/writing/tags/accessibility",
  "feed_url": "https://dustinedwards.info/writing/tags/accessibility/feed.json",
  "description": "Posts tagged accessibility.",
  "language": "en-US",
  "authors": [
    {
      "name": "Dustin Edwards",
      "url": "https://dustinedwards.info"
    }
  ],
  "items": [
    {
      "id": "https://dustinedwards.info/writing/observable-plot-inside-a-worker",
      "url": "https://dustinedwards.info/writing/observable-plot-inside-a-worker",
      "title": "Rendering Observable Plot charts inside a Cloudflare Worker",
      "content_text": "\nObservable Plot can render charts inside a Cloudflare Worker. As far as I can determine, this has not been documented before: Plot's own documentation covers server-side rendering in Node.js via JSDOM, the Plot team has demonstrated rendering in a browser Web Worker via a DOM substitute, and searching for the Cloudflare case returns nothing. This article reports the working configuration, measured on 2026-07-31: Plot 0.6.17 with linkedom 0.18.13 as the DOM implementation, bundling at 930 KiB raw and 226 KiB gzipped as measured in this site's production Worker, rendering deterministically (200 in-process renders and multiple separate processes producing one distinct output), and, the property that mattered most here, producing byte-identical SVG in Node and in workerd, verified by SHA-256 comparison. It also reports the three candidate configurations that failed, with their exact errors, because the failures define the boundary of what works and two of them generalize well beyond charts.\n\nEverything below is reproducible; the chart in this article is rendered by the pipeline it describes.\n\n## Why render charts in the Worker at all\n\nThe obvious architecture for a static blog is to render charts at build time only, and if that fits your system, you should do it and skip the hard parts of this article. The requirement here was stricter because of two standing rules on this site. First, charts are content: the data lives in the post's markdown as a fenced block inside a chart directive, and the rendered SVG is part of the stored HTML rather than an asset beside it, the same regime [all content here lives under](/writing/posts-in-git-served-from-d1). Second, that render has two writers, the Node build and the Worker's save path (the browser editor, and the [agent-operated publishing API](/writing/agent-write-access-to-a-live-site) this post arrived through), and the whole design depends on both writers producing identical bytes. Together these rules mean the chart renderer must run in the Worker, produce output byte-identical to the Node build's, and do so deterministically forever. That combination, not any single requirement, is what eliminated most of the field.\n\n:::sidenote{kind=\"Constraint\"}\nByte-identical is the word that decides this. Two writers producing *equivalent* SVG would still fail the build's comparison, because the gate compares bytes rather than rendered pictures.\n:::\n\n## The configuration that works: Plot plus linkedom\n\nPlot requires a DOM: it builds its output through d3-selection against a document. Cloudflare Workers have no DOM. The bridge is [linkedom](https://github.com/WebReflection/linkedom), a lightweight pure-JavaScript DOM implementation, passed to Plot through its documented `document` option:\n\n```ts\nimport * as Plot from \"@observablehq/plot\";\nimport { parseHTML } from \"linkedom\";\n\nconst { document } = parseHTML(\"<html><body></body></html>\");\nconst chart = Plot.plot({\n  document,\n  width: 640,\n  height: 400,\n  marks: [\n    Plot.barY(data, { x: \"library\", y: \"kib\", fill: \"var(--chart-1)\" }),\n    Plot.ruleY([0]),\n  ],\n});\nconst svg = chart.outerHTML;\n```\n\nThat string is the chart: static SVG, no client JavaScript, servable directly or embedded in generated HTML. Three properties of the output are worth stating precisely because each was a requirement I verified rather than assumed.\n\nDeterminism. Two hundred renders in one process produced exactly one distinct output by SHA-256. Separate processes agreed. The generated-ID hazard I expected (Plot creates clip-path IDs in some configurations, the same class of nondeterminism that [broke this site's syntax highlighter](/writing/posts-in-git-served-from-d1)) did not appear for standard marks: the output contained no generated IDs at all. I would still treat this as a property to verify per Plot version rather than a permanent fact, and this site's build now does, on every run.\n\nCross-environment byte parity. The same chart rendered in Node and in workerd (via a bundled invocation under miniflare) hashed identically. This is the property the two-writer gate requires, and it held both in the initial probe and when reproduced against the production module. One subtlety from the earlier probe is worth recording: parity holds when both environments use the same DOM implementation. My first Node measurements used a different shim than the Worker and the hashes diverged, which was the shims' serialization differing, not Plot misbehaving. Standardize on one DOM implementation everywhere and the question disappears.\n\nCSS custom properties pass through. Plot accepts `var(--chart-1)` anywhere it accepts a color, and the string lands in the SVG untouched. Because the SVG is embedded inline in the page, those variables resolve against the site's stylesheet at display time, which means one stored render serves light and dark themes and the palette's [contrast gate](/writing/color-palette-the-build-can-check) governs chart colors with no additional machinery.\n\n## The configuration the Plot team demonstrated, which does not transfer\n\nThe natural starting point was domino, the DOM substitute the Plot team used to demonstrate Plot inside a browser Web Worker. It works in Node. It cannot ship to a Cloudflare Worker, and the failure is categorical rather than a bug: domino's `lib/sloppy.js` uses `with` statements, which are illegal in strict mode, and Workers bundle as strict-mode ES modules. The build fails with five errors of the form:\n\n> With statements cannot be used with the \"esm\" output format due to strict mode\n\nNo configuration fixes this; it is a property of the package's source. The general lesson: a dependency that works in Node and in browser Workers can still be structurally excluded from strict-ESM targets, and you find out from the bundler, not the documentation.\n\n## The candidate that fails at runtime: Vega-Lite\n\nVega-Lite was the strongest alternative on paper: a scholarly grammar, headless rendering in Node with no DOM needed, deterministic output, CSS variables passing through. It renders fine in Node. In workerd it returns a 500:\n\n> EvalError: Code generation from strings disallowed for this context\n\nVega's runtime compiles its expression language via dynamic code generation, and Workers forbid runtime code generation as a security policy, the same policy that [forbids runtime WebAssembly compilation](/writing/posts-in-git-served-from-d1) and shaped this site's highlighter and social-card architecture. Vega ships an alternative AST interpreter for CSP-restricted environments, which I did not pursue because Plot had already passed every test. The finding stands on its own for anyone evaluating Vega on Workers: the default path cannot run there, and the error will not appear until runtime.\n\n## The candidate that lost on the axis it was supposed to win: Apache ECharts\n\nECharts has the best server-side story in the charting field on paper: a zero-dependency SSR mode, introduced in 5.3, that renders to an SVG string with no DOM at all. I expected it to win on bundle size for exactly that reason, since Plot drags a DOM implementation along. Measured, the intuition inverted: ECharts bundled for Workers at 2958 KiB raw and 634 KiB gzipped against Plot-plus-linkedom's 1070 and 254 in the same probe harness. The shim is small; the engine is not. I record the caveat that aggressive tree-shaking of ECharts' modular imports could narrow this, so treat the ECharts figure as worst-case, but the headline holds: needing no shim does not make a library light, and the only way to know a bundle size is to measure it.\n\n:::chart{type=\"bar\" x=\"configuration\" y=\"gzipped_kib\" title=\"Worker bundle size by charting configuration\" alt=\"Bar chart of gzipped Worker bundle sizes measured in the probe: Observable Plot with linkedom 254 KiB, Vega-Lite 412 KiB, Apache ECharts 634 KiB. Plot with linkedom is the smallest at less than half of ECharts.\"}\n```csv\nconfiguration,gzipped_kib\nPlot + linkedom,254\nVega-Lite,412\nECharts SSR,634\n```\nGzipped Worker bundle size per configuration, measured 2026-07-31 with wrangler 4.116.0 in one probe harness. Vega-Lite's figure is included for comparison although it cannot run in workerd at all.\n:::\n\n## Accessible charts, enforced by the build rather than promised\n\nA chart library's output is not accessible; an implementation is. Plot's raw SVG carries no role and no accessible name, so this site's chart directive wraps every chart in a structure the build enforces: the SVG itself carries `role=\"img\"` and an `aria-label` from a mandatory alt attribute (a chart without one fails the build, the same rule images here have always had), a `figcaption` carries the caption, and an equivalent HTML data table, generated from the same fenced data, sits beneath the figure so a screen reader user gets the numbers rather than a summary. Because the data lives in the markdown, the table is automatic and cannot drift from the chart.\n\nOne correction from building this is worth passing along, because I wrote the wrong version into the specification first and the implementing session caught it against the WAI-ARIA spec before shipping. The natural-looking structure, `role=\"img\"` on the `<figure>` wrapping everything, is a compliance bug: `role=\"img\"` makes all descendants presentational, so it would have generated the caption and the data table and then hidden both from assistive technology, the accessibility features defeating themselves. The role and the accessible name belong on the SVG element, the thing that is actually an image, leaving the figure, caption, and table as ordinary reachable semantics. The corrected structure is now a planted violation in the build gate: a chart whose SVG lacks a role or accessible name fails the build.\n\n## Where the boundary is: charts yes, diagrams no\n\nThe same probe method was applied to text-to-diagram tools (Mermaid, and the smaller Pintora), and every candidate failed under linkedom, each with a different first error and the same root cause. Mermaid fails immediately at `ReferenceError: CSSStyleSheet is not defined`, and behind that lies its documented dependence on `SVGTextElement.getBBox()`; Pintora fails at `TypeError: Cannot set properties of null (setting 'font')`, reaching for a canvas text-measurement context that does not exist. The distinction generalizes: Plot computes its layout from data, scales mapping numbers to coordinates, while diagram layout requires measuring rendered text, and text measurement requires real font metrics that no lightweight DOM shim carries. That is why charts can satisfy a two-writer byte-parity requirement on Workers today and diagrams cannot; diagrams on this site render at build time as external assets instead, under [the rule that a reproducibility gate should compare only what its inputs fully determine](/writing/blog-reading-without-javascript).\n\n## Limitations\n\nThe measurements are dated 2026-07-31 and version-pinned (Plot 0.6.17, linkedom 0.18.13, wrangler 4.116.0); the determinism and parity properties are re-verified by this site's build on every run precisely because they are properties of versions, not laws. The zero-generated-IDs result is scoped to the marks tested; configurations using explicit clipping may behave differently. The ECharts bundle figure is a worst-case without tree-shaking effort. The parity test bundles the chart module rather than the whole content pipeline, a scope choice stated in the gate itself. And the no-prior-art claim is a search result, not a proof; if someone has done this before and written it down where I could not find it, I would genuinely like to read it.\n\n## Update, 28 August 2026: the byte-parity requirement outlived the gate that checked it\n\nThis post describes the requirement as serving a committed artifact that a build\ngate byte-compared against a fresh generation. That artifact left git on\n2026-08-26, for reasons set out in [the content pipeline\narticle](/writing/posts-in-git-served-from-d1): it made every editor save\ndownload the whole thing from GitHub, and the check it enabled had moved into\ncontinuous integration anyway.\n\n**Nothing in the engineering above changes, and that is the interesting part.**\nThe requirement was never really about the file. It is about two independent\nwriters rendering the same source, and the site still has exactly two: the Node\nbuild and the Worker. What moved is where the disagreement is caught. It used to\nbe a byte comparison at commit time. It is now a drift table printed at deploy:\nevery row in the database records the git blob hash of the markdown it came from\nand a hash of its own render, the deploy renders the corpus fresh, and a row\nwhose source is unchanged while its render differs is named by slug and fails\nthe run after the deploy stands.\n\nSo the chart renderer must still run in the Worker, still produce output\nbyte-identical to the Node build's, and still be deterministic forever. Both\nproperties are still checked on every build, in-process and across processes and\nacross the Node/workerd split, by the same gate this post describes. A\nnon-deterministic renderer used to fail a byte comparison at random; it would\nnow report render drift at random, which is the same finding wearing a different\nname and reaching a reader no later.\n\nThis post extends [the series on rebuilding this site on Cloudflare's developer platform](/writing/ten-years-on-cloudflare). It was drafted by the site's operator agent, staged through [the MCP tools the series describes](/writing/mcp-server-on-workers-with-oauth), and its figure was rendered by the pipeline it documents. Publication, as always here, required the human.\n",
      "summary": "How to render Observable Plot charts inside a Cloudflare Worker: the linkedom shim that works, the domino and Vega failures that don't, byte-identical output across Node and workerd, and accessible charts enforced by the build.",
      "date_published": "2026-07-31T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "cloudflare",
        "data-visualization",
        "observable-plot",
        "workers"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/color-palette-the-build-can-check",
      "url": "https://dustinedwards.info/writing/color-palette-the-build-can-check",
      "title": "Designing a color palette the build can check",
      "content_text": "\nThis is a method for building a site palette the way you would build any other engineered artifact: requirements first, arithmetic before taste, and a check in the build so the properties you proved stay proved. I used it to build the palette this site is wearing. You need about fifteen lines of Python, a free evening, and the willingness to let a computation veto a color you like.\n\nThe constraints I was working under, so you can map them to yours: one locked brand color (a deep purple, :swatch[#4F2D7F]), light and dark modes, WCAG 2.2 AA everywhere, and a regional character I wanted to keep (warm West Texas neutrals rather than the blue-gray everything defaults to). Your brand color and character will differ. The method does not.\n\nThree of my attempts failed along the way. I have left them in as warnings at the point in the procedure where you would make the same mistake, because each one produced a rule you can apply directly.\n\n## Step 1: write the WCAG contrast ratio function before choosing any color\n\nWCAG contrast is a formula, not a judgment. Relative luminance for each color, then `(L1 + 0.05) / (L2 + 0.05)` with the lighter luminance on top, as defined in [WCAG 2.2](https://www.w3.org/TR/WCAG22/). Write it yourself rather than relying on a web checker, because you are about to run hundreds of pairs and you want them in a loop:\n\n```python\ndef srgb_channel(c):\n    c = c / 255\n    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4\n\ndef luminance(hexcolor):\n    h = hexcolor.lstrip(\"#\")\n    r, g, b = (int(h[i:i+2], 16) for i in (0, 2, 4))\n    return (0.2126 * srgb_channel(r)\n          + 0.7152 * srgb_channel(g)\n          + 0.0722 * srgb_channel(b))\n\ndef ratio(a, b):\n    la, lb = luminance(a), luminance(b)\n    hi, lo = max(la, lb), min(la, lb)\n    return (hi + 0.05) / (lo + 0.05)\n```\n\nThe targets you will hold every pair to: `4.5:1` for normal text, `3:1` for large text and for user interface components such as borders and focus indicators. Those come from WCAG 2.2 success criteria 1.4.3 and 1.4.11. Write them down as constants; they are about to become your veto.\n\n## Step 2: inventory the color roles before the colors\n\nA palette is not a list of nice colors, it is a set of jobs. List the jobs first, because the jobs determine how many computed pairs each color owes you. The inventory that has worked for me, and that most sites eventually converge on whether they planned to or not:\n\nNeutrals: page background, one or two raised surfaces, body text, muted text, disabled text, a default border, a strong border. Brand: the brand color itself, hover and active steps, a tinted surface, a visited-link color, text selection, and a focus ring, including a focus ring that will sit on top of brand-colored fills, which people forget until their primary button's focus outline vanishes into the button. Semantic roles, one hue each: danger, warning, success, info. For each semantic role you want four variants: a text color, a tinted background, a border, and a saturated fill for interactive elements. The fill tier is the one most palettes are missing, and the reason it matters is a dark-mode problem I will get to in step 6. Then a highlight color for search marks, and a categorical set for charts.\n\nCount the pairs this implies: every text on its background, every text on its tint, every border on its surface, every fill against its label text. Mine came to sixty-plus pairs per mode. That number is why step 1 was a function and not a website.\n\n## Step 3: work in OKLCH, not HSL\n\nWhen you start generating candidates, use OKLCH coordinates (lightness, chroma, hue) rather than HSL. HSL lightness is a transform of screen RGB, not of human vision: a yellow and a blue at the same HSL lightness differ enormously in perceived brightness, so any reasoning you do about \"same lightness\" in HSL is wrong before you start. OKLCH is approximately perceptually uniform, browsers support it natively in CSS now, and every serious color tool speaks it.\n\nOne caveat you should carry: the OKLab model's approximation is weakest in deep blues and purples. If your brand color lives there, as mine does, treat OKLCH as the drafting space and the WCAG ratio function as the verdict, and verify every pair involving the brand individually rather than trusting anything derived.\n\nA warning from my first failed attempt. I found a published palette whose violet sat sixteen degrees of hue from my brand purple and tried to adapt the whole palette by applying the full transform between the two violets, hue shift plus the saturation and lightness scaling, to every color. The result was neon: the sand tone went to pure white and the red landed on :swatch[#FF1942]. The depth of a dark brand color is a property of that color, and propagating its lightness transform destroys its neighbors. If you adapt an existing palette, rotate hue only, then re-tune each color's lightness individually against your contrast targets. Formulas draft. They do not decide.\n\n## Step 4: run the full pairwise contrast matrix, and rerun it after every change\n\nPut your candidate hexes in a table of (foreground, background, required ratio) triples and loop the ratio function over all of them. Any pair below its requirement fails the whole candidate set; adjust the OKLCH lightness of the offender and rerun. You will iterate this loop dozens of times, which is fine, because it is a loop.\n\nThe warning attached to this step comes from my second failure. Partway through, a hue analysis showed my decorative rust accent sitting sixteen degrees from my danger red, close enough that the two could be confused, so I moved rust away. Then, choosing a warning color, I picked a burnt tangerine that sat almost exactly where rust had been, and recreated the same collision between two colors that now both carried meaning. The fix was moving warning to the amber family, where decades of interface convention already put it. The procedural lesson is the one to keep: the pairwise check, both the contrast matrix and a simple hue-distance scan between roles, runs after every change, not once at the end. A palette is a system, and edits have neighbors.\n\n## Step 5: how many colorblind-safe chart colors are possible\n\nCategorical chart colors have a harder job than interface colors: they must be distinguishable from each other, not just from the background, including by readers with color vision deficiency. The reliable channel for that is lightness, because it survives every type of CVD. So build your chart set as a lightness ladder: pick your hues, then force their OKLab lightness values apart by a fixed margin.\n\nHere is the arithmetic that will stop you from promising too much, and it is worth doing for your own numbers before you commit to a set size. I wanted every pair of unlabeled categorical colors separated by at least `0.08` in OKLab lightness, a margin I chose as an engineering value, not a citation. Six colors means five gaps, so `0.40` of total lightness span. But every chart color must also hold `3:1` against the page background, and on my light background that constraint caps the usable lightness span at roughly `0.30`. Six fully lightness-separated categorical colors cannot exist inside AA contrast bounds. That is not a property of my palette; it is arithmetic, and it is why mature data visualization palettes are small or lean on redundant encoding. My resolution, which I recommend as the general pattern: a core set of five on a strict ladder, safe anywhere; a sixth color permitted only in charts whose series are directly labeled; and a binding rule that hue alone is never the distinguishing channel between adjacent series.\n\nI claimed my initial six chart colors were CVD-safe on the assumption that the lightness spread covered it, then computed the pairwise differences and found three pairs within `0.01` of each other, including an orange against a green, the classic deuteranopia collapse. The rule that survives: compute before you claim.\n\n## Step 6: simulate color vision deficiency with the Vienot matrices\n\nSimulate protanopia and deuteranopia with the Vienot, Brettel, and Mollon (1999) matrices, applied in linear RGB, then measure the OKLab distance between each pair of colors that carry meaning near each other. The pairs to care about most are the ones your interface will actually juxtapose: danger against success (form validation puts them side by side), link color against body text, and adjacent chart series.\n\nMy results, so you know what to expect: danger red versus success sage was marginal in light mode and collapsed in dark mode under deuteranopia, and blue versus purple failed under both deficiency types. Yours will fail somewhere too, because no six-hue palette passes on hue alone, and knowing that changes what the fix is. The fix is not better hues. It is the rule WCAG codifies as success criterion 1.4.1: color is never the only channel. Errors are red plus an icon plus a message. Links are underlined. Charts label series directly. Once those rules are binding, the CVD simulation stops being a pass-fail test of your hues and becomes a map of exactly where the second channel is load-bearing.\n\nTwo disclosures to attach if you publish your own numbers, because a careful reader will ask. The Vienot matrices model complete dichromacy, the worst case; most real CVD is anomalous trichromacy and milder, so simulated results are a floor, not a portrait. And any OKLab distance threshold you adopt as a pass mark is a chosen engineering value; there is no standard to cite for it, and pretending otherwise is the kind of overclaim that gets a methods section rejected.\n\n## Step 7: APCA vs WCAG 2 in dark mode\n\nDark mode will force every accent color light, because that is what the contrast math demands against a dark background. Run those pastels through WCAG 2 and they score generously; my dark danger text scores `8.4:1`. Then run them through [APCA](https://git.apcacontrast.com/), the candidate successor contrast algorithm built on more recent perceptual research, and watch the same pastel score around `Lc 60`, adequate for large text and short of body-text targets. That disagreement is a known property of the WCAG 2 formula in dark polarity, and it has a practical design consequence you can adopt regardless of which algorithm you trust: interactive semantic elements in dark mode should use saturated fill variants, not pastel text colors, because a soft pink Delete button reads gentle, and gentle is the one thing a delete button must not be.\n\nUse APCA as an advisory column, not a gate, since WCAG 2.x remains the standard with legal weight. And if you implement APCA yourself, verify the implementation against the published keystone test vectors before you quote a single number from it; mine reproduces them to the last decimal, and checking took ten minutes that made every subsequent claim defensible.\n\n## Step 8: a build gate that recomputes every contrast pair\n\nThe palette is only proved for as long as nothing changes, which is to say, it is not proved at all unless the proof runs automatically. The last step is a script in your build that reads the shipped CSS custom properties, recomputes the entire pairwise matrix from the actual deployed values, fails the build if any pair drops below its requirement, and prints the APCA advisory alongside. Mine currently checks the full matrix across both modes on every build, and if a future edit nudges one hex below its floor, the build fails and names the pair.\n\nBefore you trust that gate, break it on purpose: change one value to something failing and confirm the build goes red with the right pair named. A gate you have never seen fail is a hope, not a gate.\n\nTwo smaller rules that earn their keep once the gate exists. Keep the palette's authoritative values in one specification document with every ratio recorded, so the gate has a source of truth to check the CSS against rather than checking the CSS against itself. And write your usage rules down as numbered law next to the values: color never the sole channel, links underlined, charts labeled, one semantic tint per view, fills for interactive semantics. The colors pass contrast; the rules are what make the system accessible, and a palette document without them is half a deliverable.\n\nWhat you get at the end is fifty-odd tokens per mode, every ratio recorded, a handful of binding rules, and a build that refuses to ship a regression. The method costs one evening more than picking colors by eye. The difference is that when someone asks whether your palette is accessible, you can answer with a script instead of an adjective, and when you change a color next year, the build will tell you what you broke before your readers do.\n\nThis is the second post in [a series on rebuilding this site on Cloudflare's developer platform](/writing/ten-years-on-cloudflare); the next one covers [the git-backed content pipeline](/writing/posts-in-git-served-from-d1) that gates this palette's check script alongside everything else.\n",
      "summary": "A method for accessible color palette design: the WCAG contrast ratio function in Python, OKLCH candidate selection, color vision deficiency simulation with the Vienot matrices, chart lightness ladders, and a build gate that recomputes every pair on every deploy.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "color",
        "design",
        "wcag"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/blog-reading-without-javascript",
      "url": "https://dustinedwards.info/writing/blog-reading-without-javascript",
      "title": "A blog reading experience that works without JavaScript",
      "content_text": "\nThis article describes how to build a modern blog reading experience under one strict constraint: every public page must work completely with JavaScript disabled, with client-side script permitted only to decorate what the server already rendered. The result on this site is a reading interface with a scroll-synced table of contents, a reading progress indicator, code copy buttons, heading anchor links, footnote hover previews, and an image lightbox, whose entire client-side cost is one small bundle of gzipped JavaScript, loaded by a single script tag: 1.67 kB at the current measurement (2026-08-26; it was 1.59 kB as a lazily loaded chunk plus a 0.22 kB loader when first published). The article covers the method for holding that constraint, the pipeline rules that emerge when two different programs write the same content, and several decisions about what not to build, with reasons.\n\nProgressive enhancement is an old idea, and I am not claiming novelty for it. What I can add is a worked example with current tooling, exact costs, and the specific places where the discipline required a decision rather than a habit.\n\n## The progressive enhancement rule: server-complete, with every fallback named\n\nThe rule I recommend, and the one this site enforces, has two parts. First, every feature on a public reading route must have a server-rendered form that is complete without script: navigation navigates, filters filter, code arrives highlighted. Second, every client-side enhancement must have a named fallback recorded somewhere a reviewer can find, even when the fallback is \"nothing.\" A reading progress bar's fallback is nothing, because it is decoration; writing that down is what distinguishes a decision from an omission.\n\nThe second part sounds bureaucratic and is the entire method. When each enhancement must declare its fallback before it ships, the declaration forces the design conversation at the right moment: if you cannot name what happens without script, the feature is not an enhancement, it is a dependency, and it either moves to the server or does not ship. In practice the inventory for this site is short. The table of contents is server-rendered anchor links; script adds scroll-spy highlighting. Code blocks arrive fully highlighted from [the build pipeline](/writing/posts-in-git-served-from-d1), including highlighted line ranges baked into the stored HTML; script adds a copy button and a language label. Footnotes are ordinary bidirectional links; script adds hover preview cards. Images link to their originals; script intercepts the click into a lightbox. Tag and year filters are server-side query parameters. Nothing on the list requires the network after page load, and nothing renders content that was not already in the HTML.\n\n## How a complete blog UI fits in 1.59 kB of gzipped JavaScript\n\nThe figure (1.59 kB gzipped when this was first published; 1.67 kB re-measured 2026-08-26) is not the product of heroic minification. It follows from the architecture: when the server renders everything and script only attaches behavior to existing elements, the script contains event listeners and class toggles, which are cheap. There is no framework runtime in the bundle, no templates, no state management, because there is no client-rendered state. Since 2026-08-26 that sentence is true of the whole page, not just the bundle: this site's public routes no longer hydrate a client framework at all, so the reading enhancements are prebuilt into a self-contained module and loaded by an ordinary script tag, served only on blog routes. The rest of the site pays nothing.\n\nThe measurement itself deserves a paragraph, because my first measurement was wrong in an instructive direction. During verification, a 250-line TypeScript file emitted a 0.05 kB chunk. It was not plausible, and implausibility was the correct alarm: a misconfigured `?url` import had caused the bundler to copy the file verbatim as a static asset, meaning the browser would have been served raw TypeScript. The general procedure I would recommend for any bundle claim: distrust any number that surprises you in either direction, open the emitted files, and confirm the served bytes are the compiled form. A second false signal came from the automated browser harness, where a form-input tool intermittently failed to reach the framework's synthetic event system, making a working autosave feature look broken; real keyboard input resolved it. Verification tooling fails in both directions, and the habit that catches it is treating every surprising result as a claim about the harness first and the code second.\n\n## Rules when a build script and a live server both write the same content\n\nThis blog's content is written by two different programs: a build script running in Node, and the editor's save path running in the Worker. When this article was first published, both produced the same generated artifact, and [the pipeline's byte-comparison gate](/writing/posts-in-git-served-from-d1) required the two writers to agree exactly. Three rules fell out of making that true, and each was learned by watching the gate fail. The artifact came out of the repository on 26 August 2026; the update at the end says which of the three rules survived it, which is all three.\n\nFirst, derived values that depend on commit history cannot live in the gated artifact. I wanted a \"last updated\" date derived from git. The artifact is generated before the commit that will contain it, so a git-derived date records the previous commit, and the next build computes a different value, which means the gate fails after every ordinary content commit, by construction rather than by accident. Dates of that kind belong in a layer outside the byte-compared artifact; here they are written at database sync time.\n\nSecond, corpus-level derived data must be recomputed over the whole corpus by every writer. Related-posts lists are a property of the set: adding one post changes the correct lists of others. An editor save that updated only its own post's related list would leave the artifact inconsistent with what a fresh full generation produces, and the gate would correctly flag it. Both writers therefore recompute relatedness globally on every write. At this corpus size that costs nothing; at a much larger size you would need either an incremental algorithm both writers share or a decision to move relatedness out of the gated artifact entirely.\n\nThird, and this is the operational hazard I most want to pass along: if the deployed Worker's pipeline version differs from the main branch, the live editor becomes a machine for writing artifacts that main cannot reproduce. This happened during development, when a Worker deployed from a feature branch accepted an editor save and committed an artifact in the new pipeline's shape against a main branch still running the old pipeline. The gate on main went red through no fault of the content. The rule that prevents it: a deploy that changes the pipeline and a mainline that has not merged it cannot coexist with an active editor. Sequence deploys with merges, or lock the editor during the window.\n\n## Open Graph card generation at build time: keep external facts out of the gate\n\nOpen Graph card images here are generated at build time with satori and resvg at 1200 by 630, stored in R2 under a key derived from the slug plus a hash of slug, title, and description, and served immutable. A per-post cover image takes precedence when present. Two decisions in this subsystem generalize.\n\nGeneration runs at build time in Node, not in the Worker, because the measured costs said so: the Worker-compatible rendering path would have added 1.87 MB to an already large Worker for code that runs only on saves, and the resvg WebAssembly build requires the runtime compilation Workers refuse. The accepted consequence is that a post created or retitled in the editor has no card until the next build runs. The editor stores no image reference in that window rather than a URL that would 404, on the principle that a missing image is a degraded state and a broken image reference is a bug.\n\nThe second decision is subtler. The card's R2 key is deterministic and could have been written into the gated artifact, and it deliberately was not. Whether an object exists in R2 is a fact about R2, not a fact about the markdown, and the gate compares statements about the markdown. Mixing an external system's state into a byte-compared artifact means the gate fails for reasons no content change explains. The card's location lives in a database column instead. As a general rule: a reproducibility gate should compare only what its inputs fully determine.\n\n## Version history from git, and an autosave that never commits\n\nVersion history in the admin is a window onto git rather than a mechanism of its own: it lists a post's commits through the GitHub API, renders diffs, and implements restore as a new commit through the same atomic save path used everywhere else, never a history rewrite. I verified the non-destructive property live: after a restore, both earlier commits remained intact and the gate was green. One detail worth copying into any implementation: restored content re-runs the validation gates, which closes a hole where restoring an old commit would republish text that predates a rule and was never checked against it.\n\nAutosave is the deliberate opposite. Draft state persists locally in the browser and survives a reload, verified with a forced reload mid-edit, and it never commits. A system whose integrity story is \"every change is a reviewed commit\" cannot also run a background process that quietly commits keystrokes; the commit button remains the only writer.\n\n## What was refused, and why the refusals are documented\n\nPart of the method is recording what you chose not to build, with one sentence of reasoning each, in the project documentation rather than in anyone's memory. From this phase: no comments, pending an actual decision about moderation and storage rather than a default; no view counters, which need their own privacy and measurement decisions; no rich text editor, because a textarea plus a live preview rendered by the one true pipeline was sufficient to ship and WYSIWYG is a separate commitment; and no 103 Early Hints, because the feature requires a zone and this hostname is not one until a pending DNS cutover, a fact established by checking rather than assumed. The purpose of writing refusals down is insurance: the cheapest way to prevent a future contributor, human or AI agent, from helpfully building the wrong thing is a sentence explaining why it is absent.\n\n## Limitations\n\nThe no-JavaScript claim is scoped to public reading routes; the admin interface requires script and makes no such promise, which I consider the correct boundary since its audience is one authenticated person. The bundle figures are from the production build at the time of writing and will drift as features land; what should not drift is the rule that produced them. And the two-writer rules above were learned against a byte-comparison gate; a project without one avoids those specific failure modes and loses the corresponding guarantee, which is a trade you should make knowingly rather than by omission.\n\nThis is the fourth post in [the series](/writing/ten-years-on-cloudflare), following [the content pipeline](/writing/posts-in-git-served-from-d1); the next one covers [the search engine underneath this reading experience](/writing/site-search-on-d1): SQLite FTS5 on D1, two indexes, rank fusion, and a median query time of six milliseconds.\n\n## Update, August 2026\n\nTwo findings from an external review month belong in this article's inventory. The first is the class-closure story: one public route had no cache headers at all, not because anyone decided that, but because the headers were copied route by route and the newest route was forgotten, the copy-paste failure this article's own two-writer section warns about wearing a different hat. The fix that matters was not adding the missing headers; it was adding an assertion that every public route exports them, so the class of miss is closed rather than the instance. The second finding is the browser permissions header, and it vindicates the fallback inventory in a direction I did not anticipate: when deriving the site's Permissions-Policy from what the code actually uses, the one capability that must never be denied turned out to be clipboard access, because the copy buttons this article describes swallow a refusing clipboard silently, by design, since a copy button that throws is worse than no copy button. An enhancement that fails politely is invisible to every test that only checks the happy path, which makes the written fallback inventory the only place the dependency was recorded. Derive your permissions from your inventory, not from a copied denylist, or the denylist will eventually deny something your own page quietly needs.\n\nA third finding arrived after the review month closed, and it retires the machinery behind this article's middle section. The committed artifact that the two-writer rules were written against came out of the repository on 26 August 2026, for the reasons recorded in [the pipeline article's update](/writing/posts-in-git-served-from-d1#update-26-august-2026-the-committed-artifact-came-out): git holds markdown only, the database holds the only rendered copy, and each row carries the hash of the source it was rendered from. The three rules survived the change better than the design did. Derived values that depend on commit history still cannot live in what two writers must agree on, so the last-updated date is still written at sync time. Corpus-level derived data is still recomputed over the whole corpus by every writer; it is now read from the database rather than from a file. And the deploy-versus-mainline hazard is now caught by a drift table the deploy prints, post by post, rather than by a red gate on main. The Open Graph section's rule survives untouched, since it was about keeping external facts out of whatever two writers must agree on, and that requirement did not go anywhere.\n",
      "summary": "How to build a full reading experience, table of contents, progress bar, copy buttons, footnote previews, lightbox, that works with JavaScript disabled and enhances in under 2 kB gzipped. Includes the fallback inventory method and two-writer pipeline rules.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "cloudflare",
        "performance",
        "progressive-enhancement"
      ]
    }
  ]
}
