Skip to content

Replace the Exchanges bank counter hero with the approved homepage vortex - #128

Merged
HodlDee merged 3 commits into
mainfrom
codex/exchanges-approved-vortex
Sep 16, 2026
Merged

HodlDee merged 3 commits into
mainfrom
codex/exchanges-approved-vortex

Conversation

@HodlDee

@HodlDee HodlDee commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Exchanges opened on a photograph of a bank counter under the headline "From FIAT IOUs to true BITCOIN ownership". This gives it the money vortex and the EXIT FIAT / ENTER BITCOIN treatment the homepage uses, so the page that explains leaving fiat opens on the image of it.

Two commits: the hero itself, then an accessibility and reduced-motion pass on it.

What changed

Three source files. Everything else in the diff is generated.

File Why
build/content.mjs the Exchanges hero definition
docs/assets/css/site-refresh.css hero rules
docs/assets/js/site-refresh.js the hero video's pause/resume control
75 × generated HTML asset-version query string only

No assets added. The approved exchanges-cash-vortex-final3.mp4 and its poster are already tracked on main — the homepage has been serving both — so there is no new provenance to record. exchanges-hero.jpg stays; index.html still uses it in the explore card.

docs/exchanges.html keeps data-page="exchanges". The preview inherited the homepage's styles by claiming data-page="home", which would have shipped a page whose navigation and metadata disagreed with its body; that shortcut is not here. Active navigation reads Compare → Exchanges, and the hand-maintained head is untouched.

The stylesheet

The vortex rules were written against body[data-page="home"]. Each now names both pages, the way the devices and exchanges photo-hero rules already named theirs. Exchanges comes out of that photo-hero set, and the rules that only ever described the photograph — its scrim, its saturation, its copy box — go with it.

This keeps the rocket homepage's path clear: when that hero replaces the vortex on index.html, the change is dropping home from each of those selector groups, not unpicking an inheritance.

Where the two pages deliberately differ

The band below the hero. Its comment says its top tone exists to continue whatever the hero's bottom is. That was #121211 for the photograph and is #08090b for the vortex, so the value follows. The overlap is 100px against the homepage's 148px, because the section underneath is the Two models band rather than the homepage's safe-path section, and 100px is where the eyebrow lands inside the video's fade.

The phone action buttons. The homepage keeps its two side by side down to 320px, and the comment on that rule records the measurement it relies on: ~252px of buttons inside a ~282px capsule. That is true of "Explore Guides" and "Get Help" and false of "Compare exchanges" and "Withdrawal guide" — each wrapped to two lines, and the 83px slab that produced read as hanging below the copy rather than belonging to it. Exchanges takes the site-wide phone default instead: one button per row, full width, with 30px above so the gap under the lead matches the gap between the title and the lead.

Accessibility

The heading announced every decorative layer. Each outlined word is painted three times — ::before for the cream outline, ::after for the stroke, .sc-word-fill for the texture — and both pseudo-elements draw with content: attr(), which screen readers include when computing a name from content. Read aloud it came out EXIT FIAT FIAT FIAT ENTER BITCOIN BITCOIN BITCOIN. The layers are decoration, so they are aria-hidden outright and the heading carries one plain sentence: "Exit fiat. Enter bitcoin."

The homepage hero has the same defect and is byte-identical to before this branch. It is left for the rocket homepage change rather than edited silently inside an Exchanges commit.

Reduced motion

The vortex autoplayed regardless of prefers-reduced-motion. The <source> now carries media="(prefers-reduced-motion: no-preference)", so for a reader who asked for reduced motion the browser selects no source at all — NETWORK_NO_SOURCE, nothing played, and the poster (the same artwork held still) is what they see. The 4MB video is not fetched either.

Handling that declaratively rather than from script matters both ways. The browser acts on an autoplay attribute while the document is still parsing, so stopping playback from script lets a frame or two escape first; and moving playback into a play() call puts it at the mercy of autoplay policy, which refuses in a background tab and does not retry the way the attribute does. Both were observed while building this.

Ordinary playback gets a control: a real <button> with real text rather than an aria-label alone, so it is translated, found by in-page search, and read consistently. It sits at the hero's bottom-right, outside the figure — that figure is aria-hidden decoration, and a control buried inside it would be unreachable by the people most likely to want it. 44px square, hidden under reduced motion where there is no source to start.

The one case the declarative form cannot cover is a reader who turns reduced motion on mid-visit, since source selection happens once at load. site-refresh.js drops the source and reloads, returning the element to the state it would have had if the preference had been set first.

Evidence

Vortex placement against Home, same viewport. Hero box, figure box, video box, copy box and h1 size are identical at 1440, 1024 and 768. Only the actions width differs, because the labels differ.

Phone. 320 and 375: stacked, one line each, 50px tall, no horizontal overflow; gaps 26/30 and 30/30. 390 and 600 also checked. The media control is inside the hero, clear of the buttons, 44×44 at every width.

Reduced motion, measured with the source's media query unmatched: networkState: 3, readyState: 0, paused, poster rendering, control hidden by rule. Verified in both directions on an isolated probe page before adopting the approach.

The heading, computed with an accname "name from content" walk that skips aria-hidden subtrees and includes generated content. With the fix removed it reproduces the reported string exactly; with it, Exit fiat. Enter bitcoin.

The control: play → pause → resume. Frame held on pause, label and icon move together, keyboard activation works.

Runtime: no console errors, no 404s, video autoplays muted/looping/inline at 1616×1072, single h1, #exchange-compare intact, both CTA destinations unchanged, skip link and focus order fine.

CI, run locally step for step. Site gate: ci-completeness, ci-scope, asset-version, render.mjs + all six guards, polarity-guard, and the clean-tree check — all pass, and a second build introduces no further changes. docs/entropy.html changed (asset-version only), so the Entropy Workshop job scopes in: all ten steps pass, including sha256sum -c. entropy-offline.html and its .sha256 are unchanged, so the offline digest does not move. lifehash and wasm scope out.

Not in scope

No Entropy Workshop or crypto behaviour is touched. No comparison pages, moon experiments, ring overlays, mockups, tmp/, experiments/ or backups/. .gitignore is untouched. The content, comparison tools and links below the hero are unchanged.

🤖 Generated with Claude Code

HodlDee and others added 3 commits September 16, 2026 00:16
Exchanges opened on a photograph of a bank counter under the headline
"From FIAT IOUs to true BITCOIN ownership". This gives it the money
vortex and the EXIT FIAT / ENTER BITCOIN treatment the homepage uses,
so the page that explains leaving fiat opens on the image of it.

The hero markup moves to the homepage's shape: no eyebrow, the two
command lines with the red EXIT and green ENTER signs, and the lead
leading on a bold "Compare exchanges". Both buttons and their
destinations are unchanged; their labels are now wrapped in spans, as
the homepage's are, because the ghost button's overlay pseudo-elements
sit above an unwrapped label.

The video and poster are already in the tree -- the homepage has been
serving both since the vortex landed there -- so this adds no assets
and no new provenance to record.

On the stylesheet side the vortex rules were written against
body[data-page="home"]. Rather than have Exchanges borrow the home
page's identity (which is what the preview did, and what would have
shipped a page whose navigation and metadata disagreed with its body),
each of those rules now names both pages, the way the devices and
exchanges photo hero rules already named theirs. Exchanges comes out of
that photo hero set, and the rules that only ever described the
photograph -- its scrim, its saturation, its copy box -- go with it.

Two places where the two pages deliberately differ:

The band below the hero. Its comment says its top tone exists to
continue whatever the hero's bottom is; that was #121211 for the
photograph and is #08090b for the vortex, so the value follows. The
overlap is 100px against the homepage's 148px, because the section
underneath is the Two models band rather than the homepage's safe path
section, and 100px is where the eyebrow lands inside the video's fade.

The phone action buttons. The homepage keeps its two side by side down
to 320px, and the comment on that rule records the measurement it
relies on: ~252px of buttons inside a ~282px capsule. That is true of
"Explore Guides" and "Get Help" and false of "Compare exchanges" and
"Withdrawal guide" -- each wrapped to two lines, and the 83px slab that
produced read as hanging below the copy rather than belonging to it.
Exchanges takes the site-wide phone default instead: one button per
row, full width, with 30px above so the gap under the lead matches the
gap between the title and the lead.

Verified against the homepage at 1440, 1024 and 768: hero box, figure
box, video box, copy box and h1 size are identical at every one. The
remaining 77 files are the asset-version query string, which every page
carries and which moves whenever a stylesheet does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems found reviewing the vortex hero.

The heading announced every decorative layer. Each outlined word is
painted three times -- ::before for the cream outline, ::after for the
stroke, .sc-word-fill for the texture -- and both pseudo-elements draw
with content:attr(), which screen readers include when they compute a
name from content. Read aloud the heading came out "EXIT FIAT FIAT
FIAT ENTER BITCOIN BITCOIN BITCOIN". The layers are decoration, so
they are now aria-hidden outright and the heading carries one plain
sentence for assistive technology: "Exit fiat. Enter bitcoin."

The vortex autoplayed regardless of prefers-reduced-motion. The
<source> now carries media="(prefers-reduced-motion: no-preference)",
so for a reader who asked for reduced motion the browser selects no
source at all: NETWORK_NO_SOURCE, nothing fetched, nothing played, and
the poster -- the same artwork held still -- is what they see. The
4MB video is not downloaded either.

Handling that declaratively rather than from script matters both ways.
The browser acts on an autoplay attribute while the document is still
parsing, so stopping playback from script lets a frame or two escape
first; and moving playback into a play() call puts it at the mercy of
autoplay policy, which refuses in a background tab and does not retry
the way the attribute does. Measured here: with the source unselected
the element sits at networkState 3, readyState 0, paused.

Ordinary playback gets a control. It is a real button with a real text
label rather than an aria-label alone, so it is translated, found by
in-page search, and read consistently; site-refresh.js keeps the label
and the icon in step, and each names what pressing it will do. It sits
at the hero's bottom-right, outside the figure -- that figure is
aria-hidden decoration, and a control buried inside it would be
unreachable by the people most likely to want it. 44px square, and it
is hidden under reduced motion, where there is no source to start.

One case the declarative form cannot cover is a reader who turns
reduced motion on while the page is open: source selection happens
once, at load. site-refresh.js drops the source and reloads, returning
the element to the state it would have had if the preference had been
set first.

Both fixes are scoped to Exchanges. The homepage hero is byte-identical
to before this branch, and carries the same heading defect -- worth its
own change rather than a silent edit inside an Exchanges commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in the control added by the previous commit, both found in
review.

The button could not be clicked on desktop. The band below the hero is
pulled up 100px so it starts inside the video's fade, and the button
sat 24px from the hero's bottom -- inside that overlap, where the
section painted and hit-tested over it. Its z-index could never have
won: .sc-hero sets isolation:isolate, so the button is confined to the
hero's own stacking context while the section is a sibling of the hero
in the root one. Raising the hero instead would put its opaque
background over the band it is meant to sit behind.

So the button clears the overlap rather than fighting it, and the
overlap is now one custom property that both rules read. They were
written independently -- -100px in one place, 24px in another -- which
is how they came to disagree; naming the value once means they cannot
drift apart again. Below 992px the band no longer rides up, the
property goes to 0, and the button returns to the hero's bottom edge.
Keyboard activation always worked, because focus does not hit-test.

Playback could not be started after turning reduced motion off. Source
selection runs once, when the element loads, so a page opened under
reduced motion has no source selected -- and the change handler only
acted when the preference turned on. Turning it off revealed the
control and left it attached to an element still at
NETWORK_NO_SOURCE, so pressing play did nothing at all.

The handler now runs load() in both directions, since either one
changes which source matches. Turning the preference on reloads to
nothing selected; turning it off reloads and selects the video. The
autoplay attribute is dropped either way, so neither reload starts the
video by itself: someone who has just left reduced motion has said
they can tolerate movement, not that they want it unasked.

Verified with a harness that stubs matchMedia over the real page, so
this exercises the shipped handler rather than a restatement of it.
Loaded reduced: networkState 3, readyState 0, no source. Preference
off without the reload: still 3/0, play does nothing -- the reported
bug. With it: networkState 1, readyState 4, paused; play then runs and
the time advances. Turning it back on returns to 3/0 and the poster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HodlDee
HodlDee merged commit 5bfe74d into main Sep 16, 2026
14 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.

1 participant