Skip to content
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Changelog

Notable changes per release. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versioning follows [SemVer](https://semver.org/), with the 0.x caveat that
breaking changes are allowed in minors until 1.0.

Everything below is pre-publication. Nothing has been released to npm yet, so
this file starts at the first release rather than reconstructing the churn that
led to it — `git log` has that, and it carries the reasoning.

## [Unreleased] — 0.1.0

First release. The runtime, the CLI, and the porting skill.

### Runtime

- PJAX navigation over a single configurable container (`#MainContent` by
default), with link interception, history, scroll restoration, and named
transitions.
- Component registry — `setupGlobal()` once, `init(root)` on every load and
swap, optional `destroy(root)`.
- Hooks: `onBeforeNav`, `onBeforeLeave`, `onAfterSwap`, `onAfterInit`,
`onFirstLoad`, `onNavError`. Async handlers are awaited.
- Events on `document`: `pjax:before-nav`, `pjax:content-swap`,
`pjax:islands-revalidated`, and `cart:mutated` as the cart contract.
- Prefetch with per-template TTLs, soft/hard staleness, hover, pointer-down,
focus and viewport warming, and a concurrency cap of two so speculative work
never starves a real request.
- Islands — `[data-island][data-section-id]` regions revalidated through the
Section Rendering API after a cached navigation.
- Head sync for title, meta, `body[data-template]`, scripts and stylesheets.
- Analytics bridges: `shopify`, `customEvents`, `standardEvents`, `trekkie`,
`ga4`, `dataLayer`. Standard Web Pixel events are **not** reachable on a soft
navigation — this is a platform constraint, measured rather than assumed, and
the README's "Analytics & tracking" section says what does and does not work.
- Standard storefront events — `shopify:page:view` is re-dispatched per
navigation, which is the sanctioned way app code learns a soft navigation
happened.
- Accessibility: focus moves to the hash target, then an `[autofocus]` element
in the incoming content, then the container; an `aria-live` region announces
the new title; `prefers-reduced-motion` skips transitions.
- Theme editor: PJAX is off under `designMode`; `shopify:section:load` and
`shopify:section:unload` are handled.
- `appCompat.sectionEvents` and `appCompat.reexecuteExtensionScripts` —
experimental theme-app-extension repairs, both **off by default**. Enabling
both together compounds multiplicatively and warns at boot.

### CLI

- `pusha audit` — classifies every script in a theme into buckets A–H and K,
plus the surface buckets J, L, M, P and X. `--json` makes the report
addressable by an agent, narrowable with `--bucket`, `--action` and `--file`.
- `pusha init` — Path A (vendored `pusha.min.js` + snippet) or Path B (npm),
detected from the theme.
- `pusha skill` — installs the porting skill for Claude Code, Cursor or Aider.

### Known limitations

- Pixels wired through Shopify admin (Meta, GA4, TikTok, Klaviyo) do not
receive a pageview on a soft navigation. No supported theme-side fix exists
today. See the README.
- Theme app extension code with no re-init hook goes inert after a swap,
regardless of how it is loaded. Apps that listen for `shopify:page:view` or
that ship custom elements recover for free.
- `<html lang>` and `<html dir>` are not synced. Route language switches
through a full browser navigation.
58 changes: 47 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ This is the command that converts a curious reader into a user. It's also where

#### `npx @mimeticthemes/pusha audit`

Runs the audit against the current directory (or path argument). Buckets A–H (per-script) plus the surface buckets: J (analytics), K (portal-to-body custom elements), L (per-request Liquid in the shell), M (persistent-shell stateful UI), P (partials). `--json` emits structured output for tools/agents. The CLI is the canonical audit implementation — there is no longer a parallel bash script.
Runs the audit against the current directory (or path argument). Buckets A–H and K (per-script) plus the surface buckets: J (analytics), L (per-request Liquid in the shell), M (persistent-shell stateful UI), P (partials), R (island candidates), X (theme app extensions). `--json` emits structured output for tools/agents. The CLI is the canonical audit implementation — there is no longer a parallel bash script.

The authority on this list is `BUCKET_RULES` in `bin/pusha.js`; `skill/SKILL.md` restates it for the porting agent. All three drifted once already — X shipped in the CLI and the skill while this line still stopped at P.

#### `npx @mimeticthemes/pusha init`

Expand Down Expand Up @@ -286,7 +288,28 @@ The Shopify Section Rendering API lets you fetch any section's HTML in isolation
- On navigations where prefetchConfig's `soft` TTL has elapsed but `hard` has not — page HTML is reused, islands fetched fresh
- Never on uncached navigations — the page was just fetched, everything is current

**Visual contract**: while an island is revalidating, it gets a `.is-revalidating` class that themes can style for a subtle dim/skeleton. The class is removed when the new HTML is in place.
**Visual contract**: while an island is revalidating, it gets a `.is-revalidating` class that themes can style for a subtle dim/skeleton. The class is removed when the revalidation cycle ends — **on every path, including the ones that swap nothing**. It used to come off only because `wrapper.replaceWith()` destroyed the node carrying it, so an island whose `#shopify-section-<id>` wrapper was absent kept the class on a live element forever and a themed dim never lifted. Found in a browser, 2026-09-12; the diagnostic that should have caught it counted keys in the response rather than nodes replaced, so it printed `swapped 1` for a run that swapped nothing.

**Never send `Accept: application/json` on the sections request.** On a product
URL that header triggers Shopify's product-JSON content negotiation, which beats
the query string: `?sections=` is ignored and the response is `{"product": {…}}`
at status 200 with content-type `application/json`. Every check downstream
passes, the single key names no section, and nothing is swapped. Islands
therefore never worked on a product page — the case they exist for — from the
first release until 2026-09-12, and no test caught it because jsdom fixtures
answer whatever the stub is told to answer. Measured with curl on the same URL:
with the header, the product object; without it, the section HTML keyed by the
requested id. The Section Rendering API keys off `?sections=` and needs no
Accept header.

**The audit proposes islands; it never marks them.** Bucket R lists stale-prone
regions inside the container (price, availability, inventory, selected variant,
render-time values) and checks conformance of any `data-island` already present.
Candidates are `decide`, never `transform`: revalidating costs a request and
re-runs the section's JS, so whether a region is stale-prone enough to spend
that on is a question about the store. Broken markers — no `data-section-id`, a
marker in the shell, a snippet whose render sites pass no `section:` — are
`transform`, because those are mechanical and each one fails silently.

**Exported as `@mimeticthemes/pusha/islands` subpath** for explicit imports. Auto-imported by main entry when prefetch is enabled.

Expand Down Expand Up @@ -410,7 +433,7 @@ Themes that want fine-grained control populate `cartStatefulRoutes` with the rou

PJAX nav has real a11y implications. The runtime handles:

- **Focus restoration**: after DOM swap, focus is moved to either the URL hash target (if any) or to `#MainContent` (or whatever element matches `[data-page-container]`). Without this, keyboard users get lost after every nav.
- **Focus restoration**: after DOM swap, focus is moved to the first of these that exists — the URL hash target, an `[autofocus]` element inside the incoming content, `#MainContent` (or whatever matches `[data-page-container]`). Without this, keyboard users get lost after every nav. The `[autofocus]` step exists because the browser will not run its own autofocus processing on swapped-in content (the document already has a focused element, so it declines and logs a warning), which would otherwise make the attribute silently dead on every soft navigation. The hash wins over `[autofocus]`: it is intent expressed later than the markup.
- **Screen reader announcement**: an offscreen `aria-live="polite"` region is updated with the new page title after PJAX swap, so screen readers announce the navigation. Without this, the URL silently changes and assistive tech doesn't know.
- **Reduced motion**: when `prefers-reduced-motion: reduce` matches, transitions are skipped (instant swap, no `is-transitioning-*` classes). Themes can override per-transition.

Expand Down Expand Up @@ -518,14 +541,27 @@ Earlier text here described prefetch as an "opt-in plugin (Barba-style — core
small, prefetch is optional)"; that was intent, never implementation, and it is
removed rather than restated so nobody designs against it.

**This is a deliberate hold, not an oversight.** Measured 2026-09-11 on a clean
build: 32.2 kB raw / **10.7 kB gzipped** for the UMD bundle — already under
`@barba/core` alone (9.9 kB) before Barba adds the plugins needed to match, and
that is with the analytics bridges, islands and the a11y handling included.
Tree-shaking would save single-digit kB at best, Path A cannot benefit from it at
all (one UMD file by construction), and moving optional subsystems behind dynamic
imports would add a round-trip to the first navigation — the exact latency the
product sells against.
**This is a deliberate hold, not an oversight.** Measured 2026-09-12 on a clean
build: 35.2 kB raw / **11.7 kB gzipped** for the UMD bundle, 27.1 kB / 8.1 kB for
the ESM entry — with the analytics bridges, islands and the a11y handling
included. Tree-shaking would save single-digit kB at best, Path A cannot benefit
from it at all (one UMD file by construction), and moving optional subsystems
behind dynamic imports would add a round-trip to the first navigation — the exact
latency the product sells against.

⚠ **This paragraph previously claimed 10.7 kB gzipped and "already under
`@barba/core` alone (9.9 kB)".** Both were wrong, and the second inverted the
argument: at 11.7 kB Pusha is *larger* than Barba's core, not smaller. The README
had the honest framing on the same day — comparable feature set means
`@barba/core` + `@barba/prefetch` + `@barba/css` + `@barba/router` at ~12.5 kB,
and that is still without an analytics bridge or Section Rendering API
revalidation. Keep the comparison feature-for-feature; the core-only number
flatters us and does not survive a reader checking it.

Bundle figures are measured, so they go stale on every merge. Re-measure with
`npm run build` before quoting them, and keep this paragraph and the README's
"Bundle sizes" table in step — they disagreed for a day while both claimed the
same measurement date.

**The trigger to revisit is a subsystem growing, not a size threshold.**
`app-compat.ts` is ~0.3 kB gzipped today (measured by unwiring it and rebuilding).
Expand Down
78 changes: 78 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Contributing

Pusha is small and opinionated, and it is pre-1.0. The most useful contribution
right now is a theme it breaks on — a real Online Store 2.0 theme, the audit
output, and what went wrong in the browser.

## Setup

```sh
npm install
npm test # node's test runner over test/*.test.ts, jsdom fixtures
npm run typecheck
npm run build # vite ESM + UMD, then tsc for the .d.ts files
```

Node `^22.13.0 || >=24.0.0`. CI runs both, and it has caught a real difference
between them at least once — if a test passes locally and fails on 22.13, that
is a signal, not noise.

## The four surfaces must agree

`pusha init`, `pusha audit`, the skill (`skill/SKILL.md` + `skill/PATTERNS.md`),
and the README describe one contract to four different readers. Three of them
are consumed by an agent or a machine rather than a person, so a stale one does
not read as out of date — it reads as authoritative and wrong. An audit rule
that describes a removed config key teaches the removed key to every agent that
runs it.

Any change to config keys, defaults, hook or event names and payloads, bucket
rules or their remediation text, install layout, or the `--json` contract
touches all of these:

1. Source (`src/`) — the behaviour.
2. `bin/pusha.js` — `audit` rules and remediation, `init` writes.
3. `skill/SKILL.md` and `skill/PATTERNS.md` — procedure and patterns.
4. `README.md` — the documented contract, and the tie-breaker when two
surfaces disagree.
5. Tests, including the audit golden files.

If one genuinely does not apply, say so in the commit message. Silence reads as
an oversight, because usually it is.

## Tests

Every bug fix gets a test that fails without the fix. Check that it does —
comment the fix out, watch it go red, put it back. A regression test that never
failed is decoration.

Assert on the damage, not on the mechanism. A test that asserts "no stray
request reached the wire" keeps working when the internals move; one that
asserts on a private counter breaks for reasons that are not bugs.

The jsdom fixture in `test/dom.ts` is deliberately thin. jsdom leaves some DOM
APIs undefined rather than warning, so a code path can be silently unreachable
from the suite — `Element.scrollIntoView` hid the whole hash branch of
`focus.ts` until it was stubbed. If a branch looks covered but has never gone
red, check that the API it depends on exists in the fixture.

## Commits

Write the reasoning, not the diff. The subject says what changed; the body says
what was true that made it necessary and what you ruled out. `git log` is where
this project keeps its reasoning, so a message that only restates the patch
throws that away.

## Browser verification

jsdom coverage is not browser coverage, and the gap is not theoretical: the
abort race, the navigation timeout, and hash-link handling all needed a real
browser to be believed. Anything touching navigation timing, focus, or
transitions should say in the commit message whether it was walked in a browser
or only tested in jsdom.

## Scope

Cart is theme code. Analytics is framework code. If you are not sure which side
of that line something falls on, the README's "Cart is theme code" section
explains the test being applied.
Loading