Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.
_log
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-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.
[2026-09-02] ingest | Site search
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.
[2026-08-03] ingest | Link checking
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:
- Permalinks and Obsidian-friendly filenames said twelve types use the plain
page.fileSlugform — it’s eleven, and as written the page’s own categories totalled seventeen of sixteen types. - Anatomy of a post type said every type carries “exactly these four keys” in a
.json. Photo doesn’t:photos.11tydata.jsis an ES module with a fifth key, aneleventyComputed.photoExifthat reads EXIF at build time. A script globbingsrc/posts/*/*.jsonfor sixteen hits finds fifteen. Now called out, with the top-level-key reason. - Same page: eighteen layout aliases → nineteen.
- The PhotoSwipe lightbox described
{% bento %}twice as shipped. It’s on the unmergedfeat/bento-gallerybranch — a third unmerged branch, alongside the two known parked ones. - 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:
- The design-token divergence is narrower than assumed. jedee kept Lene’s two-step
colorsBase.json→colors.jsonpipeline and hernpm run colorsscript verbatim; it adds two files to her nine. It did not replace the pipeline. - Three pieces of EE machinery are dead source in jedee —
details.njk+details.js(thesrc/docs/collection they rendered is gone),gallery.njk+dialog.js(superseded by PhotoSwipe), andcustom-easteregg.js(still copied to the output folder behind a flag that isfalse). 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. css.md’s third CSS stream doesn’t exist on either side.src/assets/css/components/is globbed bybuild-css.jsin 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.