web-platform-patterns
Use before any web (vanilla HTML/CSS/JS, no build step, static hosting) UI / routing / data / offline / image work. The web umbrella skill — view-system architecture, URL-driven state + canonical shar
SKILL.md.claude/skills/web-platform-patterns/SKILL.md
GitHubWeb Platform Patterns
The umbrella skill for the framework-free web app — the patterns that shipped three production web apps (two as PWAs) on GitHub Pages with no build step and no backend. Most generic frontend skills cover components; this covers the architecture, data, offline, and deploy-reality layer that vanilla web apps live or die on.
The five web backstops
- The URL is the state. Every surface is a shareable URL; filters live in the query. If a view can't be reached by URL, it's not done.
- One router, one data module.
route()reads the location,showView(name)toggles<section hidden>; ALL external calls go throughjs/api.js. Neverfetchfrom view code. - Errors are user-visible, inline. Console-only errors don't exist for users. Empty states are explicit sentences, never blank space.
- No
position: fixedoverlays; modals are<dialog showModal>. (Safari compositor at the Dynamic Island; native focus trap + ESC.) - Mobile-first,
min-widthqueries only, test 375px before 1280px.
View system architecture
- Each view is a
<section>;showView(name)toggleshidden. Per-viewIntersectionObservers are disconnected on every view switch — leaked observers from a previous view fire on recycled sentinels and corrupt paging state. - Event delegation for dynamic content: one listener on the
container switching on
closest('[data-action]'), not per-element listeners that vanish on re-render. - Infinite scroll = an
IntersectionObserversentinel + page size (~60); always show the REAL total count, not the loaded count. - Debounce text search ~180 ms; mirror the query into the URL.
- A one-shot render replaces before it appends. A
renderedflag guarding an append-only render stacks the page on the first re-render (AW Home: 28 sections became 81, stale cards still showing). - Facets are computed against the OTHER facet's selection. Chips built from the whole result set offer combinations with zero rows. Assert "no offered chip yields zero rows", in a node test over the shipped function.
- One rule, one function. Two callers of one rule drift; name it
(
searchFacets(),nativeControlSet()) and call it from both. - View Transitions: every DOM update of the swap goes INSIDE the
startViewTransitioncallback (outside it is captured half-applied); skip the API underprefers-reduced-motionin JS too (a media query cannot cancel a snapshot already taken); test thatshowViewswaps identically with and without the API.
URL-driven state + the canonical-twin contract
- Hash routes for the SPA (
#/browse?type=x&decade=1930&sort=az) — every filtered view shareable. - Canonical share URLs are PATHS (
/item/{id}) — the exact URLs native apps emit. On a static host,404.htmlforwards paths into the hash router. Never change this shape once apps ship — installed apps depend on it. Legacy routes get forwarder entries, not breakage. - A merged id's page forwards; it never 404s. Generate
/item/<old>/as a zero-second meta refresh with a canonical link to the survivor, outside the sitemap; give same-title pages distinct<title>s (seecanonical-entity-identity). - Percent-encode slugs in routes — non-ASCII slugs are real.
- "Open in app" on Detail:
appname://scheme on Apple UAs, anintent://URL with the current page as fallback on Android.
State in the link (no backend)
User-made lists (playlists, collections) can travel inside the share
URL. Full rules + measurements: DEEP_LINKS.md "State in the link".
- Deflate + base64url (
CompressionStream('deflate-raw'), no library). AW measured 50 items at a median 1,368 chars (p95 1,542); ~2,000 is the practical ceiling (chat apps, email, QR). Cap at ~50, say so on screen, never truncate silently. Version-prefix the payload; keep an uncompressed variant for platforms without deflate. - Shape:
/list/#<blob>. Route in the PATH (Android intent filters cannot see a fragment); payload in the FRAGMENT (never reaches a server). /list/index.htmlis a REAL generated page with generic preview copy. GitHub Pages serves404.htmlwith HTTP 404 and crawlers skip previews on a 404. Keep the 404 forwarder only as a safety net; the deploy refuses to ship without the static page.- Old link shapes decode forever.
- Declare the AASA path only after the Apple app that handles it is LIVE (not approved); Android's intent filter ships in the APK, so it is safe in the same release.
- TVs share the same link as a QR code; prove it by decoding a photo of the screen back to the exact string.
Data fetch hygiene
- Bound every external fetch with
AbortSignal.timeout. A third-party metadata endpoint that "usually works" can hang 30s+ on specific items; an unbounded await freezes the view. Measured, not hypothetical. - Build-time data beats runtime APIs. If a value can be baked into your published data (a URL, a synopsis), the live API is a bounded FALLBACK only — never a runtime dependency.
- Never
fetch()hosts that don't send CORS (verify with GET, not HEAD);<img>/<video>elements are CORS-exempt — use them. Full matrix inshared-data-plane-contract. When fetching arbitrary third-party pages IS the feature (reader mode, link previews), use the bounded multi-proxy chain inweb-content-extraction. - Auth/token refresh on
visibilitychange, not timers — a background tab's timers are throttled; refresh when the user returns, before the first action. - Composing consumer surfaces from a live third-party search/scrape API bypasses every policy filter your pipeline bakes in — surfaces compose from YOUR published data only.
Images: the fallback chain that survives throttling
Image hosts throttle burst loads with transient 503s — a
one-shot onerror fallback leaves broken tiles until a manual
refresh:
- Try sources in order (primary → secondary host).
- Retry the whole chain up to 2× with jittered backoff.
- When nothing loads, render a local typographic placeholder card (title + accent bar) — never the host's generic gray placeholder, and never a broken-image icon.
- Know which IDs are NOT fetchable images (synthetic/derived ids) and skip straight to the placeholder — don't burn requests on guaranteed misses.
Cards: object-fit: cover at a fixed aspect; poster + two text
lines, nothing else.
Service worker discipline
- Shell cache-first, with a version string bumped on EVERY shell
change (
const CACHE = 'shell-v7') — a forgotten bump is the classic "deployed but users see the old app." fetch(url, {cache:'no-store'})does NOT bypass a service worker. A fetch that "proves the server is wrong" may be the worker answering. Clear the SW (DevTools > Application) before trusting it.- Data files (indexes, config JSON): network-first with last-good fallback.
- Never cache video/streams. Pass media requests through untouched.
- Skip admin/secondary tools' paths entirely (they stay live). A
root-scope SW caches EVERYTHING not excluded: AW's
/pulsedashboard served yesterday's reading beside today's timestamp. Guard with a test that regex-reads thestartsWithbypass clauses out of the shippedsw.js(test_sw_bypass.mjs). - Offline scope is honest: open + browse cached data; streaming playback offline is out of scope.
IndexedDB
- Version the schema; add stores only in
onupgradeneeded(schema v2 addsplaylists, v3 addschannels, …). - Progress/resume: persist every ~10 s + on close/end; resume only inside the 10s–95% window.
- localStorage is for tiny scalars and seen-flags (with a rolling window so it can't grow unbounded); structured user state lives in IndexedDB.
CSS gotchas that cost real iteration
An author
displaybeats the UA's[hidden] { display:none }..thing { display:flex }on ahidden-toggled element leaves it on screen (AW shipped a 19px empty bordered strip on every desktop page). Write.thing:not([hidden]) { display:flex }, never!important.Turning an element into
<a>inherits the barearule (accent color, underline). A link not shaped like a link needscolor:inherit; text-decoration:none, affordance on:hover/:focus-visible.A two-column breakpoint set too high makes the main thing grow as the window narrows. Just below it, the player takes the whole width, gets taller than it was at a wider window, and pushes the side column out of sight (humanshaped.org's exchange at 56rem). Put the breakpoint where the side column still fits at its minimum, and cap media by the window's height too:
width: min(100%, calc((100svh - 8rem) * 16 / 9)); aspect-ratio: 16 / 9.A grid with no column template sizes its column to min-content, so one unbreakable line (a URL in a
<pre>) widens the whole column past the screen. Writegrid-template-columns: minmax(0, 1fr).A phone-only layout keyed on width alone lands on narrow desktop windows (an upright 3:4 camera, taller than a 640px-wide window). Add
(pointer: coarse) and (orientation: portrait).A cross-origin iframe on a dark page gets an opaque white backdrop (Google's sign-in button) when its document is light. Give the iframe
color-scheme: light.Two-axis sticky (rail
left:0+ rulertop:0) requires rows set towidth: max-content— otherwise the sticky containing block is the visible width and the pinned rail detaches mid-strip.Scroll-snap rails need
scroll-paddingmatched to the container inset, or the first/last card snaps half-clipped.Continuously-scaling heroes: every dimension a
clamp()in container-query units (cqi) — scales with the container, no breakpoint jumps. Never hard-crop key art into a banner: ambient blurred layer behind a sharp, fully-visible poster.Carousel auto-advance pauses on hover/touch AND hidden tabs, and is disabled (along with ambient drift) under
prefers-reduced-motion. Dots are real<button>s synced from scroll position.System font stack when there's no build step — webfonts mean FOUT with no bundler to inline them.
The Safari body rule (
docs/platforms/web.md):100dvhflex-column body,main { flex:1; overflow-y:auto; min-height:0 }, noviewport-fit=cover.
Native media controls: never draw one twice
The browser's own <video controls> bar already has some controls, and
which ones differs per engine (AW measured, 2026-09): Safari (mac/iPad)
has PiP + a speed menu in the bar; Chrome's overflow has speed but PiP
is right-click only; Firefox has neither in the bar. One function
(nativeControlSet(video, doc)) decides which of OUR controls to hide;
test it read out of the shipped JS with an engine fixture per row. Do
not assume the matrix; open each browser's menu.
The same holds for a big play button over a paused video: iOS and
iPadOS Safari, Chrome on Android, and Firefox draw their own, while
desktop Chrome and Edge show nothing until the pointer moves onto the
video (humanshaped.org, 2026-10-16: an iPhone showed two stacked play
buttons). Decide it in one tested function from the user agent and
(pointer: coarse), and add the page's own button only where the
browser has none.
Screen recording on a phone: hand it to the phone's recorder
A page records a screen only through getDisplayMedia, and on phones that
is thin: Chrome on Android has never had it, and Safari on iPhone and
iPad gained it in iOS 27 (MDN browser-compat-data, October 2026). The
phone's own recorder (iOS Control Center's Screen Recording, Android's
Screen record tile) works on every phone, records any app rather than
only the browser, and saves to Photos or the phone's videos, which a
plain <input type="file" accept="video/*"> reaches. So on a phone,
"Screen" shows the steps for that recorder and a button to choose what
it saved; offer in-page sharing as well only where getDisplayMedia
exists. Detect it with typeof md.getDisplayMedia === 'function', never
a user-agent ban, and pick the steps by platform remembering that iPadOS
Safari calls itself a Mac (/Macintosh/ with maxTouchPoints > 1 is an
iPad). Ask for the microphone in the steps: both recorders leave it off
unless chosen, and a silent screen recording explains nothing.
(humanshaped.org exchanges, 2026-10-15.)
Porting deterministic logic to JS
When the same seeded logic must agree across platforms (schedules, rotations, shuffles): port hash/PRNG with BigInt (53-bit floats silently corrupt 64-bit mixers like SplitMix64); anchor "day" logic to LOCAL time when the experience is local (6 AM local ≠ 6 AM UTC); verify cross-platform agreement on a fixed seed before shipping.
Verification protocol (web changes)
One-command gate (
tools/test_web.sh): every node test, thennode --checkon each shipped script (a syntax error blanks the whole app), then a CSS brace-balance count (a truncatedcat >>append silently drops every later rule).Every
API.xthe JS calls must be exported byjs/api.js. Plain JS has no compiler: AW called a nonexistentAPI.summaryfor 115 versions and every web guest got no player. Grep call sites against thereturn {…}export block; include a control assert that proves the check can fail.Tests read the function out of the SHIPPED file (regex-extract,
new Function), so the test cannot drift from what runs.Read
PARITY.mdcolumns by header name.row.split("|")has a leading empty element; index math silently overwrote AW's macOS column four times.An insane instrument is not evidence. A zero-width browser window reports garbage layout while DOM counts stay valid; never fix CSS to satisfy it.
Execute the real JS in a Node DOM shim to verify logic — headless Chrome's
--virtual-time-budgetdistorts timers andAbortSignal, and--timeoutdumps the pre-hydration DOM.Pixel-measure layout from screenshots (PIL/sharp) when a layout claim matters.
Drag the window; don't sample two widths. Load each page at a few widths, then resize one open copy through every width from 1600px to 320px and back. Fail on sideways scroll, anything past the right edge, or a box whose size after the resize differs from a fresh load (layout fixed by script at load). Look at the screenshots at the in-between widths (640 to 900px) too, where layouts go wrong without overflowing. Drive headless Chrome over CDP with
Emulation.setDeviceMetricsOverride, which goes below the 500px window floor. Worked example: humanshaped.org'stools/responsive/check.mjs, which has no dependencies.The Pages CDN caches ~600 s — verify deploys with cache-busted URLs (
?v=...), or you're testing the previous deploy.Local server:
python3 -m http.server 8080. If it works locally but not deployed, suspect (in order): SW cache version, CDN cache, Jekyll dropping dot-paths, CORS.