Skip to content

View Transitions (3.2.0): animated query swaps + MPA theme opt-in - #12

Open
dabowman wants to merge 11 commits into
mainfrom
claude/session-context-vgj7i0
Open

View Transitions (3.2.0): animated query swaps + MPA theme opt-in#12
dabowman wants to merge 11 commits into
mainfrom
claude/session-context-vgj7i0

Conversation

@dabowman

@dabowman dabowman commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Implements the View Transitions spec (docs/specs/view-transitions.md, roadmap 6.4 — scope narrowed to View Transitions only). Two independent layers, zero configuration, zero editor surface.

What ships

Layer 1 — router swaps (default-on). Filter / live-search / pagination region updates crossfade and post items morph to their new grid positions, via the native View Transitions API. navigateTo() warms the router cache first and only wraps a settled cache-hit swap in startViewTransition (3 s prefetch guard), so the rendering freeze can never include network time. Feature-detected, prefers-reduced-motion-aware, kill switch: apply_filters( 'block_actions_query_view_transitions', true ). Query_Action::post_process_html() injects deterministic names server-side (ba-query-{queryId} on the region, ba-q{queryId}-p{postId} per item, token-exact post-{ID} matching, first occurrence only). A 400 ms timer restores the screen-reader loading announcement that core's navigate() can no longer emit on the prefetched path.

Layer 2 — cross-document/MPA (theme opt-in). add_theme_support( 'block-actions-view-transitions' ) prints one reduced-motion-guarded @view-transition { navigation: auto; } rule in wp_head. The ba-vt-post class (typed into Additional CSS Class(es)) gets a render_block-injected view-transition-name: ba-post-{ID} for archive-thumbnail → single-hero morphs. The two layers compose.

No startViewTransition exists anywhere in @wordpress/interactivity-router (verified against 2.52.0 source) — both layers are ours; neither patches core. No REST, no nonces; the query engine's page-cache posture is untouched. Graceful degradation: no API → today's instant swap, byte-identical.

Process

Spec was validated against the actual router 2.52.0 package source and repo code before implementation (validation findings folded in as d239ca0). Implementation ran as three ownership-scoped chunks, each adversarially reviewed before its commit. The reviews caught real defects pre-commit, including a kill-switch bypass on the debounced live-search path (runGenerator() resumptions run with an empty namespace stack, so a bare getConfig() read the empty config) — fixed with an explicit namespace and locked in by a fails-on-revert regression test.

Testing

  • Jest: 235 tests / 12 suites green (+14 covering the transition branch, guard timing, announcement, kill switch, fallbacks)
  • PHPUnit (wp-env): 122 tests / 283 assertions green (+16 covering naming, dedupe, lookalike class tokens, config, theme-support printing, ba-vt-post injection)
  • ESLint + PHPCS clean; production build compiles with @wordpress/a11y externalized as a module dep
  • Outstanding: the spec's manual browser pass (grid morph, throttled-network SR check, no-API fallback, MPA hero morph) — needs human eyes in wp-env
  • Version bump to 3.2.0 deliberately left for the release step

🤖 Generated with Claude Code

claude and others added 6 commits August 1, 2026 17:38
Interview-driven spec for the 6.4 cycle, narrowed from the five-primitive
modern-primitives pack to View Transitions only. Two layers: router region
swaps (default-on, prefetch-then-wrap around navigate(), crossfade plus
server-injected per-item morph names scoped by queryId) and cross-document
MPA transitions (theme-support opt-in emitting the @view-transition rule,
plus the ba-vt-post class helper for per-post names that connect archive
thumbnails to single-post heroes). No editor UI; reduced-motion disables
both layers; core router verified to have no native VT support (2.52.0),
so the wrap is ours and patches nothing.

Roadmap: tick shipped 6.1-6.3, record the 6.4 scope decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7d5iHfz3R4qucXkVSWajG
Verified against router 2.52.0: fetchPage() never rejects (failures
resolve the cache entry to false, which navigate() answers with a hard
reload, not a re-fetch) and it has no timeout. The real hazard is a hung
fetch, so the wrap now races the prefetch against a 3s bound and only
starts a transition once the fetch has settled — the rendering freeze
can never include network time. Hung fetches fall back to a plain
navigate under core's own 10s timeout. Accessibility note and test plan
updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7d5iHfz3R4qucXkVSWajG
Plugin-side 400ms loading announcement (core's can't fire on the
transition path), popstate declared a non-goal, accurate hard-reload
freeze semantics, token-exact post-ID matching, and version-citation
wording for router 2.52.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Query_Action::post_process_html() names the region root
(ba-query-{queryId}) and each post item (ba-q{queryId}-p{postId},
token-exact post-{ID} match, first occurrence only) so surviving posts
morph on router swaps. enqueue_view_script() delivers the
block_actions_query_view_transitions kill switch and the translated
loading announcement via wp_interactivity_config.

New View_Transitions class: theme opt-in
(add_theme_support 'block-actions-view-transitions') prints the
reduced-motion-guarded @view-transition rule in wp_head; a render_block
filter injects ba-post-{ID} on ba-vt-post-classed blocks (first-tag
exact-class guard, context postId with main-loop fallback).

Adversarially reviewed (pass; one nice-to-have noted for nested-query
regions). 122 PHP tests / 283 assertions green, PHPCS clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
navigateTo() gains the prefetch-then-wrap branch: warm the router cache
(raced against a 3s guard) so startViewTransition only ever freezes a
cache-hit swap; types signature (block-actions-query) with
callback-form fallback; updateCallbackDone awaited so the animation
never blocks the generator. A 400ms timer restores the screen-reader
loading announcement core's navigate can no longer emit on this path.

Review fix: getConfig() is called with the explicit
'block-actions/query' namespace — runGenerator() resumptions (debounced
live search) run with an empty namespace stack, and a bare getConfig()
there silently bypassed the kill switch. The interactivity mock is now
namespace-keyed so that regression stays catchable.

235 JS tests / 12 suites green, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New docs/view-transitions.md: default router animations, kill switch,
MPA opt-in, the ba-vt-post thumbnail-to-hero recipe (with the
one-per-post-per-page caveat), static-name CSS recipes, and the
::view-transition-* / transition-type styling hooks. Pointer added to
the query-loop guide; CLAUDE.md gains the new class, guide, and a
two-layer architecture summary.

Adversarial review fixed a never-matching scoping selector and a wrong
Firefox support claim before commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

dabowman and others added 5 commits August 1, 2026 14:36
Ordering. Prefetch-first made navigations enter the router in
network-settle order, defeating core's navigatingTo guard (which only
orders by entry) — a slow earlier search could land after and overwrite
a faster later one. A latestNavigation map, claimed before anything
awaits and re-checked after the prefetch race, restores latest-wins:
superseded navigations bail before burning a snapshot, and only the
owner clears the loading flag.

Duplicate names. Duplicate page-level view-transition-names make the
browser skip the WHOLE transition, and the server can only dedupe
within one render — a sticky post repeats across pages by default, so
loadMore() now strips names off infinite-scroll appends. Per-request
seen-maps guard ba-post-{ID} and ba-query-{id}; already-named items are
skipped so a nested query keeps its own identity.

Escape hatches. clientNavigationDisabled short-circuits canTransition()
(the router hard-reloads from a never-settling promise there, freezing
the page until Chrome's timeout); the kill switch now gates name
injection too (the names aren't inert with the store off — they drive
the MPA layer and create a stacking context at all times); matchMedia
is guarded and the whole transition branch wrapped so any throw falls
through to a plain navigate rather than leaving a preventDefault()ed
dead link; vt.ready / vt.finished get no-op catches; try/catch on the
new render_block filter per the repo's error-handling rule.

Quality. The .is-loading dim was baked into both snapshots — suppressed
inside a query transition via a transition-type-scoped rule with an
invalid-selector fallback. Guard timer made cancellable, append_style()
deduplicated, post_id_from_classes() uses core's class_list(), dead
pending flag removed. Docs corrected: the spec's "latest wins is
inherited from core" rationale was wrong, and the guide's "any block
carrying it" claim doesn't hold for Group/Columns.

244 JS tests / 12 suites green, ESLint and PHPCS clean, build succeeds.
The nine added or adjusted PHP tests still need Docker/wp-env to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second 6.4 slice, interview-driven. Two actions on the native Popover
API: popover-toggle (dropdown/disclosure targeting a new Popover
variation of core/group, popover=auto injected at render time via the
force_dialog precedent) and tooltip (text-only inspector field; the
renderer splices a plugin-owned themable hint-popover element with
role=tooltip and aria-describedby — no target at all). CSS anchor
positioning only, with a placement enum compiled to position-area and
flip fallbacks; non-supporting browsers get the UA centered default.
Default-click triggers on real <button> elements compile to the native
popovertarget invoker (works with JS off, browser-managed
aria-expanded); everything else rides the store with toggle-event state
sync. Button + Group hosting, no editor preview, 3.3.0.

Roadmap: record the popover slice, drop the details accordion-group
primitive (core shipped an accordion block), and capture the editor
state-indication need as new Task 6.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7d5iHfz3R4qucXkVSWajG
Capture the three-persona framing (authors as a constraint, designers
as the worst-served persona who defines the seam, developers gated on
the refactor cost of the first action — agents included) as the fixed
reference point for future specs, with the delineation razor (we ship
platform-defined primitives plus the grammar; extension is app logic),
the upstream posture, and the open wedge-vs-keystone sequencing call.

Define the two Phase 0 spikes that gate the states-and-effects
direction: context multiplexing (multi-namespace hydration and co-armed
handlers on one element, via a standalone interactivity-runtime harness)
and overlay recording (whether the existing inspector can record into a
state overlay — write-path census, interception candidates including
diff-on-exit, merged rendering, core-dependency register).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7d5iHfz3R4qucXkVSWajG
In-browser verification (interactivity 6.52.0, standalone Playwright
harness) plus runtime-source corroboration: repeated suffixed namespaced
directives on one element hydrate per-namespace, and co-armed entries
each evaluate in their own store scope. Recommended plumbing: the
transformer injects each interaction's context and qualified directives
side by side; the engine stays a pure dispatcher; single-interaction
output stays byte-identical to today's.

Constraints discovered: same-namespace contexts merge (grammar caps at
one interaction per action per block), a bare ba-fire broadcast hits
every armed entry (engine must dispatch per-tuple event names), co-armed
entries cannot cancel each other, and multi-tuple engine config moves
into the engine's own suffixed context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7d5iHfz3R4qucXkVSWajG
Source analysis of block-editor 16.1.0 vs the WP 7.0 bundle (15.13.2):
Gutenberg >= 15.21.0 (June 2026) contains block style states — a
per-block selected state that routes every core supports panel through
getStyleForState/setStyleForState into a nested path inside the style
attribute, with instance-scoped canvas preview CSS, a state-reframed
inspector, and state-scoped reset-all. State keys pass through
unvalidated, so custom named states work end to end and core generates
no frontend CSS for them.

Recommended mechanism: piggyback via the private
setSelectedBlockStyleState action plus an upstream proposal for
registerable named states; diff-on-exit (guardrailed) as the fallback.
The prop-swap BlockEdit HOC is rejected — supports panels read
attributes from the store, so prop surgery never reaches the read path.

Grammar consequences: overlays are style-only and live in the style
attribute under a collision-proof state key; interactionSettings only
names and wires states; frontend CSS is ours; the designer surface is
version-gated on the 15.21+ machinery (feature-detect, don't shim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7d5iHfz3R4qucXkVSWajG
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