Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ block-actions/
│ ├── class-action-renderer.php # Abstract base (get_namespace/get_entry_action/get_supported_triggers)
│ ├── class-interactions.php # Trigger×behavior tuples: data-interactions parsing, trigger injection, engine enqueue
│ ├── class-query-params.php # Query actions: bq-* param→query-var mapping, enhancedPagination force-off, no-JS hrefs
│ ├── class-view-transitions.php # MPA layer: theme-support @view-transition style + ba-vt-post per-post naming
│ └── renderers/ # Per-action PHP renderers
│ ├── class-scroll-to-top.php
│ ├── class-carousel.php # Structural (owns its lifecycle; no trigger UI)
Expand Down Expand Up @@ -96,11 +97,12 @@ block-actions/
│ │ ├── test-manifests.php
│ │ ├── test-query-action.php # Query renderer, bq-* mapping, no-JS hrefs, cache invariants
│ │ ├── test-interactions.php # Tuple parsing, trigger injection, kses survival, keyboard operability
│ │ └── test-modal-dialog.php
│ │ ├── test-modal-dialog.php
│ │ └── test-view-transitions.php # MPA opt-in style + ba-vt-post naming (incl. the page-uniqueness guard)
│ └── __mocks__/
│ ├── styleMock.js # CSS mock for Jest
│ ├── interactivity.js # @wordpress/interactivity mock (store REGISTRY + __setContext/__setElement)
│ ├── interactivity-router.js # navigate/prefetch spies
│ ├── interactivity.js # @wordpress/interactivity mock (store REGISTRY + __setContext/__setElement/__setConfig)
│ ├── interactivity-router.js # navigate/prefetch spies (+ __deferPrefetch for the prefetch race)
│ ├── wp-plugins.js # registerPlugin stub (build-time external)
│ └── wp-editor.js # PluginPrePublishPanel stub (build-time external)
├── scripts/
Expand All @@ -110,7 +112,8 @@ block-actions/
│ ├── EXAMPLES.md
│ ├── carousel-action.md
│ ├── query-loop-actions.md # User guide for the four query actions
│ ├── specs/ # Design specs (query-loop-actions, interactions-panel, trigger-behavior-model)
│ ├── view-transitions.md # User guide: default router animations, MPA opt-in, ba-vt-post recipe, styling hooks
│ ├── specs/ # Design specs (query-loop-actions, interactions-panel, trigger-behavior-model, view-transitions)
│ └── examples/ # Copy-paste action templates
└── README.md
```
Expand Down Expand Up @@ -194,7 +197,7 @@ Two suites: JS unit tests (Jest, no WordPress) and PHP integration tests (PHPUni
- **Framework:** Jest 29 with jsdom environment
- **Setup:** `tests/setup.js` — mocks `wp.i18n`, `window.blockActions`, fake timers, fetch
- **Test patterns:** `tests/**/*.test.js` and `tests/**/test-*.js`
- **Interactivity mock:** `tests/__mocks__/interactivity.js` — `store`, `getContext`, `getElement` with `__setContext()`, `__setElement()`, `__reset()` helpers
- **Interactivity mock:** `tests/__mocks__/interactivity.js` — `store`, `getContext`, `getElement`, `getConfig` with `__setContext()`, `__setElement()`, `__setConfig( ns, config )`, `__reset()` helpers. `getConfig()` mirrors the real empty-namespace-stack case, so configs must be read back with an explicit namespace.
- **Stats:** 221 tests across 12 suites. Run: `npm test`.

### PHP (PHPUnit, integration)
Expand All @@ -217,7 +220,7 @@ npm test && npm run test:php
- **`block-extensions.js`**: Requires mocking all `@wordpress/*` packages and `lodash`. HOC tests call the filter functions extracted from `addFilter` mock calls and inspect `wp.element.createElement` output.

#### Interactivity API store tests
- **Mock setup**: `tests/__mocks__/interactivity.js` provides controllable `getContext()` and `getElement()`. Use `__setContext(ctx)` and `__setElement({ ref })` before each test.
- **Mock setup**: `tests/__mocks__/interactivity.js` provides controllable `getContext()`, `getElement()` and `getConfig()`. Use `__setContext(ctx)`, `__setElement({ ref })` and `__setConfig(ns, config)` before each test. `tests/__mocks__/interactivity-router.js` adds `__deferPrefetch()`, which hands a test control over when `prefetch()` settles.
- **Store capture**: Tests use `beforeAll` with `require()` to load the store module once, then extract the `storeDefinition` from `store.mock.calls`. Avoid `jest.isolateModules` — it creates a separate mock registry and breaks `store.mock.calls` capture.
- **Carousel tests**: `jest.useFakeTimers()` in `beforeEach` overrides the `requestAnimationFrame` mock; re-mock RAF in `beforeEach` after `jest.useFakeTimers()`.
- **Utility tests**: `create-feedback-store.test.js` is a straightforward unit test.
Expand Down Expand Up @@ -257,6 +260,16 @@ Browser: Interactivity API processes directives, store hydrates

`query-paginate` / `query-infinite-scroll` host on `core/query`; `query-filter` (Button) and `query-live-search` (Search/Group) target a query by anchor. Every state is a real GET URL: `bq-{queryId}-tax-{tax}` / `bq-{queryId}-s` params map to query vars via `query_loop_block_query_vars` (opt-in registry recorded at `render_block_data` — that filter receives the INNER post-template block, context only). The renderer injects a router region (`block-actions-query-{queryId}` — the id doubles as the client-side anchor→queryId channel) and force-disables core's enhancedPagination on opted-in queries. No REST, no nonces (page-cache requirement). Filter links get server-computed no-JS toggle hrefs (link-style buttons only). Infinite scroll fetch-and-appends and REQUIRES a Query Pagination block (hidden once JS activates; it's the no-JS fallback and the next-URL source).

### View Transitions (two layers, 3.2.0)

Router swaps animate by default (crossfade + per-item morph) via the native View Transitions API — feature-detected, reduced-motion-aware, no polyfill; kill switch: `apply_filters( 'block_actions_query_view_transitions', true )`, read through `Query_Action::view_transitions_enabled()` and gating BOTH the interactivity config and the server-side name injection (the names aren't inert when the store is off — they still drive the MPA layer, and `view-transition-name` creates a stacking context at all times).

The store branch lives in `navigateTo()` in `src/stores/query/view.js`: prefetch first, then wrap only the settled cache-hit swap in `startViewTransition` (transition type `block-actions-query`), with a `PREFETCH_GUARD_MS` fall-through to a plain navigate and a store-side 400 ms `@wordpress/a11y` announcement restoring the parity core's in-navigate timer can't provide on that path. Two ordering invariants ride on the `latestNavigation` map: prefetch-first means navigations enter the router in network-settle order, so (a) a superseded navigation bails before starting a doomed transition — core's own `navigatingTo` guard only orders by ENTRY and can't help — and (b) only the navigation still owning the query clears its loading flag. Any throw out of the transition branch falls through to a plain navigate (the click was already `preventDefault()`ed), and `vt.ready` / `vt.finished` get no-op catches since a skipped transition rejects both.

Naming: `Query_Action::post_process_html()` injects `ba-query-{queryId}` on the region and `ba-q{queryId}-p{postId}` on items (first occurrence only; already-named items are skipped so a nested query keeps its own identity). Duplicate page-level names make the browser skip the WHOLE transition, so `loadMore()` strips transition names off infinite-scroll appends — the server can only dedupe within one render, and a sticky post repeats across pages by default.

The MPA layer (`includes/class-view-transitions.php`) is theme opt-in via `add_theme_support( 'block-actions-view-transitions' )` — prints a reduced-motion-guarded `@view-transition { navigation: auto; }` in `wp_head` — plus the `ba-vt-post` class helper: a `render_block` filter injects `view-transition-name: ba-post-{ID}` for thumbnail→hero morphs. The name is unscoped by design, so page-uniqueness is ENFORCED by a per-request seen-map (`reset_named_posts()` for long-lived processes) rather than trusted to the docs. The post id comes from `$instance->context['postId']`, which WP_Block only populates for block types declaring it in `usesContext` — so the class is a no-op on core/group and core/columns; `in_the_loop()` + `get_the_ID()` is the fallback. Infinite-scroll appends and popstate stay unanimated (non-goals). No editor UI. Guide: `docs/view-transitions.md`.

### Trigger × behavior model

Behavioral actions (all but carousel and the query actions) support triggers click / hover(+focus) / scroll-into-view / load / timer and viewport/reduced-motion conditions. Progressive serialization: the default click case keeps classic `data-action` + `data-*` markup forever; a non-default trigger or conditions ADD one validated `data-interactions` JSON tuple (kses entity-encodes it; `get_attribute()` decodes byte-identical — verified). Editor state lives in ONE registered attribute (`interactionSettings`), cleared on action change. Engine dispatch is scope-correct by construction: the transformer arms `data-wp-on--ba-fire="{entry}"` against the action's own store and the dispatcher (`block-actions/interactions`) fires a synthetic cancelable `ba-fire` event when conditions pass — the RUNTIME evaluates the entry in the element's real namespace (never call `store(ns).actions.x()` programmatically; wrong scope). One interaction per block in v1; multi-interaction awaits the context-multiplexing spike.
Expand Down Expand Up @@ -357,4 +370,4 @@ All built-in stores follow the same declarative contract:
- **Theme actions don't require rebuild:** They're ES module JS files auto-discovered by PHP
- **Action IDs come from filenames:** `my-action.js` → action ID `my-action` (normalized via `sanitize_key()`, so use lowercase kebab-case)
- Files prefixed with `_` or `.` in theme action directories are skipped
- **Script modules:** Interactivity API stores (built-in and theme) are enqueued via `wp_enqueue_script_module()` with `@wordpress/interactivity` as a dependency. The webpack config uses `@wordpress/scripts`' dual-config mode (`WP_EXPERIMENTAL_MODULES=true`) — `block-extensions.js` stays a classic script; every `actions/*/view.js` is an ES module.
- **Script modules:** Interactivity API stores (built-in and theme) are enqueued via `wp_enqueue_script_module()` with `@wordpress/interactivity` as a dependency (the query store adds `@wordpress/interactivity-router` and `@wordpress/a11y` as `import => 'dynamic'` deps, emitted into its `.asset.php` and forwarded verbatim — both are core script modules in WP 7.0). The webpack config uses `@wordpress/scripts`' dual-config mode (`WP_EXPERIMENTAL_MODULES=true`) — `block-extensions.js` stays a classic script; every `actions/*/view.js` is an ES module.
17 changes: 17 additions & 0 deletions assets/actions/query.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@
transition: opacity 0.2s ease;
}

/*
* The dim must not reach either view-transition snapshot. The flag is
* set before the prefetch and cleared after the swap, so without this
* the browser captures the OLD state at 0.5 (an element's own opacity
* is baked into its snapshot image) and the NEW state mid-fade — the
* crossfade plays at half brightness and the page pops when the
* transition ends. The state itself is untouched: `.is-loading` and
* `aria-busy` still describe the region, only the paint is suppressed
* while a query transition is running. Browsers without transition
* types drop this rule and keep the 3.2.0 behavior.
*/
html:active-view-transition-type(block-actions-query)
[data-wp-router-region^="block-actions-query-"].is-loading {
opacity: 1;
transition: none;
}

/*
* Infinite scroll: once JS takes over (`.ba-infinite-scroll-on` is added
* by the store's init), the pagination block becomes the hidden no-JS
Expand Down
5 changes: 5 additions & 0 deletions block-actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,16 @@ function (): void {
require_once plugin_dir_path( __FILE__ ) . 'includes/renderers/class-theme-action.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/class-query-params.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/renderers/class-query-action.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/class-view-transitions.php';

// URL-param → query-var mapping + core enhanced-pagination interop for
// the query actions.
Query_Params::register();

// Cross-document view transitions: theme opt-in style + the ba-vt-post
// per-post name helper.
View_Transitions::register();

// Translations are auto-loaded by WordPress.org for this plugin slug.

/**
Expand Down
130 changes: 130 additions & 0 deletions docs/personas-and-principles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Personas & Principles

**Status:** Adopted 2026-08-01
**Purpose:** The fixed reference point for every future spec and roadmap
decision. When a design question stalls, resolve it against the personas and
their adoption gates below. Companion to the external "Block States & Effects"
draft (v0.2), whose direction this document grounds in who the system serves.

## The standard

Block Actions aims to be the missing interactivity layer in WordPress — the
system that replaces the plugin category of sliders, popups, scroll animation,
and responsive visibility. To earn that, every feature must:

- **Fit seamlessly with core.** Rhyme with how WordPress wants applications
built — blocks, patterns, block supports, theme.json, the Interactivity
API — and prefer feature shapes core could absorb without leaving a scar.
- **Be pragmatic about mess without adding mess.** Real sites are hybrid
themes, coexisting page builders, half-migrated plugins, agency component
systems. Meet that reality; never make it worse.

## The three personas

### Authors — a constraint, not a roadmap

Authors insert patterns and apply simple actions. Serving them well means
building almost nothing: the action layer should feel transparent, like a
capability core always had. Every decision already made in their direction
was a decision to *withhold* surface — zero-config, no settings page,
advisory-never-blocking validation, no editor UI for engine-level polish.

**The author test:** does this leak? If an author inserting a filterable grid
can tell where core ends and Block Actions begins, the feature failed them.
Both other personas will constantly generate pressure to surface things;
this test is the counter-pressure.

**Adoption gate:** authors adopt what feels like core. They also drive
*installs* — the sentence-level wedge features ("hide on mobile", "reveal on
scroll") are what get the plugin onto sites, even though the other personas
drive retention.

### Designers — the worst-served persona, and the one who defines the seam

Designers implement complex patterns: interactions plus heavy CSS plus block
supports work. Today they get our variations, action fields, and then a
cliff — everything visual is hand-written CSS against class hooks learned
from docs (`.is-hidden`, `.ba-tooltip`, carousel classes). There is no way to
author *what open looks like* with the tools they use for everything else.
That gap is the states layer, and it is the hardest UI problem in the system.

The designer surface must let them craft pattern states **in isolation**
(the pattern editor) and compose our primitives **alongside the block
supports core provides** — meaning the existing inspector records into a
state overlay, rather than us building a parallel styling UI. An
editor-within-an-editor is the named failure mode: it wouldn't rhyme with
core, and core would eventually break or obsolete it.

**The delineation razor** (what we ship vs. what extension is for):

> Out of the box, Block Actions ships primitives whose semantics the web
> platform or core already defines — dialog, popover, view transitions,
> scroll timelines, media queries — plus the grammar to compose them.
> Extension is anything with application-specific logic.

Publicly defensible ("we wrap the platform, we don't invent behaviors") and
it tells a designer exactly when they've crossed from composing into
commissioning.

**Adoption gate:** authoring a state in the editor must beat hand-writing
the CSS. If it doesn't, designers keep the cliff and skip the system.

### Developers — adoption is gated on the refactor cost of the first action

Developers care about ease of extension and integration with existing
application infrastructure. The system must solve problems in their
applications and make them simpler and more reliable — never force a risky
refactor.

**Concretely:** a developer with a working jQuery accordion or a bespoke
React island must be able to bring *one* behavior into the system in an
afternoon — a store file and a manifest — and immediately get real returns
(editor UI, validation, kses survival, governance) **without** adopting the
states worldview, the grammar, or patterns. Incremental adoption is the
product; the whole-worldview migration is optional and late.

Reliability of the extension boundary is part of the deal: action versioning,
a deprecation policy, and defined behavior when themes update under existing
content are developer-trust questions, not serialization details.

**Agents are first-class developers.** The system must be legible to an
agent maintaining and authoring actions within its confines: constraints
machine-discoverable, not doc-buried. An agent should be able to ask what
actions exist, what structure they require, and what a valid configuration
looks like — and get authoritative answers (manifests as canonical
machine-readable source; the Abilities API integration; contracts as
schema). This makes roadmap 6.6 part of the developer adoption story, not a
tail item.

**Adoption gate:** the first action is an afternoon, the boundary never
breaks underneath them, and an agent can do the maintenance.

## What the persona lens changes about sequencing

- **Phase 0 is two spikes** (briefs in
`docs/plans/2026-08-01-phase-0-spikes.md`): context multiplexing
(multiple interactions per block — gates the whole grammar) and overlay
recording (can the existing inspector record into a state overlay — gates
the designer surface). Both are feasibility questions whose answers change
the architecture; neither is an epic.
- **Developer hardening (6.5) runs in parallel** — it's the adoption gate
for the persona that decides whether this becomes infrastructure, and it
doesn't depend on the spikes.
- **Tier-1 effects and motion presets slot after the spikes report** — they
mostly serve authors (already best-served), but they are also the install
wedge. The tension between *wedge first* (installs) and *keystone first*
(states architecture) is a live strategy call, revisited when the spikes
land. It is not settled by this document.
- **Late and deliberate:** the serialization question (markup attributes vs.
comment attributes — a 4.0-class decision with a migration story) and the
pattern promotion/demotion flow (elegant, but sits on the shakiest core
dependency and the least-validated UX; let earlier phases generate
evidence first).

## Upstream posture

Prefer features shaped like future core proposals: motion presets in
theme.json (`settings.custom.motion` now, a `settings.motion` schema as the
goal), states as a generalization of Global Styles' element states, pattern
overrides for registered interaction parameters (propose upstream, shim
in-plugin). Where core moves, we retire our shim and keep the grammar.
Loading
Loading