progressive-enhancement
Use when a page must paint before its JavaScript runs.
SKILL.mdDocumentation/Skill/Progressive-Enhancement/SKILL.md
GitHubProgressive Enhancement
The site is a static archive behind a service worker, so a reader with a cold cache, a blocked script or a browser that never runs one must still see the archive. Every surface therefore has two jobs: paint itself from the build, then hydrate for interaction. A surface that renders its content only after hydration has one job, and it is the wrong one.
The rule is one sentence: the first paint is the server's, and JavaScript only makes it interactive.
The first paint is the server's
A page reads its content at build time from the content collections and renders it into HTML. That is what the service worker caches and what a script-free reader sees.
- Props carry the data; the island never fetches it.
Source/pages/Workspace/index.astroreads the collections and handsStages,ColumnsandTilesto the islands as plain objects. What a person is doing right now - which stage is selected, which record is open, which tiles were published - lives inSource/Script/Workspace/Store.ts, which every island on the page reads. An island that fetched its own content would render an empty frame first. client:visibleoverclient:load. Aclient:loadisland competes with the first paint for the main thread;client:visiblelets the canvas paint and boots when the island is on screen. The workspace fills the viewport, so it hydrates immediately in practice - the directive only changes who wins the first frame.client:onlynever sits on a content surface. It renders nothing at build time, so the page ships an empty box and a script fills it. The audit fails on it outright.- One visible
<h1>per page, on every surface. It is the page's own name and the only heading a screen reader can outline the document from, so it is the server's to draw. The workspace's is the drawer's title (My Files): the composition draws no heading above the frame, and the title keeps its 13px semibold face because the level is the document's, not a type step. - Interaction may be absent without scripts. A button that does nothing is acceptable; a row, a tile or a heading that is missing is not. That distinction is the whole scope of this skill.
A visible island needs a rendered frame
client:visible observes the island's children with an IntersectionObserver, and the observation
only delivers while the page is being rendered. Two conditions break it, both of which look like a
broken component rather than a broken environment:
- A background tab is never rendered. A target created with
background: true- the polite way to open a tab in someone's own browser - receives no rendering updates, so the observer stays silent and the island never hydrates. Bring the tab to the front (Page.bringToFront) before measuring for this reason. A real reader's tab is in front, so this is an audit-environment rule, not a product defect. - An observer target with no box never intersects. Astro styles
astro-islandasdisplay: contents, which is why the directive observes children rather than the island itself. A child that is itselfdisplay: contents,display: noneor zero-height keeps the island dormant.
A client:visible island that stays static is diagnosed by reading the ssr attribute: the runtime
removes it on hydration, so document.querySelector("astro-island[ssr]") still matching means the
directive never fired. Report that as js=static and say the parity comparison measured two static
renders - a parity that holds between two static pages proves nothing about hydration.
The audit
It is two passes over every built route, and it is run by hand: launch a headless browser on a
throwaway profile, point it at a copy of Target/ served on loopback, and load each route twice
over CDP - once with Emulation.setScriptExecutionDisabled, once normally.
- The script-free pass is the measurement.
Runtime.evaluatestill answers while page script execution is disabled - the switch stops the document's own scripts, not the debugger's - so the pass reports what the first paint actually shows, with layout. "Visible" is a layout fact: a node insidedisplay: noneor a zero-height wrapper is present in the markup and absent from the page. - Parity is the rule that catches the defect. For every selector a route profile names, the
script-free render must show at least as much as the hydrated one. Content that appears only in
the second pass fails with
content waits for hydration. - The read is a snapshot, not the build directory.
astro buildemptiesTarget/before it writes, so a run that starts mid-build sees an empty tree and reports every route as serving no document - a total failure that looks exactly like the defect this exists to catch. The audit waits for the newest mtime underTarget/to be older than 1.5s, copies the tree aside, checks that every copied document is complete and that the route set matches the live one, and serves the copy. A build that starts after the copy cannot take the pages away from the browser. - Every route gets the generic contract, profile or not. A non-empty
<title>, at least 40 characters of prose under<main>, exactly one visible<h1>, no island unless the profile allows one, noclient:onlyisland, and server-rendered content inside every island. A route with no profile is reported asgeneric, so a new page cannot pass by being unknown. - A failing route is named, never dropped. Write the report as one line per route, pass or fail. A route missing from the report is indistinguishable from a route that passed, and the second is the claim the audit exists to make.
The browser binary is configuration, never a constant: the first installed candidate will do - Chrome, Chromium, Brave, a Playwright cache - so a clone on another machine needs no edit.
One store, two islands
A page that draws a rail and a body mounts two islands, and both read
Source/Script/Workspace/Store.ts. A signal inside each component would make them two workspaces
that happen to look alike: press a band in the rail and the seam beside it would still name the old
stage, and neither would be wrong about a value the other never had.
- State lives in the store; data arrives as props. The store owns the selected stage, the live column, the view, the open record, the published tile set and the session slot. The stages, the columns and the tiles stay with the page, which derives them at build time; a second copy in the store would be a second source of truth.
Adoptis what makes one store safe to mount twice. The first island to render seeds a field and a later island fills only what is still empty, so the order two islands hydrate in cannot change what is on screen - and hydration never resets a server-rendered view to empty.- One instance per page, one per render on the server. The module is evaluated once in the
browser, so every island that imports it reads the same value. A build renders every route in one
process, so
store()hands each render its own instance; a singleton there would carry one page's selection into the next page's HTML. - A session read happens on mount, never during render. The server's HTML and the first client render are then the same anonymous slot, so hydration has nothing to disagree with. A failed read keeps that slot, and the session layer states the failure once.
The check for this is three passes of the built workspace page, each in its own tab - anonymous, a session element that is not JSON, and a readable session - read per pass:
- two islands on the page, both hydrated (a pass that never hydrates reports it instead of measuring a static render);
- a press on a rail band that moves the seam beside it, which is the cross-island proof: the rail island dispatches and the body island renders, so a store that is not shared leaves the seam naming the stage it opened on;
- a seam that is
lockedand names no stage while there is no identity, and offers the verb once the session names one; - exactly one warning for a session element that cannot be read, and none for a page that embeds none;
- a publish that lands one more filled tile once an identity exists - skipped, not failed, when the archive holds no drawer rows, because a fresh clone is a normal state.
The session element is spliced into the served HTML rather than injected in the browser: it has to be in the document before any island script runs, or the read races hydration. The press check is falsifiable by construction - it requires the seam to name a stage it did not name before the press.
Adding a route
A new page is audited the moment it builds, and its expectations are written down before the page is read: the route, how many islands it may mount, a minimum visible count per selector it must show, and any group of alternatives where one has to hold.
The workspace's profile is the shape: two islands; at least one .rail, one .drawer and four
section.drawer-column; and, as the one alternative, at least one main ol or one main p. The
alternative is how an empty archive passes: the page shows rows when there is content and a note
when there is none, and both are complete first paints. The island count caps hydration boundaries
and defaults to none, so a page that grows an island without a reason fails. A dynamic route names
its variable segment with :, so /Bookmark/:Id/ covers every built post.
A profile names what the design promises, never the markup a page happens to use:
- Elements with a meaning -
main section,main ol,main article,main a[href]. - The parts the composition makes contractual -
.rail,.drawer,.drawer-column,.swimlane,.spectrometer__cell,.status__value,.security__mark,.provenance__band. These are the namesDocumentation/Reference/Design-Composition.mdgives its own parts. - The empty state a page renders when the archive holds nothing -
.drawer__empty.
A selector is a claim about the DOM, never about the source. The audit runs
document.querySelector inside the page, so it is immune to the minifier - but it is not immune to
a redesign, and the two look the same when a route fails. A profile pinned to .roster__row or
article.sheet fails the day the surface is redrawn, and a redraw is not a no-script regression:
when six of nine routes fail while each still renders its text with zero islands, the content
contract held and the selector profile went stale. Assert what the design guarantees, and let a
per-route profile name only classes the composition spec makes contractual. A grep over Target/ is
the weaker check in both directions: @playform/compress strips attribute quotes, so class="foo"
matches nothing on a page full of class=foo. The heading is the clearest case: the rules name
h1, never h1.board__title, because the class belongs to the component that draws the page while
"one visible heading" is the contract a screen reader depends on.
Expectations are structural selectors, not counts of records: a count that depends on the archive would make the audit fail on a fresh clone, which is a normal state.
Proving the audit bites
An audit that cannot fail is decoration. Build two pages that satisfy the structural rules and break them at render time - one draws a rail band from a script and carries an island with no server-rendered content, the other has lost its heading and its prose - and require the audit to fail on both before trusting a clean run.
Both fixtures must fail, and on the rules they were built to break: hydration parity, an empty island and a lost heading. A fixture that passes means a rule stopped catching the defect it exists for, and the fixture's report is where the gap shows. Build the fixtures from the current rules rather than keeping them beside them: a fixture archived next to the rules drifts the moment a rule changes, and then the control tests the fixture instead of the audit. Run the negative control after changing the rules.
A build that is still writing is not a verdict: the audit waits for the tree to settle and then
measures a copy, so a run that overlaps astro build reports what the finished build renders. A
page that genuinely lost its content fails the generic contract - the <main> text floor and the
one-visible-heading rule - before any profile is consulted, so the two failures cannot be confused
for one another.
Claim → test
| Claim | Test |
|---|---|
| Every built route renders without scripts | the two-pass audit reports one PASS line per route |
| The workspace island hydrates | the audit's /Workspace/ line ends in js=hydrated |
| A route's content is not hydration-only | the route line carries no content waits for hydration failure |
| Every route shows one visible heading | every PASS line carries h1=1 |
| A route mounts an island only where expected | the /Workspace/ line carries islands=2 and every other route islands=0, except the post's islands=1 |
| The audit catches the defect it exists for | both negative-control fixtures fail on hydration parity, an empty island and a lost heading |
| The audit reads a settled build | run pnpm run build in the background and audit while it runs: one PASS line per route |
| The island boots after the canvas paints | the built HTML carries <astro-island ... client=visible>; grep -c 'client=load' Target/Workspace/index.html is 0 |
| Two islands read one store | the readable-session pass reports the pressed stage in the seam |
| A failed session read is one warning | the audit's malformed pass reports exactly one warning and state=static |
| An empty session slot hydrates cleanly | the audit's anonymous pass reports no error, state=static and a locked seam |