Conversation
…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>
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
marked this pull request as ready for review
September 6, 2026 20:04
This was
linked to
issues
Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.