Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.

_log

· Revision history

Append-only. One entry per ingest / query-filed / lint, newest first. Entry format: ## [YYYY-MM-DD] ingest | Title so grep "^## \[" _log.md | head -5 lists the latest five.

[2026-09-22] ingest | Format on save, and Undefined custom properties enriched

From the session that built the lint skill. New page Format on save: Nova’s Prettier extension saved styleguide.njk empty when the jinja-template parser failed on it, the second time after reading.njk in July, and the empty file was committed and pushed. The page covers the general failure, the three guards (an ignore file, reading the diff, a pre-commit hook), and jedee’s two: *.njk in .prettierignore and the local hook, quoted in full. It links Sveltia CMS, the other tool that rewrites a file on save. Undefined custom properties enriched with the recurrence: three more upstream names the lint found, and the cause, the stack skills listing plain EE token names as the project’s own. Sources written to src/_raw/dev-notes/.

[2026-09-22] lint | The wiki lane of the first full-project lint

The new lint skill’s full run checked every page against the code and found thirteen things; all are fixed. Counts that had drifted, recounted rather than copied from the lint: fourteen types have a feed, not twelve; ten have their own card, not eleven; twelve are configured in JSON and four in JavaScript; five JSON-LD templates, with TechArticle; ten accessibility-test paths; eight WebC components, fifteen token files, twenty-one local stylesheets; 29 generated files in the input directory; 113 orienteering activities; the ⚠️ on 50 pages. Two narrative counts in Per-type feeds are now dated to the moment they describe instead of recounted. What jedee kept from Eleventy Excellent and Tailwind now show the cubeUtilities layer name, Site search reads settings.yaml, The title-less post types names link-atom-body.njk and four affected types, and line-number citations into the CSS became file names so they stop drifting. Six pages got the updated: date of their last real revision, and British spellings in prose Claude wrote became US ones, except where alt text quotes words printed in a figure.

[2026-09-21] lint | jedee against webtypography.net

Johan asked for a check of the site against every rule on webtypography.net. Of 24 rules, four were missing and are now fixed; Typographic conventions records each, with the measurements. The biggest was invisible in Chrome: Eleventy Excellent’s reset turned kerning off in WebKit, so Safari and iPhone readers saw unkerned text. Hyphenation limits turned out to be only partly honoured by Chrome, and the page says so rather than claiming the rule is enforced.

[2026-09-21] enrich | Typographic conventions

Johan asked for the hollow bullet to line up with the numbers’ last digit and for the list indent to follow the type. The page now records what the books say about indenting a list (nothing directly; the paragraph indent is the nearest rule), the move from 2.75ch to a one-line-height indent, and the spaces that centre the ring over the digit column. A one-line-height paragraph gap was tried and dropped, and the page says why.

[2026-09-20] enrich | The dev server’s memory

The dev server had crashed twice in a day, and Johan asked whether saves could be batched so one rebuild covers them all. They cannot: Eleventy drains its watch queue one file per build while --incremental is on, and says so in a comment in EleventyWatch.js. The page now carries that, with the setWatchThrottleWaitTime dead end marked — it is a real API that accepts any value and cannot do the thing.

The bigger correction is to the leak itself. August measured ~368 MB per rebuild on full rebuilds only; measuring again with --incremental in the dev script shows it is paid per page rendered, about 1 MB each — +156 MB for a 159-page content save, +1 MB for a one-page template save, +607 MB for a CSS save. That turns the whole thing into a table of what a given save costs, which is the useful shape.

It also let the August note’s unimplemented cure be built and the paragraph promising it be replaced with what happened: global CSS out of the watch target, recompiled by a node:fs watcher inside the Eleventy process, served as a plain file, and Eleventy printing (skips build). 0.79 s instead of 12 s, and production verified byte-identical rather than assumed so. Second raw source added to the footer.

[2026-09-20] enrich | Line length

Johan named the design rule behind a thing the wiki had only measured one half of. The measure is set in ch, ch is font-relative, and nothing centres the capped blocks — so every text element works out its own line length from its own type size and they all hang from the same left edge, with the right edge stepping in as the type gets smaller. Measured on Layout breakouts in a 946 px column: band 48–898 px, code block and h2 at 850 px, paragraph at 804.8 px, figcaption at 654.5 px. The same 60ch at two type sizes, with the uncapped elements filling the band.

It is deliberate and Johan wants it kept: the full-width elements between the text re-establish the band and the page still reads as balanced. The risk is that nothing in the CSS says so, and a single margin-inline: auto on the prose rule would centre every short block and flatten it site-wide — so prose.css now carries a one-line comment saying not to, pointing here by name. The wiki page also takes a correction: it cited the rule at prose.css:38, which is now :62.

This also settles the figcaption question raised during the readability pass. The wiki’s caption rule carries 60ch at the caption’s smaller size, which makes it consistent with the design rather than a divergence from it; the earlier suggestion to revisit it was wrong. The page’s source-provenance marker came off in passing, under the rationing rule set the same day.

The session that balanced the activities table’s two edges, and the figure that went stale underneath it.

Tables gains the reason a centred column drifts. data-icon centres a head and its cells together, which was already written down — what was missing is that an auto-layout table hands each column a share of whatever width is left once every column is served, so the icon column grew, and the centre its head and icons share moved right with it by half the slack. Six pixels, which is the whole difference between a first column sitting 37px in from the box and one sitting 31px in. inline-size: 1% holds the column at its label’s width; ⚠️ min-content and auto both measured identically to no rule at all, so the percentage is doing real work and is not a tidier spelling of the same thing.

Two more on the same page. The closing edge now takes the same inset as the opening one — ⚠️ and :last-child is the last cell in the markup, not the last one still showing, so a table whose columns drop by container query has to hand that inset on by name. And the inset is two tokens now rather than one: --table-edge-padding is a free choice, --table-popout-edge-padding is the one pinned to the popout track, swapped in by .table-wrapper.popout — which is exactly the set of tables the markdown renderer puts in a breakout, and so exactly the set where the number has a job.

⚠️ The keeper is on Site search, and it is a figure failing in a way a figure is supposed to be safe from. Its mockup carries real data — the top hits for “night”, read off a built /search.json — which is what makes the shot trustworthy and is also what made it rot: two of the six rows were draft notes, and deleting those notes left the picture showing results for pages that no longer exist. Nothing in the build could catch it; the mockup is a hand-written snapshot with no link back to its source. It is re-read and re-shot at seven rows, and the comment above the array now says it is a dated snapshot and when to read it again.

The prose above that figure had drifted too, more quietly. “Over half the index has no body at all — 153 of 289 entries”: the 153 is still exactly right, and so are jams (82) and orienteering activities (63) — the denominator moved, 289 → 342, when the wiki joined the searchable types and brought fifty-odd bodied pages with it. So a count can go stale without a single one of its own numbers changing, and “over half” and “the body-less majority” both quietly stopped being true while the sentence still read as if it had been checked.

[2026-09-19] enrich | Three things called cache, WebC

Two short additions from the session that cut the YouTube components’ inline CSS comments to one line each.

⚠️ The keeper is a method error, not a fact. The component’s CSS was measured on a dev build, which gave “3.5 kB of a 50 kB page, about 7%” and an argument that moving it to a bundle would buy a cacheable shared file. A production build contradicts both: head/css-inline.njk links the bundles only under --serve and inlines global and local into the head otherwise, so the same page is 85 kB with every stylesheet inline and no bundle fetched. Three things called cache already recorded the mechanism — its /bundle/<hash>.css paths are dev-only — and was not consulted before measuring. It now carries the consequence as well as the fact.

WebC gains the one genuinely new line: ⚠️ a comment in a component’s <style> reaches visitors, because cssnano never sees that block. The two YouTube components’ comments went from 1,458 to 819 bytes, paid once per embed.

What survives of the original claim: the block still loses cssnano, autoprefixer and deduplication, and /jams/nine/ emits the same rules three times, 8.4 kB where one copy is 2.9 kB.

[2026-09-19] ingest | WebC

The page this morning’s lint named as the clearest gap: 14 pages mentioned WebC, and Web components — which is about browser custom elements — mentioned it once. Written from the ten .webc files, eleventy.config.js and the built HTML, checked against upstream Eleventy Excellent on disk.

The general half’s job is the distinction the name invites you to miss: WebC is build-time, a custom element is runtime, and neither needs the other. Then what survives into the HTML, which is the only question that matters in practice — webc:root merges the invocation’s attributes onto an inner element, webc:keep saves a tag for a script to upgrade later, and ⚠️ a component with several top-level elements has no root, so WebC keeps the invocation tag as a wrapper around them. With webc:keep on an inner tag of the same name, that renders the element twice and any upgrade script runs twice on the same children. Two precisions worth having: an HTML comment does not count as a top-level element (all three of jedee’s own components open with one and emit once, verified in the built HTML), while a <style> block does — which is the usual way a component acquires a second root by accident.

In jedee: ten components, seven from Eleventy Excellent with three still byte-identical (custom-card, custom-peertube, custom-peertube-link), and three of jedee’s own — photo-lightbox, place-map and sortable-table — all the same shape, an is-land around a kept custom element. The two ends of the range are both EE’s: custom-svg runs eleventy-img and svgo at build time and leaves no trace at all, and custom-masonry is a tag with webc:keep whose JavaScript was deleted over Layout shift, now a <div class="grid"> wearing a custom element’s name.

⚠️ One measurement taken while writing, and corrected the same day. EE’s custom-youtube keeps its CSS in a top-level <style>, so it ships inline in the body, un-minified, on every page with a video, reaching neither named bundle. The first reading of this was taken on a dev build, where head/css-inline.njk links the bundles — which made the component’s block look like the only inline CSS on the page and produced a “7% of the page” figure and a cacheability argument that do not survive a production build. In production that partial inlines global and local into the head, 43 kB and 8.6 kB, so every stylesheet here is inline and no bundle is fetched. What the component’s block actually loses is cssnano, autoprefixer and deduplication: /jams/nine/ embeds three videos and emits the same rules three times, 8.4 kB where one copy is 2.9 kB. Recorded rather than moved.

Collects three traps the other pages were each carrying alone — the doubling root, the @breakout prop that exists because webc:root puts a breakout class one level too deep (Layout breakouts), and webc:setup being unable to await, which is why the YouTube poster is computed by a filter and passed in.

Wikilinks gains one more, found by hitting it in this page’s own component table: ⚠️ an aliased [[Page Title|shown text]] cannot sit in a markdown table cell, because the table’s | splits the row before the interlinker sees the link. It does not fail as a dead link — it mangles the row, dropping the overflow cell, so the build is green and the page just quietly misses a column’s worth of text.

[2026-09-19] lint | full checkup

61 content pages. Structure clean: no dead wikilinks, every page in index.md, frontmatter conforming on all 61, and no orphans — Accessibility is the only page whose sole inbound links are the index and this log. Every count the 2026-09-06 lint corrected is still right: 180 activities, ten pa11y paths, 2 OG JPEGs against a 2-post collections.article, fourteen of sixteen types with a feed, syndication: on exactly one post. 188 external links checked, one dead.

Four fixes, all approved and applied the same day.

⚠️ Four public URLs carried a word split. wiki.11tydata.js slugs a filename with @sindresorhus/slugify, which decamelizes by default — so “The YouTube embed” served at /wiki/the-you-tube-embed/, and OpenType features, The PhotoSwipe lightbox and The IndieWeb the same way. {decamelize: false} fixes exactly those four and changes no other page’s slug, checked against every title. No redirects: the site is still soft-launched behind noindex, so nothing outside has the old URLs. The relief is that the interlinker follows the permalink rather than re-deriving a slug of its own — every inbound [[link]] moved with the pages, verified in the built HTML.

One dead external link, on Syntax highlighting: prismjs.com/plugins/markup-templating/ is a 404 and prismjs.com/plugins/ is gone entirely. The page also had the category wrong — in Prism 1.x markup-templating is a component, not a plugin, which is why it ships in components/. Repointed at the source file in the tag the site actually installs (1.30.0), which is what the page’s claims about the before-tokenize hook describe anyway. A source file at a pinned tag is a more durable citation than a docs page for a project mid-rewrite: Prism’s default branch is now v2 and has no plugins/ directory at all.

A stale path: Design token sync cited _local/sketch/JEDEE - design system.sketch; the file is _local/sketch/jedee.sketch. AGENTS.md carried the same old name and was corrected with it. Its “91 Color Variables, 7 text styles, 67 symbols” could not be re-verified — the Sketch MCP server failed to connect.

A claim made stale the same morning: Tables’ Popout width ended on the style guide’s Spacing table being 32px narrower than the Sizes table above it. Both style-guide tables lost .popout hours earlier, so they are equal again; the section now says so and points at Layout breakouts for why.

Two gaps recorded, not filled. WebC is mentioned on 14 pages while Web components — which is about browser custom elements — mentions it once, and its traps are scattered across the pages that hit them. Netlify is on 12 pages with nothing describing the deploy, the headers, or the function Micropub runs on. color-mix() appears on 7 pages but always in passing, and reads fine where it is.

[2026-09-19] enrich | Layout breakouts, The place map

From the session that pulled .popout back to prose-width contexts (fix/activities-popout, merge 997ad2d). Johan reported the activities overview looking wider than the full wrapper and named the class. It was: 1424 against a 1360 content column, though not in fact wider than .full and with nothing overflowing.

Layout breakouts gets the general finding, a second silent failure to sit beside the direct-child one and the exact opposite shape — the class on the right element, a real grid child, doing nothing. The outer tracks are minmax(0, 2rem), a zero minimum, and the content track takes all free space until --wrapper-width wins its min(). So a breakout renders at content width below --wrapper-width + 2 × gap and reaches full width 128px past it, measured at both wrapper widths (1120 → 1249 at 64rem, 1456 → 1585 at 85rem). The consequence is counter-intuitive enough to be worth the page: the wider you set the wrapper, the further off-screen you push your own breakouts, and in a container already sized wide the class is a no-op most of the time and unintended the rest. Three call sites were in that state — the activities map-and-table and two styleguide tables — all in archive-listing.njk’s and the styleguide’s stock 85rem wrapper, because .prose was on a child and a custom property cannot inherit upward into the grid.

⚠️ The correction is the better half of the entry. Both table.css comments about --table-edge-padding looked stale once no hand-written table was in a breakout, and were rewritten — but markdown.js wraps every markdown table in table-wrapper | popout, and post, note and wiki bodies are all prose wrappers, so most of the site’s tables still are and the comments were right. Restored with a clause naming the two exceptions. Filed on the page as the general form: a comment explaining one value by naming another describes a pair that can break from either end, with only one end looking.

The place map gets the second finding, reported the same session: the route GeoJSON printing as ~4 kB of visible text under the map. A <script> is hidden only by the UA sheet’s script { display: none }, and the component’s own place-map > .place-map-live + * rule sets display: block — needed because on the index that sibling is <sortable-table>, an unregistered element that is inline by default and would drop its margin. On an activity page the sibling is the script. Narrowed to + :not(script), which raises the specificity rather than lowering it since :not() takes its argument’s.

No new page: both findings belong to pages that already exist and already carry the neighbouring traps. Raw source: src/_raw/dev-notes/How the popout breakout was pulled back to prose width.md.

[2026-09-19] enrich | Tables

Second pass on The activities table, from the session that rebuilt it against Johan’s reworked Penpot file (feat/table-stamped-rules, merge 31d9271). Ten claims on the page had gone stale and were corrected rather than appended to: the header’s tint, its small caps and its size, the rule colour, the drop priorities and their thresholds, the unit lines in the heads, the data-sort contract, the sort arrow’s offset, and the hover’s transition.

Four new sections. The stamped rule — a collapsed border paints one colour per edge, so a two-tone rule is a background gradient, composed with the stripe through two custom properties; and an engraved rule needs a separate role per half, because the band colour that works as a shadow on a light page is lighter than a near-black one and inverts the groove. An icon column — the icon aria-hidden beside a visually hidden name that carries both the announcement and the sort key, and Penpot’s inner-stroke emulation leaving duplicate clipPath ids in 180 inlined copies. Two surfaces that converge — a header tint and a hover tint drawn the same way meet, and the header text’s contrast is then bounded by the gap between them rather than by either colour. A hover that snaps on and decays off — the asymmetry is which rule carries the transition.

Links earned: Undefined custom properties for the none fallbacks composing the two gradient layers, and Choreographing CSS animations for the prefers-reduced-motion opt-in idiom the hover follows. Tooltips and Typographic conventions were already linked and stay. Sources: src/_raw/dev-notes/How the activities table matched the Penpot design.md, beside the original How the activities table works.md.

[2026-09-18] enrich | Design token sync, The place map

Session source: Johan changed four of the place map’s colors in the Penpot file and asked for them in the CSS. Two takeaways, both enrichments of existing pages rather than a new one.

New section A mockup states the same value twice in Design token sync, placed after the stacking section it follows from. Rebuilding a color-mix() as stacked layers means the design file holds the recipe once per place it draws it — the Place map page draws each color in the map artwork and again in a ten-swatch legend, both edited by hand, nothing linking them. The artwork is the authority, and the concrete case is what the strip cannot show: light draws --map-road and --map-road-minor as the same white at all five stroke widths, dark splits them into two grays.

New section One declaration, two themes in The place map — the trap that nearly shipped. The --map-* properties are declared in three blocks and the dark ones override only what differs, so --map-park was served to both themes by the light block; taking light from 8% to 12% took dark with it. Link earned: Undefined custom properties, the mirror case where the reference rather than the override is what’s missing.

Raw source: src/_raw/dev-notes/How the map colors came back from Penpot.md.

[2026-09-17] enrich | The place map, Design token sync

Session source: the place map’s own tiles going live and the loading pass. New section Waiting for the first tiles in The place map — the --map-water box that pulses, [data-map-loading] removed on MapLibre’s first idle (the honest signal: nothing left to fetch or draw), the route intro waiting for the same event, and the no-JS caption that flashed until @media (scripting: enabled) hid it, which is the mirror of the scripting: none rule on The main menu. Links earned: Layout shift, The main menu.

New section A computed color is not a token value in Design token sync: a tool’s color token holds a color, not a calculation over other tokens, so a color-mix() palette has nowhere to land — Penpot rejects the value with references and with hex alike. The generalisable half is that alpha compositing in sRGB is the same arithmetic as color-mix() in sRGB, so the recipe can be rebuilt as stacked layers, each bound to its own token, with the percentage as a layer’s opacity. ⚠️ It must be the layer’s opacity: binding a token resets its fill’s opacity to 1, and editing that fill’s opacity afterwards drops the binding silently.

[2026-09-17] enrich | The place map (figures)

Both figures re-shot on MapLibre and the R2 tile file, the two “captured with the old Leaflet map” warnings removed, the route-symbols alt text updated, and the intro paragraph now names the loading pulse and fade ([data-map-loading]). ⚠️ The mockups are now served over http by src/wiki/_sources/serve-mockups.js, not opened as file://: MapLibre builds absolute URLs from location.origin, which is "null" on file://, and R2’s CORS policy has no entry for the shooter’s random port, so Chrome runs with web security off. The place-map mockup reports reduced motion to the component so the route is drawn at once. npm run mockups -- <name> now shoots only the mockups whose filename contains the name. mockups:check passes on every mockup over http.

[2026-09-17] enrich | The place map

The component moved from Leaflet + OpenStreetMap tiles to MapLibre + jedee’s own Protomaps tiles of Sweden. New sections Our own tiles (colors as CSS custom properties, the color-probe trap, the canvas-not-element trap, coverage fallback, style diffing, paused animation frames in hidden tabs) and a rewritten base-layer switch; the route intro is now a line-gradient sweep. The two figures are flagged as pre-move captures.

[2026-09-15] enrich | Tables

Session source: the activities table (src/_raw/dev-notes/How the activities table works.md). New subsection The activities table under “In jedee”: the linked rows and their three traps (a sticky column shrinks the click area; Firefox paints a positioned row’s cell backgrounds over collapsed rules; Safari’s click focus on the tabindex wrapper, cause not yet confirmed in Safari), columns that drop by data-table-priority at measured container widths, units in a <small> under the column head instead of a tooltip, and <sortable-table> after Adrian Roselli’s Sortable Table Columns, with the two parts not taken. Two stylesheet bullets updated (container-type, the sticky row header) and one added (figures on the wrapper). Links earned: The place map, Tooltips, is-land. Corrected on the way: is-land (eight islands, the first nested one), Web components (the <sortable-table> row), The place map (the table replaces the list; dots colored by type).

[2026-09-15] enrich | Tables

Session source: the table tweaks on the style guide. Two of the three “not adopted” choices are now adopted. Stripes replace the rules between rows on every table, painted as a translucent image on the cells so the sticky row header carries them and the scroll shadows show through; contrast measured in both themes. Equal-width columns are an opt-in attribute used only by the style guide. New bullets on the block of custom properties that sets the tables’ look and on the style guide’s middle alignment, and a sentence under Popout width on the Spacing section, which lacked .wrapper-pass. The caption and first column now keep an inset on narrow screens too, a fluid --space-m-l whose maximum matches the popout track; it replaces the container-query formula that followed the popout track (a fixed 2rem was tried first and was too much on a phone). Later the same day the box around the table went, a very faint rule joined the stripes between rows, cells got more room above and below, and the column heads became letterspaced capitals; body cells went a step smaller and then back to the text size, after Rutter’s warning against shrinking tables. Finally the stripes went off again, after Rutter and Barker, and the rule between rows took the doubled rules’ color at 1px. The column heads then moved from capitals to real small caps at the text size. Earned link: Typographic conventions (the screen-reader risk of uppercase). caption-side: bottom stays out. No new links.

[2026-09-15] correction | Syntax highlighting

Johan pointed out that Eleventy Excellent’s top: -0.05em on inline code was likely there for EE’s own font, where the page had framed it as the declaration that would take code off the baseline. Measured (raw note src/_raw/dev-notes/How the inline-code nudge was measured.md): the nudge arrived as -0.1em in January 2024 and was halved in February, when EE set Figtree with Roboto Mono, and the February value centers that pair’s chip on the body’s cap height to within 0.002em. EE’s later font change left it 0.032em low; in jedee’s fonts an unnudged chip sits 0.053em low. The paragraph now says it is an optical correction and gives the three-row table; jedee’s no-nudge rule is described as a choice. The nudge was then tried on a branch and compared side by side; Johan kept the chip on the baseline, and the page says so.

[2026-09-15] ingest | Prefetching

Session source: the EE 4.8 follow-ups (raw note src/_raw/dev-notes/How hover prefetching was added.md). New page on the Speculation Rules API: eagerness levels and Chrome’s limits (50 for immediate, 2 first-in-first-out for the rest, and the “discarded” message that follows from it), browser support as of September 2026, Chrome 138’s service-worker support, instant.page and Quicklink, and Guess.js. In jedee: EE’s speculation-rules.njk with jedee’s not clause, measured against the 510 feed links and 136 image links in the built pages; why it pays off with a network-first worker and max-age=0 HTML; what was declined; and three environments that report a false failure (Helium, Claude’s Browser pane, localhost). Earned links: The service worker’s three strategies and Three things called cache (a sentence each, pointing back), What jedee kept from Eleventy Excellent (the rule and the bundled confetti, updated there), Sveltia CMS and Micropub (the excluded paths).

[2026-09-15] enrich | What jedee kept from Eleventy Excellent

Session source: the Eleventy Excellent 4.8.0 upgrade (raw note src/_raw/dev-notes/How the Eleventy Excellent 4.8 upgrade went.md). New section Taking a new release: files upstream adds merge in without a conflict (the llms.11tydata.js example, with its code), a file upstream changes back looks like any other conflict (svg.js async again; the theme-toggle.js fix merged in by hand the other way), orphans keep taking upstream’s changes (the unused details partial and script), and the history rewrite before 4.7.0 that the -s ours merge repaired. Two stale claims on the page corrected: the version jedee last merged (now 4.8.0), and the easter egg, on since 2026-09-11 and no longer an orphan. Earned links: Themes and starters (its “a merge conflict is a message” paragraph now names the exception, one sentence) and The interlinker’s second render pass (why svg.js stays synchronous).

[2026-09-15] ingest | Tables

A new page from the clip Styling Tables the Modern CSS Way (Michelle Barker, Piccalilli, 2024-07-18; published: filled in), plus Adrian Roselli’s two articles on the scroll region. General part: markup, the defaults, alignment, bands, overflow and its accessibility, sticky row headers, column widths. In jedee: the session that replaced Eleventy Excellent’s stacked local/table.css with jedee’s own, taught markdown.js to wrap every table in a named region at popout width with a Table: caption convention, and moved the include into base.njk, conditional on the page containing a table. Three traps recorded: the article’s invalid scope="column"; prose’s mobile word-break collapsing table columns; and an unguarded content.includes() in base.njk that silently turned every wikilink on the site into plain text, caught by building with and without the change. Links earned: Layout breakouts, Typographic conventions, Layout shift, The interlinker's second render pass. The same day, all 40 existing tables across 18 pages got a Table: caption.

[2026-09-15] enrich | Typographic conventions

A Subtitles section, from the 2026-09-14 session that added subtitles (source of record written to src/_raw/dev-notes/How the subtitle is marked up.md). General part: the WHATWG <hgroup> model, why a second heading is the wrong pattern, what a screen reader hears. In jedee: the markup in entry-header.njk, the subtitle outside p-name, the italic sans after three serif tries, the --flow-space trap, and why films have no Sveltia field. Links earned: Microformats, Sveltia CMS.

[2026-09-14] fix | The accessibility test

Stale counts, found while writing Accessibility. customPaths has held ten paths since /now/ was added on 2026-09-11, but the page said six in a heading, nine in two places and four in another, and its meta.js excerpt was two edits behind (the path list and the comments). All brought in line with meta.js, the growth history extended to ten, and the index line corrected. The coverage paragraph also claimed nothing the lightbox touches was tested; the jam on the list has opened its cover in the lightbox since 2026-08-29, so that clause went.

[2026-09-14] ingest | Accessibility

A parent page, written because about ten pages already carry accessibility material and nothing tied them together. The general part covers WCAG 2.2 (the four principles, the A/AA/AAA levels, and why AAA is not a whole-site target) and the limits of automated testing. In jedee: the public statement’s WCAG 2.1 AA target, with the two 2.2 AA criteria never checked; the test’s limits (ten pages, light mode only, color-mix() colors it cannot read); a stock-versus-jedee table counted in both repositories (:focus-visible 3 → 16, prefers-reduced-motion 3 → 12, forced-colors 0 → 5); links by principle; and two decisions, decorative contrast and cheap AAA criteria. Links earned: The accessibility test, Alt text, Syntax highlighting, Typographic conventions, Line length, Focus rings and paint containment, The main menu, The theme toggle, The lang attribute, Abbreviations, Tooltips. Found in passing: customPaths has held ten paths since /now/ was added on 2026-09-11, but The accessibility test and its index line still say nine, and that page’s “Suppressing a finding” section says “the four tested pages”. Source: this session.

[2026-09-14] fix | is-land

The islands table still listed custom-masonry.webc as an EE-stock on:visible island, eight days after its script was removed over layout shift; found while writing Web components. Row removed, the count is seven, and a line says what became of the eighth, linking Layout shift and Web components. Rechecked against the templates: seven <is-land>s, partials/gallery.njk still included by nothing, and the <is-land> in activity.njk is only a comment mention. The 2026-09-06 verification paragraph and the raw-source line were brought in line too.

[2026-09-14] enrich | Syntax highlighting — colors

A “Colors” section, from Johan asking to document that the code colors follow GitHub’s color-blind (protanopia and deuteranopia) themes, and to check that this was true. It was not. The hues are Eleventy Excellent’s (checked against upstream code.css), retuned for contrast in ce80897; the style guide’s “based on GitHub’s colorblind theme” line (from a584c17) had nothing behind it, and none of colorsBase.json’s values is a Primer color-blind color either. The section tabulates GitHub’s swaps from Primer’s published CSS (red keywords to orange, green tags to blue) against jedee’s, then a Machado 2009 simulation with OKLab pair distances: jedee’s weakest pairs sit at 5.8–8.4, GitHub’s color-blind themes at 2.3–3.2 (blue against purple). The style guide line now says the palette is due a rework, and TODO §33 holds it; the check is saved at _local/design/cvd-check.mjs. No new links: Johan’s article on color blindness is still a draft. Source: this session.

[2026-09-14] enrich | Syntax highlighting

An “Inline code” section, from the session that restyled inline code after arielsalminen.com. Inline code is a markdown-it code_inline token and never reaches Prism, so its only styling is code.css. Ariel’s four values were swapped for jedee tokens: the surface is --color-bg-accent mixed halfway toward --color-bg (Johan found the plain surface too strong in both themes), the radius is the one code blocks use, and the shadow is a new chip pair in shadows.json, smaller than popup. ⚠️ The baseline is shared because nothing moves it; EE’s top: -0.05em nudge would. A first draft here claimed inline-block would break the baseline too. Measured, it does not: it only adds the padding to the line height (0.84px) and stops long spans wrapping, which is the real reason to stay inline. The code.css comment was corrected with it. No new links. Source: this session.

[2026-09-14] ingest | Web components

A new page from Ariel Salminen’s Progressive Web Components (2026-03-25), clipped to src/_raw/. The general part covers the three browser pieces (custom elements, Shadow DOM, templates), the usual complaints about JavaScript-rendered components, and her split into composite, primitive and declarative kinds, with Elena as a one-paragraph example dated as a release candidate. In jedee, counted in a production build: five defined custom elements, all composite; no Shadow DOM (the one attachShadow is is-land’s declarative-shadow-root code, unused here); and custom-card, custom-youtube and custom-masonry never defined, kept as CSS hooks. ⚠️ WebC files are not web components. Links earned: is-land, The place map, The PhotoSwipe lightbox, The YouTube embed, Layout shift. Found in passing: is-land's table still lists custom-masonry.webc as an on:visible island, which it has not been since 2026-09-06.

[2026-09-14] enrich | Typographic conventions

A Lists section, prompted by Johan asking what Bringhurst says about styling lists. He says nothing directly; his 3.2.1 figure rule reaches list numbers, and Butterick has the bullet advice. ⚠️ Chrome’s default stylesheet gives ::marker tabular-nums, which made jedee’s list numbers lining despite .prose’s old-style setting, measured on /wiki/link-checking/. In jedee the marker now restates the figure slot, and the numbers moved to a new --color-accent-orange-text (orange-600 in light mode, since orange-500 is 3.5:1 there), shared with the bullet marker, now a hollow bullet (◦) added to the Source Sans subset along with • for plain lists (Font subsetting counts updated). Both list kinds share a 2.75ch start padding, measured to hold "10. ". No new links. Source: this session.

[2026-09-14] enrich | Font subsetting

Two additions from one session. A dev-server checker that greys out fallback characters and outlines fake styles, using a zero-width “blank font” in a canvas; the simpler two-fallback comparison missed ⁂ and ❖, and Chrome rejected the first blank font over an empty glyph table. And Cyrillic for Source Sans as separate unicode-range files, prompted by “переворот” on the A Confession page. Also recorded that FontFace.weight reads normal for a variable face with no weight descriptor. Then font-synthesis: none came off body again, and code became never italic (bold code is real and stays).

[2026-09-13] enrich | Font subsetting

A “Missing styles” section from Anders Norén’s faux-weights clip: synthesized bold and italic, font-synthesis: none, two cases that look like gaps and are not (bolder than the only bold; a variable font with no weight descriptor), and why the cmap scan misses a missing style. Tested with the shipped woff2 files in headless Chrome and Firefox. In jedee body now sets font-synthesis: none, which made the .small-caps longhand redundant (removed; Typographic conventions updated), and the two faux italics are gone: the serif headings get a new 17 KB Bold Italic subset with the Bold’s 109 characters, and the code keywords render upright. One new link, The interlinker's second render pass. Sources: the clip in src/_raw/ and this session.

[2026-09-11] enrich | Sveltia CMS

“Fields for one type”: a select with number values saves real numbers (the film star rating, checked with Johan’s test save), make stored values agree before adding a number field, nested event dates stay plain text for the seconds, per-type image folders for covers, and an empty undeclared key saves as null. The shared fields gained “Also on”. One new link, Self-hosting remote images at build time. Source: this session.

[2026-09-11] enrich | Sveltia CMS

A new subsection from the afternoon’s work on Site settings, Featured, Now and Pages: singletons drop every comment on save (checked in file/parse.js and file/format.js), two entries sharing one file would re-sort each other, a relation field reaches one collection so Featured uses a list with types:, a relation saves a subfolder post as its path inside the folder (getSlug falls back to the sub-path), resolvePicks stops the build on a missing pick, and filter limits Pages to layout: page. No new links. Source: this session, and the reference doc in _local/design/.

[2026-09-11] ingest | Sveltia CMS

A new page on the edit layer, set up on 2026-09-10. General part: what a git-based CMS is (Netlify → Decap → Sveltia) and why keeping undeclared front matter keys is the deciding property, checked in the source because the docs rarely say. In jedee: the config shape, then five traps each found by a real save (datetime drops seconds, subfolders need nested, body required by default, object fields keep undeclared subkeys, punctuation in new filenames), GitHub sign-in through Netlify with public_repo, and _local/tests/sveltia-roundtrip.mjs. Links to Micropub, Web Clipper templates, The authoring tool decides the data model, Permalinks and Obsidian-friendly filenames, and Hosting large originals off-repo. Sources: the Sveltia clip in src/_raw/ and the reference doc in _local/design/.

[2026-09-10] enrich | Typographic conventions

Ordinals, prompted by Johan trying “9th” in the Nine jam. The general half now has an Ordinals section. Figures stay on the baseline (Butterick). Spelling out follows the house style: Chicago to one hundred, AP to ninth, Swedish usually to twelve, where figure ordinals take a colon (29:e). In jedee: Chicago, and no .ordinal class on purpose. ⚠️ fontTools shows Source Sans’ ordn is a plain substitution over all 28 lowercase glyphs, not contextual, so font-variant-numeric: ordinal on a sentence would raise every letter in it. A raised suffix, if ever wanted, goes through font-variant-position: super on the suffix only. The four figure ordinals already in content are listed and left alone. No new links. The source is an added section in How the typographic defaults were chosen.

[2026-09-10] enrich | Typographic conventions

Four figure classes added at Johan’s request: .fraction, .slashed-zero, .lining-nums and .tabular-nums. They are built to combine: each fills a custom-property slot and one rule joins the slots, and .prose’s old-style default moved onto the same slots. Three traps, each caught by checking the rendered page rather than the CSS:

  • ⚠️ Three of the names are also Tailwind classes. The content scan generated Tailwind’s versions into the later tailwindUtilities layer, where they replaced the value. Tailwind’s fontVariantNumeric plugin is now off, and Tailwind records it.
  • ⚠️ Source Sans’ frac changes every digit, full stop, comma and parenthesis it covers.
  • ⚠️ Its old-style zero has no slashed form.

The style guide also gained a Blockquotes section. blockquote :last-child became blockquote > :last-child, because the old form also matched the last element inside the source line and removed the italic from a closing title.

[2026-09-10] ingest | Typographic conventions

A new page, from the session that turned the OpenType features inventory into site defaults. The general half covers Bringhurst’s rules (2.1.6, 3.2.1, 3.2.2, via webtypography.net) and Butterick’s milder versions for figures, small caps, capitals and letterspacing, plus captions and italic, line breaks, superscripts and accessibility. Every source is dated, and the unread dyslexia paper is marked unconfirmed. In jedee:

  • old-style proportional figures in .prose, with lining figures set back in tables, abbr and code (⚠️ Source Code Pro has onum too);
  • one .small-caps class;
  • --tracking-wide raised to Bringhurst’s range;
  • balanced captions and quotes, with upright, left-aligned wiki captions;
  • bare footnote markers drawn with the font’s own superscripts. ⚠️ The Source Sans subset has superscript digits but no superscript brackets, so [1] had to lose its brackets.

Two measured corrections along the way: the planned lining reset on the event date badge did nothing (the cards sit outside .prose) and was removed, and the footnote link’s 0.3ch padding, sized for the old smaller number, opened a gap at full size. OpenType features updated (the activity stats’ tabular-nums now does work) and Text wrapping extended. Earned links to Abbreviations and Text wrapping. Source is a new dev note, How the typographic defaults were chosen.

[2026-09-10] ingest | OpenType features

New page, plus the first figures for Font subsetting and Line length. The inventory is of the shipped subsets, not the families, in three passes: GSUB features and the characters each acts on, read with fontTools; source and target outlines compared, so “the feature exists” and “it changes the shape” are separate claims; and every feature rendered off and on in _sources/opentype-features.html, with mockups:check confirming all 18 pairs differ. ⚠️ Both families have tabular figures by default, so the site’s two tabular-nums rules change nothing. ⚠️ Source Sans italic has no small caps anywhere, and asking gets synthesized ones. Source Serif’s full-font stylistic sets are Cyrillic, correctly dropped. Source Sans’ dlig carries thorn abbreviations and a pronoun substitution (she/he → a glyph named t_h_e_y): Sarah Gephart’s 2018 gender-neutral pronoun glyph, added to the family by Paul Hunt, the written-only counterpart of Swedish hen. The subsetting figure uses the real pre-fix file, kept in _sources/fonts-before/; the line-length figure counts its own rendered lines. ⚠️ Re-running npm run mockups rewrote 13 unrelated figures; they were restored, not committed. Earned links to Font subsetting and Layout shift. Source is a new dev note, How the fonts' OpenType features were inventoried.

[2026-09-10] ingest | Line length

New page from Edward Scott’s Baymard article (2022-05-10) — ⚠️ the clip in src/_raw/ captured only Baymard’s navigation, so the page was written from the live article and the clip’s published: filled in. Carries the 50–75 range with its lineage (Ruder’s 50–60, Bringhurst’s 45–75 and 66, WCAG 1.4.8’s AAA ceiling of 80), and corrects one thing in the source: Baymard’s 0.12 em / 0.16 em / 2× spacing values are WCAG 1.4.12, a tolerance content must survive, not values to set. The measured part is the site’s own: EE’s max-inline-size: 60ch sets a median 72 characters per line in Source Sans at 1280 and 1920 px (10th–90th percentile 67–75), about 1.2 characters per ch, because ch is the width of “0” and running text averages narrower. 70 at 768 px, 39 at 390. No CSS change. Earned links to Text wrapping (the other half of the same prose rule) and Layout shift (the ch difference between web font and fallback).

[2026-09-10] ingest | Font subsetting

New page from a session that began with an apostrophe looking different in Sketch than on the homepage — Sketch’s smart quotes had made it a different character. The real find was that the Source Serif subset had no curly quotes at all, so every typographer ’ in a heading drew in Georgia. A scan of all 641 built pages, testing each text node’s characters against the cmap of its computed first font family, found 34 characters falling back. Filled from the bundled full sources: curly quotes, dashes, … and ü in Serif; ~, arrows and Δ in Sans. ⚠️ Serif’s full Latin-1 block was added and removed the same day, at 12 KB on every page. YouTube titles with NFD accents are now NFC-normalized in youtubeTitle. Earned links to Layout shift (the fallback faces a missing character lands in) and The YouTube embed. Source is a new dev note, How missing glyphs were found and filled.

[2026-09-09] enrich | Open Graph images — artwork beats a generated card, where a type has artwork

Started from a leftover: meta.opengraph_default was still Eleventy Excellent’s promo image, so the great majority of the site’s shareable pages advertised the starter. Replacing it exposed the wider gap — only articles ever got a generated card, and every article is currently draft: true, so no live page used one at all.

Photo now previews as the photograph (nothing generated, nothing committed). Jam, watching and reading get a composited 1200×630 card from npm run og:cards — one generator with a three-row TYPES table, since only the eyebrow and the credit field differ — 138 files rendered by Chrome and committed, a trade taken after comparing both versions as real output rather than in the abstract.

Findings: the site-wide hardcoded og:image:width/height; Apple’s 1200x630bb being a bounding box around square art; the art needing to be sized by height so portrait covers do not letterbox; eleventy-img flooring a height sharp rounds; and clean:og no longer being sufficient, since it deletes the composited set that only og:cards rebuilds. og:description stopped falling through to the site description on types that can say something specific, via a per-type autoDescription that sits below any authored one.

twitter:card and twitter:image:alt came back — X reads the og: tags for title, description and image but not for the card’s shape or its alt. ⚠️ The threshold picking large-versus-small was wrong on the first guess and was caught by counting the built output: at 1.6 a 4:3 photograph fell to the small card. 1.2 is right, because the question is whether X’s 2:1 crop cuts the subject away, not whether the image is wide.

No new pages and no new links; the existing page absorbed all of it.

[2026-09-08] enrich | Layout shift — a lab runner’s CLS is only as real as its installed fonts

TODO §28 reproduced. Netlify’s Lighthouse reported CLS 0.305 with a three-line h1 on the deploy that measures 0.079 locally. The local()-only fallback faces (Georgia, Arial, Courier New) resolve to nothing on Android and Linux, and Netlify’s build image ships only DejaVu. A sweep that fetched Roboto, Noto Serif and DejaVu into the browser and re-rendered the heading and intro under each at 360–1100 px found DejaVu Serif Bold to be the only face that takes a third line, and DejaVu Sans the only one that adds an intro line — so the number measures the audit container. Android’s raw Noto Serif Bold and Roboto agree with the web fonts on line count everywhere tested, which is why the inert matching layer costs nothing there. Also recorded: local('Georgia') gives faux bold and the serif descriptor was tuned against it; Capsize’s createFontStack as the shape for per-platform fallbacks. Decision: no CSS change. New raw source src/_raw/dev-notes/How the Netlify Lighthouse CLS was traced to DejaVu.md; the catalog line for the page was also brought up to date (it still carried the retracted “size-adjust does nothing” claim). No new links.

[2026-09-07] enrich | Layout shift — the band is 22 px wide

Johan brought two Helium screenshots of the landing page, one in each font state, plus an overlay at 50% alpha, and asked whether that is what Lighthouse sees. It is not — different viewport, no CPU throttling, a content blocker in the profile — but the better answer came from measuring rather than looking.

A width sweep with the fallback forced directly (dropping the web family from each element’s stack, which is valid because the fallback @font-face is in the initial document) finds the disagreement is far narrower than “around 412 px” suggested. The h1 and the intro paragraph never change line count at 360, 390, 412, 720, 1100 or 1280, and document height is identical in both states at every one of them. The footer is the only element that differs, across a band of roughly 409–430 px.

⚠️ Lighthouse’s mobile preset emulates 412 px, which is inside that band. That is the whole explanation for a residual mobile CLS on a page that measures clean at every other width — a 22-pixel problem the standard audit width happens to land in, not a mobile problem. Worth knowing before anyone tries to reproduce it at their own browser width and concludes it is fixed.

Two smaller things recorded. The residual metric error after the re-derived descriptors, as pure advance width on one unwrapped string: sans −1.75%, serif +0.74%, against Arial’s +6.55% and Georgia’s +16.36% with no size-adjust at all — so 92.5% overshoots slightly and 100.8% undershoots, which restates the one-number limit as what is left rather than what is required. And a reading warning that generalizes past this site: ⚠️ an overlay of two screenshots cannot show a layout shift. The intro’s first line runs 44 px longer in the fallback and its last line 149 px shorter — alarming to look at, costing nothing, because the line count and block height are unchanged. Narrower glyphs fit more words before the wrap. The only thing worth reading off such a comparison is whether the number of lines changed.

[2026-09-07] ingest | Abbreviations

New page, written from the session that put <abbr> on the wiki — prompted by reading Layout shift and not remembering what FOFT stood for. The page covers the element in general (and the honest limit: the expansion lives in a title, which is the least dependable attribute on the web, so WCAG 3.1.4’s real mechanism is expanding on first use in the prose), the PHP Markdown Extra *[X]: … syntax that markdown-it-abbr adds, and the two plugin properties that decide how to use it — it skips code tokens, and it wraps every occurrence rather than the first.

In jedee: no dependency and no CSS were needed, both being EE stock and both unused by the wiki. One glossary map in the wiki’s directory data plus a preprocessor gated on that key does all fifty-odd pages and every page written after. 31 opaque terms only; CSS, HTML, JSON and EE are deliberately excluded, since 192 dotted underlines for “CSS” is noise.

⚠️ The trap, and the reason this is a page rather than a commit message: the data key must not be called abbreviations. Eleventy calls md.render(str, data), so a page’s data object is markdown-it’s env — and env.abbreviations is markdown-it-abbr’s own store, keyed with a leading :. The plugin builds its match pattern with x.substr(1) and looks results back up with ':' + match, so an unprefixed glossary is read one character short: UA → the pattern A, TZ → Z, fifteen <abbr title="undefined">A</abbr> on one page. The site’s own definitions kept working throughout, which is what hid it. Renaming the key is the fix; the lesson is to check which env.* keys a plugin claims before naming a data key.

Earned links to Tooltips (the title attribute) and The interlinker's second render pass (the same class of markdown-it plugin reaching past its own syntax).

The 412 px footer shift from the entry below is now reserved away: min-block-size: 6rem plus align-content: flex-start on .footer-links under 27 rem, with a new class so the rule does not hang off an aria-label that is content. 0.1726 → 0.079.

⚠️ It halves the shift rather than removing it, and two claims made a few hours earlier were wrong in the same way. Reserving the box stops the footer moving, but the seven links still redistribute between three rows and two when the font lands, and that is a shift in itself. And stacking the nav into a column — which had looked like the clean fix — measures worse, 0.1055, because impact fraction is area and a much taller footer amplifies the small per-row differences that remain.

Both errors came from one mistake: comparing element positions between the two font states instead of measuring CLS. Position equality is necessary but not sufficient — the score counts every element that moved, not just the container. The general lesson to carry: when the question is CLS, measure CLS.

⚠️ Also worth keeping: align-content mattered as much as the reservation. Centring rows inside a taller box moves every row by half the slack whenever the row count changes. And the reservation has to be in rem — an lh version did nothing, because lh is font-dependent and moves with the thing it is compensating for, exactly like ch.

Not addressed: 360 px carries a separate pre-existing 0.045 from a different wrap boundary.

[2026-09-07] fix | Both fallback size-adjust values re-derived, and what an animation can’t do

Follow-up to the entry below, after Johan deployed it and saw the heading still jumping. Both fallback faces were tuned by Capsize to fonts this site does not ship, so both size-adjust values were re-derived by scoring line counts and element positions against the real page across viewport widths: serif 110.8118% → 100.8% (17/17 widths, was 12), sans 93.7639% → 92.5% (12/13, was 10). Only size-adjust moved — the vertical overrides are Capsize’s rescaled by the inverse ratio, so the line box is byte-identical. Worth 0.0997 → 0.0020 at 720 px, where the h1 took two Georgia lines against one in Source Serif.

⚠️ One shift no descriptor reaches. The landing page at ~412 px scores 0.16 because the footer link cluster wraps to three rows in the fallback and two in the web font. Tested down to size-adjust: 91.5% — 2.4% narrower than Capsize’s — and the row count never flips. Short uppercase link labels have a glyph mix nothing like the average the descriptor fits. Layout problem, not a metrics one.

⚠️ The hero fade-in does not hide a font swap, which was the session’s question. Measured: an ungated 0.6 s fade leaves CLS at 0.16, unchanged — a layout shift counts whether or not the element is mid-animation. Gating the reveal on document.fonts.ready does score 0, because an opacity: 0 element generates no layout-shift entry at all, but the gate has to outlast the font download to work and first paint doubles (568 ms → 1156 ms at a 700 ms ceiling). Same trade as optional, paid in blank screen. Not adopted.

Also settled, against expectation: fluid type is not the problem. size-adjust is a pure ratio, so the required value for the heading is 100.119% / 100.103% / 100.100% at 32 / 64 / 107 px. And the ch-unit finding — 1ch differs 4.94% (sans) and 25.49% (serif) between web font and fallback, so 60ch measures and ch trackings both move — is real but was measured not to be the cause of any shift here; converting every tracking to em changed no wrap.

⚠️ And the method note again: the sweep that said “no line-count change anywhere”, which is why the serif was left alone yesterday, reused one browser across 32 navigations and shared the font cache. A broken harness returns “no difference” by default, so a null result needs the same scrutiny as a positive one.

[2026-09-07] fix | Two retractions: optional never applied, and size-adjust works fine

Back to font-display: swap on all four blocks, and font-size-adjust: from-font deleted from global-styles.css — the one thing jedee now changes about EE’s font kit.

Yesterday’s optional bought CLS 0 by never using the web fonts. Its ~100 ms block period runs from the font request, which cannot start until the inlined-CSS HTML arrives at 250–350 ms; the fonts then take 170–350 ms. Five cold unthrottled loads of the live site painted the fallback every time, detected by cloning the live element and forcing each family. Yesterday’s trade-off check missed this because it ran on a browser that had already cached the fonts.

size-adjust is not ignored in Chromium either. Declared in the initial document, local('Arial') at 50% measures 0.5000× and at 200% measures 2.0000×; the shipped fallback measures 0.9375× raw Arial in situ, exactly its 93.7639%. The earlier control had injected the face with JavaScript after load, where a local() face does not re-resolve.

The actual cause was EE’s font-size-adjust: from-font (upstream 8536672, 2024-08-24) fighting the @font-face size-adjust. One matches by x-height, the other by average character width; Arial’s x-height ratio is 0.5186 against Source Sans’s 0.4861, so the fallback was scaled back up and the landing paragraph took four lines instead of three. Measured on a local production build, cold cache, 3G + 4× CPU, observer before navigation: swap + fsa 0.1345 / swap no fsa 0.0041 at 1440 px; 0.0589 / 0.0012 at 390 px; 0.0303 / 0.0022 on /wiki/layout-shift/; 0.0544 / 0.0008 on /notes/.

⚠️ Left alone on purpose: the Source Serif Fallback overrides really are mis-derived (Georgia 13.9% too wide, 17.6% too tall against the shipped Source Serif Bold, because Capsize’s sourceSerif4/700 width does not describe the subset shipped), but re-deriving them changed no line count at any width from 360 to 1600 px.

⚠️ Method note worth more than the fix: three harnesses gave confident wrong answers before one worked. all: initial probes strip the declaration under test; cloning with cssText reported identical numbers in both font states; and a width sweep reusing one browser shared the font cache across cases and inverted the results. What worked was one fresh browser per case with the cache off, the page’s own HTML rewritten in flight, and the painted family verified in every run rather than inferred from the label.

[2026-09-06] fix | font-display: optional, and the landing page hits 100

The end of the CLS thread. font-display: swap → optional on all four web @font-face blocks in base/fonts.css, one word each. Landing page: 100 / 100 / 100 / 66, CLS 0 — the 66 is the soft-launch noindex and clears at 1.0.0.

The measured path, all local with devtools throttling: masonry grid + swap was 0.197 at performance 91; grid removed, still swap, 0.180 at 92; grid removed plus optional, 0 at 100. So the grid was worth 0.017 and the font swap was worth the rest.

⚠️ The general finding, and it is not about this site: size-adjust is silently ignored in Chromium. A @font-face over local('Arial') with size-adjust: 200% renders at exactly 1.000× Arial; so does one at 50%. The same face with ascent-override: 200% visibly doubles the line box, so descriptors are being applied — just not that one. The FontFace object even reports sizeAdjust: "50%" back, parsed and stored and unused. Confirmed in Chrome 148 headful, Chrome 152 headless and Helium. Consequence for any site using the screenspan.net recipe: the fallback matches vertically, so line-height holds, and keeps its own advance widths, so text can still rewrap on swap. Eleventy Excellent ships exactly that recipe, correctly written; it is half-inert through no fault of the CSS. Re-test before relying on it — this is the kind of thing that gets fixed silently.

⚠️ Second general finding: Lighthouse’s per-shift culprit is a heuristic, naming the network request that finished nearest the shift. Two static tests built on that guess both exonerated the font — forcing the whole page onto the fallback families moved the footer 0 px at every width from 360 to 1728, and a line-count sweep over 58 widths found the intro wrapping identically either way. A PerformanceObserver installed before navigation under 3G + 4× CPU found the truth: 0.0922 of the 0.0993 in one event at 4.2 s, text moving up 46 px, a paragraph losing a line as the real font replaced the fallback. Forcing a family after load is not the same sequence as a real load, where first paint precedes even the local fallback face resolving.

The trade-off was verified, not assumed, cold cache both ways: unthrottled, the real font applies (Source Sans at its own 488.8 px width); on 3G with 4× CPU the fallback holds for the whole pageview at exactly Arial’s width, with the font cached for the next navigation. A first visit on a genuinely slow connection now reads in Arial and Georgia, which is the honest cost of optional and Johan’s call to accept.

Method note for anyone repeating this: the harness is a puppeteer script driving the system Chrome with Network.emulateNetworkConditions and Emulation.setCPUThrottlingRate, three runs per state — spread across runs was under 0.002, so a single run is trustworthy here.

[2026-09-06] fix | The masonry grid is gone, and it was not the cause

Johan’s call, acting on the entry below. custom-masonry.webc lost its <is-land>, its <template data-island> and its script; the tag and class="grid" stayed so that all ~20 call sites and every stylesheet were untouched, and src/assets/scripts/components/custom-masonry.js was deleted. The landing page’s twenty demo blocks, which existed only to illustrate the grid, went with it.

⚠️ First, a correction to the entry below, which was wrong about something important. It says a local build cannot see this shift and that only production can. Not true — the cause was Lighthouse’s default --throttling-method=simulate, which loads at full speed and models a slow connection arithmetically afterwards. A layout shift is a real event during a real load; if the font arrives before anything can reflow, there is nothing to model. The identical local build run with --throttling-method=devtools reproduces the live number exactly: CLS 0.197, same element, same named cause. Use devtools throttling for anything about layout shift.

The A/B, both runs local with devtools throttling. / went 0.197 → 0.180 and performance 91 → 92. /notes/ is now 100 / 100 / 100 / 66 with CLS 0.003, though with no matched baseline — the earlier /notes/ run used simulated throttling and cannot be compared.

⚠️ The finding is what happened to the blame. The 0.196 that had sat on the masonry grid is now 0.179 sitting on <footer class="site-footer">, a 190 px element, with the identical cause: source-sans.woff2 loaded. The grid was never the cause — it was the largest thing standing downstream of the font swap, and removing it just promoted the next-largest thing. Worth 0.017.

So the open question moves to the fonts, and it is a genuine puzzle rather than an oversight: every recommended mitigation is correctly in place, including the fallback families actually appearing in the font-family stacks where the metric overrides take effect (fonts.json emits ["Source Sans", "Source Sans Fallback", "sans-serif"]). The swap still moves the page far enough to score 0.18. Next measurement named on the page rather than guessed at here: a run with web fonts blocked outright, which settles whether the size-adjust triple is mistuned or something other than text metrics is resizing.

Best Practices and Accessibility are at 100 on both pages. SEO 66 remains the soft-launch noindex.

[2026-09-06] ingest | Layout shift

Johan’s request, after the is-land work turned into a conversation about the landing page’s masonry grid and a Lighthouse 4×100 target. He wanted the page written from a real production measurement rather than from the guidance, which changed what it says twice over.

The general half is CLS: the impact × distance formula, the hadRecentInput exclusion, the 0.1 and 0.25 thresholds, and the one instruction all the mitigations are versions of — reserve the space before you know what goes in it. Fonts get their own paragraph because the fix is the least obvious: a metric-matched fallback @font-face with size-adjust and the two override properties, so the swap is invisible in layout terms.

Four Lighthouse 12 runs, saved to src/_raw/lighthouse-2026-09-06/. The live landing page is 90 / 100 / 100 / 66 on mobile, and the entire performance deduction is CLS 0.197. Accessibility and Best Practices are already at 100; the SEO 66 is the soft-launch noindex and clears at 1.0.0.

⚠️ The same commit measures CLS 0 served locally and 0.197 live. The local run is unloaded — everything arrives instantly from a local static server, so the font is in place before there is anything to reflow. This is the keeper: a local Lighthouse run cannot see a shift that needs network latency to exist, and it is 100% of the score gap here.

⚠️ 0.196 of the 0.197 sits on one element, <div class="region feature">, the landing page’s masonry grid, 7,093 px tall — with the cause reported as source-sans.woff2 loading. The same font swap moves the “Hej hej!” greeting at 0.0013 and the breadcrumb caret at 0.0000: one trigger, a 150× spread by area. Which makes the page’s actual argument that the element that moves is a poor guide to what to fix.

The mechanism is left genuinely open rather than guessed at, having already been guessed wrong twice today. Every standard font mitigation is in place (both faces preloaded, font-display: swap, metric-matched fallbacks over Georgia and Arial — all EE stock, unmodified). What is unusual about the block is that custom-masonry computes its layout in JavaScript, setting an explicit margin-top on each child from measured offsetTop/offsetHeight a frame after hydration, and recomputing only on resize — never on font load. So either the font swap reflows a very large block, or the masonry pass moves it after first paint, or both. Removing the grid is planned anyway and is the clean A/B.

⚠️ Third finding, on measuring rather than on shift: a normal browser profile scored Best Practices 96 on the live site, on one console error — cloud.umami.is/script.js — net::ERR_BLOCKED_BY_CLIENT, a content blocker in the auditing browser. From a clean headless profile the same page is 100 with no console errors. Recorded with its real-world corollary, that a visitor with a blocker does hit that error and is simply not counted.

Earned links to The YouTube embed, The lang attribute and is-land.

[2026-09-06] fix | The autoinit import is gone

Acting on the finding in the entry below, which was written up as a measurement and left alone. Johan’s call to drop it.

src/assets/scripts/bundle/is-land.js is one import line now instead of two, and the bundle inlined into the head of every page is 4,054 bytes instead of 4,592. The removed import, @11ty/is-land/is-land-autoinit.js, mounts petite-vue / vue / svelte / preact components for an island carrying an import= attribute; nothing here has one and the site has none of those frameworks, so it was never reachable code.

Verified in a browser and not only by byte count, because a runtime that fails to hydrate looks exactly like a page with nothing on it: both on:idle islands on the homepage reach ready on load, and the theme toggle switches data-theme on click. The served page carries no trace of the framework mount table.

⚠️ Two things worth carrying forward. The first reload measured the old page — the bundle file had already been rewritten on disk while the pages that inline it had not been re-rendered, so a byte count on disk and a grep of the served HTML can disagree for several seconds; check the served HTML. The second is unrelated to this change and wants a look on its own: the homepage’s on:visible masonry island did not hydrate on scroll in either version, before or after, which is why the A/B was worth running rather than assuming the failure was new.

Both import lines were Eleventy Excellent stock, so this is now a small deliberate divergence — noted on is-land rather than only here, since an EE upgrade that restores the line will look like a regression to whoever meets it next.

[2026-09-06] ingest | is-land

The last finding from the day’s checkup, which named is-land as the strongest un-written page: mentioned on eight pages, each carrying a piece of it, with nowhere to point. Written from the code and from the package at 4.0.1 rather than from the docs, so the counts and the byte figures are measured here rather than quoted.

The general half is partial hydration — why it exists, and the honest limit that it costs a decision per component and gives nothing automatically. Then is-land as one framework-agnostic custom element: the five loading conditions, data-island="once", the ready attribute, and :not(:defined).

In jedee is a table of the eight islands with their condition and their origin, which is a cleaner split than expected — on:idle for everything in the page chrome, on:visible for everything embedded in a post’s body, and nothing using on:interaction, on:media or on:save-data at all. Four are jedee’s own, four are Eleventy Excellent stock, and one of the stock four (partials/gallery.njk) is dead source nothing includes, which is already known from the TODO but had never been written down beside the others.

Three traps that existed on separate pages are collected here because they are all the same boundary seen from different sides: a sizing rule inside <template data-island> collapses the box until hydration (The YouTube embed carries the warning in a code comment); is-land:not(:defined) and <noscript> are different questions, and the embed had to move from the first to the second precisely because the island selector matches during the window a space reservation exists to cover; and a breakout class has to land on the <is-land> rather than the WebC root, since the island sits between the grid and the component (The place map).

⚠️ One new measurement. The stock bundle entry imports is-land-autoinit.js beside the element. Autoinit mounts framework components — its type table is petite-vue, vue, svelte, svelte-ssr, preact — and it only runs for an island with an import= attribute. No island here has one and the site uses none of those frameworks, so the code path cannot run. Rebuilt without it, the bundle goes 4,592 → 4,054 bytes: 538 bytes inlined into the head of every page for nothing. Both import lines are EE stock and the amount is small, so it is recorded as a finding rather than fixed.

Earned inbound links from The PhotoSwipe lightbox, The theme toggle and The YouTube embed, the three pages whose first mention was a plain sentence a link fits into. The other five say it inside a code span or a selector, where a wikilink cannot go without rewriting the sentence around it, and were left alone — the point of the exercise above was that a new page should not be born an orphan, not that every mention becomes a link.

[2026-09-06] lint | The five orphan pages, linked

The follow-up to the checkup above, which found five pages with no inbound wikilink from any other page and left them as findings. All five now have one, and each went where the subject was already being discussed rather than into a “see also” list bolted on the end.

Tailwind and Syntax highlighting both from What jedee kept from Eleventy Excellent — the first on the line that already names Tailwind as a token pipeline rather than a utility framework, the second on the blocks/code.css row of the kept/deleted table. Open Graph images from Favicons, which is its closest sibling and which it already linked to, so this makes the pair reciprocal: both are generated by a hand-run script in src/_config/setup/ and committed rather than built on deploy. Site search from The main menu, also reciprocal, on the fact the two panels anchor to the same header row and each trigger closes the other. The lang attribute from The accessibility test, in the opening list of machine-checkable rules, where a missing lang on <html> belongs next to the missing-alt-text example that already links Alt text.

Verified against the running dev server rather than by eye: all five render as anchors and no page emits a raw [[ — the failure mode that hid a dead link on The place map for weeks.

The remaining finding from the checkup is untouched: is-land is mentioned on eight pages with a real trap surface and has no page. That is an ingest, not a lint fix.

[2026-09-06] lint | Full checkup, and Micropub

A general lint of all 48 pages, prompted by the missing-source-links fault caught in the same day’s ingest, plus the one new page it turned up as the clearest gap.

Clean. Every external link in the wiki was fetched: 121 URLs, all 200. No rot. (The single failure was http://…, a placeholder inside a code sample.) No dead wikilinks outside code samples — the two dozen a first pass flagged are all [[…]] syntax examples on Wikilinks and The interlinker's second render pass, which is what those pages are for. Every page is listed in index.md and no index entry points at a missing file. Frontmatter conforms everywhere: exactly description + date, ISO, no in-content # Title (the one hit was a # comment inside a fenced block).

Two link faults, fixed. Personal websites wrote the Zeldman clip’s filename with a straight apostrophe where the file on disk has a curly ’, so the Raw source: path did not resolve — and the clip read as un-ingested in the lint’s own scan, which is how it was caught. Text wrapping cited Josh Crain’s clip in its footer but linked only css-tricks’ 2013 article, which is Crain’s own source for the pre-text-wrap JavaScript approach; the page was linking its source’s source. Crain now linked. Scanning every clip against the page citing it, those were the only two — every other Raw source: footer had its live URL in the body.

Four stale counts, corrected. All four were live claims about a number that keeps moving, written in the present tense with nothing to flag them when they drift.

  • The accessibility test: customPaths is nine paths, not five. Worse, the page already contradicted itself before the drift — its code sample listed six while its prose said the list “was corrected to five”. Three jam/reading/watching paths were added after writing, which also falsified the claim that “no note, photo, jam, reading or event page is tested”. Code sample, count, and the untested-shapes list all updated, and the historical 2026-08-08 sentence kept but given the later dates so it reads as history rather than current state.
  • Open Graph images: the folder held 6 JPEGs against 5 article files with one orphan, not the 17/13/seven the page described. A npm run clean:og has been run since. The ⚠️ was rewritten rather than deleted, because the mechanism — the existsSync guard only ever adds, so a retitle or delete strands its image — is unchanged and will rebuild the backlog. Only the backlog was cleared, not the cause.
  • The activities archive: 180 posts, not 157. The orienteering figure (112) had not moved, which is the tell — it is the closed set the original import recovered, while the total is a live count. Both now say which they are.
  • index.md’s own Per-type feeds line said “twelve types have one, four deliberately don’t” where the page it summarizes says fourteen of sixteen, and the page is right: only events and reposts lack a feed. An index summary contradicting its own page is the failure mode worth watching for, since the index is what gets read first.

New page: Micropub. The clearest gap the lint found — mentioned in prose on five pages, a W3C Recommendation, an endpoint live on this site, and nowhere internal to point; The IndieWeb had just been written describing it with only the spec to link. Written from netlify/functions/micropub.js rather than from the spec. It carries the general shape (the client/server split, the IndieAuth bearer token, discovery, and the spec’s deliberate silence about storage), the reason a static site wants one at all, and then the three patch points the engine exposes no hook for: the unix-timestamp filename prefix, the slugify lowercasing, and the hyphenated mf2 keys. ⚠️ The load-bearing trap is the category collision — Micropub’s category is the post’s tags, this stack’s category is the post type — resolved by the Function never writing the key at all and letting the folder’s directory data supply it. Earned inbound links from The IndieWeb, The authoring tool decides the data model, Web Clipper templates, The title-less post types and The activities archive, all of which had been describing it without a target.

Left as findings, not fixed. Five pages have no inbound wikilink from any other page — Open Graph images, Site search, Syntax highlighting, Tailwind and The lang attribute. All are in the index so they are reachable; they just sit outside the link graph, and three of them are recent enough that nothing has had occasion to cite them. The runner-up page candidate is is-land, mentioned on eight pages with a real trap surface, not written.

[2026-09-06] ingest | Personal websites, The IndieWeb

Two new pages from the six clips saved that morning, plus background research. They fill the “The open web” section, which had been an empty placeholder since the catalog was reorganized, and give “Publishing & the IndieWeb” the anchor page it had been missing — the section held three implementation pages with nothing naming the thing they implement.

Personal websites takes Zeldman’s Memories Can’t Wait, Elizabeth Tai’s Substack writers, you need a website! and Max Böck’s Make Free Stuff. All three argue the same position, and none of them date the vocabulary they argue in, so the page does: silo (IndieWeb), digital sharecropping (Nicholas Carr, Rough Type, 2006 — the word Tai reaches for via Coding Horror), enshittification (Doctorow 2022, American Dialect Society word of the year 2023) and link rot, which is the only part with numbers — Pew Research 2024 found 38% of pages that existed in 2013 unreachable ten years later, and a quarter of everything sampled across 2013–2023 gone, mostly individual pages deleted from sites that still work.

The page’s own contribution is separating two claims the sources run together. Control is Tai’s argument: the platform changes the rules while you are still using it. Durability is Zeldman’s, and his framing carries it — the threat is no longer loss through destruction but loss through indifference, a shelf nobody is paying for rather than a fire. They come apart, and a self-hosted site on a lapsed domain fails the durability test while passing the control test perfectly. Owning is not enduring, and the argument is routinely used as if it settled both. From that follows the four-item requirements list in descending order of importance, where hosting ranks last — proved by Tai’s own example, John Scalzi publishing 28 years continuously on hosted WordPress.com, which is fine because the domain is his. Nothing in the argument requires a server, a static site generator, or code.

Böck’s half is the extraction culture rather than the plumbing, and the page keeps his Wordle example together with his own update: it sold to the NYT for seven figures a week after he published. Quoting the essay without the coda is quoting it as if it ended where it stopped.

The IndieWeb takes Böck’s The IndieWeb for Everyone, Lily Mara’s Technoelitism and the IndieWeb movement and the Jolly Teapot’s Why are static site generators so complicated to use?. The blocks half is straightforward reference — 2011 IndieWebCamp origin, the principles, and the seven pieces with their W3C dates, linking out to Microformats, Webmentions and Per-type feeds rather than restating them. The substantial half is the criticism, all three sources converging from different directions: Böck’s general axis that the more independence a technology gives you, the higher its barrier to adoption, and his admission that it is hard even for professional devs; Mara’s observation that the surviving personal web is mostly about computers while the fandom and recipe and birdwatching sites went to the silos and stayed; the Jolly Teapot’s Dark Souls reading of static site generators, with Blot as the counter-example and the question of why the powerful generators produced no simpler tools in their own image.

Two things recorded in In jedee rather than left implicit. Mara’s observation is the stated reason this wiki is public but unfeatured — the personal site whose main subject is how the personal site was built is a real genre, and this is that material, deliberately kept off the front; the site’s own dev-note genre was retired on 2026-07-31 for the same reason. And Böck’s 2022 barrier chart has no position for the on-ramp that actually got these blocks wired here: an assistant doing the work he calls hard even for professionals. That is not a simpler tool in Blot’s sense — the git repo and the Node build are still underneath — but it moves where the barrier sits, and the page says plainly that this site is no evidence about whether it helps the people Mara wants back.

One honest measurement fell out of the writing: syndication: is set on exactly one post. POSSE is wired here as a mechanism and is not yet a practice, and both pages say so rather than describing the field as if it were in use.

No page was named after a person, though four of the six sources are strongly authored — the arguments are cited with ordinary external links, per the standing rule.

Corrected on review: the first draft of both pages linked the research sources (Carr, Pew, Doctorow, the specs) but named the six clipped articles without linking them, which broke the convention every existing page follows — the clipped source gets an ordinary inline link to its live URL, not just a Raw source: path that no reader can open. All six now link out.

Raw sources are the six clips in src/_raw/, all clipped 2026-09-06, all with their published: dates already filled: Böck 2022-01-25 and 2022-11-12, Mara 2024-07-20, the Jolly Teapot 2024-10-15 (the clip’s front matter said 2024-10-24; corrected in the clip after checking the article, which displays “15 Oct 2024”, matching its URL), Tai 2026-06-10, Zeldman 2026-07-06. The date spread is load-bearing on both pages — Böck’s Mastodon onboarding complaint is the November 2022 exodus and has partly aged out, while his barrier axis has not.

[2026-09-06] enrichment | Watch loops

Watch loops gets a second section, The second loop: a writer that is not the build. The page had described one mechanism, the build rewriting its own watched tree; this one is another program — Obsidian, whose vault is src/ — writing src/.obsidian/workspace.json on every click, and Eleventy reading the .gitignore line .obsidian as ./.obsidian at the repo root, so the folder was never hidden from the watcher. The stale standing rule about setUseGitIgnore(false) is amended, since that call left on 2026-09-05 and the two watchIgnores lines stay regardless. The section records the measurement that separated the real trigger from the apparent one (images generated on scroll go to dist/, unwatched) and the general rule: a gitignore line without a leading slash hides nothing inside the input directory from Eleventy’s watcher. Links to The dev server's memory for the per-rebuild cost. Source of record is a new dev note, src/_raw/dev-notes/How the Obsidian watch loop was found.md.

[2026-09-05] public | The wiki is reachable, and the inner repo is gone

The wiki left its private inner git repo and became ordinary tracked files in the jedee repo, features.yaml went to public, and the 48 pages now build on Netlify like everything else. Johan chose a clean start over preserving the inner repo’s 86 commits — this log is the readable version of that history anyway. Two of the three “how far does public go” questions are answered yes: the pages are in the site search (via the existing searchable tag, no new machinery) and in the sitemap (which globs all of src/, so it needed nothing). No feed, deliberately — a feed is the thing that would make the wiki featured, which is the opposite of the point.

Un-ignoring the folder also let setUseGitIgnore(false) go from eleventy.config.js. It only existed because Eleventy honours .gitignore and the wiki was hidden by it; it is also the call that once un-hid the generated _includes/css and _includes/scripts and set the dev server rebuilding itself in a loop. The dial block is now four lines.

One genuinely dead wikilink turned up in the check and is fixed: The place map wrote [[Layout breakouts|`.popout` breakout]], and backticks inside an alias stop the interlinker matching, so it had been rendering as raw brackets. Worth remembering as a rule — code formatting goes outside the wikilink, never inside its alias. Every other [[…]] left in the built HTML is a deliberate code example.

Two things to be aware of now that pages are live. The Raw source: footers name files in src/_raw/, which stayed local — a reader cannot open them, and that is accepted as provenance rather than a link. And nothing on the site links to /wiki/ yet: the colophon that was meant to carry that link does not exist, so the wiki is public but currently unreachable except by typing the URL.

[2026-09-05] lint | Do the pages open plainly enough to be read by anyone?

Ahead of making the wiki publicly reachable, a pass over all 46 content pages asking one question: does the page open by explaining its subject to someone who is simply interested, before any developer detail arrives? The three-part shape Johan wants — plain overview, then the general technique and what Eleventy Excellent contributes, then ## In jedee — turned out to be what the pages already do. Forty-three passed.

Three did not, and all three failed the same way: they opened inside the problem. Undefined custom properties began at var() and “invalid at computed-value time” with no sentence saying what a custom property is. The interlinker's second render pass began with an npm package name. The dev server's memory began with a raw error string. Each now has a lead paragraph before the existing one, and the sentence each new lead made redundant was cut rather than left to repeat.

Two things noted and deliberately not changed. Scroll-aware CSS during view transitions gives its general reader one defining sentence before the first code block — thin, but it does define the thing. And six pages carry no ## In jedee section because they are entirely about jedee: Anatomy of a post type, The activities archive, Permalinks and Obsidian-friendly filenames, The title-less post types, The interlinker's second render pass and What jedee kept from Eleventy Excellent. AGENTS.md allows that, though a public reader meets “jedee has sixteen post types” with no explanation of what a post type is.

[2026-09-05] ingest | Tailwind, sourced from Andy Bell and Lene Saile

Johan clipped the two articles the page had only been linking to — Andy Bell’s A CSS project boilerplate (Piccalilli, 2024-02-12) and Lene Saile’s What is Tailwind CSS doing here? (2023-11-30) — into src/_raw/. Both published: fields were empty and are now filled from the sources; the clipper had also recorded Andy’s author as “Topic: CSS”, corrected to his name. Tailwind now cites both as raw sources alongside this site’s own measurements.

The find is an attribution one, and it explains a bug the page already documents. Andy’s global.css uses plain @import with no layer() at all — source order alone, and he says so. The cascade layers are Lene’s addition when she brought the boilerplate into Eleventy Excellent, which is also what created the tailwindBase / tailwindComponents / tailwindUtilities names. So the utilities name collision is not in the original and could not be: Andy’s version names no layers. She also inverted his first two imports — Andy loads blocks then compositions, EE the reverse — so a block outranks a composition here and does not upstream. And jedee predates a later EE addition, a per-token-file prefix key driving a tokenPrefixes map; jedee still hardcodes the prefixes in the groups array, so a new token file needs a config edit.

Also recorded from Andy directly: he routes the :root token block through addComponents and calls it “a bit of a hack” himself, which is worth having in the author’s own words on a page that otherwise reads the arrangement as deliberate.

⚠️ And the ingest demonstrated the previous entry’s finding by accident, which is the best evidence on the page. Saving these two articles into src/_raw/ took tailwindUtilities from 52 selectors to 55, one of them .md\:text-right — a responsive variant now shipped to every visitor, generated because Lene’s prose names that class while explaining that variants are available. Clipping an article about Tailwind changed the CSS the site serves. Nothing in any markup changed.

[2026-09-05] correction | Most of tailwindUtilities was never a class

Johan asked what the 52 rules in tailwindUtilities actually are, and the answer retracts the “drift” claim Tailwind made this morning. I had read a list of compiled selectors and concluded that Tailwind utilities had crept into the markup. A compiled stylesheet cannot tell you that. Checking each of the 52 against every class="" attribute in the project says otherwise.

28 of the 52 appear in no class attribute anywhere. Tailwind’s content scan reads every file under src/ as plain text and keeps any substring that could be a class name, and a great many ordinary English words qualify — block, hidden, italic, absolute, static, table, visible, collapse, contents, filter, outline, transform, truncate, uppercase, resize, isolate. Writing prose about CSS generates CSS. Excluding src/wiki/, src/_raw/ and src/posts/ from the scan drops the layer from 52 to 42, so this wiki alone accounts for ten of them — the private wiki inflates the public stylesheet, since Tailwind scans the filesystem directly and neither .gitignore nor the visibility dial applies.

Three more exist only inside wiki code samples, and one of those is this page’s own doing: .flex and .items-center come from Tailwind quoting class="flex items-center gap-4" as the anti-pattern to avoid. Documenting the anti-pattern emitted it.

Everything genuinely in use is named after a design token — the gutter-* / flow-space-* / region-space-* CUBE families, the text-step-* type scale (twelve files for text-step-1 alone), mt-* / my-s-m / p-s-m, font-base, and two alignment helpers. That is the sanctioned use of this setup, not drift. .grid and .relative look like exceptions and are not: .grid is the CUBE composition of the same name, with Tailwind’s coincidental duplicate setting only display: grid, and .relative is one WebC component positioning an embed.

⚠️ The lesson is the one this page keeps re-learning, in a third form. Measuring the output answers a question about the output. “Are utility classes used in the markup” is a question about the input, and the only way to answer it is to read the markup. Two earlier claims on this page failed for related reasons — hand-counting braces instead of parsing, and inferring a duplicate rule that never existed — and all three were confident, specific, and wrong in the same direction: reading more into a measurement than it could carry.

[2026-09-05] fix | The utilities layer is now cubeUtilities

One word in global.css, applying the previous entry’s finding: @import-glob 'utilities/*.css' layer(cubeUtilities). The folder keeps its name — only the layer label collided with Tailwind’s directive. The compiled stack now reads compositions (21) → blocks (166) → cubeUtilities (9) → tailwindUtilities (52), which is what the file always said it wanted.

A third correction to Tailwind came out of verifying it, and it makes the story simpler rather than more complicated. The page said the inverted precedence was harmless “because the only class both sides define is .grayscale, and both declare the same thing, so cssnano emits it once”. Comparing the two layers’ selectors directly shows something better: not one selector is defined in both. There is only ever one .grayscale rule in the output, the hand-written one — Tailwind never generated its own, because the config’s theme is a full replacement with no grayscale scale in it. So the duplicate I described did not exist, and the inverted precedence had nothing to decide. The bug was real; its consequence was zero.

Verified by diffing the compiled stylesheet as a set of rules: 305 before, 305 after, none added, none removed, only the order changed — .grayscale moves from index 296 to 244. Since no selector appears in both layers, the rendering is provably identical rather than merely unchanged-looking, which is a better check than a screenshot would have been.

[2026-09-05] correction | The vanished utilities layer, traced — and one retraction

Re-measured Tailwind's two layer findings with a PostCSS parse rather than the hand brace-matching that produced them. One is confirmed and now explained; the other was an artifact and is gone.

Confirmed and traced. The missing utilities cascade layer is Tailwind’s doing, not the import plugins’. Tailwind 3 has its own @layer directive — base, components, utilities — which predates native cascade layers and means “put these in that bucket”. It cannot distinguish a real @layer utilities { … } from its own directive, so it eats the block and hoists the rules into its utilities output. That is exactly why compositions and blocks survive: Tailwind does not recognise those names, so it leaves them alone. Bisecting the PostCSS pipeline stage by stage pins it with no ambiguity — imports only: utilities 9 rules, tailwindUtilities 0; plus tailwindcss: utilities absent, tailwindUtilities 61; autoprefixer and cssnano change neither number. And the prediction the explanation makes holds: rename to layer(cubeUtilities) and the layer reappears at 9 rules with tailwindUtilities falling to 52, the same nine rules going home. The fix is one word in global.css, and since this is EE stock CSS the bug is upstream’s too.

⚠️ Retracted: Tailwind’s responsive variants do not escape the cascade layers. The page claimed the compiled file ends with an unlayered @media (min-width:1000px){.md\:text-right{…}} that would outrank every layer. Parsed properly, the stylesheet has zero top-level nodes outside a layer — that @media is inside tailwindUtilities with everything else. The brace counter I used walked straight past the escaped \: in the selector and closed the layer early. The lesson is narrow and worth keeping: a claim about CSS structure has to be measured with a CSS parser, because the thing most likely to break a hand-rolled scan is exactly the escaped and nested syntax that structural claims live among. It is the same shape as the The accessibility test lesson — a measurement that can only come back green is not a measurement — except here the flawed method came back with a confident false positive rather than a false pass.

Nothing else on the page changed; the token-bridge measurements (tailwindComponents at 3.0 KB with zero class selectors, ~58 utility classes surviving, real Tailwind spacing utilities in templates) were taken with the parser-independent counts and still hold.

[2026-09-05] ingest | Tailwind, and Open Graph images

Two pages, and a change of purpose behind them: the wiki is becoming the home for the site’s technical writing so the post feed can stop being about its own construction. Both subjects were previously covered only by Eleventy Excellent’s demo articles — Lene’s prose, sitting in src/posts/articles/. Nothing was copied. The pages were written from the code, cite her and Andy Bell as sources, and the demo posts can now go.

Tailwind is the CUBE boilerplate inversion: the config is the point, the utilities are optional, and a plugin walks the resolved theme emitting every token as a :root custom property through addComponents. jedee’s divergence from EE is the adapter — dtcg-to-tailwind.js reads the DTCG $value shape, converts fluid {min, max} pairs to clamp() for the Utopia scales, and composes Penpot-shaped shadow layers into a box-shadow string.

Three measurements against the compiled global.css, and they complicate the received description of this setup. tailwindComponents is 3017 bytes containing zero class selectors — the token block and nothing else, which is the design working. tailwindUtilities is 3370 bytes with ~58 classes, and among them are genuine Tailwind spacing utilities in templates (.mt-xl, .mt-s-m, .m-l, .my-s-m, .p-s-m) beside the three intended CUBE-property families. So “no utility classes in the markup” is not true here, only mostly true. A near-miss worth recording: region-space-top appears 24 times in templates and looks like the generated utility, but every one is style="--region-space-top: …" — an inline custom property, not a class.

⚠️ The utilities layer does not exist in the compiled output. @import-glob 'utilities/*.css' layer(utilities) should sit between blocks and tailwindUtilities; there is no @layer utilities at all, and region, ontop, grayscale, heading-line, visually-hidden and spin are emitted inside the tailwindUtilities block, after Tailwind’s own rules. Confirmed by brace-matching rather than by eye: the block spans 35638–39034 and .region{ sits at 38522. The effect is an inverted precedence — separate layers would let Tailwind win, one merged layer gives it to whatever comes last, which is the hand-written set. It is invisible today because the only class both sides define is .grayscale and both declare exactly filter: grayscale(100%), so cssnano emits one rule. The cause is untraced; the suspicion is postcss-import-ext-glob’s handling of a globbed import that carries a layer() parameter, and that is worth a session of its own. ⚠️ Separately, the file’s last rule is an unlayered @media (min-width:1000px){.md\:text-right{…}} — Tailwind’s responsive variants escape the layer wrapper, and unlayered CSS outranks all of it.

Open Graph images is the lighter of the two mechanisms and the more surprising design. No headless browser: the image is a 1200×630 SVG rendered by Nunjucks, and text layout is a lookup table — splitlines(22) chops the title into 22-character lines and a chain of {% if %}s maps the count to a vertical start. The whole shape follows from fonts. An SVG naming Source Serif 4 needs it installed on the rasterizing machine, a build server has neither the font nor a text engine, so EE generates on the author’s machine and commits the JPEGs — which is why svg-to-jpeg.js reads from dist/ and writes back into src/.

⚠️ Two findings. Both the generator (data: collections.article) and the head reference (layout == 'post') are article-scoped, and checking every src/posts/*/*.json shows articles is the only type that sets layout: post — so every note, photo, jam and response post shares one static fallback. On vanilla EE, where articles and notes are nearly the whole site, that is a small gap; here it means the feature misses most of what gets shared. ⚠️ And the !existsSync guard only ever adds, never removes: 17 committed JPEGs against 13 article files, seven of them belonging to EE demo articles deleted long ago, still shipping. The same guard means a retitled article strands its old image, and a JPEG rasterized without the font is never retried — the Favicons pattern again, where a committed generated set makes a bad generation permanent until someone clears it deliberately.

Earned links: Configuring a layout composition, Undefined custom properties, Design token sync and Watch loops from Tailwind; Anatomy of a post type, One JSON-LD envelope for sixteen types and Favicons from Open Graph images. Both cite What jedee kept from Eleventy Excellent's territory without duplicating it.

[2026-09-05] ingest | Themes and starters

Source is the session itself, written up as src/_raw/dev-notes/How an Eleventy project has no theme layer.md. It began as a plain vocabulary question — a WordPress designer would call this a theme, so what is the word here — and the answer is that there is no word smaller than the site, because “theme” only names something where presentation is separable from content. WordPress separates it with a database, Hugo without one, Eleventy not at all.

The half worth keeping is the mechanism rather than the vocabulary: a theme layer is a per-file lookup order, and everything else about it follows. Hugo resolves each template against the project’s layouts/ before the theme’s; Hugo Modules generalize that to mounted Go modules; WordPress does it with the template hierarchy and child themes. Eleventy has no chain to resolve against — dir.includes and dir.layouts each normalize to exactly one path in ProjectDirectories.js, with no array form.

⚠️ The page corrects a claim made earlier in the same session, and the correction is the reason the page is worth having. Virtual templates (addTemplate, 3.0+) look like the missing mechanism, and I said a real file on disk would take precedence over one at the same path — “a lookup order, in miniature”, hedged as unverified. Read against the installed 3.1.6 it is the opposite: EleventyFiles.js concatenates the virtual paths onto the real ones and throws on any duplicate (“A virtual template had the same path as a file on the file system”), and the paths are filtered by isTemplateFile() first, which returns false for anything under layouts or includes. So a plugin cannot ship a layout at all, and cannot underlay a page it could ship. Virtual templates are strictly additive. Plausible-sounding precedence was the trap; reading the source took two greps.

The Hugo comparison is recorded in both directions rather than as an argument for switching: an override is a copy, so a Hugo theme goes stale in silence where a forked starter’s merge conflict names the file and shows both sides.

Earned links: What jedee kept from Eleventy Excellent, because with no layer boundary in the tree that inventory is the only record of the seam; and Watch loops, because the one piece of genuine mess the page names — compiled CSS and JS written into src/_includes/ so a template can inline them — is the precondition for that loop, and is EE’s choice rather than Eleventy’s.

[2026-09-04] ingest | The browser-mockup group, and mockups that run the site’s own code

Fifth pass on the same day, the plan’s step 6 and the last of its named tiers. Ten shots across five pages: The main menu (the panel open, the no-JS pill row at two widths, the flexbox trap), Site search (the panel open on real hits), The YouTube embed (the poster fade as three frozen frames), The place map (the orienteering symbols, and the three modes) and The PhotoSwipe lightbox (the clickable strip, and the overlay). No new pages.

The keeper, and it would have shipped a lie: a fixed-width <div> cannot stand in for a viewport. Every spacing and type token on this site is clamp(…, …vw, …), so a 375px-wide box inside the shooter’s 1400px viewport renders 1400px-scale type and spacing — a frame captioned “375px” showing nothing of the sort. Only an <iframe> gets its own viewport, and with one the media queries, the vw clamps and 100% all resolve against the frame width. That is the accidental-variable trap in a fifth disguise and the first one that would have been invisible in the output: the shot looks entirely plausible either way. The frames are scaled down with a CSS transform rather than resized, because scaling a 1280px page is what keeps it a 1280px page.

A mockup can run the site’s own JavaScript, not only borrow its stylesheet. The place-map and PhotoSwipe mockups load the shipped esbuild bundles and render the real custom elements; the menu and search mockups execute the same handful of lines nav-menu.js and search.js do. So the shots are the components, not drawings of them. ⚠️ A fetched module script is blocked from file:// as a cross-origin request (origin null) — both bundles are esbuild IIFEs with no imports, so a plain <script src> loads them where type="module" fails. And Leaflet’s tiles arrive fine over file://, because a tile is an ordinary <img> request and CORS never enters into it: the map mockup was expected to have to draw its own ground and did not.

The gate had to learn to see into frames. page.$$eval('[data-compare]') only ever walks the top document, so a pair drawn inside iframes was silently not being compared at all. It now iterates page.frames() and screenshots element handles rather than clips built from getBoundingClientRect — a handle resolves the frame offset and any CSS transform on the way up, which a hand-built clip does neither of. Proven red on the new pair before being trusted green, and the nine existing pairs still pass unchanged.

Three claims the pictures caught, all of them the page being stale rather than the shot being wrong. The main menu said its no-JS pill row is two rows at 1280px; it is three, the post-type count having grown since. The place map described the route line as a saturated blue standing in for orienteering purple; it is the accent orange, and place-map.css records why — a purple vanished on some of the three tile sets. The same page never mentioned that route mode opens on Topographic rather than the themed Map default. Site search's body-less count moved from 152 of 288 to 153 of 289. A picture is a second reader of the prose, and it does not skim.

The flexbox trap needed both of the fallback’s declarations overridden, not just the one the page names. Relaxing only flex: 1 1 22rem back to auto left --repel-wrap: wrap in place, the pill row dropped onto its own line, and the breadcrumb survived intact — no finding at all. The crush only exists with the row still pinned to one line, which is the state breadcrumb.css sets and main-nav.css relaxes. Reproducing a fixed bug means restoring everything the fix changed; a partial revert can quietly report that there was never a bug.

The 1rem band at the top of every browser frame is the site’s own body { border-top }. Four hypotheses were wrong before that was measured — margin collapsing, background propagation to the canvas, the iframe’s backdrop, a scrollbar gutter — and each was disproved by changing one thing and re-sampling the same pixel column. What settled it was bisecting the frame down to an empty red body with only global.css linked, at which point the band was still there. Undefined custom properties’ lesson in reverse: the surprising thing was deliberate.

Also filed, since the previous handoff had it otherwise: on a 1440px window a wiki page’s .popout figure renders at 1088 CSS px and .feature at 1248, not the ~700 assumed. Every new shot measures between ×1.28 and ×1.76 of its display width, the same range the existing specimens sit in, so none of them is upscaled. The route symbols are shot at zoom 16 rather than the map’s own fit zoom — they are geographic shapes, so they grow with the map, which is the page’s point and also what makes them legible.

[2026-09-04] ingest | Progressive web apps

New page, Progressive web apps, and the plan’s step 5. Two sources: the clip src/_raw/How to turn your website into a PWA.md (Max Böck, published 2017-07-07 — the empty published: field filled from the article), and a session-written note, src/_raw/dev-notes/How the site is a PWA.md, describing the shipped code, which had never been written down in one place.

Scope, so the three pages do not overlap. This page carries what makes a site installable and what installing changes; The service worker's three strategies keeps the caching; Favicons keeps the icon set and the two chrome colors. The service worker page gained a link back and an external link to Böck’s original worker in eleventastic, which it named but never linked.

The correction worth having is an attribution one, and it went the opposite way to the assumption. site-manifest.njk, the <link rel="manifest"> and both <meta name="theme-color"> tags are Eleventy Excellent stock — diffed against /Users/johanedlund/Projects/eleventy-excellent, where the only difference is that EE appends the site description to name. So an unmodified EE site already has a manifest and is one service worker away from installable. Only the worker and its six-line registration are jedee’s own. The first draft of the note said the whole layer was jedee’s; checking upstream rather than assuming is what caught it.

The mockup is the one thing Favicons’ six frames do not show. The plan asked for install prompt, splash and the display modes — but four of those already exist as frames on Favicons, shot from the icon angle, and re-shooting them would have duplicated pixels for nothing. What was genuinely missing is the browser-versus-installed comparison, so _sources/pwa-display-modes.html draws the same page twice and the two bands of browser furniture are the whole of the finding. ⚠️ Both status bars are the same color on purpose: the browser tints its own from the same <meta name="theme-color"> the installed app uses, so a browser-gray bar would have drawn a difference that does not exist. That is the accidental-variable trap once more, caught before shooting rather than after.

Age note on the source, since 2017 is old for this subject. sw-precache is retired in favour of Workbox; the Lighthouse Chrome extension the article tells you to install has been a DevTools panel for years; and “a better Google ranking” as the reason to adopt HTTPS reads as its own era. The three-step shape — manifest, HTTPS, service worker — is unchanged, and the misconceptions the article opens with have aged well.

Also filed on the page: start_url is meta.url and therefore build-time, so the manifest is wrong after any domain or environment change until a rebuild; and the deliberate absences — no beforeinstallprompt handling, no push, no share target, no shortcuts or screenshots.

[2026-09-04] ingest | The two filmstrips, and freezing a transition

Fourth pass on the same day, the plan’s step 4. Choreographing CSS animations gained a six-frame filmstrip of the start page typing its own name; The theme toggle gained a five-frame sun-to-moon morph and a strip of the four oranges drawn as suns on the light page. No new pages.

A mockup can now say when it has settled. Both shooters wait on window.__mockupReady !== false after load. A mockup that freezes an animation sets the flag false up front and true once it has built, seeked and let two frames pass; a mockup that does not touch it is ready as soon as it loads, because undefined !== false. That is what makes the seeking deterministic instead of a race with networkidle0.

The filmstrips are made with the technique Choreographing CSS animations documents, applied to itself. Six copies of the real header, every animation in each paused and its currentTime set to a different instant — so nothing is running when the shot is taken, and the page’s own warning about a screenshot showing the previous frame cannot arise. Belt and braces anyway: the ready flag is only set two requestAnimationFrames after the seek.

⚠️ A transition only exists where a value changes, and that cost the first version of the morph. The theme toggle is built from transitions, not animations. The first attempt rendered each frame with data-theme="dark" already on it, so no property ever changed, no CSSTransition was ever created, getAnimations() returned nothing to seek — and all five frames came out as the finished moon. Measured, not seen: getComputedStyle reported matrix(1.75…), opacity: 0 and cx: 17px on every one of them. The frames have to be built in the light state, flipped, and then frozen, with a computed-value read in between to force the recalc that creates the transitions. A CSSTransition is an Animation, so pause-and-seek works on it once it exists.

The gate caught two things worth having. First, the reveal’s frame at t=1000ms and its frame past the end are pixel-identical — both are the bare logomark — which is the correct answer even though the two mean different things; the first frame moved to 1080ms and the second of dead time became prose. Second, and more useful: data-compare was on the whole cell, which also holds a per-frame caption, so the gate was passing on the label and would have gone green with five identical glyphs. Moved onto the glyph alone, it failed immediately and found the transition bug above. A gate that includes anything that differs by construction is not a gate.

A table can be a picture. The theme toggle's argument that a light background and a yellow object cannot both be true at 3:1 was a four-row contrast table. Drawn as four suns at orange-200 through orange-500 on the one background the sun is ever shown against, the two that fail are visibly barely there. Same numbers, no reading required.

Also filed: both theme-toggle strips are .popout rather than .feature — at 666 and 686 CSS pixels they already fill the popout column, and the wider breakout would only scale them up and ship them soft.

[2026-09-04] ingest | Three comparisons, and a gate that had to stop reading words

Third pass on the same day, working the plan’s step 3: Undefined custom properties, Focus rings and paint containment and Syntax highlighting each gained the one picture their argument turns on. No new pages.

The comparison gate could not survive the move off text. mockups:check compared where each side’s line breaks fell, which is the right evidence for a wrapping specimen and no evidence at all for these three: a border width, a focus ring and a syntax color do not move a single word, so all three would have failed a check that was working correctly. It now hashes a screenshot of each [data-compare] element instead and fails when two sides are pixel-identical. That is strictly the more general claim — the picture shows a difference — and it still catches the wrapping case, since two columns of the same string at the same width that break the same way also render the same. Proven the same way as before, by forcing it red on the new pair rather than trusting a green run.

The accidental variable turned up again, in a new disguise. The two syntax panels wrapped at different points, because code.css sizes code as well as pre, so a font-size set on the <pre> never reached the text, and the highlighted half — the one carrying token spans — broke where the flat half did not. A difference in line breaks is not the finding when the finding is color. Forcing the size and white-space onto every descendant of both <pre>s brought them to 513.8px and 514.1px, the whole remaining difference being one italic keyword. Found by measuring the rendered boxes, not by looking at the shot — which is the third time this pattern has cost something.

A mockup can borrow the site’s own stylesheet instead of copying it. The syntax specimen links the compiled src/_includes/css/global.css by relative path and sets data-theme="light" on the root, so the token colors in the shot are code.css’s and cannot drift from it — the answer to the snippet-goes-stale problem for any mockup whose subject is the site’s own styling. Only the two @font-face rules have to be redeclared, because the bundle’s point at absolute /assets paths that resolve to nothing under file://. The two code panels are likewise the literal output of markdownLib.render(), pasted in rather than hand-written.

One measured correction to a page’s own claim. Focus rings and paint containment said a keyboard user “sees nothing” when tabbing to the clipped play button. Drawing the real component showed that is a little stronger than the truth: lite-yt-embed ships .lyt-playbtn:focus { filter: none } over a base grayscale(100%), so the glyph does go from gray to red. It is not a focus indicator in any deliberate sense — it belongs to the embed’s own hover styling, it is a filter on a graphic rather than a bounded indicator, and it is gone the moment a colored poster loads behind it — but the page now says so, and the shot shows both halves focused and both red so the claim and the picture agree. The left half is deliberately a picture of an absence; the caption carries that, because an empty frame otherwise reads as a missing image.

The shooter was printing a rounded size. It doubled the clip rectangle, which came out a pixel short on the syntax shot — 637 against a real 638 — and a wrong intrinsic size is exactly what makes eleventy-img ship a soft image. It now reads the width and height out of the PNG’s own header.

Filed for the next three: both rows of the button specimen are drawn in equal-width columns with the buttons stretched, because sizing each to its own content would have let the 3px row grow four pixels per button and passed that off as part of the finding. Same rule as the divider track, one layer down.

[2026-09-04] ingest | Text wrapping (specimens), and mockups as a general routine

Second pass on the same day as the Favicons frames, generalizing what that session built into something any wiki page can use, and proving it on Text wrapping.

The shooter is now per-mockup, not per-script. shoot-device-frames.js hardcoded one mockup file, one .phone selector, one pwa- filename prefix, and a hand-kept list of frame names that had to stay in the same order as the elements in the HTML. It is now shoot-mockups.js (npm run mockups, renamed from npm run devices): it walks every _sources/*.html, shoots every [data-shot] element, and writes each to the filename the attribute names. Adding a visual to any page is now one new HTML file and no script edit. This is less code than before — the names list went, and so did the count-mismatch check that existed only to catch that list drifting. Verified by reproducing all six device frames byte for byte after the rewrite. The CSS opt-out was renamed with it: data-wiki-devices is now data-wiki-mockup, because a specimen card has its own rounded transparent corners for exactly the same reason a phone does.

The keeper, and it cost the first version of the specimen: a side-by-side comparison is worthless unless both columns are exactly the same width. The first text-wrap specimen put the divider on as border-inline-start plus padding-inline-start on the second column, which made it 24px narrower than the first. It produced a confident, plausible difference in every pair — and the difference was entirely the narrower measure. With the divider moved into its own grid track and the columns equalized, both pairs rendered identically. Every claim the specimen appeared to make was an artifact of the layout.

That is the general shape rather than a one-off slip: the thing you are varying and the thing you are accidentally varying look the same in the output. The only defence is to measure the rendered result, not to look at it — a rendered line is not an element, so each word’s top edge is what says where the breaks actually fell. npm run mockups:check now does that for any [data-compare] pair and exits non-zero when two sides render the same, and it was tested by forcing a failure rather than trusting a green run — the same lesson as The accessibility test, where a path that no longer exists scores zero errors.

Where the values actually differ is measured, not chosen. Sweeping 200–520px found that text-wrap: balance diverges from greedy at only some widths for a given string, and that pretty is much more conservative than the page’s prose implies: on the paragraph originally drafted for it, pretty changed nothing at 21 of 23 widths tested. It does work — at 300px in Chrome 151 it pulls a word down to turn a lone entity. into space entity. at the same line count — but a paragraph has to be chosen for it rather than assumed. Both specimens are pinned at the swept width with a ⚠️ saying the width has to be re-swept if the text changes.

Also filed: the specimen carries its own paper — an explicit light ground and border — the way the device frames carry their own bezel, which is what lets one shot sit correctly on both themes of the wiki page without a <picture> and a prefers-color-scheme guess that a manual theme toggle would get wrong anyway.

The survey of where else in the wiki a visual would earn its place is _local/design/Plan - Wiki visuals.md: 6 pages where the central claim is invisible in prose, 7 more where a picture adds real clarity, 15 with no case for one, and 4 that want a diagram or a chart rather than a mockup — a different job.

[2026-09-04] ingest | Favicons (device frames)

Enrichment of Favicons: the icon set rendered into the six places an installed app shows it — home screen, install prompt, splash, running in light and dark, and the app-switcher card — as six figures whose captions are real text rather than pixels in a screenshot. The frames existed as a throwaway preview page from the previous session and would have been swept with the scratchpad; they are now regenerable from the repo, which is the part worth keeping.

The preview page lives in the wiki’s own repo at _sources/pwa-preview.html and points at the committed icons rather than copies of them, so it previews whatever the set currently is. npm run devices re-shoots the six PNGs from it, and it carries the constants that kept getting lost by hand: 2x scale, the page’s own plate and drop shadow switched off, a clip tight to the device, omitBackground for the rounded corners. Run against a clean tree it reproduced the committed PNGs byte for byte, which is the check that the tracked source really is the source.

Three traps, all of which look like nothing until they are on screen. A transparent-cornered image inherits a rectangle from prose. .prose :is(img, video) carries border: var(--stroke) and .prose img a radius — right for a photo, wrong for a device: the border traces the file’s bounding box, not the phone inside it. Opted out with a data-wiki-devices exception in the wiki’s local bundle rather than by touching the site-wide rule. Declaring an image’s display size makes eleventy-img serve that size. width="280" on a 560px source had the transform emit a 280px file, soft on any retina screen; the attributes have to carry the true intrinsic size and let CSS scale it down. A mockup’s own colors are tuned against the page it was drawn on. The phone bezel was #1c1f24 against the preview’s light ground, and the dark theme’s page is #141619 — the device outline disappeared entirely. Same shape as Undefined custom properties’ lesson about a value only being valid on the surface it was measured on.

Also filed: .popout reaches markdown content through .wrapper-pass’s subgrid, so a breakout works one level down from .wrapper without any new CSS (Layout breakouts). And the before/after page is tracked alongside, with the pre-orange icon set recovered from git history and frozen beside it — a before/after page needs a “before” that will never be in the live folder again.

[2026-09-03] ingest | Favicons

New page from the session that regenerated the icon set in the brand orange, gave the tab icon a 14-of-16 mark and unified every app icon on one tile. It earned a page rather than an enrichment because nothing in the wiki covered icons at all, and because the general half is a real rule rather than a project detail: the treatment of each file follows from whether its destination frames the icon for you. A tab does not — transparency, almost no padding. iOS, Android launchers, the install sheet and the splash all do — opaque, generous inset, and corners only where nothing masks them.

Three consequences of that rule are easy to get backwards, and EE’s stock script gets all three wrong without failing a build: apple-touch-icon must be opaque (iOS composites transparency to black) and must not carry baked corners (iOS masks it again, so cut corners become notches), and a maskable icon must be opaque and full-bleed or the wallpaper shows through the launcher’s crop. EE’s maskable is resize(512).extend(50) — 612px against a manifest declaring 512 — and transparent.

The load-bearing find is sharpsToIco. It appends its own resize() to whatever sharp pipeline it is handed; sharp applies resize before extend regardless of call order, and a second resize() replaces the first. So resize(28).extend(2) silently became resize(32).extend(2) and wrote a 36px .ico — 44px at an earlier padding value. No error, a valid file at the wrong size. EE’s version never hits it because it hands over a bare sharp(svgBuffer) with nothing pending: the trap is created by adding padding.

Also filed: why theme_color feels invisible (<meta name="theme-color"> overrides the manifest once a page loads, leaving it only the splash and the app-switcher card), and why an icon plate in the manifest’s own background_color is invisible on the splash — which is what settled the icons on the orange tile.

Links earned: Design token sync for why the corner radius is a proportion rather than a radius token, The theme toggle for the dark-mode chrome color that changed alongside, and The service worker's three strategies as the other half of the installed-app experience. Source is a new dev note, How the favicon set is generated.

[2026-09-03] ingest | Site search (enriched)

Enrichment of Site search with a new section, “Opening it, and clearing it”, from the session that polished the panel. No new page: both findings are about this component, and both are the kind that read as a JavaScript failure and are neither.

The first is a transitioned visibility. input.focus() on open did nothing at all — no error, no rejected promise — because visibility was in the panel’s transition list and is still hidden on the frame the transition starts, where focus() is a silent no-op. What kept it hidden is that reopening within the 200ms closing fade works, so only the first open of a page session failed. The fix keeps both reasons visibility was transitioned in the first place (a closed panel’s links must not be Tab-focusable; the panel must survive its own fade out) by flipping it instantly on open and delaying it on close.

The second is a specificity point worth stating plainly: [hidden] is a UA rule, so any author display beats it at equal specificity — the class does not have to outrank it, only to exist. .search-clear { display: inline-flex } meant a dead X sat in the field from the moment the panel opened. Filed alongside it, the reason ::-webkit-search-cancel-button gets a rule of its own rather than a shared selector list: Firefox does not know the pseudo-element, and an unknown one invalidates every selector grouped with it.

Earns no new links; the page’s existing ones still carry it. Source of record written as src/_raw/dev-notes/How the search panel opens and clears.md, so the page now cites two raw sources.

New page Site search, written from the clip of Ariel Salminen’s Building Search Index with Eleventy alongside the session that built jedee’s own panel. Her published: was empty and the article carries no date in its body, so it is filled in as 2025 from the URL — the truncated-from-the-right form the convention allows.

The page leads general, as the shape asks: three places search can happen on a static site (a hosted service, a search library over a prebuilt index, a plain Array.filter over JSON) and the honest note that at a few hundred entries the third is not a compromise — the library would cost more bytes than the index it searches. Ariel’s implementation is credited and then departed from in one specific way: roughly half her code is regex stripping characters that would break the JSON, which is a symptom of assembling JSON by interpolation inside a Nunjucks template rather than anything the problem requires. A JavaScript template and JSON.stringify escape correctly by construction.

The find worth the page is page.rawInput. It is populated on collection items, which means a page’s body is reachable at collection time where templateContent still throws “Tried to use templateContent too early” — the error that pushes most implementations into defining a separate collection. A throwaway probe confirmed both halves of that in one run. The cost is symmetric and is recorded as the page’s central trap: rawInput is the source before rendering, so /imprint/ indexed {{ personal.address }} verbatim and /about/ was findable by searching “todo” from its HTML authoring comments. Both shipped into a working index before being caught, which is exactly the kind of thing a wiki page is for.

Links earned: The main menu (the other header disclosure — shared surface, shared anchor, and the mutual close that follows from both), The interlinker's second render pass (the {% if %}-wrapped include calling {% svg %} is the shape that once blanked the nav; this partial is that shape and is safe only because {% svg %} stayed synchronous), Undefined custom properties (why the panel redeclares its surface instead of inheriting --megamenu-surface, which is declared on a .mainnav that does not exist in a production build), Anatomy of a post type (collection keys are singular; the plurals are archive URLs and yield an empty index with a green build), The activities archive (the type the only narrowing map exists for), Tooltips, The theme toggle, Wikilinks and The accessibility test (pa11y never opens the panel, so the combobox and listbox are hand-checked).

One thing recorded because it has now been hit twice from different directions: measure color-mix() contrast through an oklab→sRGB conversion, not a regex over getComputedStyle().color. Chromium serializes the mix as oklab(L a b) and a parser expecting rgb() reads those floats as channels — it scored two genuinely failing values as 1.00 and 2.73. And the narrower version of the same lesson: a mix that clears 4.5:1 against the panel can fail on the highlighted row, because a row that changes background on hover is two surfaces, not one.

[2026-09-02] ingest | Why the theme toggle’s sun can’t be yellow

Enriched The theme toggle with the contrast constraint behind its icon color, after a session that tried to make the resting sun lighter and warmer. It cannot be. The sun only ever appears in the light theme, on #f4f4f2, and WCAG 1.4.11 asks 3:1 of a graphic you need in order to operate a control — which an icon-only button’s icon is, aria-hidden notwithstanding, since that attribute governs the accessibility tree and not what a sighted user has to see. Measured: orange-400 reaches 2.12, orange-300 1.54, orange-200 1.16. orange-500 at 3.50 is the lightest shade that clears the bar, so that is what shipped.

The general form is worth keeping: a light background and a yellow object cannot both be true at 3:1, which is why yellow suns live on dark headers. Also recorded the one-declaration trick that made a subtle hover work in both themes — mixing toward --color-text rather than a fixed second color darkens the icon on a light page and lightens it on a dark one, so a single color-mix() moves the right way in each.

All four states measured on the painted pixels via a 1×1 canvas, because Chromium serializes an oklab color-mix() as oklab(…) rather than rgb and a naive parser reads those floats as channel values — which is how an earlier pass in the same session got 19.05 and 1.16 for the two hover states instead of 3.94 and 5.56.

Extended the same day. Matching the logomark to the toggle turned up two live contrast failures, on every page except the start page: the trail’s home crumb takes --color-logo-icon rather than the aria-current orange, and that token was a pale #bbbfca in light (1.67) and a deep #8c2100 in dark (2.01). Both were invisible-by-familiarity — the icon is small and reads as “the orange logo” at a glance, which is why measuring the fill rather than trusting a screenshot was what found it. One accent orange now serves every page and both themes, and the aria-current special case went with it. The moon, by contrast, went the other way to plain --color-text: a night-sky object rather than a warm one. Eight measured states now on the page.

Third pass, same day: the moon gained a 12% blue tint, and the page gained the reasoning. Worth keeping because it is the inverse of the sun’s problem — contrast was the hard ceiling there and is a non-issue here (every mix from 6% to 30% lands between 8.9 and 10.6 on the dark ground), so the percentage is purely aesthetic. The justification is perceptual, not physical: the lunar surface is grey-brown at roughly asphalt albedo and moonlight is reflected sunlight, so it is spectrally faintly warm; we read night as blue because of the Purkinje shift to rod vision. Two constraints recorded — the ramp has no light blue at all (three dark values, the lightest reading as a link blue that would compete with the orange logomark in the same header), and the mix must come from blue-vivid, since accent-blue resolves to a desaturated #2f536e in dark and 12% of it barely registers.

Links earned: none new; The theme toggle already links Tooltips.

[2026-09-02] ingest | Tooltips

New page Tooltips, written from the session that added a CSS-only tooltip to the header’s breadcrumb logomark and light/dark toggle, adapted from the one on arielsalminen.com. The general half sorts the three kinds — the title attribute, a CSS ::after on attr(data-tooltip), and a scripted aria-describedby tooltip — by what each can honestly carry, and hangs the rest on one fact: generated content is not a dependable accessible name, so a CSS tooltip must duplicate a real visually-hidden label and never hold anything that exists nowhere else. Then the two things copy-and-paste tooltip CSS usually gets wrong: a :hover-only trigger that shows a keyboard user nothing, and a centred label on a control at the page edge, which grows a horizontal scrollbar on every page because a visibility: hidden element is still in the layout.

Enriched Undefined custom properties with the inherited-branch twin of the bug it was written for. font-weight: var(--font-normal) — the weight tokens are --font-regular/--font-bold/--font-extra-bold — fell to unset and, being inherited, took .menu-toggle’s 700. That is the same failure as the 3px button border from 2026-08-22 on the other branch, and it refines that page’s claim that the inherit branch “looks fine”: it does when the parent is body text, and does not when the parent is a control styled deliberately unlike body text. Generated content is the sharp case, since a ::after always inherits from exactly such an element.

The theme toggle gained a short “The label” section — the visually-hidden name, aria-pressed for state, and the title that was removed so the browser would stop drawing a second native tooltip beside the styled one.

Corrected same day. The page said the block is “no JavaScript”; the theme toggle now dismisses its tooltip on click, which CSS cannot express — :active only covers the press, and on mouseup :hover still matches. Added a “Dismissing after use” section: why :hover:not(:focus) was rejected (Safari on macOS does not focus a button on click, and the suppression would outlast the pointer leaving), and the four-line data-tooltip-dismissed attribute that replaced it, set on click and cleared on pointerleave/blur by the control’s own script.

Extended again when the footer’s Atom-feed icon got a tooltip. Recorded the vertical twin of the page-edge overflow the page already covered — a tooltip below a control near the foot of the document adds dead scroll under the footer (16px here, measured against scrollHeight), which is the same hidden-element-still-lays-out fact seen on the other axis. Also the small structural point that makes both fixable at once: expressing placement as two custom properties the exceptions rewrite, rather than two rules each rewriting the whole translate, is what lets an alignment and a position combine on one control.

Extended a third time, when the theme toggle’s tooltip was made to say what a click will do. That turned out to be the page’s most interesting a11y point rather than a detail: on a toggle button the accessible name must stay static, because aria-pressed already carries the state and a name that flips announces it twice in two vocabularies — but a tooltip is exposed to no one, so it is free to be dynamic. A static name plus a dynamic tooltip is the one case where the two strings should deliberately differ. Recorded on both Tooltips and The theme toggle, along with the footer row (ten controls now carry one) and the choice to let the script add data-tooltip rather than ship a wrong string in the markup.

Links earned: Tooltips → The theme toggle, Undefined custom properties, Choreographing CSS animations, Focus rings and paint containment; back from Undefined custom properties and The theme toggle. Source: src/_raw/dev-notes/How the header tooltips work.md.

[2026-08-31] ingest | Design token sync

No new page — Design token sync gained a general section and a second worked example, from the session that moved the design mirror off Penpot onto Sketch. The page had been written entirely around one tool, so a second tool with a different set of losses was what it needed to become general.

Three things Sketch does not model that DTCG can express, each pushing work back into the generator: references (resolved in the build, so the file is a snapshot with the relationship living only in the repo), themes on a color token (the light/dark pair becomes two named groups, and anything referencing a theme-dependent name has to be resolved once per theme rather than once), and component variants (every state and theme is its own symbol — seven buttons with a hover state in two themes is twenty-eight, each a copy that will not follow the others).

The useful inversion is in the last one. Because a symbol cannot carry a theme, the sixteen post-type icons were made theme-neutral and the row that holds one tints its icon slot instead — one icon set for both themes, and the tint survives swapping which icon a row shows, which per-shape overrides would not. The constraint pushed the color to the right place. Left out of the page deliberately: the Sketch plugin-API traps, which are tool operation rather than the token boundary, and live in memory instead.

[2026-08-29] ingest | The PhotoSwipe lightbox

No new page — The PhotoSwipe lightbox enriched and corrected, from the session that wired jam album covers to the existing lightbox on feat/jam-cover-lightbox. The correction matters most: the page said an activity’s race map was the only one of the five cover-bearing layouts that could have a lightbox, because the shortcode prepends ./src and breaks on the remote service URLs that reading / watching / jam / recipe covers use. True of the shortcode, not of the lightbox — photo.njk had been invoking <photo-lightbox> directly with a remote R2 URL for months. Rewritten to put the constraint where it belongs, with a new section on the direct-invocation path.

Three findings from the build. PhotoSwipe needs the slide’s pixel dimensions up front, so the zoom target has to be read at build time — and once eleventy-img has opened the file to measure it, self-hosting is free, so the zoomed artwork never reaches Apple’s or Bandcamp’s CDN (the same bargain the YouTube poster strikes). The read has to happen as computed data rather than a filter, because Nunjucks cannot await an async filter inside {% set %} or {% if %} and the layout branches on whether a lightbox is warranted at all. And the trap: slot="image" on an <img> that also carries eleventy:optional / eleventy:widths is silently dropped by WebC — clean build, no warning, an <a> with correct href and data-pswp-* wrapped around nothing. A <div slot="image"> fixes it. Found by control experiment against a built photo page after two wrong guesses, the first being the {% set %} block capture.

Also on the page: a <= 448 guard that withholds the lightbox from artwork no bigger than the displayed cover, which is 20 of the 119 jams. Earned a link to The YouTube embed (build-time fetch of a remote asset to keep a third party off the page).

[2026-08-28] ingest | Choreographing CSS animations

New page, from the session that built the start page’s wordmark reveal — the parked second half of the 2026-07-06 breadcrumb motion spec, now shipped and merged. General half: the delay chain and why the step start times belong in named custom properties (the first version wrote the sums inline, and adding one --reveal-delay knob meant editing six delays by hand); clip-path wipes versus the monospace-only steps() typewriter; a caret that rides the wipe edge by animating a percentage inset-inline-start, the one length that tracks text whose rendered width CSS cannot name; step-end for an honest blink; and authoring the base rule as the no-motion resting state so a reduced-motion visitor gets the bare logomark rather than a wordmark stuck mid-reveal.

Two findings measured during the build rather than read about. A percentage offset on an absolutely positioned child resolves against the padding box, so the caret’s right edge lands on the padding edge and its own width eats the gap — 0.22em of padding yielded 0.45px of daylight until the padding was made calc(gap + caret-width). And an on-load CSS animation starts when the element is first styled, not at load or first paint: 12 ms against a 33 ms DOMContentLoaded and a 72 ms first paint, meaning 60 ms of the reveal played before anything was drawn.

Earned links to Undefined custom properties (the same session hit it live — a padding-inline-end referencing an undefined --reveal-caret-gap fell to 0 rather than being skipped), The theme toggle (the site’s other decorative motion, same reduced-motion split) and Configuring a layout composition (custom properties as a published API). Links out to Scroll-aware CSS during view transitions for animating across a navigation, which is the still-parked sibling of this work.

[2026-08-26] ingest | The YouTube embed

No new page — The YouTube embed enriched with the oEmbed pre-commit check, from the jams session that filled 34 archive videos. The build already degrades a dead poster (the filter’s catch hotlinks hqdefault.jpg), so a removed video ID doesn’t fail the build — but for a dead video the fallback thumbnail is gone too, leaving a broken poster over a play button that opens an unavailable video. YouTube’s oEmbed endpoint is the cheap pre-commit check: 200 plus title/author_name for a live, embeddable video, 404 for removed/private/embedding-disabled; author_name also tells - Topic/VEVO/artist uploads from the fan re-uploads that later get geo-blocked (the Dalliance case). The rest of the session — normalizing all 121 jams to one front-matter shape, filling videos, fixing two artist/title swaps baked into the This Is My Jam dump — was archive curation and stayed in LOG.md. Earned a link to Rebuilding an archive from the Wayback Machine (same youtube_ok check).

[2026-08-23] ingest | Layout breakouts

The page The place map had been linking to for weeks, written at last after the lint pass that removed the dead link. Source is the code rather than a session: wrapper.css, checked against the live templates. The general half is Ryan Mulligan’s named-column grid — seven symmetric columns with [full-start]/[feature-start]/[popout-start] line names, everything defaulting to content, and --content: min(--wrapper-width, 100% - gap*2) giving a capped column with a guaranteed margin and no media query. The half worth writing down is the constraint: grid-column is a property of a grid item, so the breakout classes only act on a direct child of .wrapper. Any wrapper between them and the grid — a container element, a component root, a shortcode’s generated markup — makes the class inert, with no error and a result identical to a typo. jedee hits this in four places and answers it four ways, which is the spine of the page: .wrapper-pass re-publishing the named lines via subgrid for post bodies (jedee’s own addition to the EE composition, @supports-guarded so a browser without subgrid degrades to content width, and :where()-scoped so a one-class breakout still wins); a plain <div class="popout"> in audio.njk / photo.njk; the image shortcode’s containerClass landing on the outer div it emits; and <place-map>'s @breakout prop, needed because webc:root sits on the inner element so a class on the invocation reaches a grid grandchild. Also records .prose-wrapper as EE stock nothing in jedee uses. Earned links to Configuring a layout composition (--wrapper-width as a composition knob), What jedee kept from Eleventy Excellent, Microformats (the e-content wrapper is why post bodies need the pass-through at all), and The place map.

[2026-08-23] ingest | Syntax highlighting

Build-time code coloring, written up from the session that noticed every njk fence on the wiki was rendering flat gray while the js and css blocks beside it were colored. The general half is Prism’s packaging: languages are separate components loaded on demand, so naming one in a fence is all the registration it needs — and naming one Prism does not have fails with no error at all, falling back to a default that emits no tokens. An unhighlighted block and a block deliberately left bare are indistinguishable on the page, which is why fifteen of them sat there unnoticed. The interesting half is why the obvious fix is the worse one: Prism’s markup-templating hook, which lifts {% %} out and tokenizes the rest as HTML, is keyed on the language name passed to Prism.highlight, not on the grammar object. Aliasing prism.languages.njk = prism.languages.jinja2 therefore gets the grammar and skips the hook, coloring the tags but reducing the surrounding markup to token operator / token variable. Rewriting the fence info to jinja2 in a markdown-it core rule keeps the name and gets real attr-name / attr-value / tag tokens, including jinja2 inside an attribute value. Also records the audit method and its miscount trap — a closing fence is also three backticks, so the empty-tag count is mostly closers; counting openers properly gave 13 bare fences, all correctly bare (terminal sessions, ASCII pointer diagrams, error text). Earned links to The interlinker's second render pass and Wikilinks.

[2026-08-23] correction | The main menu

Its “flexbox trap next to it” section quoted the pre-refactor CSS block verbatim — flex-wrap: wrap / flex-wrap: nowrap declared on .repel and its cluster. Replaced with the current --repel-wrap / --cluster-wrap form, plus the :has(.mainnav) soft-launch guard the original omitted. The flexbox lesson underneath is unchanged and still correct: shrinkage is distributed in proportion to base size, so flex: 1 1 22rem rather than auto is what keeps the breadcrumb from being crushed. Now links to Configuring a layout composition for the composition-versus-block side.

[2026-08-23] ingest | Configuring a layout composition

The boundary between CUBE’s composition and block layers, written up from the session that found main-nav.css on the wrong side of it: rebuilding the cluster composition by hand for the no-JavaScript pill row, and declaring flex-wrap on .repel from outside alongside breadcrumb.css doing the opposite. The general half is the rule that a component configures a composition through its published custom properties; the interesting half is the two traps that rule brings with it, both found by measurement rather than reading. First, a custom property used as a knob inherits, unlike the declaration it replaces — pinning the header cluster to --cluster-wrap: nowrap reached straight into the pill row nested inside it, which had just become a cluster itself, and nothing in either stylesheet looked wrong. Registered the property in a scratch page to check whether @property { syntax: "*"; inherits: false } actually closes it: it does, the nested element fell to the var() fallback instead of the ancestor’s value. Second, a knob loses to a hard declaration on the same element, because blocks outranks compositions in the layer order — so main-nav.css moving to --repel-wrap alone would have been inert and breadcrumb.css had to convert in the same commit. Also records what should not become a knob (flex, min-inline-size size the element as an item of its parent, not the composition’s internals) and the leftover: with two blocks writing one property the winner is selector specificity, which nobody decided, and header.njk already knows the deciding condition at build time. Earned links to The main menu and Undefined custom properties.

[2026-08-22] ingest | Design token sync (enriched)

The session that closed the border-width and shadow token gaps. The page gains a general section on a token type a tool supports in its UI but cannot create through its plugin API, and on reading a hand-made token back as the way to learn the canonical value shape — better than the reference docs, which here listed an inset key that is not stored, omitted a derived colorResult, and did not mention that the color carries its alpha as 8-digit hex. In jedee: the nine rejected addToken shapes, the alpha as the one unit that survives the trip (so shadows.json stores Penpot’s layer shape and the CSS string is derived, inverting the usual direction), and the discovery that an import rewrites the active-set selection. Two stale claims corrected — seven emitted sets rather than six, and core/button now emitted from buttonColors.json rather than safe by accident. No new links earned; the existing Undefined custom properties and Three things called cache relations still hold.

[2026-08-22] ingest | Undefined custom properties

What actually happens when var() references something undefined with no fallback: the declaration is invalid at computed-value time and the property becomes unset, which forks into inherit for inherited properties and initial for non-inherited ones. That fork is the page — it is why the same mistake in two adjacent lines of button.css was invisible on font-family (inherited Source Sans, looked right) and shipped a visible 3px border on every button on the live site (border-width falling to the keyword medium). Both names, --border-thickness and --font-body, are Eleventy Excellent / Every Layout stock that jedee’s token rewrite orphaned. Includes the separate border-size / --button-border-size bug in code.css, where neither the property nor the variable existed, and the grep-for-orphans sweep that finds this class of bug. Earned links to Design token sync and Text wrapping.

[2026-08-22] ingest | Design token sync

Generating a design tool’s tokens from the code that owns them, and the three ways the two sides drift anyway. The headline is that a DTCG import replaces a set rather than merging into it, so a token created by hand inside a generated set is deleted on the next import along with anything bound to it — “in sync” had been a subset relationship, not equality, and twelve button colors plus ten Button variant bindings were one import away from vanishing. Also covers what cannot be exported at all: ch (font-relative, so the Penpot letter-spacing is matched by eye and legitimately does not equal the CSS number — measured 0.7% apart in rendering, while a literal copy would be 5% off), clamp() fluid values (collapse to their maximum), and color-mix() (flattened to a literal that goes stale). Plus rem radii being accepted on import but rejected by the binding validator, and Penpot’s layout settling asynchronously so a read straight after a write returns stale geometry. Earned links to Undefined custom properties and Three things called cache.

[2026-08-11] ingest | The place map (base-layer style switch)

Enriched the same page from a later session that added a tile-style switch to <place-map>: a bottom-right control toggling the map between the themed default and Satellite (Esri), Topographic (OpenTopoMap) and Streets (CARTO Voyager), across all three modes (they share one builder). New “A base-layer style switch” section under In jedee. The durable content is two undocumented Leaflet behaviors the feature turned up. First: Leaflet wires attribution removal only in its layeradd handler (a layer.once('remove', …) hook), so a layer added to the map before the attribution control — picked up by the control’s onAdd catch-up loop instead — gets its credit added with no removal hook and keeps it forever; the fix is to add the themed base layer only after the attribution control. Second: within one Leaflet corner, the last-added control stacks nearest the edge, so attribution-first / switch-second pins attribution to the bottom. Plus the design call (native <select> over L.control.layers, whose toggle PNG the build doesn’t ship, and which rides into the maximize overlay for free as a control-inside-canvas) and the theme interaction (setUrl redraws when shown, no-ops when detached, so a theme flip never clobbers a manual choice). No new links earned — it sits inside a page already well cross-linked. Source is a new dev note, How the place map switches tile styles.

2026-08-15 — corrected two existing pages, no new page earned. The place map: its Groups mode is gone, replaced by a flat Places mode reading [data-place-list], after /activities/ became a single chronological index — the grouping was that mode’s only user, so the group toggles, chips and per-group color plumbing came out of the component with it. Two lessons kept: reserve the map box server-side (it was built and prepended on idle, dropping the page by 16:9-of-the-column a second after paint — CLS 0 once the empty box ships in the markup and buildBox() adopts it), and beware the double reverse (byCategory and the located filter each reversed the collection, so the visible list ran oldest-first for months). The accessibility test: its “missing browser” section had prescribed npx puppeteer browsers install chrome across three sessions, treating a symptom. Real cause is that puppeteer is transitive under pa11y-ci, keeps its browser in the machine-wide ~/.cache/puppeteer, pins one exact Chrome build that any dependency bump invalidates, and swallows a failed download in its postinstall so npm install exits clean. Now fixed by pointing pa11y at an installed Chrome via meta.tests.pa11y.chromePath — the same executable the page’s own two-theme script had been using in another section all along. Diagnostic tell recorded: an empty cache with no version folders means the download never succeeded, since puppeteer never prunes old builds. Source is this session; see LOG 2026-08-15.

[2026-08-11] ingest | The place map

New page for the Leaflet map component (<place-map>), which had none — written from the session that added recorded GPS route lines to the activity pages. General-first: interactive maps as progressive enhancement over server-rendered data, and the GeoJSON [lon,lat] vs Leaflet [lat,lon] coordinate-order trap. In jedee: the component bundles Leaflet behind is-land and dispatches one of three modes off the slotted markup — single pin, place groups (the list is the no-JS/SR path), and the new route mode. Route mode in depth: a <slug>.geojson sibling committed next to the post (FIT semicircles or GPX degrees, downsampled but keeping exact endpoints); the routeGeoJSON filter reading it by page.inputPath not page.fileSlug (Eleventy strips the leading date); the manual [lon,lat]→[lat,lon] swap kept because the array is reused for the start/finish markers; the --color-route-start green token (the accent green reads grey on tiles); and the “View on Strava” link as the honest no-JS fallback. Earned links to The PhotoSwipe lightbox (same is-land + esbuild bundling), The activities archive (where routes live; its overview map is this component’s groups mode), and The theme toggle (the map re-tiles on data-theme).

Also corrected Three things called cache, which this fix made stale. The page had said the component scripts share the fonts’ year-long cache and had excused it as “must-revalidate stops it being served stale” — both now wrong. New “A long cache on an unhashed URL is a time bomb” section: must-revalidate only revalidates after max-age lapses, so an unhashed URL under a long cache serves stale until expiry; changing the header can’t fix copies already cached (only a URL change does); and the two correct shapes are hashed+immutable or unhashed+max-age=0. The bug was mixing them.

[2026-08-10] ingest | Per-type feeds (the link-post bodies)

Fourth and last pass on Per-type feeds today, closing the ⚠️ the previous pass had just written down. Likes, bookmarks, replies and RSVPs all used the plain shared body, which emits post.content and nothing else, and a link post’s substance is its front matter. 46 of the 50 bookmarks have no body at all — the bookmarks feed was fifty entries of a title with no indication of what had been bookmarked.

The part worth keeping is how it hid for so long. It was spotted on likes, where it’s two of four and reads as an edge case worth deferring; it’s forty-six of fifty on bookmarks, which is a broken feed. Same defect, same cohort, and the type you happen to be looking at decides whether it looks like a rounding error or an emergency. Counting the other members of a cohort is cheap and would have re-ranked it immediately.

link-atom-body.njk / link-json-body.njk put the link line and the description ahead of the body, wording copied from each type’s layout so a post reads the same in a reader as on its page — the rule activity-atom-body.njk already followed against the archive card. One pair for four types, with the verb chain in a link-line.njk fragment both include, which is the shared-partial move the previous pass predicted the third duplication would earn.

Two traps recorded on the page. RSVP is tested before reply — an RSVP carries inReplyTo too, so the obvious ordering turns every RSVP into a reply. And the fragment holds no async shortcode on purpose: it’s included inside a {% set %} capture inside a {% for %}, the exact shape the interlinker plugin blanks with a clean build and no error. Generalized on the page as: a partial reached through a capture has to stay synchronous.

Verified across all four feeds in both formats — 59 entries, none blank, every one carrying its link line. Bodies now nine, still thirty files in src/feeds/.

[2026-08-10] ingest | Per-type feeds (the coverage decisions)

Third pass on Per-type feeds in one day, and the one that closes the section. Johan settled the three questions the page had been holding open, and two of the answers are more interesting than the code that implements them.

The canonical-URL question. Audio and video both want /<section>/feed.xml for two different feeds — the regular Atom one and the RSS 2.0 podcast one — and the podcast had it only because it was built first. The regular feed takes it now; the podcast moved to /audio/podcast.xml and /videos/podcast.xml. The reasoning generalizes: you subscribe to a podcast by pasting a URL into an app once, but a generic reader is what guesses /audio/feed.xml, so the guessable URL should go to the audience that guesses. The format with the narrower, more deliberate audience is the one that can afford to move. Free to do here because the podcast feed had never been submitted anywhere — the channel artwork still being the site’s OpenGraph default is the tell.

The likes-and-reposts question, which is the one worth keeping. The page had them grouped as a single deliberate omission: every liked URL as a feed entry is a low-signal firehose. They were grouped because they look identical in the data — a URL plus a title, same link-post cohort, same field shape. But a like is a small judgment and a repost is a relay, so a likes feed is a recommendation stream and a reposts feed is noise. The shared shape was the weaker signal and it had quietly decided the question for both. Likes get atom+json; reposts and events stay feed-less, now each with its own reason rather than a shared one.

Also recorded: a ⚠️ on the link-post cohort. Likes, bookmarks, replies and RSVPs all use the plain shared body, and a link post’s substance is often its description front matter with no body at all — two of the four likes syndicate a title and nothing. Same wrinkle photo and activity hit, but it belongs to the whole cohort at once rather than a fifth body variant, so it waits on the deferred link-post display pass. The regular audio/video feeds carry no media file for the same “one fix, not four” reason: Atom’s rel="enclosure" and JSON Feed’s attachments would both do it, and the podcast feed already does.

Counts move: fourteen of sixteen types with a feed, two without, thirty files in src/feeds/, still seven shared bodies.

[2026-08-10] ingest | Per-type feeds (JSON pair for activities)

No new page. Per-type feeds corrected the same day it was corrected: it had recorded activities as Atom-only, framed as “one format is enough until someone asks for the other.” That framing didn’t survive being read back — every other non-podcast type publishes both, so Atom-only was the smaller half of a job rather than a decision. activity-json-body.njk is the Atom body’s stat line in the JSON Feed shape, including the eleventy:widths="1200" cap on the race map, and /activities/feed.json now carries all 157 entries with no empty content_html. The page’s shared-body table goes to seven.

Worth noting for the next type: the JSON variant was a mechanical translation of the Atom one, which is the second time a per-type body has been duplicated rather than shared (photo was the first). Two copies of the same stat line is fine; a third is the point where the line wants to be its own partial.

[2026-08-10] ingest | Per-type feeds + Self-hosting remote images at build time

No new page — two existing ones extended by the session that gave /activities/ a feed. Per-type feeds had recorded activities as one of four types without a feed, and the only one where no decision was written down anywhere; it now has one, so the page’s counts move (thirteen of sixteen with a feed, three without) and the shared-body table gains a sixth entry. The interesting part is that the page’s own warning predicted the work: a feed renders post.content and no front matter at all, and an activity’s content is its front matter — 100 of the 157 posts have an empty body, so the plain Atom body would have syndicated a hundred entries with a title and nothing else. Same problem photo hit, same fix. Recipe remains the third case, where the data moved into the body instead and the feed problem dissolved.

Self-hosting remote images at build time gained a subsection on the eleventy: attribute family. The page already documented eleventy:optional as error handling; the general point had been missed, which is that the prefix overrides the plugin’s global options per image — and the global widths: ['auto'] means “the original, whatever that is”. So every plain <img> the transform touches ships the source file at full size, which is invisible until a route that isn’t the page wants a smaller copy. The feed’s race maps are the worked example: eleventy:widths="1200" took 22 covers from ~2 MB each to 9.3 MB total and left the page untouched, because the page reads the same file through {% imageKeys %} with its own widths. The general shape is worth keeping — two routes over one source file can want different sizes, and neither should have to compromise for the other.

Links earned rather than invented: Self-hosting remote images at build time → The PhotoSwipe lightbox (the reason the page’s top width is the full scan, which is exactly what the feed must not copy). No link added between the two extended pages: Per-type feeds already links the images page through the recipe/clipper thread, and the feed’s cover is an implementation detail of one body, not something the feeds page should carry.

[2026-08-09] ingest | Timestamps without a time zone

New page Timestamps without a time zone, from the session that imported the three 2026 O-Ringen Göteborg stages and one run from a fresh Strava export. The general half is the naive-datetime problem and, more usefully, how to settle it: not by reading the exporter’s documentation and not by which reading looks plausible, but against a source that is absolute by construction — a spec-defined UTC field (FIT, EXIF offset tags, HTTP Date), a physical artefact, or an already-correct record of the same event elsewhere in the system. The failure mode is that resolving the ambiguity never errors: the parser applies a default and returns, and a parse/format round-trip test passes because the same wrong assumption runs both ways.

The “In jedee” half is the finding itself. activities.csv’s Aktivitetsdatum is UTC; the 2021 backfill script pinned process.env.TZ = 'Europe/Stockholm' and wrote a comment asserting the opposite, so roughly 150 posts carry +02:00 on a value that was already UTC and read two hours early. Three sources agreed against the comment — the FIT recording’s first record message (UTC by spec) reading identical to the CSV rather than two hours from it, the photographed O-Ringen split printout’s 11:00:09 CEST start, and the recorded elapsed time matching the printout’s official time to within a few seconds of watch overrun. Johan confirmed the 11:00:09 independently. Not swept: every affected activity is in daylight, so no date rolls over and no permalink is wrong. Worth noting the archive’s own “recorded numbers only, never derived” rule is what keeps a future sweep to one arithmetic shift.

The activities archive extended in the same pass: counts corrected 153 → 157 and 109 → 112 races, plus a new “topping it up” section for the things the second export run has to re-learn — the two columns both named Distans (kilometres comma-decimal, then metres, and a DictReader silently takes the second), the Media column now supplying the race maps directly so pass 3’s match-by-printed-event-name is retired, and the 1600 px downsizing that makes those maps smaller than the 3024 px originals already in the repo. Also that not every attachment is a map — one is a split printout.

The PhotoSwipe lightbox gained its second automatic surface: activity covers, alongside featured images. The page now says why this is the only one of the five cover-bearing layouts that can use the shortcode at all — {% imageKeys %} prepends ./src and breaks on a remote URL, and only activity covers are local. And a finding worth its own subsection: the clickable area is the wrapper’s job. The component’s <a> is display: block, so capping only the <img> at 14 rem left a full-column-wide strip that opened the lightbox when clicked; cover.css now caps .cover-zoom in the same rule as .cover.

Links earned rather than invented: Timestamps without a time zone → The activities archive (the archive that carries the mistake) and → Permalinks and Obsidian-friendly filenames (why a two-hour shift changes no URL); The activities archive → Timestamps without a time zone, → The PhotoSwipe lightbox and → Self-hosting remote images at build time (the local-versus-remote split that decides which pipeline a cover takes); The PhotoSwipe lightbox → The activities archive and → Self-hosting remote images at build time for the same reason. The wiki is now 31 concept/technique pages.

[2026-08-08] ingest | Alt text

New page Alt text, from the session that gave five cover-bearing post types a coverAlt field. The general half is the informative-versus-decorative decision and the distinction between alt="" and a missing alt; the “In jedee” half is the nine templates that had hardcoded the value, the Nunjucks default filter (substitutes only on undefined, which is what makes an intentionally empty alt expressible where or cannot), and the eleventy-img serialization that writes an empty alt as a bare alt — so a grep over the built HTML reads it as absent while the DOM reports alt === "". Filed under Images & media.

The accessibility test corrected and extended in the same pass. It had claimed the pa11y path list was four URLs; a fifth was added 2026-08-05 and the page never followed, so it also missed that a post layout is now covered. Two failure modes were added, both hit in one run this session: a path that no longer exists is requested, resolves to the 404 page, and scores zero errors rather than failing — the quiet one, and the reason the rename in this session had to be carried into meta.js by hand; and a missing puppeteer Chrome, which kills the run outright before any page is tested, for at least the second time.

Links earned rather than invented: Alt text → Self-hosting remote images at build time (which pipeline an image took decides how its alt reaches it), → The accessibility test (a rule engine sees a missing alt, never a wrong one), → Web Clipper templates (the clipper cannot generate alt text, which is why the fallback stays); The accessibility test → Alt text and → Permalinks and Obsidian-friendly filenames (filenames drive permalinks, which is what moves a path out from under the test).

One candidate was checked and NOT ingested: the local CSS bundle arriving as a linked /bundle/<hash>.css rather than inline, which cost debugging time this session. Three things called cache already documents it — “the /bundle/<hash>.css paths are dev-only” — so the wiki knew and the session hadn’t queried it.

[2026-08-08] reverted | Channel imbalance

A “Channel imbalance” page was ingested and then reverted the same day at Johan’s request: the wiki is about web development, not audio or photo technique. The recording gear behind a media post is out of scope even when the post itself is on the site. The page and its index entries are gone; the material lives on in LOG.md 2026-08-07 and _local/design/Plan - Audio field recordings (Nybrostrand).md, which is where it belongs. Recorded here so a future ingest doesn’t re-derive the same page from the same session.

[2026-08-03] ingest | The main menu

From the session that made the nav usable with JavaScript off. The page covers the disclosure pattern’s contract — button injected from a <template>, so nothing hides the list unless the script that operates it ran — and the choice between the two ways CSS can detect that state: @media (scripting: none) for rules that must apply before any script runs (jedee uses it inverted, in nav-menu-cls.css, to suppress the pre-JS flash), and a structural :not(:has(.menu-toggle)) for everything else, because the button’s absence also catches a script that was blocked or threw, and unlike the media query it can be tested by deleting an element in DevTools.

Two findings the session paid for. The first is that a dropdown’s fallback must not be the dropdown layout with the hiding removed: the two-column minmax(15rem, 1fr) panel dropped into a header row that can’t give it 30rem collapsed to one column, sixteen rows tall. The second is a flexbox rule that reads as obvious afterwards and cost the breadcrumb its trail before it did — shrinkage is distributed in proportion to base size, so a flex-basis: auto neighbour whose max-content is ~2000px takes nearly all the row’s shrinkage out of a ~200px breadcrumb that had none to give. A fixed 22rem basis fixes it and doubles as the wrap threshold on narrow screens.

Also records that moving the count numbers from the panel’s lifted surface to the plain page background dropped a mix calibrated at 4.5:1 to 4.43:1 with no color value changed — the same surface-dependence finding as the earlier --megamenu-surface measurement, from the opposite direction.

Links earned rather than invented: The main menu → Text wrapping (the breadcrumb’s line-clamp truncation, which the fallback has to leave working), → Focus rings and paint containment (the dense rows clip each other’s offset rings; same failure mode), and → The theme toggle (the same opt-in-to-motion idiom, and the other header control sharing that row). The wiki is now 30 concept/technique pages.

Third session-sourced ingest. The interlinker’s dead-link report had been printing 13 warnings, and every one of them was a false positive; the session that fixed it produced one new page and two corrections. Source of record written after the fact into src/_raw/dev-notes/How the dead-link report was made usable.md; the fix is on main (bb70bb2, 41a7049, merges 02a7ccc and 367c20a).

Link checking, filed under “Build & delivery”, is the general one: a checker can scan rendered output or source, and scanning source is what buys you a backlink graph but also guarantees two classes of false positive — code samples containing the exact sequences the matcher wants ([[plugins]] is TOML, [['default', fn]] is JavaScript, a fenced HTML example is full of href="/…"), and template expressions that are not paths yet. Neither is a bug in the regex; both follow from reading source rather than output. The page’s other half is about reports rather than parsing: an all-false-positive report is not a weak report but the exact equivalent of no report, and worse, because it is usually documented as the safety net for the thing it is failing to catch.

That second point is not abstract here. The noise was hiding a real one-way-rule breach: two wiki pages use [[Anna Karenina]] as a code example, detection ignored the backticks, and the published /reading/anna-karenina/ post was consequently rendering a Backlinks nav pointing at two wiki URLs. Production was safe only because features.yaml sits at local and the wiki isn’t in a production build. It was found by diffing the whole built site against a pre-patch baseline, not by reading the warnings — worth recording as the technique, since the warning count alone would have shown the fix “working” and nothing else.

The page also carries the three traps, all of which cost time: the resolver cannot help (it renders links already found, so it runs after detection); a deep import of the parser throws ERR_PACKAGE_PATH_NOT_EXPORTED because the package’s exports map publishes only index.js, so the class has to be loaded by file path; and there are two parsers, so patching the wikilink one alone left the href= matches behind.

Wikilinks corrected in two places it had got wrong, both surfaced by this work. Its dead-link section presented the console report as the build-time safety net for the one-way rule — true only from 2026-08-03, and flatly untrue before, which is how the breach above survived. And its “Scale” section counted bracket pairs by grepping the tree, a figure that answers a different question than “what does the plugin see”: the plugin reads a page’s body only, so the Jam front-matter wikilinks dominating that count have never been detected, never produced a backlink, and never appeared in the report. Recounted and reframed around the number that matters, which is a report of 0.

Links earned rather than invented: Link checking ↔ Wikilinks (the resolver’s legitimate job versus what it cannot do), and Link checking → The interlinker's second render pass, the same plugin’s other structural surprise. The wiki is now 29 concept/technique pages.

[2026-08-03] structure | General-first shape for the seed-ingest pages

Not an ingest — a shape pass over the pages written before the concept-title/general-first convention landed on 2026-08-01. Of the 28 pages, 5 already followed it (Microformats, Text wrapping, The lang attribute, Watch loops, The dev server’s memory) and 6 are purely project-internal, where AGENTS.md says there is no general concept to lead with: Anatomy of a post type, The activities archive, What jedee kept from Eleventy Excellent, The title-less post types, Permalinks and Obsidian-friendly filenames, The interlinker's second render pass. The remaining 17 were converted.

Four of them already opened general and only lacked the break, so they gained an ## In jedee heading and a fuller general half: Webmentions (the W3C protocol, source/target verification as the security model, why a static site needs a hosted endpoint), Focus rings and paint containment (WCAG 2.4.7 and 2.4.11, why outline is used over border, the three declarations that clip paint), One JSON-LD envelope for sixteen types (two machine-readable layers with different audiences, and why sites converge on one generic envelope), Rebuilding an archive from the Wayback Machine (dump-versus-crawl as complementary halves, the CDX API, the id_ suffix, and the silent-attribution failure).

Thirteen were jedee-first throughout and needed a general half written from scratch: Wikilinks, Per-type feeds, Three things called cache, The theme toggle, The service worker's three strategies, Self-hosting remote images at build time, Hosting large originals off-repo, The PhotoSwipe lightbox, The YouTube embed, The accessibility test, Web Clipper templates, Scroll-aware CSS during view transitions, and The authoring tool decides the data model.

Two judgement calls worth recording. Scroll-aware CSS during view transitions is general almost end to end, so its ## In jedee sits at the bottom and is the one-line “nothing on this site ships it” that AGENTS.md allows — the general half explains the cross-document transition model instead. And The authoring tool decides the data model looked like a page with no general half to write, since its title is a claim rather than a concept; it turned out to have one (the asymmetric cost of a producer that cannot emit your shape versus a consumer that can absorb any shape), so it was converted rather than reclassified as project-internal.

Titles were deliberately left alone. AGENTS.md promotes finding-style titles to concept titles opportunistically, not as a mass rename, and the shape fix is where the value is — renaming would have rewritten roughly 150 wikilinks for a change each page needs judged individually. Descriptions were updated to lead general, matching the converted pages. No new pages, no page splits, no index restructuring; sub-headings under ## In jedee demoted to ###.


[2026-08-02] ingest | Watch loops, The dev server’s memory

Second session-sourced ingest — the dev server had been dying with JavaScript heap out of memory on a single CSS edit, and the cause turned out to be two unrelated problems stacked on each other. Source of record written after the fact into src/_raw/dev-notes/How the dev server was made survivable.md; the fixes are on main (049ff02, 0c36651, 6cf006e).

Watch loops, filed under “Build & delivery”, is the general one: a build that watches a tree it also writes into will retrigger itself, and the .gitignore-as-watch-list default hides that fragility until something switches it off. In jedee that something was making the wiki browsable locally — setUseGitIgnore(false) un-hid the 23 files the build generates every pass, so each build started the next. Nine rebuilds from one edit, and 18 when the fix was removed again to confirm the cause. The page carries the standing rule it leaves behind: anything calling setUseGitIgnore(false) owns the job of re-ignoring generated paths by hand, with src/assets/og-images/ named as the next candidate. It also records the **/.git/** entry that was added on plausible reasoning and removed after testing showed it did nothing — the reasoning is repeatable, so the disproof is worth keeping.

The dev server's memory, same section, is the diagnostic one: what heapUsed / external / rss each mean, why GC has to be forced before reading them, and how to isolate an owner by elimination. Its “In jedee” section exists mainly to separate the site’s two OOM crashes, which share a message and nothing else — the cold-build native spike (sharp, fixed by Image.concurrency) versus Eleventy’s own ~370 MB-per-rebuild retention in --serve (not ours, blunted with an 8 GB ceiling). It records two wrong explanations with their disproofs, since both are the kind a reader would otherwise re-derive: the CSS is not inlined in dev, and the content-hashed bundle URL is not what forces the full rebuild.

Links earned rather than invented: the two pages cite each other (the loop multiplied the leak — nine rebuilds at 370 MB is why one keystroke killed it outright), and the memory page cites Three things called cache, where production CSS inlining is the cache-busting strategy. The wiki is now 28 concept/technique pages.


[2026-08-01] ingest | The lang attribute

First ingest whose source is a work session rather than a pre-existing clip or note — the session that marked the site’s Swedish text with lang="sv" for accessibility. The source of record was written after the fact into src/_raw/dev-notes/How Swedish text is marked with lang.md, so the page has the usual raw-source footer; the code it describes was already on main.

One new page, The lang attribute, filed under “Web standards & markup” — general-first (WCAG 3.1.1/3.1.2, BCP 47, sv over sv-SE, the proper-name/vernacular exceptions) then “In jedee” for the four sites: homepage spans, the all-Swedish bethink-yourselves note wrapped in a markdown <div lang>, activity captions via an activities.json captionLang default with one per-post en override, and activity titles detected at build time (_config/utils/looks-swedish.js).

Two inbound links it earned rather than invented: The accessibility test (pa11y flags invalid lang values, never missing ones, and its four paths skip activities/notes) and The authoring tool decides the data model (mixed-language Strava titles are that page’s “compute the field at build time when the authoring path can’t record it” pattern, exactly). The wiki is now 26 concept/technique pages.


[2026-08-01] migrate | Wiki became a real post type

Structural change, not an ingest. wiki.njk was rebuilt on base.njk, so every page now carries the full site chrome — header, nav, breadcrumb, footer, theme toggle — plus a small popout-width eyebrow naming the pages as AI-written and Johan-supervised, and the standard backlinks partial. The features.yaml dial gained a working public position: a production build now includes the wiki when the dial says public (still off — local/private keep production wiki-free, which stays the safety floor). Folder-wide schema: BlogPosting was added to wiki.11tydata.js so the JSON-LD include resolves.

Content pass over all 27 files: each gained description + date frontmatter (date = the page’s first commit in this repo) and lost its in-content # Title line — the layout renders the title from the filename now. “Wikilinks and backlinks” was renamed to Wikilinks (five inbound links updated); no other page split or renamed. AGENTS.md’s LLM-wiki section was updated to match: three-position dial, two-key frontmatter, no in-content H1, single-word concept names where natural, parent pages only when earned. Still 25 concept/technique pages plus the index and this log.

[2026-08-01] query-filed | Should jedee add schema.org alongside mf2 and JSON-LD?

First run of the Query routine ever. The index worked: its one-line summaries led straight to One JSON-LD envelope for sixteen types and Microformats, which together held the whole answer — no page had to be read cold. Answer: no; schema.org is a vocabulary and the JSON-LD envelope already encodes it, so “adding schema.org” would mean a redundant third encoding (microdata/RDFa) with no new consumers. Filed back as one clarifying paragraph at the top of the JSON-LD page rather than a new page.

[2026-08-01] ingest | Text wrapping

First external source the wiki has taken: src/_raw/Prevent Widows in Post Titles with CSS.md (Josh Crain, joshcrain.io, 2024-04-24 — published: was backfilled by hand when the clip was made). First page born under the new structure: Text wrapping, general explanation of text-wrap: balance and pretty first, jedee’s four cascade sites second (EE-stock reset + prose.css, jedee’s own .intro balance in local/post.css, and the breadcrumb.css:68 line-clamp workaround where the global pretty reset beats an explicit white-space: nowrap).

The age rule earned its keep exactly as predicted: the article’s browser-support caveats (balance minus Safari, pretty Chrome/Opera-only) were its main hedge in 2024 and are now obsolete — the page says so with dates instead of repeating them. The clip’s body image embed (![[_raw/assets/…]]) stayed in the raw layer, not copied into the page. The wiki is now 25 pages.

[2026-08-01] structure | Concept titles, general-first pages, no person pages

Johan wanted the wiki to read like a normal wiki before it starts taking general (non-jedee) material. Rules added to AGENTS.md: a page is named after its concept, plain noun title; it opens with the general explanation and sources, then a section headed “In jedee” — or one line saying the site doesn’t use it, which is what lets the wiki hold pages on things read but not adopted. No person pages, ever (decided against explicitly): people are cited with plain external links. Existing finding-style titles get promoted opportunistically, when an ingest touches them — no mass rename.

Done in this pass: Microformats are data, not style hooks renamed to Microformats as the model conversion — a four-paragraph general intro (vocabulary, prefixes, mf2 as the IndieWeb’s parsing layer) on top, the whole former page demoted one heading level under “In jedee: data, not style hooks”. Seven inbound links updated; this log keeps historical titles. index.md reorganized into general section names (Web standards & markup / CSS & design / Images & media / Publishing & the IndieWeb / The open web / Build & delivery / The jedee site itself), with “The open web” empty on purpose — it’s the declared home for the personal-website / copyright / ownership material to come. The former “Content graph” and “post-type system” sections folded into “The jedee site itself”.

[2026-07-31] lint | First pass over all 23 pages

All 23 read, the wikilink graph computed mechanically, and every shaky number checked against live code rather than re-read. Six corrections, one new page, one orphan resolved. The wiki is now 24 pages.

Five claims were wrong against the code, and all five were countable things nobody had counted since writing them:

  1. Permalinks and Obsidian-friendly filenames said twelve types use the plain page.fileSlug form — it’s eleven, and as written the page’s own categories totalled seventeen of sixteen types.
  2. Anatomy of a post type said every type carries “exactly these four keys” in a .json. Photo doesn’t: photos.11tydata.js is an ES module with a fifth key, an eleventyComputed.photoExif that reads EXIF at build time. A script globbing src/posts/*/*.json for sixteen hits finds fifteen. Now called out, with the top-level-key reason.
  3. Same page: eighteen layout aliases → nineteen.
  4. The PhotoSwipe lightbox described {% bento %} twice as shipped. It’s on the unmerged feat/bento-gallery branch — a third unmerged branch, alongside the two known parked ones.
  5. Wikilinks carried “187 distinct titles, of which 178 have no page”, which implies nine resolve and then concludes none do. Recounted from the tree: 298 bracket pairs, 179 distinct, 179 dead, 0 resolving. The conclusion was right; the arithmetic wasn’t, and it came in from the raw note.

One genuine contradiction between two pages, and the wiki won the argument with the source. The service worker's three strategies said runtime caching picks up the hashed /bundle/<hash>.css paths; Three things called cache said a production build emits no /bundle/ directory at all. The cache page is right — css-inline.njk only links the bundle files when runMode === "serve", and in dev, where they do exist, IGNORED_HOSTS makes the worker intercept nothing. The wiki page was faithfully repeating a comment in src/pages/serviceworker.njk, so the fix went into the source file too — the first time a lint pass has corrected code rather than just the wiki.

The one orphan worth fixing was Web Clipper templates — no inbound links at all, while being the origin of the idea the wiki repeats most. Per-type feeds and Self-hosting remote images at build time both argue from the clipper’s limits without ever pointing at it. That recurrence is also what earned the new page, The authoring tool decides the data model: four worked cases (recipe structure, covers, the vault-wide property-type registry, the Strava import) plus the escape hatch of computing a field at build time when nothing in the authoring path can write it. The point that makes it worth a page rather than a repeated aside: all three clipper cases were specified first and discovered second, and two left planned templates that were never built and briefly read as backlog.

Scroll-aware CSS during view transitions is still an orphan and stays one — nothing ships it and no other page touches view transitions. Deliberate, not an oversight.

Method note for the next lint: the mechanical half (inbound-link counts, pages missing from the index, dead wikilinks) is a ten-line script over the folder and is worth running first, because it says exactly which pages to read closely. The judgement half is entirely in checking numbers against the tree — every one of the five errors above was a count that had drifted or had been copied from a raw note without being recomputed.

[2026-07-31] ingest | Lene’s upstream EE docs (seed session 3)

Ingested /Users/johanedlund/Projects/eleventy-excellent/src/docs/ — 22 files, ~3,770 words (the handoff estimated 24 files), read in place at tag 4.6.1. Two new pages. Seeding is complete; the wiki is 23 pages.

The corpus is tiny, so the risk was duplication rather than volume: the eleventy-excellent skill was built from these same docs and already encodes most of their content. Nothing was copied across. What the wiki genuinely lacked was the comparison — AGENTS.md requires a page to say which part of an inherited feature is EE stock and which is jedee’s divergence, and until now individual pages asserted that only where they happened to know it. What jedee kept from Eleventy Excellent is the systematic version, with what-delete.md used as the yardstick: jedee removed almost exactly the set Lene marks as optional and nothing beyond it.

Every claim was checked against both checkouts rather than transcribed, which changed three of them:

  1. The design-token divergence is narrower than assumed. jedee kept Lene’s two-step colorsBase.json → colors.json pipeline and her npm run colors script verbatim; it adds two files to her nine. It did not replace the pipeline.
  2. Three pieces of EE machinery are dead source in jedee — details.njk + details.js (the src/docs/ collection they rendered is gone), gallery.njk + dialog.js (superseded by PhotoSwipe), and custom-easteregg.js (still copied to the output folder behind a flag that is false). All three are residue of a deletion, not a decision. Nothing is broken; the page records them so their presence isn’t read as evidence the features are in use.
  3. css.md’s third CSS stream doesn’t exist on either side. src/assets/css/components/ is globbed by build-css.js in both repos but the folder is absent from both checkouts.

⚠️ The docs are stale in Lene’s own repo. details.md describes a <custom-details> WebC component at length, and the string appears nowhere in the 4.6.1 source tree except inside that doc file. Good confirmation of the Ingest routine’s instruction to date-stamp a source that may have been overtaken — five months was enough here.

The second page, The accessibility test, fills a real gap: the wiki had nothing on testing. The pa11y machinery is stock, but tests.pa11y.customPaths is non-empty (four paths, EE’s own default list with /blog/ swapped for /articles/), so a green run covers four pages out of a sitemap in the hundreds and no post-type layout at all. It also only ever measures light mode, since chromeLaunchConfig passes nothing but the sandbox flags. Both are worth knowing before “the a11y test passes” is used as an argument.

Next: seeding is done, so the routines that matter now are Query and Lint. Nobody has yet checked the 23 pages for contradictions, stale claims or orphans.

[2026-07-31] ingest | Project docs (seed session 2)

Ingested _local/project_docs/ — 23 files, ~64,000 words, read in place. Seven new pages, three existing pages enriched.

The fifteen per-type specs are highly parallel (identity, permalinks, front matter, microformats, card, archive, feed, nav, schema, wiring), so they became five cross-cutting pages rather than fifteen near-copies: Anatomy of a post type, Permalinks and Obsidian-friendly filenames, The title-less post types, Per-type feeds, One JSON-LD envelope for sixteen types. Johan’s instruction was that activities — the sixteenth type, the one with no spec — be a full participant rather than a footnote, so every page covers all sixteen and its wiring was read from live code.

The two caching docs were net-new territory: Three things called cache and The service worker's three strategies.

Four docs were deliberately not ingested wholesale. web-clipper-pattern.html and micropub-pattern.html are a living ledger and a skill’s territory — the wiki keeps deferring to them. The interlinker and cover-image docs overlapped existing pages, so only what was genuinely new was merged in: the scaling benchmarks into The interlinker's second render pass, the local-cover escape hatch and the type-specific cover class into Self-hosting remote images at build time.

The specs are stale in three places, all found by checking live code. (1) Every one of the fifteen defers the microformats h-entry nesting caveat; all sixteen layouts now carry the entry root on the <article>, so it is closed — recorded in Microformats are data, not style hooks. (2) Recipe’s spec commits to a real schemas/Recipe.njk shipping with its front matter; the template was never written and the layout still declares BlogPosting. (3) Recipe’s spec plans a recipe-aware feed body for front-matter ingredients; the shipped recipe keeps ingredients in the body, so the plain Atom body carries them and no new body was needed. The second and third are the same constraint from opposite ends — worth reading together.

Nothing was moved or edited: project docs are read in place by design, so the git mv-into-_raw/ trap from session 1 never came up.

[2026-07-31] ingest | Dev notes (seed session 1)

Ingested all thirteen factual dev notes from src/posts/notes/ into fourteen wiki pages, and retired the notes to src/_raw/dev-notes/. None had ever been published — all were draft: true — so no live URL was lost; Johan’s call was wiki-only rather than publishing them as site notes too.

Two notes merged (the pair of Web Clipper notes → one page), and two pages were split out of a single note because the technique generalizes beyond the component that produced it: Focus rings and paint containment and Self-hosting remote images at build time both came out of “How the YouTube embed loads”.

One claim was corrected rather than copied: “How backlinks work” describes dead wikilinks rendering as raw [[brackets]], which stopped being true earlier the same day. Wikilinks documents the current behavior and flags that the raw note predates it.