Skip to content

develop - #40

Merged
LibbyUX merged 84 commits into
mainfrom
develop
Sep 6, 2026
Merged

LibbyUX merged 84 commits into
mainfrom
develop

Conversation

@LibbyUX

@LibbyUX LibbyUX commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

LibbyUX and others added 30 commits September 4, 2026 10:32
…out manifest

Both checkers hardcoded the current file layout: check-maintained-pages.mjs
embedded ~40 literal page and asset paths, and check-story6.mjs pinned
story6.html, story/6 and story/6/img throughout. Relocating a story meant
editing nearly every assertion.

Replace those literals with a single `stories` manifest in
check-maintained-pages.mjs (page, dir, cssDir, jsDir, narrative) and a
four-value layout block in check-story6.mjs. Every page path, story-owned
asset path and asserted reference string is now derived:

- relativeRef()/ref() resolve shared and story-owned targets from each page's
  own directory, so the same code covers root-level pages and story/N/ pages.
- Per-story special cases key off manifest flags (`narrative`) and story
  numbers instead of literal filenames.
- Story 6's image-reference scan resolves candidates against the page
  directory and filters by the resolved image directory, replacing a
  hardcoded "story/6/img/" prefix test.
- Add readSource() so a stale manifest entry reports which contract lost its
  file instead of crashing on ENOENT.

Behavior-preserving: all three checkers produce output byte-identical to the
pre-refactor baseline. Verified with mutation tests that the derived
assertions still fail when the manifest, token URL or narrative flag is wrong.

Prerequisite for migrating Stories 1-6 to story/N/index.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
story/7/README.md has declared story/<number>/index.html as the layout for
new stories, but nothing followed it: Stories 1-5 kept root storyN.html
entry points with flat assets under stories/storyN/, and Story 6 sat in
between - assets under story/6/ but its HTML still at the repository root.
Three conventions, none of them the documented one.

Move all six onto the declared convention:

  story/N/index.html
          css/  js/  images/  video/  end-matter.json

- stories/storyN/*.css -> story/N/css/, *.js -> story/N/js/,
  images/ -> images/, videos/ -> video/, config/ -> config/ (Story 4).
- Story 6: img/ -> images/ and story6.js -> js/story6.js, so all six stories
  now share one shape. Its module imports become siblings ('./animations.js').
- Root storyN.html become redirect stubs on the pattern already used by
  story0.html, so every previously shared URL keeps working.

Reference rewrites, by resolution context:
- Page-relative: shared/* -> ../../shared/*, sibling stories -> ../M/,
  landing -> ../../index.html.
- CSS-relative: url('images/...') gained a ../ hop, since stylesheets moved
  one level deeper into css/.
- Document-relative at runtime: Story 3's rui-animations.js backgroundImage
  URLs and Story 2's quiz.js swaps resolve against the page, not the script,
  so they become images/...; the three dramatic.swf.mp3 Audio() paths become
  ../../shared/....

Also extract the redirect stubs' duplicated 43-line inline <style> into
shared/css/redirect-stub.css and rebuild story0.html, VisualizingCells.html
and organExample.html from the same template, cutting each stub from ~90
lines to 38.

Verification: check-local-links resolves 588 references across 58 files with
no new breakage; check-maintained-pages and check-story6 pass against the
updated manifests; all 28 JS files parse; and every page plus all 340
subresources returns 200 over a local HTTP server, including each redirect
stub and both prototype trees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ared/js

Stories 2-5 each carried their own copy of the same GSAP setup. Comparing the
blocks byte-for-byte (modulo indentation) showed exact duplicates:

  intro fade + typewriter   stories 2, 3, 5
  .container2 cross-fade    stories 2, 3, 5
  .scrollbubble fade        stories 2, 3
  .container cross-fade     stories 2, 3, 5
  .overlaybubbles fade      stories 2, 3, 5
  .talkbubble cross-fade    stories 2, 3, 4, 5

Move all six into shared/js/narrative-timeline.js, exposed as
window.hraNarrativeTimeline and loaded as a classic script after gsap and
ScrollTrigger, matching how the story animation files already load. Each story
keeps only its own ScrollTrigger scene list, Story 2 keeps its MotionPath
tractor section, and Story 5 keeps its flowing-layout fallback.

Selectors and timings are parameters with defaults matching current usage, so a
future story can vary one without forking the timeline again.

One latent oddity is preserved deliberately: the .talkbubble config declared
`scrub` twice (0.5, then true), so the later value always won. Only
`scrub: true` is kept, in the position the first declaration occupied, so the
resulting config object is identical key-for-key.

1073 lines across four files become 582 story-specific lines plus 248 shared.

Verified by recording every gsap/ScrollTrigger call each file makes against a
stub and diffing the sequences: all four stories produce byte-identical call
logs before and after (57, 49, 15 and 55 calls respectively). All three
repository checkers pass.

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

landing/js/main.js was a copy of shared/js/main.js: the same four imports and
the same four calls in the same order, differing only in import depth and one
comment. index.html now loads shared/js/main.js directly and the copy is gone,
leaving landing/ as pure CSS and assets.

Story 6's reveals.js contained the same reveal scaffolding twice - an enabling
body class, an IntersectionObserver built only while motion is allowed, and a
live `change` subscription. Both now call one createScrollReveal() helper,
parameterized by what to observe, which body class to set, and whether the
observer stops after the first intersection.

Verified equivalent by replaying both implementations against a DOM stub across
six scenarios (reduced motion on/off, crossed with idle, scrolling, and
toggling the preference in both directions): identical event sequences in all
six.

Note on two items this pass did not do, because the premise was wrong:

- story/4/js/motion.js is not a duplicate of shared/js/narrative-motion.js.
  It carries Story 4's SMIL pausing and its ambient-animation toggle, and its
  hraStory4MotionEnabled flag and story4-* classes are read by app.js,
  animations.js, diagram-overview.js, diagram-detail.js, accessibility.css and
  the page itself. Deleting it would break all of them. The genuinely shared
  part is only the ~35-line preference gate.
- story/1/js/reveals.js and story/6/js/reveals.js implement the same idiom with
  different semantics (dataset.revealState with group reveals versus is-visible
  with live re-arming). Merging them needs more options than call sites.

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

Stories 2-5 paired GSAP core 3.11.5 with ScrollTrigger 3.7.1 (and Story 2 with
MotionPathPlugin 3.7.1). GSAP requires core and plugins to match, so four of
six stories were running an unsupported combination. Bump the plugins to
3.11.5 so each page loads one matched version; the core is untouched. Story 6
stays on its matched 3.15.0 pair.

shared/js/narrative-motion.js and story/4/js/motion.js also duplicated the same
motion gate: identical media queries, the same enabled/flowing class pair
alongside a global flag, and the same GSAP teardown. Extract that into
shared/js/motion-preferences.js (QUERY, setMotionState, haltAnimation,
stabilizeScrollGeometry) and have both consume it.

Each caller keeps what is genuinely its own: narrative media handling for the
shared gate; SMIL pausing, the ambient-animation toggle, and the story4-*
classes that app.js, animations.js, diagram-overview.js, diagram-detail.js and
accessibility.css read for Story 4.

Story 4 gains the coarse-pointer scroll stabilization it never had, so mobile
browser-chrome collapse no longer forces a mid-scroll ScrollTrigger refresh -
the same fix Stories 2, 3 and 5 already carried.

The coarse-pointer contract moved with the code, so its checker assertions now
target motion-preferences.js, with narrative-motion.js and Story 4 each
asserted to still call stabilizeScrollGeometry.

Verified by replaying both implementations against a stub across six
preference combinations (default, reduced motion, reduced transparency, forced
colors, short viewport, coarse pointer), each through initial state,
DOMContentLoaded, and flipping every preference: narrative-motion is identical
in all six; Story 4 is identical in five, differing only in the coarse-pointer
case by exactly the two added calls that constitute the inherited fix.

narrative-motion.js 127 -> 80 lines; story/4/js/motion.js 101 -> 97; 110 shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same HRA palette was encoded independently in three places:
landing/css/tokens.css (--hra-* roles, 27 hex values), shared/css/tokens.css
(--site-chrome-* roles, the same values again), and story/1/css/story1.css,
which hand-copied landing's role names and values into a #one block on a page
that never loads landing's stylesheet.

Every colour value now lives in shared/css/tokens.css, declared three times and
only three times:

  --hra-light-*   the fixed light value, never changes with the theme
  --hra-dark-*    the fixed dark value, never changes with the theme
  --hra-*         the theme-reactive role, aliasing one of the two above

The fixed sets exist because chrome pinned to one appearance - a dark footer on
a light page - cannot consume a theme-reactive role. --site-chrome-* now
references --hra-light-* in its light block and --hra-dark-* in both dark
blocks, with selectors unchanged.

Four values that had no role (#6f7b8c, #9392a3, #e2e0f8, #3d3d50) were promoted
to --hra-outline-strong, --hra-outline-muted, --hra-acknowledgments and
--hra-on-acknowledgments so nothing is left stranded as a literal.

  landing/css/tokens.css   177 -> 91 lines, 0 hex literals
  story/1/css/story1.css   0 hex literals; its shadowing --hra-* block is gone,
                           the --color-* aliases now pin --hra-light-* directly
  story/6/css/theme.css    62 -> 10 hex literals; the 10 remaining are Story 6's
                           own values with no shared equivalent

color-scheme is deliberately NOT declared at :root. Stories 1-5 have no theme
control yet, so declaring it would give them dark form controls and scrollbars
under a dark system preference while their content stayed light.

Verification, two independent methods:

- Every one of the 57 --site-chrome-* aliases was checked to resolve to the
  exact literal it replaced before the file was written; the build fails
  otherwise.
- Screenshot diff across 210 captures (7 pages x 3 viewports x light/dark x 5
  scroll depths): no differences outside a known-unstable set, established by
  comparing repeated runs of identical code.
- Computed-style diff in-browser across 14 page/theme contexts and 13,916
  resolved property values: the only changes are hex letter-case (#3D3D50 ->
  #3d3d50) and --hra-* on Story 1, which no rule reads - every consumer there
  was repointed at --hra-light-*. Confirmed no bare var(--hra-<role>) reads
  exist outside Story 6, which defines all 39 of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Story 4 set `font-family: "Inter", serif` on #four and pulled Inter from Google
Fonts, making it the only story not using the self-hosted house faces. Body
copy now uses var(--font-body) (Nunito Sans) and headings a new
#four :where(h1..h6) rule using var(--font-heading) (Metropolis), matching how
Story 1 applies the same pair.

Story 4 also loaded Cutive from Google Fonts. Neither Cutive nor Inter is
referenced anywhere on the page once the CSS switches over - its inline SVG
text uses Helvetica and Post - so both links are removed. That takes two
render-blocking third-party requests off the page and leaves Story 2 as the
only page with a Google Fonts dependency, where it is load-bearing: Story 2's
inline SVG genuinely sets font-family="Inter-Regular, Inter".

Correction to an earlier claim: this Inter reference was NOT silently falling
back to serif. Checking the pre-change build in a browser shows #four computing
to "Inter, serif" with Inter 400 and 700 actually loaded, because the Google
Fonts link supplied them. This is therefore a deliberate typeface change, not
a bug fix - which is what was asked for, but it does change how Story 4 looks.

Verified: the screenshot diff moves 30 captures, every one of them Story 4, and
nothing on any other page. Light and dark deltas are identical, as expected for
a page with no dark theme yet. Computed styles confirm the new faces resolve
rather than falling back - #four gets Nunito Sans Variable and h1 gets
Metropolis, both reported loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Story 1 was pinned light: no theme controls, navigation-only.js instead of the
full chrome runtime, and site-chrome--light/--dark modifiers holding the menu
light and the footer dark regardless of preference.

It now carries the same appearance and high-contrast controls as index.html and
Story 6, loads shared/js/main.js, and drops both modifiers so its chrome
follows the theme like every other themed page.

Supporting change: the --color-* component roles that Story 1 needed lived in
landing/css/tokens.css, which Story 1 never loads - which is why it had
hand-copied them in the first place. They move to a new
shared/css/component-roles.css, with their dark overrides, so any page can
consume them. landing/css/tokens.css drops to 50 lines and now owns only the
hero artwork and landing-specific layout. Story 1's #one block no longer
declares a single colour or radius role.

Only one piece of Story 1 needed a genuine dark counterpart: the hero mesh is
tinted artwork rather than a role, so it gets a dark variant mirroring the
landing hero's.

Fixed while testing: the motion-control button set white text on
var(--color-accent-text). That is fine in light (#95001e) but resolves to
#ffb3b3 in dark, giving white-on-pink at 1.70:1 - well under WCAG AA. It now
uses the --color-action / --color-on-action pair, which is identical in light
and correct in dark. Automated contrast sampling of every text node on the page
reports no AA failures in either theme.

Visual verification: in light, the only change is the footer, which moves from
the forced-dark #101113 to the site-standard #201e3d navy that index.html and
Story 6 already use - the modifier removal working as intended, and 799 pixels
across the whole page. Dark is entirely new. No other page moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stories 2-5 join Story 1, index.html and Story 6: the same appearance and
high-contrast controls, shared/js/main.js instead of navigation-only.js, and no
site-chrome--light/--dark modifiers. All seven pages now report identical
chrome classes and an identical footer colour.

The themeable surface turned out to be far smaller than the raw count of baked
SVG fills suggested. Those fills are illustration artwork drawn against
deliberately black, full-bleed scenes, and both stay fixed in either theme -
inverting them would invert the artwork's own contrast. What actually follows
the theme is about twenty tokens:

- shared/css/narrative-foundation.css gains dark values for the reading
  surface, its text, and the intro text-shadow, which themes Stories 2, 3 and 5
  at once. The four inverse/scene roles are documented as intentionally fixed.
- Stories 2-5 gain dark blocks for their own page surface, quiz and choice
  surfaces, controls, links and focus colours, all sourced from --hra-dark-*.
- Story 4's accessibility.css had bare #ffffff/#202124 literals for its motion
  toggle; these become --story4-control-surface / --story4-control-text with
  dark values.

Two real contrast failures found and fixed, both the same shape - a foreground
that stayed light while its background flipped:

- Story 2's quiz button drew --story2-color-on-scene (white) over
  --story2-color-accent, which resolves to #ffb3b3 in dark: 1.70:1. It now has
  its own --story2-color-on-accent, white in light and --hra-dark-on-primary in
  dark, keeping --story2-color-on-scene for text over the black scene.
- Story 2's --resource-* link colours are declared on .story-quiz, so the dark
  override on the body never reached them and links stayed #b20a2f on #1f2023:
  2.31:1. The override is rescoped to .story-quiz.

Automated contrast sampling of every text node on Stories 2-5 now reports no
WCAG AA failures in either theme. One pre-existing light-mode reading remains
on Stories 2 and 3 (1.18:1) where the intro typewriter sits over background
imagery the sampler cannot see; light mode is pixel-identical, so it is not
introduced here.

Visual verification: in light, the only movement anywhere is the footer on
Stories 1 and 4 changing from the forced-dark #101113 to the site-standard
#201e3d, about 800 pixels per capture. Dark is entirely new.

Known limitation: in dark, the character scenes in Stories 2, 3 and 5 still
show the illustrations' own white canvases, because the artwork bakes them in.
They render identically in light, so nothing regressed, but tinting them would
require re-authoring the SVGs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu, footer and appearance controls are copied by hand into all seven
maintained pages, and they had already drifted: Stories 1-5 carried
site-chrome--light/--dark modifiers the other pages did not, and Story 6 closed
its back-to-top <path> differently. Since we are not adding a build step, the
copies stay - but drift becomes impossible.

Each block is now stored once under shared/fixtures/ and asserted per page by
check-maintained-pages.mjs. {{root}} in a fixture stands for the page's path
back to the repository root, so one fixture covers index.html and story/N/.

Comparison ignores whitespace between and inside tags, and the aria-current
marker on the active navigation item - the formatter re-wraps the surrounding
tags whenever that marker moves. Attribute values, element order and text are
compared exactly.

Two supporting changes:
- Story 6's back-to-top <path/> is closed like every other page's.
- Story menus referenced siblings as ../N/ while index.html used story/N/. They
  now share the ../../story/N/ form, which resolves identically, so the block
  is one template rather than two. The prev/next widget keeps its own links.
- check-local-links.mjs skips shared/fixtures, whose {{root}} placeholders are
  resolved by the page checker, not a browser.

Verified by mutation: re-adding a modifier class to a footer, editing a nav
link's text, deleting the high-contrast fieldset, changing a logo path, and
dropping an aria-label are each caught, and named to the right page and block.
Screenshots are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three sets of duplicate binaries:

- The body-intro artwork existed four times: 7.4 MB of PNG in
  shared/assets/images used by Stories 2, 3 and 5, plus 739 KB of WebP in
  story/6/images that Story 6 used alone. Same artwork, same dimensions
  (2264x3359 and 1509x2239). The WebP files move to shared/assets/images and
  all four stories now reference them; the PNGs are gone.

  Rendering the two encodings at identical size and diffing shows 0 and 1
  differing pixels out of 1,080,000, and the full screenshot sweep shows at
  most 1 pixel across 36 affected captures. No <picture> fallback: .fadeimage
  is a flex container whose children are the images themselves, so wrapping
  them would introduce a new flex child and risk the layout, and Story 6
  already shipped these exact files as bare WebP.

- prototypes/organ-example/images/Organ-body.png and
  prototypes/visualizing-cells/images/3-l-2.png were byte-identical, as were
  Organ-kidney.png and 3-l-3.png. Both pairs now live once under
  prototypes/shared/images/, beside the chrome.css and typography.css those
  prototypes already share.

Together that removes 7.8 MB of duplicated source assets from the working tree.

Also adds package.json - no dependencies, no build step, no framework. It
exists so the checks that were previously tribal knowledge have names:
`npm run check` runs all three, and check:links carries the --allow-known flag
that the link checker needs to exit zero against its documented baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documentation was updated only after all code changes landed, so it describes
one state rather than a sequence. 113 stale references across eight files:

- README.md: the experiences table and repository tree now show story/<number>/
  and the redirect stubs; the token section describes the three-tier colour
  layer and shared component roles; the chrome section records that all seven
  pages carry appearance and High contrast controls and are asserted against
  shared/fixtures/; validation is `npm run check`.
- AGENTS.md: paths rewritten to story/<number>/{css,js,images,video}; the
  "preserve root entry points until migrated" rule replaced with the redirect
  stub contract; site-chrome--light/--dark now documented as forbidden on
  maintained pages, since the fixtures omit them; validation renumbered.
  It also now states that README.md owns architecture and defers to it, rather
  than restating it - the two documents had drifted apart while describing the
  same thing.
- docs/architecture.md: the "Intended organization" tree was missing story/6/
  and story6.html entirely; it is replaced with the actual layout, including
  shared/fixtures/ and the new shared modules. The ownership table, the High
  contrast paragraph, and the site-chrome--light paragraph are corrected - that
  modifier now applies only to the prototypes.
- docs/asset-map.md, shared/README.md: story paths, story/6/img -> images, and
  the removed landing/js/main.js.
- prototypes/README.md: it cited `shared/chrome.css` and
  `shared/typography.css`, neither of which has ever existed at those paths;
  both are under prototypes/shared/.
- story/6/AGENTS.md: the entry point is no longer at the repository root, img/
  is images/, story6.js is js/story6.js, and the body-intro artwork it tells
  maintainers to re-check now lives in shared/assets/images/.
- story/7/README.md: reframed from "the convention for new work" to the
  convention every story already follows, with a section on the shared
  foundations to check before adding anything, and a note that registering a
  new story means one entry in the check-maintained-pages manifest.

Verified: no stale path patterns remain, all 329 documented file paths that
name a real file resolve, every internal markdown link resolves, and all three
checkers pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four problems in the splash header, three of them mine.

Dark mode inverted the gradient. --story4-blue (#201e3d) and
--story4-blue-muted (#4b4b5e) are inverse surfaces carrying white text, but the
theming pass mapped them to light roles, so the gradient went pale while the
text stayed white - white on #e3dfff, effectively illegible. They are renamed
--story4-inverse-surface / --story4-inverse-surface-muted to match the existing
--story4-inverse-background pair, their dark overrides are removed, and a
comment records the rule that was missing: a surface carrying fixed-colour text
must not follow the theme. Both tokens are used only in this gradient, so
nothing else is affected.

The splash was sized from viewport WIDTH. `padding: 20% 2.5rem` resolves
percentage padding against the container's width, which is why the section
behaved like a fixed aspect ratio. It now uses flex centring with
`min-height: 100vh`, upgraded to 100svh where supported - the small-viewport
unit, so collapsing mobile browser chrome cannot reflow the splash mid-scroll.
`box-sizing: border-box` keeps the padding inside that height. particles.js
already re-reads offsetWidth/offsetHeight on resize, so the canvas follows with
no script change. The flowing-mode `min-height: 32rem` override is kept: with
particles hidden and the scene summary shown, height should follow content.

The title and subtitle could not be selected. #particles-js is absolutely
positioned over the whole header and painted above the text, with no
pointer-events rule, so it swallowed every pointer interaction. It is
decorative and aria-hidden, so it now sets `pointer-events: none`, and the text
is raised to z-index 1 - which also lifts it out from under a 40%-opacity
canvas. The ambient toggle keeps z-index 2 and stays clickable.

The subtitle used the heading face. It was an <h2>, so it inherited Metropolis.
It becomes <p class="subtitle">, matching Story 6's splash, and is set from the
shared --type-title-large-* scale: Nunito Sans 500 at 1.375rem/1.75rem, the
same values Story 6 uses. The static page now has one h1 and no h2; the shared
end-matter runtime still contributes a real "Resources" section heading.

Also fixed, pre-existing and the same class of bug: the flowing-mode scene
summary sits inside the navy header but was painted with --story4-text, giving
dark text on navy in light mode. It now uses --story4-inverse-text.

Verified: measured against the real rendered backdrop rather than computed
background colour, all three splash text nodes reach 13.25:1 or better in both
appearances. Drag-selection returns the full title and subtitle text in both
appearances, and the ambient toggle still responds. The header measures exactly
900px in a 900px viewport with the canvas matching. Screenshot sweep moves 30
captures, every one of them Story 4. All three repository checkers pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The splash was correct but plain: a centred h1 at the browser's default 2em
floating in the middle of a navy gradient. It now uses the kicker / title / deck
pattern Story 1 established in `#one .story1-heading`, arranged in the
bottom-left composition Story 6 uses for its own splash.

Story 1's typography rather than Story 6's translucent card: a card suits a
photographic backdrop, but Story 4's backdrop is an animated particle field, and
a panel would cover the one thing that makes the splash interesting.

  KNOWLEDGE GRAPHS          kicker, uppercase, 0.12em, accent colour
  Data Detangle             Metropolis, clamp(2.4375rem, 4vw, 3.75rem)
  ----------------------    hairline rule
  Discover how knowledge    deck, constrained to a 32rem measure
  graphs organize ...

Markup adds `.story4-title-block` as the single grid item and a
`<p class="story4-kicker">`. The kicker is a sibling of the h1, not a span
inside it: Story 1 nests its kicker because "What is a" + "Human Reference
Atlas?" is one sentence, whereas nesting here would make the heading's
accessible name "Knowledge graphs Data Detangle". Every type value comes from
the shared scale in shared/css/fonts.css.

Contrast drove two decisions, both measured against the real rendered backdrop
with the gradient frozen on its lightest phase and the particle field drawn:

- The accent is --hra-dark-primary, not HRA red. Red is about 3.2:1 on #201e3d
  and would fail AA for the kicker's small bold text.
- A reading scrim was needed. The gradient's muted stop plus a particle dot
  behind a glyph measured 2.89:1 for the kicker - the particles brighten local
  patches well past the stop colour, which sampling a flat background colour
  never reveals. Story 6 solves the same problem the same way, with a scrim over
  its splash photo. Ours darkens the text column and fades out by 78%, so the
  particle field still reads across the rest of the splash.

With the scrim, worst-case contrast at that phase is 6.46:1 for the kicker
(needs 4.5), 10.98:1 for the title and 11.98:1 for the deck (need 3). The
kicker clears AA as ordinary small text rather than relying on the large-text
exemption.

Responsive ladder follows Story 6's: block centres vertically and widens at
601-900px, stacks full-width at 600px and below with a vertical scrim, and
tightens on short landscape viewports. Verified nothing overflows at 1440, 768,
390 or 320 wide, with no horizontal scroll.

Preference modes: reduced motion keeps the stacked fallback, with the title
block and the now-visible scene summary in source order; reduced transparency
drops the scrim and the 40%-opacity particle layer and swaps the deck's
color-mix rule for a solid one; forced colors resolves both rules to CanvasText
so neither disappears. The ambient toggle moves to z-index 3 to stay above the
scrim, and still responds.

Also fixes a layout bug found while testing: the title block lacked
box-sizing: border-box, so at 390px and below `width: 100%` plus its padding and
border pushed the accent rule three pixels off-screen.

Screenshot sweep moves 6 captures, all of them Story 4's splash; no other page
changed. Title and deck remain selectable in both appearances. All three
repository checkers pass.

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

Four faults, three of them regressions from the theming pass.

A dark canvas showed through in Light mode. Story pages advertise
`color-scheme: light dark` in their meta tag but never declared it in CSS, so
the browser painted the canvas from the OS preference - a dark canvas even when
the visitor had explicitly chosen Light. Anything that does not paint its own
background showed it through: Story 4's ~35 .talkbubble blocks are height:100vh
with visibility:hidden, which is the reported "black bar", and .scene2 was the
one scene missing from the background-color selector list, which is the "dark
section".

The declaration has to sit on the ROOT element; on the body it styles only that
box and leaves the canvas alone. Story 6 had it on `#six` and Stories 2-5 had it
nowhere, so five of six stories were affected - only Story 1, which sets it on
`html.story1-page`, was correct. Each story's stylesheet is loaded by exactly
one page, so a plain `:root` rule there is already story-scoped, matching how
landing/css/tokens.css does it. Verified across all seven pages in four
combinations each, with isolated storage per run.

Text selection fell back to the browser default on Stories 2-5.
shared/css/selection.css scopes ::selection to `.site-chrome` and its
descendants, but `#main-content` is a sibling of the chrome, not a descendant,
so story content was never matched; only Stories 1 and 6 had written their own
rule. Adds one `#main-content ::selection` rule beside the chrome one, using the
theme-reactive roles already on :root. Stories 1 and 6 keep their own, which are
more specific and still win.

Story 4's bubble text was invisible in dark mode. `.talkbubble` drew
`var(--story4-inverse-background)`, pinned #000000 and never overridden, while
the scene behind it had become --hra-dark-surface. It now uses --story4-text,
which already follows the theme. That leaves --story4-inverse-background with no
consumers, so it goes.

Also: `.scene2` joins the background-color selector list, and two redundant
XHTML `<link rel="stylesheet">` elements that Sketch left inside the inline SVGs
are removed - inline SVG is part of the host document and already styled by it,
so they only cost two extra fetches of a stylesheet the page already has.

Tooling: the contrast sampler collected `button, a, h1, h2, p, legend, span, li`
and so never saw the bubble text, which lives in a `<div>`. It now includes div,
restricted to elements with their own direct text - without that restriction it
matches containers whose textContent aggregates descendants, including SVG
<title>/<desc>.

Verified: the reported scenario (system dark, Light selected) now resolves
color-scheme to light on every page; .scene2 paints; bubble text follows the
theme; a ::selection rule matches article text on Stories 2-5; Story 4 meets
WCAG AA in both appearances with div text now sampled. The screenshot sweep
moves 18 captures, all Story 4 - Stories 2, 3, 5, 6 and the landing page are
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
story/4/index.html was 9,810,661 bytes. 24 base64 PNG payloads accounted for
9,427,724 of them - 96.1%. Everything else, all the vectors, text and markup,
was 383 KB. The page therefore re-downloaded six megabytes of artwork on every
visit, none of it cacheable, and the images could not be opened or edited
without hand-decoding them out of the HTML.

Each payload is now a file under story/4/images/ and each <image> carries a
relative xlink:href. 24 embeds become 22 files: two pairs were byte-identical
(scene20-panel-1 and scene20-closeuplinks3, 1,043,208 bytes between them) and
are now referenced twice each.

  index.html   9,810,661 -> 383,132 bytes   (96.1% smaller)
  images/      6,027,561 bytes across 22 files, cacheable

The SVGs stay inline, deliberately. story/4/css/styles.css hides ~45 artwork
elements by ID and story/4/js/diagram-overview.js animates #SideLogos,
#LogoLine1-3 and #TypeLogos among others; moving the SVGs to external <img>
would sever every one of those hooks. Only the rasters move, which is where all
the weight was anyway.

Names come from each embed's enclosing <g id>, camel-case split and slugified,
prefixed by its scene - scene17-brand1.png, scene20-gtex-logo.png. Where the id
was a placeholder left by the design tool (dfsdfsdf, sdfsdf, ok, Group-3) the
file falls back to scene-N-panel-N.

tools/check-local-links.mjs now also collects xlink:href, so inline-SVG image
references are verified like any other. It resolves 635 references, up from 613.

Verified:
- All 24 payloads round-trip byte-identically from the committed original into
  the extracted files.
- Outside the href values, the markup is character-for-character unchanged, so
  every SVG id survives - confirmed by the maintained-page checker's frozen
  45-entry duplicate-ID baseline for Story 4 still matching, and by resolving
  all 22 CSS- and script-targeted ids in the live DOM.
- The screenshot sweep is pixel-identical: 204/210 unchanged, movement only
  within the known-unstable set.
- With motion enabled and the page scrolled end to end, all 24 <image> elements
  carry a href, all 22 files return 200, and no request fails.

Note the git history still carries the old blobs, so the packed repository does
not shrink; what changes is what visitors download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
story/4/css/styles.css was a 485-line monolith holding the token layer, page
foundation, splash, scene sequence and preference overrides together - the
shape every other story has outgrown. Story 6 keeps these apart, one file per
concern, and Story 4 now does the same:

  theme.css   76 lines  canvas appearance, #four tokens, dark overrides
  base.css    66 lines  document layout, typography, link roles
  splash.css 230 lines  title block, scrim, responsive ladder, particle field
  scenes.css 126 lines  pinned scenes, dialogue bubbles, SVG reveal hooks

accessibility.css is unchanged; it was already a separate concern.

The split was done mechanically rather than by hand: the source was parsed into
its 40 top-level rules and at-rules, verified to reassemble byte-for-byte, then
each block assigned to a destination. Two blocks genuinely spanned concerns and
were divided explicitly - the `#four` rule mixed token declarations with five
layout properties, and the reduced-motion block held one rule for the document
and one for the splash.

Verified equivalent two ways rather than by eye:
- 2,520 computed values compared between the old and new builds - 18 key
  elements x 4 appearance/motion contexts x 35 properties, covering every
  property the stylesheets declare - all identical.
- The screenshot sweep is unchanged: 203/210 identical, movement only within
  the known-unstable set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Story 4 loaded five scripts, four of which opened with the same
`if (window.hraStory4MotionEnabled) {` wrapper. The decision to animate was
restated four times and could drift, and nothing named what each file was for.

Story 6's shape applies cleanly: one module per concern, each exporting a single
setup function, and one place that decides whether any of them run.

  story4.js             entry: gates once, then calls the four setups
  app.js                setupParticles()
  animations.js         setupSceneTriggers()
  diagram-overview.js   setupDiagramOverview()
  diagram-detail.js     setupDiagramDetail()

The entry also checks gsap and ScrollTrigger are present before wiring the
scroll-driven work, while still starting the particle field without them - the
field does not depend on GSAP, and previously a missing CDN would have taken
both down together.

particles.js stays a classic script: it is vendored and defines the particlesJS
global. motion.js stays classic too, because it must run in the head to set the
flowing class before first paint.

check-maintained-pages.mjs asserted the particle gate by looking for
`window.hraStory4MotionEnabled` inside app.js. The gate moved to the entry, so
the assertion follows it and now also requires the entry to reference
setupParticles - the contract is unchanged, only its location.

Verified behaviourally, not by eye. The GSAP CDN bundles were replaced at the
network level with a recording stub, so both builds ran the story code against
an identical deterministic gsap: 246 calls each, sequences identical. A first
attempt that shimmed gsap after load was discarded as invalid - classic scripts
run during parse and modules are deferred, so it measured a different window in
each build and reported a false difference. The screenshot sweep is unchanged.

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

Revisiting the scene sequence found no defect to fix. Measured at 1440x900, the
document scrolls 50,682px and the furthest trigger ends at 50,400px; the eight
scene pin windows are contiguous, gap-free and interleaved with the dialogue
bubbles. The choreography works - it was simply undocumented and carried dead
declarations that misrepresented it.

Removed four `top` declarations that had never applied: `.talkbubble`'s 23vh,
`.scene1`'s 0, `.scene15-20`'s 6.25rem and `.scene2`'s 9.375rem. Scenes and
bubbles are position:static until ScrollTrigger pins them, and the flowing
fallback sets `inset: auto !important`, so none of these could take effect in
either mode - confirmed in-browser before removing them. They invited the
reader to believe scenes were offset in CSS when scroll position belongs
entirely to the triggers. `.svgparts svg { top: 50vh }` is genuinely active and
stays.

Added story/4/README.md recording what had to be re-derived to work on this
story: the file layout, the measured pin windows, and the traps. Chief among
them, the scene names are not in visual order - .scene17 plays before .scene16
and .scene15 plays second, because position comes from DOM order rather than
from the name or from the order of the create calls in animations.js. Also
documented: which SVGs are deliberately short-framed, why the splash gradient
must not follow the theme, and why the splash needs its reading scrim (the
particle field brightens local patches, which is invisible to any check that
samples a flat background colour).

Screenshots unchanged.

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

Story 4's prose was illegible because it was drawn on top of the artwork by
design. shared/js/narrative-timeline.js pins every .talkbubble with
`pin: true, pinSpacing: false` at `start: "top top"`, and animations.js pinned
each scene the same way, so both occupied one viewport. scenes.css then set the
bubble to width:80%, text-align:center and align-items:center, putting a single
sentence in the middle of the screen at a ~1150px measure over a diagram. That
also caused the unpredictable line starts: every fragment was centred
independently, so no two began at the same x.

Scene 1 now uses a two-column layout - a native sticky illustration stage beside
a scrolling prose column - following the idiom Story 6 established in cde.css
and cde-comparison.css. Nine fragments become six steps, merging each untriggered
lead-in into the triggered fragment it introduces, so every artwork beat and
every id survives:

  step 1            "It takes a lot of data ... not all data is created equal!"
  step 2  Change1   source logos and connecting lines
  step 3  Change2   format logos
  step 4  Change3   the mess
  step 5  Change4   locks
  step 6  Change5   reset

Prose comes first in source order and is placed into the second column with
grid-column, so Reader View, screen readers and the no-JS document get the
narrative before the decorative artwork.

Two ancestors had to change from `overflow: hidden` to `overflow: clip`
(`html` in base.css, `.svgparts` in scenes.css). `hidden` makes an element a
scroll container, which silently stops any descendant `position: sticky` from
sticking - the illustration scrolled away after the second step until this was
found. `clip` clips identically without that side effect, and this story already
used it in the flowing fallback.

Collapses to a sticky band above the prose below 64rem rather than Story 6's
48rem: these diagrams carry baked-in labels and need the extra width.

The pilot is isolated. Scene 1's text is no longer `.talkbubble`, so
fadeTalkBubbles() skips it automatically and scenes 15, 2, 17, 16, 18, 19 and 20
keep the existing mechanism untouched.

Verified:
- GSAP call logs compared against the previous build with timeline ids
  normalised (they renumber when a timeline is removed): exactly one call shape
  disappeared, the .scene1 pin, and none appeared. All five #Change timelines
  intact with identical targets.
- Step text measures 17.86:1 in light and 13.64:1 in dark against the real
  rendered backdrop; previously it sat on artwork and could not be measured.
- Reduced motion and JavaScript-disabled both render prose above artwork in
  source order with the stage static.
- The frozen 47-entry duplicate-ID baseline is unchanged, and all three
  checkers pass.
- Screenshot changes are confined to Story 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The illustration always letterboxes inside its box - it is width-constrained at
every viewport size - so every pixel taken from the prose column, the gap or the
outer padding goes straight into how large it renders.

  1920px   64% -> 72% of the viewport
  1440px   54% -> 63%   (773px -> 909px wide)
  1250px   47% -> 59%

The prose column moves from a fixed 30rem to a minmax(16rem, 26rem) range, so it
yields on narrower viewports instead of squeezing the artwork, and the gap and
inline padding tighten. Its max-width came off the step itself, since the column
now governs the measure.

The stacking breakpoint moves from 64rem to 75rem. Just above the old
breakpoint the two-column stage was down to about 515px, while the stacked
layout at that width renders the artwork near full width - roughly 960px against
600px at 1100px. Below 75rem the artwork is simply bigger stacked, so it stacks.

That change exposed a second problem: stacked, the prose column is the full page
width, and the measure ran to 115 characters at 1100px - the fault this redesign
exists to fix. It is now capped at 34rem. Measures across the range are 46
characters in two columns, 60 stacked and 39 on a phone, all inside a
comfortable band.

Verified: text and artwork have zero 2D overlap at 1920, 1440, 1250, 1100, 900
and 390, checked at every step position rather than only at rest. Step text
holds 17.86:1 in light and 13.64:1 in dark. All three checkers pass and the
screenshot changes stay confined to Story 4.

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

On narrow viewports the prose scrolled under the illustration and appeared to
come back out on top of it. Two causes, both mine.

A grid item's sticky containing block is the grid CONTAINER, not its row. I had
assumed the row, and reasoned that a stage exactly as tall as row one had no
room to stick. It has the whole section: the band stayed at the top of the
viewport for all six steps, and because the stage follows the prose in source
order it painted above it. Measured, every scroll position from 700 to 2700 had
31,000-47,000px of text sitting under the stage.

Sticking is the right behaviour - it is how the artwork stays present while its
prose is read on a phone. What was wrong is that the band was transparent
(`background: none`, added when the stage was only ever a side column), so text
showed through the artwork instead of disappearing behind it. The band is now
opaque, shortened from 60svh to 42svh to leave real reading room, and carries a
hairline bottom edge so prose passing beneath reads as scrolling under a panel
rather than as text cut in half.

Correcting an earlier claim: the "zero 2D overlap at 1920, 1440, 1250, 1100, 900
and 390" reported with the previous commit was not sound. That check called
scrollIntoView and measured in the same evaluate without awaiting the scroll, so
it only ever measured the unscrolled state - where nothing overlaps. The
replacement samples every scroll position across the section with the sticky
element allowed to settle, and additionally reports whether the stage is
transparent where text passes under it.

Now: zero overlap at any scroll position on two-column widths, and on stacked
widths text passes under the band as a sticky header should with zero positions
where it shows through. Reduced motion and no-JS still stack static with prose
above the artwork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The artwork now sits in its own outlined container at every breakpoint: a 2px
--hra-outline-variant border and a soft shadow, hugging the illustration with no
inner padding.

The plate is a fixed light surface in both appearances, which is what makes the
artwork cohere in dark mode. These illustrations are black-stroke, white-fill
line art, so on a themed surface their strokes disappear and their filled panels
float unanchored. Measured on scene 1 by sampling the rendered frame:

  before   light 95% very-light / 1% very-dark     dark 53% / 43%
  after    light 95% / 1%                          dark 95% / 1%

Two per-artwork hooks, both defaulting so nothing needs setting per scene unless
an illustration asks for it: --story4-stage-padding (0) and
--story4-stage-ratio (scene 1's viewBox).

Sizing took three attempts, recorded so the next scene does not repeat them.
`height: 100vh` from the base scene rule leaked in and stretched the plate to
the viewport. `aspect-ratio` alone cannot fit a box to a container: it only
fills in an unspecified dimension, so once max-width clamped one axis the other
stopped following, and which axis binds flips between breakpoints anyway - width
on desktop, height in the narrow band, width again on a phone. The plate now
states both axes against the stage's container size, so it fits whichever
dimension binds. Verified at seven widths: ratio 1.78 everywhere, always inside
the stage.

The stage keeps its own responsibilities - sticky positioning, and staying
opaque in the stacked band so prose passing beneath is hidden rather than
showing through around the plate.

Caught while verifying: `container-type: size` needs a definite height, and the
flowing fallback sets the stage to `height: auto`, so cqh resolved to zero and
the plate collapsed to 4px - its border. Flowing mode now opts out of container
sizing and uses aspect-ratio directly.

Verified: text shows through the band at zero scroll positions across seven
viewports; sticky still holds with container-type applied; reduced motion and
no-JS render the plate at full width with prose above the artwork; all three
checkers pass; screenshot changes stay confined to Story 4.

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

The plate gains a 16px radius, exposed as --story4-stage-radius alongside the
padding and ratio hooks. `overflow: hidden` was already set, so the radius clips
the artwork's full-bleed edges, which is what makes it read.

Its surface moves to surface-bright. That token already existed at exactly the
intended values - --hra-light-surface-bright #fafbfc and
--hra-dark-surface-bright #34383c (shared/css/tokens.css:39,74) - so nothing was
added. The plate takes the fixed LIGHT value in both appearances: these are
black-stroke illustrations, and on #34383c their strokes measure 1.78:1 against
20.27:1 on #fafbfc. Using the theme-reactive token would have undone the
dark-mode fix in 0236551. Same rule as --story4-inverse-surface - a surface
hosting fixed-colour content cannot follow the theme - and the reasoning is now
recorded in the stylesheet.

Scenes also alternate which side holds the artwork, via nth-of-type rather than
a per-scene class. nth-of-type counts <section> elements and the unconverted
scenes are still <div>, so the alternation stays correct through the rollout.
Prose remains first in source order throughout; only the column assignment
changes, and the stacked layout below 75rem is single-column and unaffected.
Scene 1 is artwork-left, so the effect first appears when scene 15 converts.

Verified: plate renders #fafbfc with a 16px radius in both appearances and holds
its 1.78 ratio; all three checkers pass; screenshot changes confined to Story 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three more scenes move off the pinned overlay onto the sticky plate beside
scrolling prose, and the alternation now has something to alternate: artwork
sits left, right, left, right down the page.

Fragments grouped by the rule scene 1 established - each untriggered lead-in
merges into the beat it introduces - so every artwork beat and every id
survives:

  scene15   4 bubbles -> 2 steps   (datachange1)
  scene2    5 bubbles -> 3 steps   (Change8, Change9, Change10)
  scene17   4 bubbles -> 2 steps   (change13)

Scene 2 deliberately keeps three short steps. They are one sentence built across
three beats - "moving from data" / "to knowledge" / "to insight." - which is a
rhetorical build rather than fragmentation, and merging it would have cost two
artwork beats.

Scene 2's illustration is also a different shape from the rest (viewBox
1921x1180 against 1922x1082), so it sets --story4-stage-ratio. That override has
to sit after the plate rule: both selectors carry one id and three classes, so
source order decides, and placed earlier it silently lost.

Verified: each plate matches its own viewBox ratio (1.78, 1.78, 1.63, 1.78) and
alternates side correctly; all five new beat ids resolve and now sit on
.story4-step with their ScrollTriggers still bound; the GSAP diff shows exactly
three call shapes gone - the three scene pins - and none added; text shows
through the band at zero scroll positions across seven viewports; reduced motion
and no-JS render every plate full width with prose above its artwork; the frozen
47-entry duplicate-ID baseline is unchanged; screenshot changes confined to
Story 4.

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

Three fixes.

Right-side scenes kept both columns on phones. The alternation selectors carry
a `:nth-of-type(even)` pseudo-class, so they outrank a bare `#four
.story4-scene` inside the narrow media query and the two-column layout survived
the breakpoint - the artwork ended up as a thumbnail in the corner. Every
stacked rule now matches that specificity. Verified at 1100, 779, 600 and 390:
all four scenes stack, plates at identical widths.

Scene 3's prose is one block. It was three steps carrying one sentence, and the
first opened lowercase mid-thought - fine as a fading overlay, wrong as the first
line of a paragraph. It now reads "We put our data into context and create a
framework for moving from data, to knowledge, to insight." Only one of its three
ids had a timeline; Change10 had none, so merging cost a single beat, and that
one is re-keyed onto the merged block starting 40% of a viewport later, so the
artwork still advances twice as the block passes.

Scenes are renumbered to match the order they play. They were 1, 15, 2, 17, 16,
18, 19, 20 in DOM order, which read as a bug and was not one - position has
always come from DOM order. Now 1 through 8, so the class name, the DOM order and
the reading order agree. 65 references across the page, stylesheet, animation
module and README.

The renumber also rewrote the extracted raster filenames in the markup, since
those are prefixed by scene. The 22 files on disk are renamed to match rather
than reverting the references, keeping artwork discoverable by the scene it
belongs to. All 22 fetch 200.

story/4/README.md is updated: the pin-window table is replaced by a mechanism
table, since converted scenes have no pin at all. The "scene names are not in
visual order" trap is retired and replaced with the three traps found since -
position still comes from DOM order and nothing enforces the numbering, a
per-scene ratio override must follow the plate rule to win on source order, and
stacked rules must match the alternation's specificity.

Verified: each plate matches its viewBox ratio and alternates correctly; two
triggers remain bound to #Change8; all 22 images load; checkers pass with the
frozen 47-entry duplicate-ID baseline unchanged; screenshot changes confined to
Story 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A converted scene's stage unsticks when the section's bottom edge reaches the
bottom of the stage, so the space below the last step is the only thing keeping
the plate on screen. At 30svh it was not enough for any scene: measured at
1440x820, the closing paragraph arrived 8-37px after the plate had already
left, and scenes 1 and 3 ran 172px and 529px of artwork animation past it. The
last state of an illustration was never seen - in scene 2 that is the structured
-data network, the whole point of the beat.

Name that space --story4-scene-outro and raise it to 90svh, 30svh stacked where
the band is 42svh and unsticks much later. Scene 3 overrides to 130svh: its
prose was merged into one short paragraph, so both beats hang off a single step.
Its second beat also carried a start: "top -40%" from that merge, which alone
pushed it past the release point; it now picks up where the first ends.

Verified at 1920x1080, 1440x820, 1250x700, 1100x900, 779x1000, 600x800 and
390x844: in every scene the release point falls after both the last step's top
and the last beat's end, and Data2, Combined and Brand4 all reach opacity 1
while the plate is still stuck. Overlap harness unchanged - zero text showing
through the stacked band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scene 3 is one short paragraph against an illustration that changes twice, so
the artwork is the content and the text should wait for it. Add a held-scene
treatment: the step sticks near the top of the viewport, the reader scrolls
through both beats with the text standing still, and the column moves on only
after the finished artwork has been on screen a while.

The hold's length is a spacer element after the step, not the step's own margin.
A sticky box is constrained to its containing block's content box reduced by its
own margins, so margin-bottom on the step buys it exactly nothing - margin box
and content box grow together and the travel stays zero. Computed style still
reads `sticky`, so it presents as a browser bug rather than a layout mistake;
an injected margin-less sibling in the same column stuck correctly, which is
what isolated it.

The beats key to the section instead of the step, because ScrollTrigger measures
a trigger's position once per refresh and would read a stuck element's shifted
rect. Stacked, there is no hold at all - the stage is a band across the top of
the viewport and a held step would sit behind it - so the spacer collapses to 0,
the step goes back to static, and the beats return to #Change8 with their
original spans, split by gsap.matchMedia() on the same 75rem breakpoint.

Verified at 1920x1080, 1440x820, 1250x700, 1100x900, 779x1000, 600x800 and
390x844: 0px of step drift while the beats run, beats finishing 175-270px inside
the hold in two columns, the stacked layout unchanged, and every scene's plate
still outliving both its last step and its last beat. Repo checks and the
overlap harness are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three artwork states had no dwell between them. Both beats were contiguous -
the second started on the exact scroll position the first finished - so the
opening state changed the instant the scene stuck and the middle state held for
only 18% of a viewport before dissolving again. Sampling opacity across the
scene rather than reading trigger offsets shows it: 31%/18%/88% of a viewport
per state, and the whole first state elapsed before the text even locked.

Separate the beats with a dwell and lengthen the hold to fit: each state now
rests for 42-77% of a viewport with the text held still throughout.

Stacked was worse and wrong for a different reason. The beats keyed to the step
with start: "top", which fires when the step reaches the top of the VIEWPORT -
but the stage is an opaque 42svh band pinned there, so the step was already
behind it. The sequence ran 563-712px after the prose had gone, and the step was
only on screen for about half a viewport, too little to carry three states. It
now holds too, just below the band, and keys to the section like the held path;
the two layouts differ only in how far past the section top the sequence starts.

Verified at 1920x1080, 1440x820, 1250x700, 1100x900, 779x1000, 600x800 and
390x844: beats finish 160-324px inside the hold, the step stays visible and
clear of the band at every sampled position through the sequence, and every
scene's plate still outlives its last step and last beat. Repo checks and the
overlap harness unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LibbyUX and others added 26 commits September 6, 2026 07:22
The inline link button's 2.75rem pointer target stretched the line box of the
step it sits in, leaving the first step visibly taller than the two below it.
The button is centred on the text and the extra height pulled back out of the
line, so the list rhythm is even and the 44px target is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The theme swap was written to out-specify `.site-footer__organization-link
img`, which set `display: block` on both marks. That works but is fragile:
any later, more specific `img` rule reintroduces the bug, and the selectors
had grown long enough to obscure what they were doing.

The base rule no longer sets `display` at all, so the marks own it outright
and the swap is stated plainly. Verified in both appearances and under
forced colors: exactly one mark per organization is painted, never both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The splash lines used --color-outline-variant, which measures 1.53:1 against
the light hero and 1.68:1 against the dark one - both under the 3:1 non-text
minimum, so they were faint in Light and invisible in Dark. They now take
--color-border, the theme-reactive role already used for meaningful
boundaries: outline-strong at 4.15:1 in Light, outline at 8.58:1 in Dark. The
same swap covers the figure card, the GIF cards and the scale figure, which
had the same problem less visibly. The dark remap added for the old role goes
with it.

The GIF cards' remaining gap was not padding - that was already zero, and the
image measured flush to the border. Each GIF carries a transparent margin
baked into its canvas: 46px of image1's 400px on the left, and 79px of
image3's 303px on every side, about a quarter of the frame. The card cannot
hug artwork it cannot see, so each image is scaled and re-centred on its own
content box, measured from the files, and the surplus clipped by the card.
The zoom is eased just under the exact fit so no illustration is cut off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The splash figure card carried an inset on three sides and letterboxed the
video with `object-fit: contain`, so a margin of card surface showed around
the human. The card now has no padding and the footage fills the frame. The
pale field still visible around the figure is the video's own background, not
the card.

shared/css/numbers.css lifts the landing page's numbered circle out as
.hra-number so stories can number their own sequences with the same mark.
Story 1's GIF cards drew theirs as inline Bootstrap glyph SVGs, matching
neither the size, the palette, nor the typography; they now use the shared
mark at its compact size. The landing page keeps .story-number for its own
spacing and takes appearance from the component. The component hides its own
assistive label rather than relying on the landing page's .visually-hidden,
which the stories do not load.

The third GIF card's artwork reached the bottom of its box and ran behind the
caption. The caption is now an opaque band on its own rule below the frame,
and the number leads the line as a flex row instead of riding the baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GIF card numbers now head their caption the way the landing page's story
cards do, instead of sitting inline with the first line, and the cards take
the landing card's own roles - --color-card and --color-card-ink - rather than
the plain surface. The caption colour is set on the paragraph because
`#one #main-content p` outranks the caption container.

From the audit of the rest of Story 1:

The card artwork is transparent line drawing in near-black ink, drawn for a
light ground, so on the dark card its outlines all but disappeared while
carrying information the caption does not repeat. The image area now keeps a
fixed light backing in both appearances, as the full-bleed scenes in Stories
2, 3 and 5 keep a fixed dark one.

`setMotionPaused` wrote the state attribute twice, once in a branch and once
unconditionally, which read as a redundant line. It is load-bearing - the GIF
swap has to straddle it in opposite orders on pause and resume - so the
ordering is now explicit and commented rather than accidental.

buttons.css and numbers.css were loaded by all seven maintained pages but
documented nowhere; both are now described in AGENTS.md and README.md,
alongside a rule to build controls and numerals from them.

Checked and found sound: heading order, alt text, duplicate ids, ARIA
references, focus order, reduced motion, forced colors, and layout from 320px
up. Every colour pair the cards use measures AAA in both appearances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Card 3 carried its own frame ratio, 0.96 against the shared 0.94, so its
image box was five pixels shorter and its divider rule sat above the other
two. The ratio override is gone and all three frames match; card 3's zoom
rises from 1.75 to 1.85 to fill the slightly taller frame, which the measured
content box clears without clipping.

Also corrects the card comment, which still said the artwork used no frame
ratio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image-policy loop matched the pre-rename `story/6/img/` path, which no
longer occurs anywhere. `storyImages` was always empty, so the alt-text,
dimension, `decoding` and `loading` assertions never ran on any of the page's
42 images while the check still reported success.

Derive the pattern from the existing `imageRef` constant, and assert the match
set is non-empty so an empty result fails loudly instead of passing vacuously.
No image needed fixing once the check ran.

Add a GitHub Actions workflow running `npm run check` on push and pull request.
The checkers were the repository's only safety net and nothing invoked them,
which is how this regression survived the rename. Ignore `.claude/`, which was
excluded only by unshared local configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Story 6 defined `--hra-secondary` inside `#six`, and again in a deliberately
theme-invariant media block. Shared `tokens.css` swaps that token per theme, and
the story stylesheets load after it, so the override also pinned the shared
badge, text-selection and button focus-ring roles to a light-mode value in dark
mode. Rename both to the story-private `--story6-secondary`, keeping the
transition artwork theme-invariant as intended without reaching into the shared
namespace.

Story 2 loaded Inter from fonts.googleapis.com on top of the self-hosted house
fonts. Nothing in the story referenced Inter after the Nunito Sans migration; it
was the only third-party font request on the site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The six videos offered a bare `.mov` source with no `type` and no fallback. The
streams are already H.264/AAC, but the container is branded `qt  ` with no
MP4-compatible brand, so Firefox refuses them and the failure was silent.

Declare `type="video/quicktime"` and add fallback text so an unsupported browser
says so. The real fix is a lossless container remux, which needs `ffmpeg`; the
dependency policy forbids installing it, so record the constraint and the exact
command in the technical-debt list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The WebP migration rewrote every `xlink:href` from `.png` to `.webp`, which left
21 of the 22 PNGs unreferenced and quietly invalidated both generators. The docs
still described the previous state.

Record that the PNGs are retained archival masters rather than live assets, that
only `scene8-pub-med.png` is still served, and that both `webp:story4` and
`images:story4` are spent: each selects its sources from the `.png` references
the migration removed. Note the same at the top of each tool, where someone is
most likely to reach for it.

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

The pre-paint appearance script was 42 identical lines inlined into all seven
maintained pages, and it was the largest hand-copied block in the repository
that no fixture asserted, so it could drift silently. Move it to
shared/js/theme-bootstrap.js and load it as a blocking classic script. It has to
stay blocking: a module script is deferred, which would paint the system
appearance and then flash to the stored one.

Assert what the extraction relies on: that every page loads the shared file
rather than an inlined copy, and that it precedes the stylesheets.

The appearance and contrast fieldsets sit inside the Menu, so shared/fixtures/
held them twice and asserted them twice; editing one radio meant editing two
files or failing the check. Slice them out of menu.html instead and delete the
duplicates. Drift messages now name the file that actually has to change.

Removes 359 lines from the pages and 72 from the fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the remote Inter stylesheet left 104 inline SVG labels still naming
`font-family="Inter"`, a font the page no longer loads, so they fell back to
whatever generic sans the operating system chose and rendered differently across
platforms.

Retarget them to the self-hosted Nunito Sans with an explicit `sans-serif`
fallback. Each label already carries its own `font-weight`, so the four Sketch
export names collapse to one family without losing weight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documentation described an architecture the code had moved past, and in
places contradicted itself within one file.

Correct what was false: all seven maintained pages load `shared/js/main.js` with
appearance and High contrast controls, not the navigation-only variant, which
belongs to the three prototypes. `site-chrome--light` and `site-chrome--dark`
pin chrome to one appearance and must not be added to a maintained page; the
instruction to add them to Stories 1-5 was the inverse of the rule stated
elsewhere. Story 2 no longer makes a remote font request. Story 6's entry point
is `story/6/index.html`, and its smoke test now opens the page rather than the
redirect stub.

Fold the two validation procedures into one. Neither was a superset: AGENTS.md
had `node --check` and the reporting step, README.md had fixture drift,
text-spacing and DOI links, so a maintainer had to read both. AGENTS.md now
carries the full numbered list and README.md points at it.

Document what was missing: the asset generators appeared in neither top-level
document, and CI, the shared appearance bootstrap, and the derived fixture
blocks are all new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The visually-hidden pattern was declared eight times across seven stylesheets
under seven class names, with identical property bodies. One copy in numbers.css
carried a comment explaining that it existed because stories do not load the
landing page's copy.

Define it once in navigation.css, which every maintained page and every
prototype loads, aliasing the existing class names so no markup changes. Verified
in Chrome that all seven aliases compute as hidden, with an unrelated class as
the negative control.

landing/css/tokens.css declared --focus-color identically to
shared/css/component-roles.css across all three theme branches. index.html loads
component-roles.css afterwards, so the landing copy never applied and would have
silently taken over if the order changed. Remove it and record where the token
lives. Confirmed the landing page still resolves it per theme.

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

No external script carried an integrity attribute, and the versions had drifted:
Story 6 ran GSAP 3.15.0 while Stories 2-5 ran 3.11.5, and all three prototypes
paired a 3.11.4 core with a 3.3.3 ScrollTrigger, an eight-minor mismatch between
a plugin and the core it extends.

Align the maintained stories on 3.11.5. Story 6 uses only gsap.timeline,
registerPlugin and three ScrollTrigger statics, all long stable, so the
downgrade costs it nothing. Give the prototypes a ScrollTrigger matching their
own core. Add integrity, crossorigin and referrerpolicy everywhere, using the
digests cdnjs publishes; each was verified against the bytes the CDN actually
serves. Story 4 loads GSAP from JavaScript, so loadScript now takes an optional
digest and its local particles.js call is unaffected.

Pin model-viewer, which the prototypes fetched unversioned from unpkg and which
therefore tracked latest: it has moved to 4.3.1 since those pages were written.
Drop the nomodule model-viewer-legacy tags; that build no longer exists at any
version, so it was a guaranteed 404 for the pre-module browsers it targeted.

Verified in Chrome that all five animated stories load GSAP past the integrity
check and register their ScrollTriggers, Story 4's motion-gated loader included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The six story pages were the only HTML in the repository without a canonical
link or social metadata; even the 38-line redirect stubs carry one. Shared links
to the stories rendered bare, and the stubs pointed at pages that did not
confirm their own canonical URL.

Add the landing page's metadata block to each story, reusing the description
each page already had, and matching the canonical URLs the stubs already target.
Story 1's title also carried a leading space and lowercase "is" while the Menu
and story navigation said "What Is"; align it, and give every story the
"| Human Atlas Stories" suffix the landing page and stubs use.

The published site still serves the pre-migration layout, so /story/N/ is not
live yet; these URLs match what the redirect stubs have always declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Widen the local-reference checker to JSON. It scanned only markup and
stylesheets, so the cross-story links in three end-matter files went unchecked;
the wider scan immediately found a broken path in Story 4's particles config.
That reference is inert - shape.type is "circle", so the image block was
never fetched - and it named a file that has never existed in this repository,
so remove the block rather than baseline a path that can never resolve.
Verified in Chrome that Story 4 still renders its particles.

Record the two documented dead ends. Story 6's cwebp command cannot run here
and the same document forbids installing the tool, so point at the repository's
own browser-based WebP encoder as the approvable path. Story 7's prescribed
layout required a per-story README that four of six stories do not have; make it
optional and say which stories keep one.

Explain that the end-matter schema is deliberately wider than current content:
funding and funders are implemented through the schema, the renderer and the
stylesheet, so a story can add funding credit as content alone. They are unused,
not unfinished.

Let the Windows working directory be overridden rather than hardcoded, and
anchor the stock `dist` rule, which would otherwise ignore any future directory
of that name at any depth in a repository served straight from source.

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

component-roles.css maps the palette onto semantic roles, but only index.html
and Story 1 loaded it. Shared components therefore could not name a role
outright: buttons.css and numbers.css carried six var(--role, var(--raw-token))
workarounds, and buttons.css restated the whole dark mapping a second time for
the pages that lacked the file. A fallback substitutes a different colour rather
than failing, so nothing reported the gap.

It had already caused a real defect. story/2/css/quiz.css set
border-radius: var(--radius-small, 0.5rem) on a page that never loaded the
definition, so the quiz shipped a hard-coded radius that the design token could
not reach.

Load it on all seven pages, directly after the palette, and let the components
name their roles. No story defines a --color-* of its own, so the addition
cannot override anything: sampling 24 role tokens plus the rendered button and
badge colours across all seven pages in both appearances, 240 previously unset
roles now resolve and every rendered colour is unchanged.

Widen the stylesheet-order check from five entries to the full shared preamble,
and assert that narrative-accessibility.css stays last on the narrative stories,
where its html.story-flowing rules use !important to win over story
presentation. Story 4's theme.css leads because it defines values; Story 6's
trails because it also overrides them. Both are correct, so both stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
story/3/index.html was 3.9 MB, of which 3.6 MB was four base64 data URIs on
single lines over 880 KB each. The browser had to parse all of it before
painting anything, and the bytes could not be cached, ranged, or lazily
requested. Two of the four were the same image embedded twice.

Extract the three unique images to story/3/images/ under the descriptive names
the directory already uses, and downscale them to roughly twice their rendered
size with the repository's own PNG codec, as Story 4 did in 1f06250. The
registration screenshot was already below that target and is untouched.

story/3/index.html: 3.86 MB to 256 KB, a 93.5% reduction. The extracted files
total 1.5 MB, so the page now ships 1.4 MB less overall and defers all of it
until the artwork is actually needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extract Story 2's last embedded raster. It was declared `data:image/png` but is
actually a 10-frame animated GIF, so it is now story/2/images/
squiggy-cilia-animation.gif with an honest extension. The page drops from 252 KB
to 130 KB, and no base64 payload remains anywhere in the repository.

Rename the three filenames containing spaces, each referenced from exactly one
prototype page, and update those references.

Record the .git weight the audit measured: roughly 822 MB against a 152 MB
working tree, from binaries committed and later deleted under the retired `img/`
and `stories/` layouts. It stays as it is, since reducing it means rewriting
history, but a fresh clone's cost should be written down.

Correct the duplicate-ID entry, which claimed 11 ids for Story 2. There are 9
distinct repeated ids there and 18 in Story 3, matching the checker's own
baselines. Drop the drag-and-drop entry: it described the prototype as organized
under its own directory, which is the resolved state, not outstanding debt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Record the new invariants so they are not undone by accident: every external
script is pinned with a Subresource Integrity digest, including the two Story 4
loads from JavaScript, and CI runs npm run check on push and pull request. Both
trees now show .github/workflows/check.yml.

Correct what today's work made false. The caution against reformatting Stories 3
and 4 stood on their embedded rasters, which are now extracted; it still holds,
but because inline SVG path data makes up 71% and 63% of those files on very
long lines, so say that instead. Story 4's presentation has not lived in a
single styles.css since it was split by concern, and the file named in three
places does not exist; its images are 21 WebP with the PNG masters beside them,
not 22 referenced PNGs.

Add the extracted files to the asset map with their provenance, and note the
prototype renames.

Verified that every repository-relative path named in the documentation resolves,
except story/7/index.html, which story/7/README.md describes how to create.

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

Links inside a character bubble rendered pale and washed out against the
unchanged pastel background. Story 3 set them from --narrative-color-text, which
flips to a light value in Dark, while the bubbles are deliberately
theme-invariant: their backgrounds and --dialogue-color-text are identical in
Light and Dark. The link therefore turned near-white, rgb(212, 221, 230), on
pink.

Move the rule to character-dialogue.css, which owns the bubbles and that fixed
colour, and inherit rather than name a token, so a link cannot drift from the
text around it again. Cover :visited, :hover and :active too, and keep the
underline so the link stays distinguishable without relying on colour.

Only Story 3 has bubble links; Story 2's in-content links are quiz buttons and
Story 5 has none. Placing the rule in the shared stylesheet means Stories 2 and
5 are already correct if they gain one. Verified in Chrome that both links now
compute to rgb(0, 0, 0), matching their bubble text in both appearances.

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

The splash and intro sat off centre on narrow viewports, and Stories 2, 3 and 5
scrolled sideways. Four separate causes, each measured in Chrome:

ScrollTrigger pinned .container and .container2 directly. Those are 90% wide and
centred with auto margins, so the pin-spacer copied the resolved margin
(20.75px at 415px) and then added its own `inset: ... left: 20px`, counting the
same 5% twice: 41px of gutter on the left against 1px on the right. Pin the
full-width parent instead, which has no auto margin to resolve. The trigger is
unchanged, so scroll timing is identical.

The episode eyebrow, title and description take width: 100% of the intro column
and add an inline padding and 4px accent border. Without border-box those fell
outside the declared width and pushed the page 17px wider than the viewport.

Story 4's stacked prose column had the same defect: width: min(100%, ...) plus
1rem of inline padding overflowed the band, which clips, so the text was cut off
mid-word rather than scrolling.

Story 3's decorative bubble offsets used a fixed left percentage on a 90%-wide
bubble, which overhung by 3px at 320px. Cap each at the space actually left over
so the nudge degrades to flush instead of overflowing.

The Menu panel sized itself from 100vw, which includes the classic scrollbar.
Publish documentElement.clientWidth as --site-viewport-width and clamp against
it, keeping 100vw as the no-JavaScript fallback.

Guard the two shared causes in tools/check-maintained-pages.mjs: pinning the
centred container again, or dropping border-box from the episode block, now
fails the check with the reason. Verified both guards fail when the old code is
restored, and that all six stories are contained and centred at 320, 390, 430
and 768px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Story 4's paragraphs rendered at 18px against 16px on every other story, and
its steps carried a separate line-height: 1.6 rather than the token's 1.5rem.
Neither was a design decision: git traces the hard-coded 1.125rem to the commit
that first isolated Story 4's styles, where it was introduced beside
font-family: "Inter". Inter's smaller x-height wants the larger size; the house
Nunito Sans does not. The later migration to Nunito Sans removed Inter and left
the size behind, so this was the only story whose prose sat off the type scale.

Take the size, line-height and letter-spacing from the body-large role, and drop
the local line-height so the token governs it. Story 4's distinct roles are
untouched: the splash subtitle stays 24px and the kicker keeps its letter
spacing.

Guard it in tools/check-maintained-pages.mjs, which now fails on a raw font-size
in any story's prose rule, and record the rule in README.md and AGENTS.md.

Verified all six stories now render prose at 16px/24px; Story 4 keeps its eight
scenes, 26 steps and 22 ScrollTriggers, with no overflow at 320px or 430px. The
14px paragraphs on Stories 2, 3 and 5 are end-matter resource-card descriptions,
a separate shared role, and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every maintained page now holds an opaque, page-coloured veil over the
document until its first paint is trustworthy, instead of showing about a
second of elements shifting as fonts swap in and scroll runtimes measure
geometry. Story 6's local gate is retired in favour of the shared one.

shared/js/loading-gate.js follows the theme-bootstrap.js precedent: a
blocking classic script in <head>, after the appearance bootstrap and ahead
of the stylesheets, because a module script is deferred and would paint bare
first. The veil is added by JavaScript rather than CSS, so a reader without
it sees content immediately. It lifts on readiness, never on a timer:
main.js calls releaseWhenReady() for every page, Story 6 releases first with
its settled pin geometry, and the watchdog is only a ceiling.

Two accessibility gaps in the original are fixed once here rather than
copied sevenfold: content is inert while veiled, so keyboard focus cannot
land on what the reader cannot see, and a visually hidden role="status"
region announces the transition. prefers-reduced-motion now collapses the
hold as well as the fade, cutting that wait from 1400ms to about 151ms.

The gate holds scroll with overflow: hidden, so loading-gate.css also
reserves the scrollbar's space with scrollbar-gutter: stable on html;
without it the content re-centres into the reclaimed width and jogs
sideways when the veil lifts. Measured at 3.8px before the fix and 0 after.

Reduce the work the veil was hiding, so it lifts sooner rather than covering
a fixed delay:

- Story 3 preloaded three ~1.3 MB SVGs at highest priority, starving the
  fonts and the scroll runtime. Only the scene styles.css paints first stays
  a preload; the two that js/rui-animations.js swaps in later drop to
  prefetch, returning about 2.6 MB of critical-path bandwidth.
- Preload both critical font faces on every page. They were otherwise not
  discoverable until fonts.css had parsed and matched a rule, so text
  painted in the fallback and then re-wrapped.
- preconnect to the GSAP CDN on the four stories that load it from there.

Fix two inverted flashes at their source rather than masking them. Stories 1
and 5 applied their state class from deferred modules, so content painted
and then blinked out; both now take it from the gate before first paint.
Story 1's rule matches :not([data-reveal-state="revealed"]) so the waiting
state no longer depends on an attribute that also arrived late.

check-maintained-pages.mjs asserts every page loads the gate, that it is not
deferred, that it precedes the stylesheets, and that both font faces are
preloaded. check-story6.mjs now asserts the shared gate and Story 6's own
settle step in place of the retired class names.

Verified in headless Chromium across all seven pages: the veil clears with
no stranded inert, aria-busy or scroll lock; the state classes are present
at 60ms, before the deferred modules run; and with scripts blocked the pages
render with no veil at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loading gate landed with four follow-ups left open. Track them where this
repository already tracks deferred work, alongside the Story 5 container remux
and the repository-size entry, so they are visible to whoever picks them up
rather than living only in a conversation.

Each entry names the measurement behind it, the reason it was deferred, and
the concrete path forward: the landing hero is still 664 KB of PNG across its
two theme variants and tools/generate-story4-webp.mjs already records the
browser-encoded WebP path that halved Story 4's artwork; fifty images across
Stories 2, 3 and 5 carry no intrinsic dimensions; Story 1's hero video
reserves no height until metadata arrives; and the gate's effect on CLS and
LCP has not been measured, only its behaviour verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LibbyUX
LibbyUX marked this pull request as ready for review September 6, 2026 20:04
This was linked to issues Sep 6, 2026
@LibbyUX
LibbyUX merged commit 39ec1aa into main Sep 6, 2026
2 checks passed
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.

Fix story 2-3 & 5 splash section Create page loader component

1 participant