Skip to content

Let an authored [autofocus] win over the container - #6

Merged
ephnagler merged 9 commits into
mainfrom
a11y-autofocus
Sep 12, 2026
Merged

ephnagler merged 9 commits into
mainfrom
a11y-autofocus

Conversation

@ephnagler

Copy link
Copy Markdown

Backlog item 6, plus the consistency pass the new CLAUDE.md rule asks for.

The autofocus collision

A collection page logged Autofocus processing was blocked because a document already has a focused element on every soft navigation, reproduced twice. That message is the browser declining to run autofocus: swapped-in content arrives while the document already has a focused element, so it never tries. The attribute is therefore silently dead on every PJAX navigation — and Pusha's focus-to-container then arrived and took the focus the author had asked for.

Pusha now applies it in the browser's place. Precedence: hash target → [autofocus] in the incoming content → container. The hash stays first because a click on an anchor link is intent expressed later than the markup was written.

Verified rather than trusted: [autofocus] on a disabled input, a hidden element or a plain <div> is a no-op, so focus is checked and falls through to the container when it did not take.

A test-fixture gap worth knowing about

jsdom leaves Element.scrollIntoView undefined rather than warning, so calling it is a TypeError — which means the hash branch of focusContainer could not be reached by any test until this PR stubs it. The existing hash tests pass because their hash matches no element and the branch is skipped.

Surface reconciliation

A pass over the four surfaces against bin/pusha.js. Three drifts, all in the predicted direction — the CLI moved and the prose did not:

  • Bucket X was missing from CLAUDE.md's audit description, which stopped at P, while CLAUDE.md discusses X at length five sections later.
  • Bucket K sat in the README's surface group and SKILL.md's per-script group. The CLI settles it: a K finding is one custom element with a list of render sites. The README moves.
  • --skip-detect and --ignore-manifest ship in the CLI and were absent from the README. The agent-facing SKILL.md knew about a flag the human-facing docs did not.

Repo hygiene

CONTRIBUTING, CHANGELOG, SECURITY (backlog item 5, the part that needs no decision). SECURITY routes through GitHub private reporting — there is no security email, and I would rather not invent one that bounces.

Verification

149 tests pass; the three autofocus tests were checked to fail without the fix.

Comment thread bin/pusha.js Fixed
Ephriam Nagler and others added 9 commits September 12, 2026 08:57
Backlog item 6. A collection page logged "Autofocus processing was
blocked because a document already has a focused element" on every soft
navigation, reproduced twice. The message is the browser declining to run
autofocus on swapped-in content — the document already has a focused
element by then, so it never tries. The attribute is therefore silently
dead on every PJAX navigation, and Pusha's focus-to-container arrived
afterwards and took the focus the author had asked for.

Apply it in the browser's place. Precedence is hash target, then
[autofocus] in the incoming content, then the container. The hash stays
first: a click on an anchor link is intent expressed later than the
markup was written.

Verified rather than trusted — [autofocus] on a disabled input, a hidden
element or a plain <div> is a no-op, so focus is checked and falls
through to the container when it did not take. Leaving focus on the
outgoing page's link would be worse than the default.

Also stubs Element.scrollIntoView in the jsdom fixture. jsdom leaves it
undefined rather than warning, so calling it is a TypeError, and the hash
branch of focusContainer could not be reached by any test until now —
the existing hash tests pass because their hash matches no element.

Surfaces updated together: README "Accessibility", CLAUDE.md, and
PATTERNS.md step 8, which the porting agent reads. No audit rule or init
behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
A pass over the four surfaces against `bin/pusha.js`, which is the first
thing the new consistency rule asks for. Three real drifts, all in the
direction the rule predicts — the CLI moved and the prose did not.

- Bucket X was missing from CLAUDE.md's audit description, which still
  stopped at P, while CLAUDE.md discusses X at length five sections
  later. SKILL.md and the CLI both had it.
- Bucket K sat in the README's *surface* group and in SKILL.md's
  *per-script* group. The CLI settles it: a K finding is one custom
  element with a list of render sites, not a whole-surface check. The
  README moves.
- `--skip-detect` and `--ignore-manifest` ship in the CLI and were absent
  from the README, the surface that is supposed to be the contract of
  record. `--ignore-manifest` was in SKILL.md, so the agent knew about a
  flag the human-facing docs did not mention. Also documents the
  `--json`-only narrowing flags together rather than scattered.

No behaviour change; no source touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
Backlog item 5, the part that does not need a decision.

CONTRIBUTING carries the four-surfaces rule, because a contributor is the
reader most likely to change one surface and not the other three, and two
things this repo learned the hard way: check that a regression test fails
without its fix, and treat jsdom coverage as not-browser-coverage.
Element.scrollIntoView is the example — jsdom leaves it undefined rather
than warning, so the hash branch of focus.ts looked covered and was
unreachable.

CHANGELOG starts at 0.1.0 rather than reconstructing forty commits of
pre-publication churn. git log has that, with the reasoning attached,
which is the part a rebuilt changelog would throw away. The known
limitations are in it on purpose: the pixel gap and the inert-app-code
gap are the two things a reader needs before adopting, and burying them
would be the kind of omission the honesty pass was for.

SECURITY routes through GitHub private reporting. It deliberately names
what is NOT a vulnerability — the pixel gap and app code going inert are
documented, measured limitations, and a triage queue that has to
re-litigate them each time is worse than one that says so up front.

There is no security email. If you want one, add it and I will wire it in
— I am not inventing an address that would bounce.

No source, CLI, skill or runtime-contract change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
…ment

Caught by rebuilding to refresh the rig's vendored bundle. CLAUDE.md and
the README both claimed a measurement from 2026-09-11 and disagreed with
each other: 32.2 kB / 10.7 kB against 34.7 kB / 11.5 kB. A clean build
today says 35.3 kB raw / 11.7 kB gzipped for the UMD bundle.

The second number was doing real work and doing it backwards. CLAUDE.md
argued the modularity hold was safe because Pusha was "already under
`@barba/core` alone (9.9 kB)". At 11.7 kB it is larger, not smaller — so
the sentence that justified not splitting the bundle rested on a
comparison that goes the other way.

The README had the honest framing on the same day: a comparable feature
set is @barba/core + prefetch + css + router at ~12.5 kB, still without
an analytics bridge or Section Rendering API revalidation. That argument
survives a reader checking it; the core-only one does not. The wrong
claim is recorded rather than quietly replaced, since it is the kind that
gets re-derived.

Both surfaces now carry the same measured figures, and a note that these
go stale on every merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
…plied

Found in the first browser walk islands have ever had. The run reported
`cached: true`, one revalidation event, `swapped 1 in 229ms` — and left
`.is-revalidating` on a live element.

The class was only ever removed because `wrapper.replaceWith()` destroyed
the node carrying it. Every path that does not swap — no
`#shopify-section-<id>` wrapper in the document, an empty response, an
island marker sitting outside the wrapper being replaced — stranded it on
a live element permanently. CLAUDE.md promised "removed when the new HTML
is in place", which was true by accident. A theme styling it as a dim or
skeleton, which is what the README tells them to do, gets a region that
never comes back.

Removed explicitly now, for every island, on every path.

The second half is why the walk looked clean. `swapped ${keys.length}`
counted keys in the response, not nodes replaced, so it printed
`swapped 1` for a run that may have touched nothing — the diagnostic that
should have caught the stranded class certified the run instead. It now
counts what applied, names what did not, and logs NO TARGET for a section
id with no wrapper.

`pjax:islands-revalidated` had the same flaw: `sectionIds` was what was
requested. A listener re-initialising the regions it names was re-running
against markup that never changed. It now carries what was applied, with
`requestedIds` alongside.

Whether the Horizon run hit the no-wrapper case or the outside-the-wrapper
case is still unknown — the next run says, which it could not before.

Both tests verified to fail without the fix. The pre-existing hash test's
fixture has an island with no wrapper and never noticed.

Surfaces updated together: README (event table + islands section),
CLAUDE.md (visual contract), PATTERNS.md (the id must name a wrapper that
exists). No audit rule or init behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
The first run with honest counting produced a confusing pair of lines:
Pusha asked for `template--22360478679180__main` and the loop reported
`NO TARGET for product`. Both true, and together they read like a theme
markup problem — when the actual event is that the response was not keyed
by the ids that were requested.

Diagnostic only. What to DO about a mismatched response is not decided
yet, because the cause is not known: this was measured on
`shopify theme dev`, and the local proxy is already known to behave
differently under load. Writing a fallback now would be guessing, and a
wrong fallback here swaps the wrong markup into a live page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
Islands have never worked on a product page. Not on Horizon — on any
theme, since the first release.

`Accept: application/json` on a product URL triggers Shopify's
product-JSON content negotiation, and it beats the query string:
`?sections=` is ignored and the body is `{"product": {...}}`, status 200,
content-type application/json. So the response parsed, the cycle
completed, and the one key in it named no section. Every layer reported
success. Measured with curl against the same URL — with the header, the
product object; without it, the section HTML keyed by the requested id,
wrapper included.

Removing the header is the whole fix. The Section Rendering API keys off
`?sections=` and never needed an Accept header.

Three things had to be fixed before this was findable, which is the part
worth keeping: the stranded `.is-revalidating`, the counter that reported
response keys instead of applied nodes, and the mismatch line that said
"asked for X, got Y". The first browser run reported `swapped 1` and
looked clean. Islands are the reason to distrust a green jsdom suite —
the fixture answers whatever the stub is told to answer, so a
content-negotiation bug is invisible by construction.

README needs no change: no config key, event, or contract moved. Recorded
in CLAUDE.md under Islands, since the next person to add a header there
needs to know why there isn't one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
The audit had no island coverage at all for classic OS 2.0 — `island`
appeared twice in the CLI, both about new-Liquid partials. So nothing
told a porting agent which regions were stale-prone, nobody marked any,
and islands went a full release with no marker in any theme. That is why
the Accept-header bug survived: there was nothing to exercise.

R is bucket L's question asked about the swap container. They do not
overlap — an L finding freezes because the shell is never swapped, an R
finding goes stale because the page HTML came out of the prefetch cache.
Sub-letters: A price, B availability/inventory, C variant state, D
cart-derived (already covered by cart:mutated), E render-time values,
plus `marker` for conformance of an existing data-island.

**Candidates are `decide`, never `transform`.** Revalidating costs a
request and re-runs the section's JS, so marking a region that does not
actually change taxes every cached navigation. Whether a price is
stale-prone is a question about the store, not the code, and the audit
must not answer it. Broken markers ARE `transform` — those are
mechanical, and each fails silently:

  - `data-island` with no `data-section-id` (the selector needs both)
  - a marker in the persistent shell (islands are collected from the
    swapped container only)
  - a snippet writing {{ section.id }} whose render sites pass no
    `section:` — {% render %} does not inherit scope, so the attribute
    renders empty

That last one is verified, not assumed. The first version flagged every
snippet reading section.id and reported the rig's correctly-wired island
as dead. It now resolves the actual render sites and names the ones that
miss it. Reporting on shape alone is the same error as suppressing on
shape, inverted.

`section` IS in scope in theme blocks — checked against a Horizon port,
where 13 of 93 blocks read section.id and buy-buttons.liquid already
writes data-section-id. I had assumed otherwise; the assumption was
wrong and the check would have encoded it.

New fixture covers all four marker cases against a correctly-wired
control. Goldens regenerated: only the R section and summary line moved,
one legitimate finding each in os2-sections and analytics-surface, no
noise anywhere else.

All four surfaces: BUCKET_RULES.R + report + JSON action mapping in the
CLI, SKILL.md (batching guidance), PATTERNS.md (the four things that must
hold, each of which fails silently), README and CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
CodeQL flagged the new marker check high severity, and it was right about
the shape even before the security framing: `new RegExp()` with a snippet
name interpolated straight in. Any name carrying a regex metacharacter
would have matched the wrong thing or nothing, silently — the failure
mode this whole bucket exists to catch.

It also re-walked every .liquid file in the theme once per marker.

One static pattern now matches every render tag and the captured name is
compared as a string. Same findings on the fixture and on the Horizon
rig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjTmKaEaizVN7ZPTKVzrK8
@ephnagler
ephnagler merged commit d57bd80 into main Sep 12, 2026
6 checks passed
@ephnagler
ephnagler deleted the a11y-autofocus branch September 12, 2026 12:58
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.

2 participants