Skip to content

Turn aadhar.sh into a Windows-XP desktop OS (window shell, popovers, frontier features) + repo cleanup - #1

Merged
oddharsh merged 62 commits into
mainfrom
window-os-shell-and-frontier
Jun 30, 2026
Merged

oddharsh merged 62 commits into
mainfrom
window-os-shell-and-frontier

Conversation

@oddharsh

@oddharsh oddharsh commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Two days of work turning the homepage + subpages into a cohesive Windows-XP "desktop OS," plus a cleanup pass and a set of progressively-enhanced frontier web features. Everything degrades gracefully — the analogy the owner used is high-octane fuel: more capable when the platform supports it, still runs on regular when it doesn't.

All changes are deployed (Cloudflare Pages holding/ + the serendipity Worker) and were verified in a real browser via preview before each commit.

Desktop shell (nav.js)

  • Taskbar restructure: profiles moved off the taskbar; first-level subpages (garage, writing, serendipity, around, whoareyou, music, coffee) became labeled app buttons with per-section SVG favicons (gear/notepad/calendar/pin/robot/note/cup) that double as each route's tab favicon.
  • Desktop shortcuts: draggable icons on the Bliss wallpaper (Notepad + the 5 profiles, relabeled Instagram→Photos, Spotify→Music), positions persisted in localStorage. Icons render below windows (z-index), gated to wide screens.
  • OS-window model: body is a clipping flex desktop, each .window/.np-window is pinned with its content scrolling internally behind a custom XP scrollbar; windows are draggable (top is a hard boundary) and resizable (bottom-right grip). :has()-gated so windowless pages keep normal scrolling.
  • Run palette hover hot-tracking; the /writing notes open as stacking popovers (popover="manual") that composite over the folder index and cascade.

Frontier features (octane + fallback)

  • Speculation Rules prerender shell destinations on hover-intent (shell-wide via nav.js), so opening a window is near-instant; cross-document View Transitions animate only the window — retuned to map onto XP's minimize/restore zoom toward the taskbar.
  • Container queries: serendipity's master-detail now stacks on window width (resizable-window-aware), not just viewport.
  • Temporal (with Date fallback) for the taskbar clock, Notepad F5 stamp, image-EXIF dates, and serendipity relative times.
  • appearance: base-select Luna combobox, text-wrap: pretty site-wide.

Fixes

  • Killed the cross-document white flash by painting the html root with the Bliss tone (the VT freezes the root group); fixed-size photo thumbnails; tooltips suppressed while scrolling; serendipity sidebar fills full height; writing-folder no longer ballooned by resize.
  • Footer "Last modified" auto-derives from the newest photo upload (no more hand-stale dates).

Repo cleanup ("clean engine bay")

Ran a multi-agent audit and applied it: removed orphan code/CSS (pinColor, dead pics/My-Pictures bits), rebuilt the sitemap (4 → 14 routes), refreshed llms.txt / index.md / README / CLAUDE.md (constants, photo counts 120→146, JPG+WebP→AVIF+JPG), and dropped stale /source references.

Reviewer notes

  • Runtime caveat: Cloudflare Workers has no Temporal yet (no compat flag exists), so server-side it runs the Date fallback today and auto-upgrades later; browsers that ship Temporal use it now.
  • HTTP Last-Modified headers + perf timers were deliberately left as Date (Temporal can't emit RFC-1123; the rest aren't calendar dates).
  • Declarative Shadow DOM was considered and declined (it'd hide content from the agents/crawlers the site courts, and doesn't degrade gracefully).
  • Trademarked app logos are intentionally not reproduced — desktop/taskbar icons are original stylized CSS/SVG glyphs.
  • SW CACHE_VERSION bumped across the range (now v62); cf-garage/cal not touched.

🤖 Generated with Claude Code

oddharsh and others added 30 commits June 7, 2026 10:21
…tons

Restructure the XP taskbar to match the real Quick Launch layout:
- profiles (Twitter/Instagram/Curius/Beli/Spotify) move into a compact
  Quick Launch strip right of Start, drawn as stylized CSS glyphs +
  brand colours (generic camera, @, music note, lettermarks — no
  trademark repros, zero image bytes), with an engraved separator.
- first-level subpages (garage, writing, serendipity, around,
  whoareyou, music, coffee) take over the labeled .axp-pin app-button
  treatment; internal nav so they ride the window View Transition.
- SW bumped v49 -> v50 to sweep the cached nav.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Desktop philosophy: taskbar = runnable apps only. Profiles move off the
  Quick Launch onto the wallpaper as desktop shortcuts, alongside new
  My Pictures + Notepad icons. Icons sit in the wallpaper gutter (gated
  to >=1024px so they never overlap the centred window), VT-frozen.
- Run dialog: mouse hover now hot-tracks the list (selection follows the
  cursor), matching XP list controls — OK/Enter act on the hovered row.
- Temporal: taskbar clock (nav.js) + Notepad F5 stamp (notepad.js) use
  Temporal.Now when present, Date as fallback.
- base-select: the garage/horizon demo's drop-arrow is now a real raised
  XP combobox button that depresses when open; field gets a sunken inner
  shadow. @supports/native fallback unchanged.
- SW v50 -> v51 to sweep cached nav.js + notepad.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nside)

The page used to be the scroller, so windows slid up under the taskbar.
Flip it: body becomes a clipping flex "desktop" (overflow:hidden), the
window frame is pinned and bounded to the desktop, and its inner region
scrolls internally — like a real OS window. Generalizes the model Notepad
already used.

- body:has(.window|.np-window|.wrap) → flex column, centred, overflow
  hidden. :has gates it so windowless pages (raw dir index) still scroll;
  unsupported browsers fall back to the scrolling body.
- .window → flex column, max-height:100% (sized to content, never taller
  than the desktop); .window>.content / .window>.body scroll internally.
- serendipity's .wrap made a transparent flex pass-through.
- skeuomorphic XP scrollbar inside the scroll regions.
- window now can't flow under the taskbar (hard floor) and stays static
  while you scroll; dragging is clipped to the desktop too.
- SW v51 -> v52.

Verified live across all three window families (homepage/garage/worker,
Notepad, serendipity) at 1280x800: title bar stays fixed on scroll,
window bottom never crosses the taskbar floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strict windowing + window-management polish (all architecture-agnostic):
- Custom XP scrollbar mounted on each window's right edge (native bar
  hidden via scrollbar-width:none + ::-webkit-scrollbar). Sunken track,
  raised thumb + arrow buttons; thumb drag, track paging, arrow stepping;
  ResizeObserver keeps it synced. Wheel/keyboard still scroll natively.
- Drag clamp: the top is a hard wall (a window can't be dragged off the
  top, where it'd be unretrievable); the title bar also can't slide under
  the taskbar or fully off the sides.
- Desktop icons are now freely draggable on the wallpaper, positions
  persisted in localStorage (#axp-icons became a pointer-events-pass-
  through full-desktop layer; icons absolutely positioned).
- Resizable windows via a bottom-right grip (sets width/height, clamped
  to the desktop; scrollbar re-syncs through its ResizeObserver).
- Removed the "My Pictures" shortcut (no Apache R2 bucket view to open).
- SW v52 -> v53.

NOT done: ripping the 2008 Twitter/Spotify logos — those are trademarked
brand assets; kept the original stylized CSS glyphs instead.

Verified live-equivalent in a real browser at 1280x800: scrollbar thumb
tracks 0..max exactly, drag-up clamps at top=8, drag-down moves, icon
drag persists, resize widens past the old 660 cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /writing folder now inlines every note as a popover Notepad window
(notes are tiny .txt — cheap to inline, no fetch). Clicking a file opens
its note in the top layer, cascaded down-and-right of the folder so the
"selecting menu" stays visible alongside it — no navigation, same page.

- notepadWindow() gains a popId param → renders id + popover="auto" +
  a close button that hides the popover.
- handleWritingIndex inlines all notes after the folder window; list <a>'s
  keep their real /writing/<slug> href (no-JS / permalink fallback).
- notepad.js refactored single-window → per-window enhance(), plus folder
  wiring: click → showPopover + pushState(/writing/<slug>); ✕/Esc/click-
  away → history.back so the URL reverts and Back closes the note.
- CRITICAL fix: our .np-window{display:flex} beat the UA
  [popover]:not(:popover-open){display:none}, leaking closed notes into
  flow — added .np-note:not(:popover-open){display:none!important}.
- popover notes are .np-window, so they inherit drag, resize, and the
  custom scrollbar for free.
- SW v53 -> v54.

Verified in a real browser at 1280x800: closed notes hidden, open note
cascades 38px right of the folder (menu peeks), URL pushes to the
permalink + reverts on close, scrollbar/resize present on the note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roll

- Desktop icons now render BELOW the window (icon layer z-index 1, window
  frames get position:relative;z-index:2) — XP-correct; a window dragged
  over an icon covers it instead of the icon floating on top.
- Photo thumbnails are fixed-size again: grid uses repeat(auto-fill,184px)
  + justify-content:center instead of repeat(3,1fr), so they never rescale
  when the window is resized (they reflow count) and the centered grid
  leaves a hover-safe gutter on each side.
- Tooltips are suppressed while the content is actively scrolling (+220ms
  after it settles), so wheeling over the photo grid / tracklist no longer
  trips the cursor-follow tooltip. Returns on the next hover-move.
- SW v54 -> v55.

Verified at 1280x800: iconsZ=1 < winZ=2, grid cols fixed 184px centered,
scroll dispatch raises no error + leaves the tooltip closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Favicons: per-section CSS/SVG glyphs (colored tile + white pictogram) for
garage/writing/serendipity/around/whoareyou/music/coffee. Used BOTH as each
first-level route's tab favicon (nav.js setFavicon, exact-match only so garage
sub-pages keep their own) AND its taskbar app-button icon — replacing the
generic colored swatches. Favicons and the desktop shell now share one
language.

Cleanup (from the audit workflow's punch-list):
- nav.js: removed orphan pinColor() (0 calls), dead .pic CSS + "pics" ternary
  (My Pictures leftovers), and now-unused SUBPAGES color fields.
- sitemap.xml: rebuilt — was 4 stale URLs, now 14 (writing + 2 posts + all
  garage pages + serendipity), current lastmods, /coffee excluded by design.
- llms.txt: writing/photos no longer "planned" (they're live); homepage no
  longer called a "holding page"; added /writing + /serendipity to Pages.
- index.md: photo pool ~120 -> ~146.
- README.md: rewrote the obsolete "under construction placeholder / swap to
  site/" text to describe the actual live site.
- colophon.txt + horizon.html (x2): dropped references to the removed /source
  viewer.
- CLAUDE.md: nav.js + notepad.js rows rewritten for the desktop shell / OS-
  window / popover notes; THUMB_VERSION 10->18; CACHE_VERSION -> v56; photo
  counts 120/131 -> 146; JPG+WebP -> AVIF+JPG; /source removed; design/
  tokens.css -> design/tokens/.
- SW v55 -> v56.

Verified live: nav.js section icons + setFavicon present, 0 orphan refs,
sitemap 14 routes, llms.txt updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Display labels are now Photos / Music. Kept the real destinations,
brand-coloured tiles, and glyphs (camera / music note) via a new `icon`
key on PROFILES that the tile-colour + glyph lookups read instead of the
label. `hint` carries the brand ("Instagram"/"Spotify") so the Run palette
still finds them by brand and the tooltip shows where they go.

- buildIcons uses qlColor/qlGlyph(it.icon || it.label).
- SW v56 -> v57.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Notes were popover="auto", which light-dismisses — opening a second note
closed the first. Switched the inlined notes to popover="manual" so they
stack like real windows: opening one no longer closes the others.

- _worker.js notepadWindow: popover auto -> manual for popId notes.
- notepad.js openNote: cascade each new note 26px down-right (offset by how
  many are already open), clamped to the desktop; re-open of an already-open
  note raises + focuses it. Dropped the single-note pushState/popstate/Back
  coupling (a single URL can't represent several open windows; the standalone
  /writing/<slug> permalink pages still work for deep links). Esc closes the
  topmost note (manual popovers don't light-dismiss); ✕ closes one.
- SW v57 -> v58.

Verified in a real browser: both notes open simultaneously + cascade, folder
stays visible, closing one via ✕ leaves the other open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /writing resize: the folder window is a fixed-content listing — excluded
  it from initResize (it hugs content instead of ballooning into empty
  space). Note windows still resize, and .np-text now flex:1 so resizing a
  note grows the textarea rather than leaving a blank gap below it.
- serendipity sidebar: the OS-window model scrolled the whole .body
  (sidebar + main) together, so the 200px sidebar ended mid-scroll with
  white below it. Override so .window>.body doesn't scroll and the main
  .content scrolls internally — the sidebar now fills full window height.
- tooltips: lowered the post-scroll suppression cooldown 220ms -> 120ms so
  they feel snappy again right after scrolling.
- SW v58 -> v59; serendipity worker redeployed.

Verified in a real browser: serendipity pane fills body height + content
scrolls; folder has no grip; note textarea flex-grow:1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Image metadata date (index.html fmtDate): EXIF capture time is a wall-clock
  with no zone, upload timestamps are instants. Now uses Temporal.PlainDateTime
  for the wall-clock (shown exactly as recorded) and Temporal.Instant→local for
  instants; the no-Temporal fallback parses parts directly (no Date local
  reinterpretation). Verified: "2026:05:23 08:41:43" -> 05-23-2026, 08:41;
  ISO Z -> viewer-local.
- serendipity relativeTime + fmtDateTime: Temporal when present, Date fallback.

Runtime note: the Workers runtime has NO Temporal yet — there is no compat flag
for it ("No such compatibility flag: enable_temporal"), so serendipity runs the
Date fallback today and auto-upgrades if/when Cloudflare ships it. Browsers that
already ship Temporal use it client-side now.

Left as Date on purpose: HTTP Last-Modified (RFC-1123 toUTCString, which Temporal
can't emit), perf timers / cache-busters / epoch values (not calendar dates), and
machine ISO timestamps (Temporal.Instant.toString() is byte-identical — no gain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ale dates)

The homepage footer date was hand-hardcoded (stuck at May 18). Pages assets are
content-addressed (ETag, no Last-Modified) and there's no build step, so there's
no deploy timestamp to read — but the photo manifest carries per-photo `uploaded`
times. The worker now rewrites the footer <time> to the most recently added photo
(a real, datable content change; the pool grows often), floored by the hardcoded
date in index.html so a copy-only edit can still bump it by hand. Bumped that
floor to 2026-06-07.

So it auto-advances whenever the photo pool grows — no more hand-stale footer.
Verified live: footer reads "June 7, 2026".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e text-wrap

Three "high-octane" features, all with clean fallbacks:

- Speculation Rules, shell-wide: nav.js injects a prerender ruleset (eagerness
  "moderate" = hover-intent) on every page, so opening a window (garage/writing/
  serendipity…) is near-instant and the View Transition plays on already-loaded
  content. Allowlist-by-exclusion: skips / (counter), /around (live crawl),
  /whoareyou, /rn (redirect), /coffee, /images*, raw text. Guarded so it doesn't
  duplicate the homepage's existing inline ruleset. Feature-detected via
  HTMLScriptElement.supports; unsupported browsers just navigate normally.
- Container queries: windows are resizable, so serendipity's master-detail now
  stacks on WINDOW width (container-type on .window + @container 560px), not just
  viewport. The existing @media stays as the viewport / no-container fallback.
  Verified: .body is row at full width, flips to column when the window is
  dragged to 460px with the viewport still 1280.
- text-wrap: pretty site-wide via nav.js (headings keep their own balance).
- SW v59 -> v60; serendipity redeployed.

Verified live: garage page gets exactly one injected speculation script
(eagerness moderate, no dup); serendipity container query stacks on window width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-document View Transition freezes the root group, so it snaps to the
destination's <html> immediately — and html was white on every page (only body
carried the desktop tone), giving a frame of white before deferred nav.js paints
the real wallpaper. nav.js can't fix first paint (it's deferred), so the fix is
per-page first-paint CSS on the root:

- index.html: Bliss gradient on html (was body-only).
- 8 garage pages: html background added (sed; body already had it).
- worker pages (around/whoareyou/bot/+1): xpChromeCss now paints html+body the
  Bliss gradient instead of the old light blue-white one — single point for all.
- writing Notepad pages (NOTEPAD_CSS): html + body -> Bliss.
- serendipity: Bliss on html.

So the frozen root group shows the desktop colour, not white. Verified live:
/garage/, /garage/encoding, /whoareyou, /around all paint the Bliss root.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The box-morph was riding the UA default (~0.25s) and the content cross-fade was
0.2/0.24s — a touch slow next to the prerender. Tightened:
- ::view-transition-group(axp-window) geometry morph retimed to .16s ease-out
  (was the UA ~0.25s default).
- old/new content fades .12s / .16s ease-out (was .2s / .24s ease).
- less zoom: scale .992 / 1.008 (was .985 / 1.015).

SW v60 -> v61. Verified live in nav.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Was a center morph + in-place cross-zoom (modern-app feel). Remapped to XP's
"animate windows when minimizing/maximizing": the outgoing window zooms DOWN
toward the taskbar and the incoming one zooms UP out of it.
- transform-origin 50% 130% (below-centre, toward the taskbar at the bottom).
- old: scale .66 + fade, ease-in (cubic-bezier(.4,0,1,1)), .18s — minimize.
- new: scale .66 + fade, ease-out (cubic-bezier(0,0,.2,1)), .2s — restore.
- group geometry morph kept but fast (.2s) so it doesn't fight the zoom.
SW v61 -> v62.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preview-server launch config + Claude Code worktrees are local-only; keep them
out of the repo alongside the existing .claude/projects/ ignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ze grip

The custom scrollbar sat at right:2px and ran the full content height, so its
raised edge abutted the window's inner bevel on the right and piled onto the
bottom bevel + the resize grip in the corner — reading as overlapping layers at
the window boundary. Inset it: right 2->3px, top +1px, and stop 15px above the
bottom so the bottom-right corner stays clear for the sizing grip (like XP,
where the corner is the gripper). SW v62 -> v63.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New /garage/safari27 — WWDC26's Safari 27 (macOS 27 Golden Gate) through the
lens of what this site already ships, not the full changelog. Leads with the
features that intersect our work:
- appearance: base-select going stable (we already ship a Luna one)
- transform-aware anchor positioning + position-anchor normal/none (our tooltips)
- scroll anchoring / overflow-anchor (our internally-scrolling windows)
- :heading, stretch sizing, revert-rule, contain: style, linear color spaces
- top-level await, Wasm JSPI, Service Worker static routing, transferable streams
- <model> going cross-platform

Each card has a "Safari 27 / now stable" tag + an "on this site" note, and the
top strip runs live CSS.supports()/interface probes so every visitor sees what
THEIR browser supports (octane + graceful fallback). Luna-styled like the rest
of the garage; original compass favicon (not Apple's mark); features paraphrased
from the WebKit WWDC26 post, which is linked as the source.

Wired into the garage index, Run palette (PAGES), and sitemap. SW v63 -> v64.
Verified live + in a real browser (7 cards, 11 chips, styled select renders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Added a "The talks" section linking WebKit's sessions post + the six session
videos, with the two that land on this site flagged:
- Grid Lanes (CSS-only masonry) — horizon flagged masonry as not-shipped
  mid-2026; this is it shipping. Direct fit for the photo grid.
- Customizable Select — the talk behind the base-select card above.
Plus the Safari 27 overview, <model>, immersive environments, Web Extensions.
Original tie-ins; session titles/URLs factual, WebKit roundup linked as source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New /garage/masonry — a ragged-height contact sheet via display: grid-lanes
(the masonry layout Safari 27 shipped), with the homepage's fixed-1:1-square
grid as the @supports fallback. Same markup both ways.

- columns: repeat(auto-fill, minmax(140px,1fr)); rows stack masonry where
  grid-lanes is supported, else fixed squares + object-fit:cover.
- per-photo aspect-ratio reserved from metadata.json EXIF width/height (set as
  --ar), so masonry lays out correctly with zero layout shift as images load.
- live "display: grid-lanes in your browser" chip + a Masonry/Fixed-squares
  toggle to A/B; toggle is disabled (and labelled) where grid-lanes is absent.
- photos lazy-loaded from the worker manifest (same pattern as the homepage),
  avif<picture>+jpg.

Wired into the garage index (newest), Run palette, sitemap. SW v64 -> v65.
Verified in a real browser: 40 photos load, distinct aspect ratios reserved,
fallback renders 1:1 squares (this engine lacks grid-lanes); live page 200 and
the worker manifest route still serves (temp local manifest used only for the
preview test, deleted before deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Overlap fix (couldn't repro in Chromium at any width incl. 300px; Safari-only):
- base-select demos had a hard min-width:220px that overflows narrow windows ->
  min-width:0; max-width:100% on safari27 .s27-sel + horizon .bsel-field.
- .demo > h2 children get min-width:0 + overflow-wrap:anywhere so the title
  wraps below the stat badge instead of colliding on narrow widths.

Voice pass (writing-style skill) on safari27 + masonry copy:
- killed every em dash in the prose (per LRS), recast as commas/periods/colons.
- straightened all curly quotes to straight quotes.
- tightened ledes/notes into the voice; no banned phrases (none were present).
- left title-bar / <title> "X — aadhar.sh" separators (site-wide convention)
  and code comments; flagged the separator for a separate call.

Queued: garage index + the 7 older demo pages (chunks, cloudflare, encoding,
horizon[219 em dashes], pretext, scroll, tooltips) for the same voice pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New /writing/education-in-tech note (BSD = Berkeley, Sun = Stanford),
formatted like the others (title + rule + body). Registered in posts.json
as the newest entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Performance / quota:
- SW: stop mirroring /images/full/* (R2 originals) into Cache Storage — the
  browser HTTP cache already holds them immutable, so it was double disk + a
  background re-fetch per hit for zero hit-rate gain.
- tooltip EXIF moves to per-photo /images/meta/<stem>.json (lazy on hover),
  histograms computed client-side — drops the metadata.json blob from the hot
  path + the Pillow dep. worker guards the route so a miss can't SPA-fall-back
  to index.html and get stamped 1yr-immutable (poisoning a tooltip).
- /index.html → 301 /, so the alias can't heuristically cache the no-store,
  re-randomizing, counter-ticking homepage.
- homepage reads (asset, tracks, manifest, counter, altMap) fire concurrently;
  playlist-id module-cached so warm isolates do one KV read for tracks.

Luna fidelity:
- drop the soft blurred drop shadow on homepage + serendipity windows for the
  hard 4px offset shadow (soft blur is the SaaS tell DESIGN.md bans).
- worker-page headings: Franklin Gothic -> Trebuchet MS (an approved stack;
  Franklin Gothic Medium isn't installed everywhere + wasn't in the 3 stacks).
- speculation rules: also exclude /coffee* and /images*.
- kept push-button border-radius at 3px (Luna), not squared.

SW v66 -> v67. Verified: 146 meta/<stem>.json serve 200 JSON; buttons 3px live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decisions from the diff-session review:
- corners: title-bar top-right 7px -> 8px everywhere (index.html, _worker.js
  chromes, all garage pages, masonry/safari27) so it matches the symmetric
  8/8 window body. it was a typo, not a deliberate asymmetry.
- amber: "pages win" -- the shipped pages use hue 47.10 (warn amber) and
  95.44 (row hover), but design/tokens/colors.css had --hue-warn:75 feeding
  BOTH. split into --hue-warn:47.10 + new --hue-rowhover:95.44 and decoupled
  --row-hover. tokens-only, reference surface -- no live page change.

Mechanical batch (landing the diff session's uncommitted sweep):
- nav.js Run dialog: 5px -> 3px title/icon radii, soft float shadow now
  matches the --shadow-float token value (navy, single blur, was black
  double-blur), Geneva added to font fallbacks.
- caption-button filter .1s -> 60ms ease-out everywhere.
- font stacks: Geneva fallback restored across worker/nav/serendipity.
- _worker.js getImagesManifest: two-key stale-while-revalidate (persistent
  manifest + 1h freshness sentinel) so only the true cold start pays the
  inline R2 list(); lapsed-freshness visitors get the stale manifest and
  the rebuild rides ctx.waitUntil.
- index.html: relocated the ~27KB cursor-tooltip IIFE to just before
  </body> so the tracklist + photo grid paint before it parses.
- cal/templates.js: palette re-sync to the canonical 5-stop title gradient,
  gel caption buttons, on-token ButtonFace + h1 navy; stale text-glyph
  comments updated to describe the gel controls.

sw.js CACHE_VERSION -> aadhar-v68-luna-polish (nav.js changed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran the copy on garage/index, chunks, cloudflare, encoding, horizon,
pretext, scroll, tooltips through the LRS writing rules (safari27 +
masonry were already done). Mechanical, no meaning or technical-accuracy
changes:

- killed every em dash in prose + author-voice comments (~320 total,
  horizon alone ~217), repunctuated by sense -- colon for a setup,
  comma for a light pause, period for a hard stop, parens for a true
  aside. never a blanket swap.
- straightened all curly quotes/apostrophes to ASCII (0 remain anywhere).
- rewrote the handful of "Not X, but Y" constructions affirmatively.
- no banned words present (leverage/utilize/robust/delve/in order to/
  furthermore) -- confirmed by sweep.

Intentionally left (non-prose / site conventions):
- <title> + og:title " — aadhar.sh" separators (sitewide convention,
  decided separately).
- tooltips tracklist <span class="sep">&mdash;</span> + the JS "no data"
  placeholder glyph -- UI separators that mirror the homepage np-sep.
- cloudflare .cf-out empty-state &mdash; placeholders (overwritten by JS).
- horizon's two em dashes inside <pre> CSS code samples (literal code).

Verified: all <div> balanced, all inline scripts parse, horizon + pretext
render clean with 0 em dashes in visible text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second-layer revision across all 10 garage pages, applying the real LRS
sentence-level method (Williams & Colomb) on top of the surface cleanup:
put a concrete character in the subject slot, its action in the verb;
kill nominalizations (-ion/-ment/-ance) back into verbs; drop bleached
"X is", "there is", "this is" cores; prefer active where the passive hid
the doer; tighten cores; old-info-first, new-info-in-the-stress-position.

Examples of the move:
- "where a tool gets validated under real load" -> "where I run a tool
  under real load"
- "Good cars get learned on mules first" -> "Carmakers learn the good
  cars on mules first"
- "aspect ratios are reserved from EXIF" -> "EXIF pins each photo's
  aspect ratio up front, so nothing reflows"
- "Three of them are wired to live demos" -> "I wired three of them to
  live demos"
- "the cut points are decided by the data" -> "the cut points follow
  the data"

Restrained on purpose: deliberate 2003 fragments ("Same spirit here,
fewer cones.") and definitional "is" cores left as voice. Surface rules
held (no em dashes/curly quotes reintroduced); div balance + all inline
scripts verified; index + horizon + pretext render clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the ' — aadhar.sh' title separator for a root-first filepath form
joined by ' / ', so every title reads like a real path:
- aadhar.sh / garage
- aadhar.sh / garage / safari 27
- aadhar.sh / writing / <slug>.txt
- aadhar.sh / around · / bot · / whoareyou
- aadhar.sh / serendipity  ·  aadhar.sh / coffee / <page>

Applied to browser <title>, og:title, and the on-screen window title-bar
labels across the garage pages, the worker-rendered pages (writing index +
notes, around, bot, whoareyou, rn/set), serendipity, and cal. Root-first
on purpose: it labels "where you are" as an absolute path (the canonical
reading), which is what "reads like a filepath" actually means; the
relative tokens . / .. are navigation, not location, so they don't belong
in a location label.

Side benefit: removes every remaining em dash from title surfaces (the one
separator class the earlier voice pass had deliberately left as a pending
decision). Homepage title (the person's name) + the authentic
"file - Notepad" window convention left as-is. JS parses; live titles
verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse ' / ' -> '/' in the filepath titles so they read like a real
path: aadhar.sh/garage/safari 27, aadhar.sh/writing/<slug>.txt,
aadhar.sh/coffee/<page>, etc. Leaf names keep their internal spaces
("safari 27"); only the separators tighten.

Targeted full-string replacements, not a blind ' / ' sweep -- prose
slashes (encoding's "AVIF / WebP / JPEG"-style comparisons, table values
like "3 / 0") are left intact. JS parses; live titles verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /writing folder listing now shows each note's size before you open
it ("Text Document · 1,388 characters · 2026-06-06"), and the folder
status bar totals them in a second beveled cell ("3 documents · 2,264
characters") -- the authentic XP folder details/status-bar layout.

No extra fetches: handleWritingIndex already pulls each .txt to inline the
popover Notepad windows, so the same text now also feeds the char count
(text.length). Refactored the two passes into one Promise.all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oddharsh and others added 9 commits June 18, 2026 12:58
Re-checked the volatile features against the page's own sources (caniuse /
MDN baseline / webkit + mozilla standands-positions / chromestatus). Five had
drifted; the rest were accurate:

- contrast-color(): was "Safari + Firefox, Chrome lagging ~m147, needs fallback"
  → now Baseline (Chrome 147, Safari 26, Firefox 146; Apr 2026). Badge + "on the
  site" note rewritten — no fallback caveat.
- command / commandfor (invoker): + Firefox 144 → Baseline across all three.
- Temporal: Chrome 144 shipped (Jan 2026), joins Firefox 139 → 2 engines, badge
  promoted trial→cross.
- field-sizing: + Firefox 152 (shipped Jun 2026) → all three.
- scroll-driven animations: corrected the optimistic "Firefox" → "FF flag"
  (still behind layout.css.scroll-driven-animations.enabled; stable in
  Chrome + Safari only).

No homepage adoption: contrast-color() is now Baseline, but the title-bar blue
(oklch ~51%) sits right at the black/white contrast crossover, so contrast-color
could resolve to BLACK and flip the XP title text — the hand-picked white is
deliberate. Not worth gambling the look for a no-op on a static surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pushes the tracker out to the absolute edge of the platform, integrated or not,
and finally represents the networking layer (a static site usually never touches
it). New cards, each with a verified June-2026 cross-engine badge + an "on the
site" hook, several with live demos:

newer platform:
- Element.moveBefore() — state-preserving reparenting (Chrome 133; FF/Safari
  positive). Live demo: an iframe uptime counter survives moveBefore() but resets
  on the appendChild fallback — exactly what the window shell's reparenting needs.
- cross-document View Transitions (@view-transition) — Chrome 126 · Safari 18.2.
- shape() responsive clip-path — Baseline (Chrome 119 · Safari 18.2 · FF 136);
  live pennant demo.
- text-wrap: pretty — Chrome 117 · Safari 26; before/after demo.
- @function custom CSS functions — Chrome 139.

networking:
- WebTransport (HTTP/3 + QUIC) — now Baseline (incl. Safari 26.4).
- Service Worker static routing (InstallEvent.addRoutes) — Chrome; relevant to
  this site's own sw.js.
- 103 Early Hints — server-side (Cloudflare-emittable); no chip since it's not a
  JS capability.

far edge:
- Local Font Access (queryLocalFonts) — Chrome-only + Safari/FF oppose; live
  enumeration demo, on-the-nose for a zero-web-font site.
- WebGPU — now multi-engine (Chrome · Safari 26 · Firefox 141).

cap-strip checks + labels added for the 9 client-detectable ones. Verified in
preview: all cards render, chips light up, moveBefore demo preserves node state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tic routing

Two horizon features graduated to production.

Cross-document View Transitions: a reduced-motion-safe @view-transition opt-in
inlined into every same-origin surface (homepage, 12 garage articles, the
worker-rendered pages, serendipity) — placed in parsed CSS, not JS-injected, so
the INCOMING page has opted in by the time pagereveal fires (a deferred nav.js
inject would be too late). Real page→page navigations now crossfade natively at
a fast 140ms; the persistent shell (wallpaper/taskbar) is identical across
pages so visually only the changing window content fades. Wrapped in
prefers-reduced-motion: no-preference, and progressive (Chrome 126 + Safari 18.2
get it; Firefox ignores it → today's instant cut).

Service Worker static routing (sw.js): declare the always-network paths
(/, /around*, /whoareyou*, /rn*, /images/*.json, /images/meta/*) via
InstallEvent.addRoutes so the browser serves them WITHOUT cold-booting the
worker — these are network-only in the fetch handler anyway, so they were waking
the SW just to no-op (notably `/`, no-store + every visit). SWR + cache-first
paths stay with the handler (they need logic). Guarded by "addRoutes" in event +
try/catch; unsupported browsers behave exactly as before. CACHE_VERSION →
aadhar-v74-swroute.

Verified: opt-in served on all four page types; sw.js parses + ships addRoutes;
_worker.js / serendipity.js still parse with the inlined CSS rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ add Speculation Rules card

Make it obvious which horizon features aren't just feasible but actually LIVE on
the real site. A hand-kept list (audited against production usage in index.html /
nav.js / _worker.js / sw.js / serendipity) gets a .shipped class via the
cap-strip script: translucent green fill, a green inset edge, and a labeled
"✓ shipped on the site" pill (so it reads without relying on colour alone).

12 shipped: text-box-trim, anchor-positioning, view-transitions, cross-doc-vt,
relative-color, @Property, @starting-style, Temporal, speculation-rules,
sw-static-routing, hidden-until-found, contrast-color.

Also:
- Added the missing Speculation Rules card (it was pitched + is shipped — the
  prerender — but never got a card). + cap-strip check/label.
- Fixed three now-inaccurate "on the site" notes the green tag exposed:
  · contrast-color — it's serendipity avatar text (NOT the homepage title bars,
    which we reverted at the b/w crossover), corrected to say so.
  · cross-doc-vt + sw-static-routing — flipped "would" → "live now" since they
    shipped this session.

Verified in preview: 12 cards green + tagged, non-shipped (e.g. moveBefore) stay
white.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…i + bounce)

The site's Run palette (⌘K) is itself a command palette, so having it fire a
Raycast command is a fitting meta-joke. Two built-in Raycast commands (every
Raycast user has them) as palette entries:
  · confetti 🎉  → raycast://extensions/raycast/raycast/confetti
  · toggle bounce → raycast://extensions/raycast/raycast/toggle-bounce-animation

New kind "raycast": go() does location.href to the protocol URL (the OS hands it
to Raycast, the page stays put); rendered with a "↗ raycast" tag and an explicit
"needs Raycast installed" hint so non-Raycast folks aren't surprised by the OS
"open?" prompt. Naturally excluded from the prerender ruleset (href isn't a "/*"
path) and from the taskbar/desktop (Run-palette pool only). PAGES tagger now
preserves a pre-set kind. CACHE_VERSION → aadhar-v75-confetti.

Built-ins only — the personal installed extensions (brew, slack, etc.) wouldn't
resolve on a visitor's machine and would leak tooling, so they're excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rt horizon card

Skip rendering off-screen repeated content where there's enough of it to matter:
- garage/horizon (48 demo cards, several with their own demos/animations) — the
  page I just made heavy; `.demo { content-visibility: auto; contain-intrinsic-size:
  auto 360px }`. The intrinsic-size reserves a placeholder so the scrollbar stays
  stable, and the `auto` keyword remembers each card's real height after first
  render → no shift on re-scroll.
- garage/safari27 (8 demo cards) — same treatment.
- serendipity event list (.ev) — long list (120+ events incl. the browsed tier);
  contain-intrinsic-size: auto 66px per row.
Not applied to the homepage (above-fold dynamic content — the CLS reason it was
removed there before) or the short single-demo garage pages (nothing meaningful
off-screen; bespoke selectors, no gain). content-visibility is Baseline 2026
(Chrome 85 / Firefox 125 / Safari 18); degrades to normal rendering elsewhere.

Also added the Compression Dictionary Transport card to horizon (dcb/dcz —
Chrome 130, Cloudflare open beta Apr 2026), honest that its payoff here is
marginal given the site's already-tiny assets. Chip-less (transport-level, not a
JS capability), like 103 Early Hints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OS model scrolls .content internally (overflow:auto behind the custom
scrollbar), which browsers don't reliably restore on reload — the no-store
homepage re-renders, and the scroller is JS-geometry-aware. So persist the
primary window's scrollTop per-path in sessionStorage (per tab, survives reload,
clears on close) and restore it.

Scoped tightly: restore ONLY when navType === "reload" — fresh navigations start
at top, and back/forward is left to the browser's bfcache. Write is cheap
(scrollend where supported, else a 200ms throttle; plus a pagehide flush).
Restore re-asserts on `load` until it sticks, so a not-yet-laid-out scroller
(images/fonts settling, or a 0-height first frame) doesn't clamp it to 0.

Verified in preview: scroll → reload restores exactly; a fresh navigation does
not. nav.js change → CACHE_VERSION aadhar-v76-scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… found by it

Local-only test suite for the (undeployed) coffee-booking worker — never bundles
into a deploy (wrangler only bundles src/index.js + its imports; tests live in
test/, devDeps in package.json, node_modules gitignored). 40 tests across the
real workerd runtime (@cloudflare/vitest-pool-workers, vitest 4):
  · sign.test.js        HMAC approve/decline link auth (tamper, rotation, malformed)
  · availability.test.js generateSlots() invariants, busy-overlap, daily limit
  · booking.test.js      KV lifecycle + the cron expire sweep
  · email.test.js        Resend payloads + .ics generation (fetch stubbed)
  · index.test.js        full request flow via worker.fetch(): routing, validation,
                         honeypot, BASE_PATH, book→approve / book→decline cycles

Writing the email tests surfaced two real .ics bugs (src/email.js), now fixed:
  1. attachment used btoa(ics) — throws on any code point >255, so an emoji/
     accent in a booking topic/name made sendInvite throw inside ctx.waitUntil →
     the confirmation email silently never sent. Now UTF-8 → base64.
  2. DESCRIPTION newlines were double-escaped (\\n then escICS'd) → rendered as
     literal "\n" in calendars. Fixed to single ICS escapes. Plus octet/codepoint-
     aware line folding so an emoji near the 75-octet fold boundary isn't split.

Run: cd cal && npm test. Nothing here deploys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…KV id)

Prep for going live as a native worker (replacing the Cal.com setup):
- cal.aadhar.sh route: custom_domain can't carry a path/wildcard — "cal.aadhar.sh/*"
  was rejected ("Wildcard operators are not allowed in Custom Domains"). Changed
  to the bare host "cal.aadhar.sh" (CF auto-provisions DNS + cert, routes the whole
  hostname). The canonical aadhar.sh/coffee + /coffee/* routes are unchanged.
- BOOKINGS KV: created the namespace + wired its real id (was a placeholder).

Dry-run now clean (44 KiB worker, KV bound). SIGNING_SECRET is set on the worker
(generated random). Still gated on two operator-only secrets before deploy:
ICAL_URL + RESEND_API_KEY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 30, 2026 •

Copy link
Copy Markdown

Deploying aadhar-sh with  Cloudflare Pages  Cloudflare Pages

Latest commit: ce33c09
Status: ✅  Deploy successful!
Preview URL: https://87a20acf.aadhar-sh.pages.dev

View logs

@oddharsh
oddharsh merged commit 1a01d3d into main Jun 30, 2026
1 check passed
@oddharsh
oddharsh deleted the window-os-shell-and-frontier branch July 12, 2026 11:49
oddharsh added a commit that referenced this pull request Jul 27, 2026
Drove an actual webmention through the deployed receiver from a public
source page. It verified and stored correctly, but the moderation email
was 280 characters of SVG path geometry:

  9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018…"> A
  throwaway source page. It exists to drive one real w

Two causes, either of which is enough on its own.

<svg> was not stripped alongside <script> and <style>, and a page like a
GitHub gist is full of inline icons. On its own that is survivable, since
the tag stripper removes complete tags with their attributes.

What made it escape is the excerpt window. It is a fixed 400 characters
either side of the target, cut at a byte offset, and the target lives
inside an href — so centring on a link GUARANTEES the cut lands mid-tag,
and often mid-attribute. A leading partial tag is not a complete tag, so
the stripper leaves it as text. That is the stray "> in the middle.

Now strips <svg>/<noscript> and drops the partial tags at both edges,
detecting a mid-tag cut by whether > precedes < in the slice. Same page
now yields the actual sentence around the link.

Also fixes two things found alongside it:

202 no longer carries a Location header. The spec ties Location to 201,
where it names a status URL the sender can poll; webmention.rocks
receiver test #1 fails a 202 that sends one. /inbox was never a status
URL for a pending mention anyway, since a pending mention deliberately
does not appear there.

Citations now skip anchors that exist to open a hover card: interestfor
on the garage pages, class="car-link" on the homepage. Every one of them
points at a Google SEARCH url, so telling google.com/search?q=Singer it
had been cited is noise, not credit. Live citation targets drop 56 -> 53,
and all three removed are search pages.

Verified the excerpt test fails with both fixes removed, reproducing the
exact garbage above. Each fix alone is sufficient, so it takes removing
both to break it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oddharsh added a commit that referenced this pull request Jul 28, 2026
* fix(social): endpoint discovery was taking three of the decoys

Ran the site's sender against the IndieWeb conformance suite at
webmention.rocks, which plants deliberate traps in its 23 discovery
pages. Three of them caught us. All three were the same shape: the
parser was matching on text rather than on structure.

  #12  rel="not-webmention"       took the decoy
  #13  decoy inside <!-- -->      took the decoy
  #20  <link rel="webmention">    gave up instead of reading on

12 is the sharp one. The old test was /\bwebmention\b/, and "-" is a
word boundary, so "not-webmention" matched. rel is a space-separated
token list, so the check is now tokenize-and-compare, in the header path
too. 13 needed comments stripped before the scan (escaped HTML in #14
was already safe, since these patterns want a literal "<"). 20 was
treating the first rel=webmention tag as the answer even with no href,
so a bare <link> shadowed the real <a> further down; it now skips
candidates without an href and keeps looking. href="" is deliberately
NOT that case: it is a legal self-reference, and #15 asserts it.

Kept the single document-order scan. Tempting to look at every <link>
before any <a>, but #16 exists precisely to punish that: it puts the
real endpoint in an <a> and a decoy in a later <link>.

23/23 against the live suite now, up from 20. Frozen as fixtures rather
than live fetches, because this file is deliberately network-free.
Confirmed the fixtures fail against the old parser.

discoverEndpoint is exported to make the suite runnable, matching the
other helpers here that are already exported for tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(social): a real mention arrived reading like spam, twice over

Drove an actual webmention through the deployed receiver from a public
source page. It verified and stored correctly, but the moderation email
was 280 characters of SVG path geometry:

  9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018…"> A
  throwaway source page. It exists to drive one real w

Two causes, either of which is enough on its own.

<svg> was not stripped alongside <script> and <style>, and a page like a
GitHub gist is full of inline icons. On its own that is survivable, since
the tag stripper removes complete tags with their attributes.

What made it escape is the excerpt window. It is a fixed 400 characters
either side of the target, cut at a byte offset, and the target lives
inside an href — so centring on a link GUARANTEES the cut lands mid-tag,
and often mid-attribute. A leading partial tag is not a complete tag, so
the stripper leaves it as text. That is the stray "> in the middle.

Now strips <svg>/<noscript> and drops the partial tags at both edges,
detecting a mid-tag cut by whether > precedes < in the slice. Same page
now yields the actual sentence around the link.

Also fixes two things found alongside it:

202 no longer carries a Location header. The spec ties Location to 201,
where it names a status URL the sender can poll; webmention.rocks
receiver test #1 fails a 202 that sends one. /inbox was never a status
URL for a pending mention anyway, since a pending mention deliberately
does not appear there.

Citations now skip anchors that exist to open a hover card: interestfor
on the garage pages, class="car-link" on the homepage. Every one of them
points at a Google SEARCH url, so telling google.com/search?q=Singer it
had been cited is noise, not credit. Live citation targets drop 56 -> 53,
and all three removed are search pages.

Verified the excerpt test fails with both fixes removed, reproducing the
exact garbage above. Each fix alone is sufficient, so it takes removing
both to break it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
oddharsh added a commit that referenced this pull request Aug 12, 2026
Renaming or moving a page turned every page LINKING to it into a 404, and nothing
in this repo noticed. routes:check sweeps the routes it is TOLD about, which is the
forward direction. Invariant #1 asserts the Worker's routes reach
run_worker_first. Neither one reads an href.

scripts/lib/link-integrity.mjs is the resolver, run as a build invariant over the
MINIFIED tree, since those are the bytes a visitor gets and the minifier rewrites
asset refs to their hashed /a/ form.

COMPLETE rather than scoped to the diff, because it costs nothing to be: 2645 refs
across 48 documents in ~45ms. A diff-scoped version would be more code and would
miss the case that matters most, where the moved page is not in the diff and its
dependents are.

No second copy of the route table. The inputs are exactly what invariant #1
already extracts (the ROUTES keys, run_worker_first's globs) plus the surface
registry and the staged tree, so a route added anywhere is understood here with no
matching edit.

Two findings from building it, both now load-bearing:

run_worker_first cannot be the resolver on its own. It answers whether the Worker
SEES a request, not whether a path SERVES a page, and it carries /garage/* and
/lwe/* — the namespaces holding most of the site's pages. Measured on a
deliberately broken ref, a glob-only resolver called /garage/renamed-page fine. So
a namespace that already holds registered surfaces is GOVERNED by the registry and
the glob buys it nothing there. The governed set is derived from
site-manifest.json, so adding a section governs it with no edit.

The scanner has to be quote-aware. minify-html unquotes attributes, so the served
bytes carry href=/coffee far more often than href="/coffee", and the first draft
read 33 refs where there are 2645 and passed with a straight face. Third naive
scanner this repo's minified output has caught.

Tested for teeth rather than agreement, since the tree currently has zero dangling
refs and a test asserting only "the real site passes" would survive the resolver
being reduced to () => true. Three unit tests cover accept, reject and the scanner;
end to end, injecting <a href=/garage/renamed-page> into www/index.html fails the
build with the path and the referring document named.

Non-goals recorded in CLAUDE.md: prose mentions of a path (this repo discusses
deliberately deleted routes, so that would false-fire on its own history), the Run
palette and nav fences (generated from the registry, asserted by invariant #8), and
off-origin links.

247 tests, build, pages:check, typecheck and 130 routes all pass.
oddharsh added a commit that referenced this pull request Aug 12, 2026
…#351)

* ci: ramp production from Actions, and stop trusting an empty variable

Four release steps ran on a workstation or not at all. Three of them move to the
repo here.

RAMP. `.github/workflows/ramp.yml` is new, and CLAUDE.md has described it since
2026-08-06 without it ever existing: no such workflow in any commit, environments
`copilot`/`production` rather than the canary/full pair, and no write token. What
landed that day was the `release-guard.mjs` rewrite alone, which asks whether a
process can authenticate instead of whether it is CI. That is what makes this
possible; the pipeline it cleared the way for is what was missing.

It splits at the human rather than removing them:

  canary  10%          production-canary, no reviewer, runs on its own
  full    50% -> 100%  production-full, REQUIRED REVIEWER, waits for a click
  verify  none         no environment, no credential, runs dcz:check

A merge reaches a tenth of traffic by itself and stops. Approving `full` is the
same decision as before, minus the terminal.

Three parts of it are load-bearing:

  - It checks out `production`, never `main`. The ramp writes the D1 changelog by
    diffing the checked-out checkpoints.json against D1, so the tree has to be
    the one that was built. This is gotcha 24 solved rather than moved: CI cannot
    ramp from a stale tree, nor from one running ahead of what is serving.
  - `full` refuses a version the canary never saw. Approval is asynchronous and
    Workers Builds uploads a version for every push, so re-resolving the newest
    build after an approval could put traffic on something nothing canaried,
    silently, with every downstream check passing because a version that built
    returns 200s.
  - Every step runs through `pnpm run`. deploy-promote.mjs shells out to `npx
    wrangler`, and npx resolves whatever it finds: measured in a tree with no
    node_modules, `npx wrangler --version` answered 4.105.0 from its own cache
    while this repo pins 4.120.0.

Both environments restrict deployments to `main`, which is not tidiness:
`workflow_dispatch` can target any branch carrying the workflow file, so without
a branch policy anyone able to push a branch could ship a modified ramp.yml and
read the token out of it.

CHECKPOINTS. `checkpoints:check` was deliberately unwired ("NOT wired into the PR
job by default", its own header) because it needs a D1 read. It needs no NEW
credential: D1:Read is already one of the six scopes on CLOUDFLARE_API_TOKEN.
Fatal on drift, which is safe because the script already tells a STAGED release
apart from a rewrite, so a PR staging v187 passes. Tiered on the token the way
infra:check is, since failing a fork PR for not holding a secret is a worse check
than no check.

OG CARDS. `og-cards.yml` bakes the cards on a runner and opens a PR.
Dispatch-only, never scheduled: the cards capture production so data-driven demos
render populated, and the photo grid, live counters and routing prober are not
deterministic between runs, so a nightly job would propose a PR of changed PNGs
every night that meant nothing. dictionary-roll.yml can be nightly because its
output is a pure function of what production serves; this is not.

AN EMPTY VARIABLE, found on the way. ci.yml passed
`CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}` to infra:check and NO
repository variable of that name has ever existed (`gh variable list` returns
nothing), so it expanded to "" on every run. It reads
scripts/print-account-id.mjs now, which cannot be silently empty and cannot drift
from the account the deploy targets, because it IS that string.

This is HARDENING, not a repair, and the difference is worth stating because the
first version of this message got it wrong. checkApi() falls back to GET
/accounts and only degrades when the count is not exactly 1; the scoped CI token
sees exactly one, so the account tier was asserting normally. Measured: `infra
ok: 44 checks passed, 1 skipped or advisory` both before and after, with the one
advisory being the documented CodeQL default-setup note and no `token sees N
accounts` warning in either run. The two-account fact belongs to the owner's
interactive wrangler login, which is a DIFFERENT credential from the CI token,
and conflating them is what produced the wrong claim.

What the helper is genuinely required for is the ramp: release-guard.mjs returns
an error unless CLOUDFLARE_ACCOUNT_ID is set in CI, unconditionally, so the
canary and full jobs cannot authenticate without it.

What did NOT need building, checked rather than assumed: md twins are build
output regenerated every build, with checkTwinFacts pinning the hand-written ones
in both directions; photos already have photo-pipeline.yml plus photos:check in
CI; dictionaries already have dictionary-roll.yml.

Environments are created but NOT declared in infra.json, which leaves the
required reviewer on the honour system. Asserting them in check-infra.mjs is the
follow-up and is deliberately not bundled with the change that created them.

244 contract tests, typecheck, build and pages:check all pass; checkpoints:check
passes against live D1 (122 released, newest v186).

* ci: pass workflow values through env, never ${{ }} inside the script

Four spots interpolated an expression directly into a `run:` block. A ${{ }}
expansion is substituted into the script TEXT before bash parses it, so a value
carrying shell metacharacters executes. `inputs.base` in og-cards.yml is the one
that matters: a URL somebody types into the dispatch form.

Dispatching needs write access, so this is hygiene rather than a hole, and the
other three values were already constrained (a hex prefix the canary matched with
a regex, and a literal true/false). Fixed all four anyway, because the safe shape
should be the habit rather than a judgement call per value.

Found by reading the diff, not by the security check: github-advanced-security
failed on this PR for the eighth recorded time with `CAPIError: 400 The requested
model is not supported`, so Copilot's agent crashed before reading anything (see
gotcha 27). `Analyze (actions)` passed and flagged none of this.

* docs: gotcha 27, the security check now fires on every push

Three more occurrences on 2026-08-12, all the same CAPIError: 400, taking it to
nine recorded. One landed on a commit whose entire diff moved ${{ }} expressions
out of run: blocks and into env:, which strictly reduces attack surface — so a red
mark here carries no information about the diff even in the direction it claims to.

Those three cost three separate investigations in one session, which is the actual
bill for a check that gates nothing. So the note now ends with the short loop:
confirm the run id is new, grep for CAPIError, stop. Do not re-read the diff, and
do not push to appease it. Names the settings-side fix for when the noise stops
being worth it, and records that CodeQL and Analyze (actions) are the checks doing
the real work.

Also records what Analyze (actions) does NOT catch, since it passed on the
${{ inputs.base }} interpolation fixed in a76ea18: the CodeQL Actions analyzer
treats workflow_dispatch inputs as trusted, so that injection class is on the
author to read for.

* refactor: pnpm exec for the eight npx call sites the sweep missed

gotcha 29 says the repo moved off npx because npx FETCHES what it cannot resolve
locally, which is a hole in a repo that pins wrangler exactly and enforces one
version on every PR. It also says "all 30 call sites" were converted. Eight were
not:

  scripts/check-checkpoints.mjs   d1 execute
  scripts/deploy-promote.mjs      the RAMP
  scripts/lens-webmcp.mjs         browser create + close
  scripts/perf-budget.mjs         deploy --dry-run, check startup
  scripts/perf-snapshot.mjs       deploy --dry-run
  scripts/release-status.mjs      versions list

Each spawned execFile("npx", ["wrangler", ...]), so no grep for `npx wrangler` as
a phrase could find them: the command is assembled from array elements and only
the word "npx" appears as a token. Same blind spot the holding/ rename hit from
the other direction, and the rule that generalises is to grep for "npx" quoted
rather than npx <binary> as a phrase.

deploy-promote.mjs was the one that mattered: the wrangler moving production
traffic depended on how the ramp was invoked. Under `pnpm run` it got the pin;
from a tree with no node_modules it got whatever npx had cached, measured at
4.105.0 against a pinned 4.120.0.

pnpm exec, not pnpm dlx. dlx is npx's true twin and would fetch from the registry
and ignore the pin, which is the hole rather than the fix; wrangler is a pinned
devDependency, so exec is correct. The three npx strings in infra.json and its
mirrors still stand on purpose: they RECORD dashboard values, and editing them
alone fails infra:check on drift it invented itself.

Exercised rather than assumed, since a bad spawn surfaces as a missing binary at
the worst possible moment: checkpoints:check reads D1 (122 released, newest v186),
perf-budget runs both wrangler calls and reports hard checks green, release-status
lists versions. 244 tests and the build pass.

Two comments I wrote hours ago went stale with this and are corrected: ramp.yml
and CLAUDE.md both explained using `pnpm run` as a guardrail against npx, which is
now consistency rather than protection.

* docs: the ramp token needs five permissions, not two

This repo has said `Workers Scripts:Edit` + `D1:Edit` "and nothing else" since
2026-08-06, and since no such token existed until now the list was never checked
against a running ramp. Derived from what deploy-promote.mjs actually executes:

  Account  Workers Scripts    Edit   versions list, deployments status, versions deploy
  Account  D1                 Edit   SELECT shipped vnums, INSERT the changelog row
  Account  Account Settings   Read   wrangler resolves the account
  User     User Details       Read   wrangler identifies itself non-interactively
  User     Memberships        Read   same

The bottom three are wrangler's own identity calls rather than anything the ramp
asks for, which is why a two-scope list read as complete.

Narrower than the token Workers Builds generates for itself in one place worth
recording: no Workers Routes, no KV, no R2. A ramp shifts traffic between versions
that already exist and never uploads one.

Also documents the control (CI=1 matters, or release-guard ignores the token and
falls back to the interactive login, so the check tests the wrong credential) and
the one failure that is silent: the changelog INSERT runs only after 100% and is
caught rather than fatal, so a missing D1:Edit costs a changelog gap on a ramp
that reports success. checkpoints:check is the backstop and goes red on the next
PR.

* docs: turning off Copilot Autofix does not stop the security check

gotcha 27 said the fix for the noise was in GitHub's settings and named Copilot
Autofix. Measured: the owner turned it off and the very next push failed
identically, 12th recorded occurrence.

The toggle did land, and the log shows it changed something real:

  COPILOT_AGENT_ONLINE_EVALUATION_DISABLED: true
  [skills] session=github/code-scanning enabled=false source=disabled

and the job ran 29s instead of ~80s. It then crashed on the same CAPIError: 400
regardless. So the setting is not the off switch, and which setting is (if any) is
now recorded as UNKNOWN rather than guessed at a second time.

This is the second wrong claim I have had to walk back in this note, both from
asserting a cause without measuring it. The note now says what is known: the check
configures nothing in this repo, it gates nothing, CodeQL and Analyze (actions) do
the real scanning and stay green.

* docs: mark which ramp-token scopes are measured and which are guessed

The scope table listed five permissions as though equally established. Two are
not: User Details:Read and Memberships:Read were inferred from the token Workers
Builds generates for itself, not from anything deploy-promote.mjs is known to
call, and CLOUDFLARE_ACCOUNT_ID is passed explicitly so wrangler need not
enumerate anything. Marked SPECULATIVE, with the guidance to grant the three
Account rows and run the control instead of collecting all five.

Also records why they look absent from the dashboard: User-scoped permissions
exist only on USER-owned tokens (My Profile, API Tokens). An account-owned token
created from Manage Account does not offer the category at all, which reads like a
missing option and is a token-type difference.

* docs: the ramp token needs three Account scopes, measured

Verified with a real token: CI=1 deploy:promote --dry-run resolved a target,
printed the current split, and skipped two non-production versions by alias, on
Workers Scripts:Edit + D1:Edit + Account Settings:Read alone.

So the two User rows come out. They were inferred from the token Workers Builds
generates for itself rather than from anything the ramp calls, they are unnecessary
because CLOUDFLARE_ACCOUNT_ID removes the account-enumeration call they would have
served, and they are unavailable on an account-owned token — so listing them sent
somebody hunting for a dashboard category that was never there.

Records what the control does NOT prove, since it only reads: a token holding
Workers Scripts:Read would satisfy it too. versions deploy and the D1 INSERT are
first exercised on a real ramp, and the D1 one is caught rather than fatal.

Third correction to this token note in one session, all three from asserting a
cause before measuring it.

* build: fail when a moved page leaves its inbound links behind

Renaming or moving a page turned every page LINKING to it into a 404, and nothing
in this repo noticed. routes:check sweeps the routes it is TOLD about, which is the
forward direction. Invariant #1 asserts the Worker's routes reach
run_worker_first. Neither one reads an href.

scripts/lib/link-integrity.mjs is the resolver, run as a build invariant over the
MINIFIED tree, since those are the bytes a visitor gets and the minifier rewrites
asset refs to their hashed /a/ form.

COMPLETE rather than scoped to the diff, because it costs nothing to be: 2645 refs
across 48 documents in ~45ms. A diff-scoped version would be more code and would
miss the case that matters most, where the moved page is not in the diff and its
dependents are.

No second copy of the route table. The inputs are exactly what invariant #1
already extracts (the ROUTES keys, run_worker_first's globs) plus the surface
registry and the staged tree, so a route added anywhere is understood here with no
matching edit.

Two findings from building it, both now load-bearing:

run_worker_first cannot be the resolver on its own. It answers whether the Worker
SEES a request, not whether a path SERVES a page, and it carries /garage/* and
/lwe/* — the namespaces holding most of the site's pages. Measured on a
deliberately broken ref, a glob-only resolver called /garage/renamed-page fine. So
a namespace that already holds registered surfaces is GOVERNED by the registry and
the glob buys it nothing there. The governed set is derived from
site-manifest.json, so adding a section governs it with no edit.

The scanner has to be quote-aware. minify-html unquotes attributes, so the served
bytes carry href=/coffee far more often than href="/coffee", and the first draft
read 33 refs where there are 2645 and passed with a straight face. Third naive
scanner this repo's minified output has caught.

Tested for teeth rather than agreement, since the tree currently has zero dangling
refs and a test asserting only "the real site passes" would survive the resolver
being reduced to () => true. Three unit tests cover accept, reject and the scanner;
end to end, injecting <a href=/garage/renamed-page> into www/index.html fails the
build with the path and the referring document named.

Non-goals recorded in CLAUDE.md: prose mentions of a path (this repo discusses
deliberately deleted routes, so that would false-fire on its own history), the Run
palette and nav fences (generated from the registry, asserted by invariant #8), and
off-origin links.

247 tests, build, pages:check, typecheck and 130 routes all pass.

---------

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
oddharsh added a commit that referenced this pull request Aug 19, 2026
…on any origin (#469)

NLWeb is a thin convention: answer natural language at /ask, return schema.org
objects, expose the same thing over MCP. This origin had the MCP half and a
search index over its own corpus, so what was missing was the endpoint, its
parameter contract, and a schema.org projection of the search records. The `ask`
MCP tool calls the same function, since the protocol specifies both doors
carrying the same arguments and the same answer.

Streaming defaults to TRUE per the spec, and the dialect is selected by request
SHAPE alone: a `query` arriving as an object is v0.55 and gets named SSE events,
anything else is legacy and gets unnamed message_type frames.

Three things are refused rather than faked, and each could have shipped as a 200
that looked right. mode=summarize and mode=generate need a language model this
Worker has no binding for, so both answer 501 naming supported_modes. A `prev`
with no decontextualized_query gets the raw query searched AND a _meta line
saying so. Descriptions are extractive and _meta says so, because the spec
annotates that field "generated by an llm" and ours are not.

/lens/nlweb walks through the door the doors tier only knocks on, grading the
answer PER FIELD. Opt-in, cached 1h per origin AND question, 4/min.

Pointing it at the new endpoint found the door probe grading a REAL NLWeb server
as absent, twice: the protocol streams by default, and `query` is required so a
conforming server must refuse a bare knock. Both read `likely` now, with the
earlier tightening intact.

Three repairs found on the way: build invariant #1 had been vacuous since
ROUTE_TABLE was extracted (0 of 85 routes checked), `pnpm run search:index` had
been exiting ENOENT since the www/ split (three live pages invisible to search),
and stripMarkup left character references in strings /ask now publishes inside
schema.org objects.
oddharsh added a commit that referenced this pull request Aug 28, 2026
webmention.rocks receiver test 1, sent at production on 2026-08-28, verified and
stored correctly and named nobody. The row:

  kind    reply          (correct: the source carries u-in-reply-to)
  author  ""             <-
  title   Webmention Rocks!
  status  pending

Two bugs, both in the fallback chain, both visible in the source's markup:

  <div class="left p-author h-card">
    <a href="/"><img class="u-photo" alt="Webmention Rocks!"></a>
  </div>
  ...
  <h1 class="p-name"><a href="/receive/1">Receiver Test #1</a></h1>

The p-author pattern matched, then captured the WHITESPACE between that div's
">" and the "<a" on the next line. A whitespace string is truthy, so the `||`
chain stopped there and the documented hostname floor never ran; clean() then
reduced the winner to "". An h-card wrapping a linked photo is the ordinary
shape on a site that marks its author up properly, so this was never a quirk of
the test post: every reply from a real IndieWeb site would have arrived blank.

And the second fallback was worse than no fallback. In microformats2 a p-name
inside an h-card is the author's name, while a p-name inside an h-entry is the
POST's name, and a pattern that cannot see which parent it sits under reads the
title. On this very document that fallback calls the author "Receiver Test #1",
which is confidently wrong where the hostname would merely have been plain.

So the chain reads the p-author ELEMENT, bounded by counting its own tag's
nesting rather than by a character window, because the entry title sits a few
hundred characters past the h-card's end and a window swallows it. Inside that
element: nested p-name, then text, then the photo's alt, which is what mf2 says
carries the name for a p-* property. Every candidate has to survive clean() as a
non-empty string to win, so the hostname floor is reachable again.

Re-parsed against the saved source: author "Webmention Rocks!", and the id comes
back byte-identical to the row production stored, so a re-send still updates
that row rather than minting a second one.

The fixture in the tests is that markup verbatim rather than paraphrased, since
both bugs live in its exact shape. Control run: the old chain fails it with
actual "".
oddharsh added a commit that referenced this pull request Aug 28, 2026
…d so (#644)

* fix(webmention): the outbound half never read a page, and nothing said so

`fetchOwnPage` used a plain `fetch()` at our own origin. A Worker cannot do
that: the request is blocked as recursion (error 1042), which perf-probe.ts and
lens's SELF_FETCH each already document. It failed by RETURNING "", so every
page hit `if (!html) continue`, every run wrote zero rows, and the job reported
the same silence a working job reports.

Measured 2026-08-28 against production: `webmentions_sent` held 0 rows and
`webmentions` held 0 rows, while /around on the same daily tick had run that
morning and written its KV snapshot at 05:41:06Z. The whole outbound half had
never fetched one of its own pages since it shipped.

Two fixes, because the first one alone would have hit the second.

Reads go through the injected dispatcher. `withSelfFetch` is factored out of the
fetch handler so `scheduled()` can build the same seam, and the cron gets it;
`fetchOwnPage` takes SELF_FETCH, falls back to ASSETS, and has no plain-fetch
arm, because that arm is the bug. Verified in local dev against the real
scheduled handler: "5 pages read, 8 citations considered", where the same line
read "0 pages read" before.

Then the run is bounded by a subrequest budget. A full sweep is ~35 pages plus
~100 citations at 2-3 D1 statements each, and Workers Free allows 50 subrequests
per invocation, so the loop could never have finished one — it would have died
partway with an exception the `.catch(() => {})` swallows, which is the same
silence again. One bulk read of prior sends replaces the per-citation SELECT
(~100 subrequests back), the loop stops while it can still afford a whole
target, and pages rotate by a self-pair marker row so a bounded run resumes
somewhere new instead of re-sweeping the first five forever. Three runs in local
dev swept 5, then 10, then 12 of 35 pages at 38/40, 40/40 and 36/40.

The tests are the other half of the finding. The three that existed covered the
pure filters; nothing covered the loop, which is exactly the seam that broke.
Three new ones pin that a run reads its pages through the dispatcher and records
what each probe learned, that a run with no dispatcher records nothing rather
than looking like a page with no citations, and that a run stays inside the
budget while advancing the rotation.

Inbound needed no change and was re-verified on the wire: discovery advertised
on every mention-enabled page, 202 with no Location, correct CORS, every
validation path, and the anti-forgery link check scored 8/8 against real pages
(including an href that exists only inside webmention.rocks' copy-paste
textarea, which correctly does not count as a link).

* feat(webmention): /inbox cites the IndieWeb wiki, which is the first target that listens

Swept every citation this site makes: 0 of 97 across 39 pages advertise a
webmention endpoint. Controls in the same run (aadhar.sh/garage/pqc,
webmention.rocks/test/1, aaronpk.com) all resolved, so the prober works and the
citation graph genuinely has nobody on the other end. It is Wikipedia, GitHub,
vendor docs and research blogs, and none of them do webmention.

So the outbound half was correct and structurally mute. /inbox is the one page
here whose prose is about webmention, and it already carried a real external
citation to the W3C REC, which advertises no endpoint. It now also cites the
IndieWeb wiki, which does: indieweb.org/Webmention answers with
webmention.io/indiewebcamp/webmention.

That makes the page's flag true in both directions, which is the coherent
reading for the inbox: the page that displays mentions accepts them, and the
page that explains the protocol participates in it. Nothing about the citation
is staged for the demo, which was the bar. adactio.com and tantek.com also
listen and are not cited here, because there is no honest reason to.

Confirmed against the local dev worker: /inbox is in the send list (36 pages),
its two citations extract cleanly, and exactly one of them resolves an endpoint.
It sits last in the rotation, so the daily cron reaches it in about a week.

The advertisement contract test caught the flag on the way in, which is what it
is for: a path can join WEBMENTION_PATHS with one manifest flag, and accepting a
mention you never advertise is the same as not accepting it. It knew how to ask
/writing and nothing else, so it now holds a table of the worker-rendered pages
and asks each through its real handler. A path in neither the table nor
_headers still fails outright.

derive:check re-locked shell/desktop, whose input is the manifest.

* test(identity): pin the rel=me chain, which is why IndieAuth needed no code

Scoping IndieAuth for the webmention.rocks receiver suite turned up that the
sign-in already works. The flow, measured 2026-08-28:

  GET webmention.rocks/auth/start?url=https://aadhar.sh/
    -> 302 indielogin.com/auth?me=https%3A%2F%2Faadhar.sh%2F
    -> 302 github.com/login/oauth/authorize

indielogin went straight to GitHub with no provider chooser, which it can only
do after reading a rel=me link off this homepage AND finding the matching link
back on that GitHub profile. Both halves are there: the shell stamps
rel="noopener me external" on all six profile anchors, and github.com/oddharsh
carries href="https://aadhar.sh".

That is RelMeAuth, not IndieAuth, and the distinction is worth keeping straight.
This site advertises no authorization_endpoint and no rel=indieauth-metadata,
and its .well-known/oauth-authorization-server is the agent-auth document with
an anonymous grant, so a client doing real IndieAuth discovery still finds
nothing. What it has is the older mechanism, which is the one indielogin uses,
so the receiver suite is reachable today without building an endpoint that would
assert domain control and carry a real security surface for it.

Pinned because the half that lives here is invisible and fails quietly. rel=
"noopener external" renders and behaves exactly like rel="noopener me external",
so losing the token costs nothing a reader could see and takes the domain's
identity with it. The test walks the committed desktop chrome, requires "me" as
a whole rel token on every PROFILES anchor, and carries a floor so an emptied
PROFILES cannot pass by checking nothing. Control run: dropping the token from
the GitHub anchor alone fails it by name.

The other half is on github.com and no checkout can assert it. The comment says
so rather than implying the test covers both.

* fix(webmention): the first real mention arrived with a blank author

webmention.rocks receiver test 1, sent at production on 2026-08-28, verified and
stored correctly and named nobody. The row:

  kind    reply          (correct: the source carries u-in-reply-to)
  author  ""             <-
  title   Webmention Rocks!
  status  pending

Two bugs, both in the fallback chain, both visible in the source's markup:

  <div class="left p-author h-card">
    <a href="/"><img class="u-photo" alt="Webmention Rocks!"></a>
  </div>
  ...
  <h1 class="p-name"><a href="/receive/1">Receiver Test #1</a></h1>

The p-author pattern matched, then captured the WHITESPACE between that div's
">" and the "<a" on the next line. A whitespace string is truthy, so the `||`
chain stopped there and the documented hostname floor never ran; clean() then
reduced the winner to "". An h-card wrapping a linked photo is the ordinary
shape on a site that marks its author up properly, so this was never a quirk of
the test post: every reply from a real IndieWeb site would have arrived blank.

And the second fallback was worse than no fallback. In microformats2 a p-name
inside an h-card is the author's name, while a p-name inside an h-entry is the
POST's name, and a pattern that cannot see which parent it sits under reads the
title. On this very document that fallback calls the author "Receiver Test #1",
which is confidently wrong where the hostname would merely have been plain.

So the chain reads the p-author ELEMENT, bounded by counting its own tag's
nesting rather than by a character window, because the entry title sits a few
hundred characters past the h-card's end and a window swallows it. Inside that
element: nested p-name, then text, then the photo's alt, which is what mf2 says
carries the name for a p-* property. Every candidate has to survive clean() as a
non-empty string to win, so the hostname floor is reachable again.

Re-parsed against the saved source: author "Webmention Rocks!", and the id comes
back byte-identical to the row production stored, so a re-send still updates
that row rather than minting a second one.

The fixture in the tests is that markup verbatim rather than paraphrased, since
both bugs live in its exact shape. Control run: the old chain fails it with
actual "".

* fix(webmention): a relative source was accepted, and turned into a fetch at an invented host

webmention.rocks receiver test 2 grades one thing: HTTP 400 for every invalid
parameter. Running its contract against production on 2026-08-28, twenty of
twenty-one shapes passed and one did not.

  400  source == target, missing either, javascript:, ftp:, file:,
       localhost, 169.254.169.254, same-origin source, unparseable source,
       relative target, foreign target, 404 target, non-mentionable target
  202  source=/some/path            <-

`validateLensTarget` prepends https:// to a schemeless string. That is right
where it lives: the /lens box is a place a visitor TYPES "example.com" and means
a website. An endpoint machines POST to is the opposite case, where the spec's
`source` is a URL and guessing at a scheme accepts something that never was one.
The lens guard is untouched; both surfaces are right about their own callers,
and the difference is who is doing the typing.

The mention then died in verification, so the only visible symptom was a
conformance test failing. What the fetch was AIMED at is the part worth reading
twice, and the first version of this fix got it wrong. WHATWG parsing is
forgiving about the slashes after a scheme, so "https:///some/path" does not
come back host-less the way it reads: it comes back with host `some` and path
`/path`. So `/some/path` was not merely accepted, it was an instruction to fetch
a hostname the sender never wrote.

That is why absoluteHttpUrl checks the SHAPE before the parse. A parse-only
version accepts `https:///no-host` and was caught by the suite's network
tripwire firing at https://no-host/, which is the check earning its keep against
a bug written while fixing the bug it is about.

Nothing reached a private address either way: privateHostBlocked still runs, and
a single-label host resolves to nothing. What it cost was a 202 the spec says
must be a 400, and an outbound request with a host nobody chose.

---------

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
oddharsh added a commit that referenced this pull request Sep 22, 2026
zenc square oriented a full-frame copy (311 MB of f32 for a 26 MP photo) and
then resampled it three times. It now hands the stored frame to halflight's new
resample_oriented, which reads it upright inside the horizontal pass and is
bitwise equal to orienting first. Pins halflight at 871487e (its
resample-oriented branch, to be merged with a merge commit as #1 was).

Same-file decode-to-resample floor, 6240x4160 JPEG, interleaved:
orientation 1 491 ms (unchanged), 3 568 -> 491, 6 582 -> 445, 8 592 -> 461.
Rotation now costs nothing, and the axis-swapping cases beat upright because
each tap streams one whole stored row.

zenc:bench against the tiled-orient branch: identical on all 24 outputs and
258 histograms; rotated sources 1.06-1.07x wall and 2.3-3.8% less CPU, each
at p <= 0.0012; upright sources not called.

images/histograms regenerated from this build (byte-identical) and re-locked,
since Cargo.lock and pixels.rs are its inputs.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
oddharsh added a commit that referenced this pull request Sep 25, 2026
)

/garage/dyno rendered per request, and a cold KV miss held the whole
document behind a fetch to raw.githubusercontent.com with a 3 s budget.
It also shipped at the edge's q4 brotli with no dcz delta, no ETag, and
the loose 'unsafe-inline' script policy, for a series that moves once a
night.

build.ts step 5b now bakes the shell, like /whoareyou (#924). The chart,
callout and recent-pulls table arrive from /garage/dyno/pulls.html
through lib/island.ts. The placeholder is the same renderer fed a
14-row model with no dates or numbers, so the swap adds no rows, and
the layout-shift lab reads the page clean on desktop and phone. Unlike
/whoareyou's values, the fragment is the same for every visitor, so it
takes the JSON endpoint's 5-minute cache instead of no-store.

Measured against the built Worker: shell 4,288 B at q11, 2,342 B as a
family dcz delta. First visit is even with today (8,158 B across two
requests against 8,060 B in one). A return visit revalidates the shell
to a 304 and pays only the fragment, about 3.9 KB.

Also:
- Accept: text/markdown on /garage/dyno returned HTML, because the
  exact route never negotiated. It serves the hand twin now, and the
  route oracle has a row for it.
- explorerDetails were passed as strings rather than {term, value}, so
  "N pulls recorded" never rendered. Dropped: a baked shell cannot know
  the count. dyno.ts goes from 3 type errors to 1.
- link-integrity's route scan had matched `new Map([...])` since the
  table became ROUTE_TABLE on 2026-08-19, so its routeKeys set was
  empty. It uses invariant #1's pattern and a 60-key floor now. It
  surfaced because /garage/dyno.json sits under the governed /garage
  prefix and this is the first built page to link it.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
oddharsh added a commit that referenced this pull request Oct 2, 2026
…1102)

A route lived in five hand-kept lists: ROUTE_TABLE and PREFIX in index.ts,
the 96-row run_worker_first allowlist, WORKERS_CACHEABLE_PATHS plus three
prefixes hardcoded in lib/cache.ts, and the GET-write list in lib/preview.ts.
build.ts and seven tests joined them by regex over index.ts, and that regex
matched nothing twice, once for five weeks.

src/worker/routes.ts is now the one list. It is data only: each route's path
or label and pattern, the run_worker_first rows it claims (a fold names a
wildcard and carries the reason it is safe), and whether it is cacheable or
writes. cloudflare.config.ts, Workers Cache, the preview and early-data guards,
build invariant #1 and the link resolver all derive from it by import.

index.ts binds handlers in maps typed Record<ExactPath | PrefixLabel,
RouteHandler>, so a route with no handler and a handler with no route are
compile errors. It stays data because index.ts and cal's Worker cannot load
outside workerd (gotcha 16). The seven island URLs moved into routes.ts too,
and the feature modules import them.

No behaviour change, measured against a baseline taken before the edit:
run_worker_first set-equal (96 rows), 104 exact paths and 20 prefix labels in
the same order, 28 cacheable paths, 6 GET-writes; the route oracle passes 325
rows with 0 hard failures; the suite passes 1148.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant