# Admin Snappy — measured SWR loading, shaped skeletons, unified motion

Audience: AI coding agents first.
PLAN_SLUG: `admin-snappy`

## Goal

Make every plugin admin route feel immediate without showing wrong-user data or destroying active UI state.

Required sequence per data region:

1. Paint final-layout skeleton when no usable cache exists.
2. Paint user/site-scoped cached data immediately when available.
3. Revalidate live data in background.
4. Patch changed DOM in place; preserve focus, selection, edits, scroll, open menus, and open modals.

Use shared primitives. NEVER implement page-local cache, skeleton lifecycle, mutation invalidation, or motion timing.

User decisions: use stale-while-revalidate (SWR) everywhere cache is safe; keep live state uncached; include browser and WordPress cache layers in one delivery.

## Evidence gate: measure before changing architecture

Current code confirms uneven implementation, not root cause: page sizes range 28–2,006 lines; only some pages use `utils/Cache.js`/`Skeleton.js`; SCSS repeats keyframes. It does NOT yet prove whether network, PHP, database, synchronous DOM work, Chart.js, or animation causes each delay.

Wave 0 MUST record baseline on realistic dev data before implementation:

- request count, endpoint TTFB, payload bytes per page;
- route-change → skeleton/first content/settled timestamps;
- main-thread scripting/render duration and long tasks;
- localStorage parse/write duration for candidate payloads;
- cold and warm results for five heavy routes: content translation, string translation, posts, history, licensing/settings;
- one simple mutation-heavy route: languages.

Store results in `docs/performance/admin-snappy-baseline.json` plus a concise Markdown interpretation. Do not invent numeric budgets before baseline. Set per-route acceptance to BOTH:

- no regression in cold settled time or main-thread long tasks;
- warm first-content time improves by at least 50%, with target ≤200ms where baseline and environment permit.

If main-thread render dominates a route, add bounded DOM batching/keyed patching to that route's migration task. Do NOT add virtualization unless measurement shows row count requires it and existing pagination cannot bound work.

## Architecture

```
route
 └─ PageShell
     ├─ one DataRegion per independently ready data block
     ├─ DataRegion: skeleton | scoped cached data | error
     ├─ DataStore revalidate (deduped + abortable)
     └─ keyed DataRegion patch after interaction-safe gate
          ↓
      ApiClient mutation event → shared AdminDataPolicy tags
          ↓
REST permission passes → RestResponseCache hook adapter → ObjectCache (Redis/Memcached/transient fallback)
                                                     ↳ generation token invalidation
```

## 1. Browser SWR store

Create `admin/src/utils/DataStore.js`.

```js
getStore(key, {
  fetcher,
  tags = [],
  ttl = 300000,
  persist = true,
  version = 1,
  maxBytes = 262144,
}): Store

Store.get(): { data, isStale, source } | null
Store.subscribe(listener): () => void
Store.revalidate({ signal } = {}): Promise<any>
Store.mutate(updater, { revalidate = true } = {}): void
Store.invalidate(): void
Store.destroy(): void
invalidateTags(tags: string[]): void
```

Rules:

- Namespace MUST be `ipz_swr_v<pluginVersion>_<siteId>_<userId>_<key>`. Add `siteId` and `userId` to `internationalPressZone` in `includes/Admin/AssetLoader.php` and equivalent localizer if both asset paths remain active.
- Default scope MUST be current user even for site-global data. Sharing browser cache across users is forbidden.
- On boot, purge foreign plugin-version/user/site namespaces. Listen for `storage` events; invalidate matching in-memory stores across tabs.
- Persist only bounded, page-sized payloads. Entry hard cap: 256 KiB serialized. Global namespace budget: 2 MiB, LRU eviction oldest-first. Oversized write MUST remain memory-only and log through existing logger; NEVER synchronously compress it.
- Any endpoint returning unbounded arrays MUST gain pagination/payload slimming before `persist: true`.
- Payload shape: `{v, data, timestamp, lastAccess, tags}`. Parse/version/quota/security errors MUST degrade to memory-only without breaking page render.
- Deduplicate concurrent revalidation per exact key.
- Use `AbortController`; aborted/old responses MUST NOT notify subscribers.
- `utils/Cache.js` becomes obsolete only after all callers migrate. Delete it only when repository search proves zero callers.

## 2. Shared data policy and browser adapter

Create `includes/Performance/AdminDataPolicy.php` as canonical route/store/mutation policy. Localize browser-safe subset through `AssetLoader.php`; `admin/src/data/adminStores.js` adapts that policy into DataStore fetchers. NEVER duplicate TTL/scope/tag/mutation rules in PHP and JS.

`utils/api.js` emits normalized `{method, path}` after successful mutation. `adminStores.js` resolves matching policy tags and calls `invalidateTags`. Pages NEVER call cache invalidation manually.

Required behavior:

- Invalidate browser tags only after successful POST/PUT/PATCH/DELETE.
- Prefer optimistic `Store.mutate()` for deterministic local changes; always revalidate afterward.
- A failed mutation MUST retain previous cache and UI state.
- Cross-tab invalidation uses same tags via localStorage event marker.
- Policy default: user scope, no persistence, no server cache. Every relaxation MUST be explicit.

## 3. Page lifecycle

Create `admin/src/components/PageShell.js`.

```js
new PageShell(container, {
  regions,
  onError,
}).mount(): Promise<void>

PageShell.destroy(): void
```

Each region declares `{store, skeleton, render, update}`. Readiness is per-region: cached blocks paint immediately while missing blocks keep shaped skeletons. NEVER block whole page because one store lacks cache.

`destroy()` MUST unsubscribe every store, abort page-owned requests, remove listeners, and mark shell inactive. Any late callback MUST no-op. Integrate with existing router lifecycle in `admin/src/main.js`, which already calls `currentPage.destroy()`.

## 4. Deep DataRegion module and shared UI adapters

Create `admin/src/components/DataRegion.js`. Keep keyed reconciliation private; callers configure data semantics, not DOM lifecycle.

```js
new DataRegion(parent, {
  store,
  view,        // adapter from RegionPresets
  skeleton,
  errorView,
}).mount(): Promise<void>

DataRegion.destroy(): void
```

Create `admin/src/components/RegionPresets.js` with shared adapters for `table`, `select`, `form`, `stat`, `list`, and `chart`. Adapters MUST compose existing `Table`, `Select`, `ErrorState`, `EmptyState`, and `Skeleton` components; pages MUST NOT recreate raw table/select/loading/error/empty markup. Componentizer baseline found raw tables in Languages, Team Dashboard, and Analytics; raw selects in seven pages; loading branches in 14 owners. Migration MUST remove confirmed bypasses or document a structural incompatibility.

Rules:

- Patch keyed rows/options/cards. NEVER replace entire region when stable keys exist.
- Text/stat leaf regions may replace text only.
- Defer patch while region has `:focus-within`, dirty form controls, selected rows, an open dropdown/menu, or a modal bound to region data. Apply newest deferred payload after interaction ends or next explicit revalidation.
- Preserve table selection, focused element, scroll position, open details, and dirty form values.
- Avoid animation when semantic data is unchanged.
- `PageShell` owns a set of DataRegions and route teardown. DataRegion owns store subscription, loading/error/empty state, abort, keyed reconciliation, and interaction deferral. Pages own data-to-view mapping only.

## 5. Skeleton presets

Extend existing `admin/src/components/Skeleton.js`; keep existing call signature compatible.

Required presets: `table({columns, rows})`, `form({fields})`, `statRow({count})`, `list({rows})`, `chart()`, `page({header, blocks})`.

Every migrated region MUST use a preset matching final dimensions closely enough to prevent meaningful layout shift. Skeleton markup MUST be `aria-hidden="true"`; containing region MUST expose accessible loading state without repeated screen-reader announcements.

## 6. Motion system

Make `admin/src/styles/_animations.scss` sole `@keyframes` source. Move duplicate keyframes; do not create aliases that preserve duplication.

Add motion custom properties in `_variables.scss`: fast/base/slow durations and standard out/in-out easing. Replace raw duration literals in touched admin SCSS with tokens. Animate only `transform` and `opacity`, except intrinsic progress-strip animation already required by component semantics.

Provide reusable classes for fade-in, slide-up, crossfade, and skeleton shimmer. One global `prefers-reduced-motion: reduce` rule MUST disable non-essential animation and shimmer. Motion consolidation is one serialized task; it MUST NOT run concurrently with page migration tasks touching SCSS.

## 7. WordPress REST response cache

Use existing `includes/Performance/ObjectCache.php`; it already provides Redis/Memcached and WordPress transient fallback. Do not add another cache backend.

Create `includes/API/RestResponseCache.php` as a deep hook adapter with explicit `ObjectCache` and `AdminDataPolicy` dependencies:

```php
final class RestResponseCache {
    public function register(): void;
    public function invalidate(string $group): void;
}
```

Rules:

- Register `rest_dispatch_request` and `rest_request_after_callbacks`: WordPress applies dispatch after route matching and permission callbacks, and its non-null response is the callback short circuit. NEVER bypass controller permission checks.
- `AdminDataPolicy` resolves route, group, scope, TTL, and exclusions. Default scope MUST include `get_current_blog_id()`, `get_current_user_id()`, plugin version, normalized args, and group generation token. Site-shared scope requires explicit route-table opt-in after proving payload is identical across users.
- Invalidation MUST use generation tokens, never prefix scans, `KEYS`, `SCAN`, or cache-wide `flush()`. Store `ipz_rest_generation_<group>` as a unique token; replace token on invalidation. Old entries expire naturally.
- Cache only successful pure-data GET results. NEVER cache `WP_Error`, exceptions, redirects, non-2xx responses, nonce-bearing payloads, activation/payment operations, progress polling, or rate-limiter state.
- Empty arrays/zero values are valid data and MAY be cached. Cache miss MUST use a dedicated sentinel, never truthiness.
- Add a recursive nonce-key guard (`nonce`, `_wpnonce`) before write; test it.
- Server TTL MUST be 30–60 seconds for mutable lists/dashboards; up to 5 minutes only for languages/settings/plans proven stable.
- Cache failure MUST execute builder and return live data.

Create `includes/Performance/CacheInvalidation.php`. Data-layer mutation methods MUST emit `do_action('ipz_data_changed', $group)` for custom-table writes, CLI paths, webhooks, and REST mutations. CacheInvalidation listens once and rotates group generation. WordPress hooks (`save_post`, `deleted_post`, option updates) bridge into same action. A short TTL is backstop, not primary invalidation.

## 8. Store and route contract

Implementation MUST refine this table with exact normalized query-key serialization during Wave 0. Do not add stores outside registry.

| Page/owner | Store key | GET endpoint(s) | Browser policy | Server policy | Tags / invalidation |
|---|---|---|---|---|---|
| dashboard | `dashboard:stats` | `/dashboard/stats` | persist 60s | user, 30s | `dashboard`, content/job mutations |
| dashboard | `dashboard:recent-jobs` | `/jobs?recent=10` | memory only, 15s | NEVER if active state included | `jobs` |
| languages + translations + posts | `languages:list` | `/languages` | persist 5m | user, 5m | `languages`, `settings` |
| settings | `settings:all` | `/settings` | persist 5m | user, 5m | `settings`, `languages` |
| analytics section | `analytics:all` | `/analytics/predictions`, `/analytics/volume`, `/analytics/costs`, `/analytics/performance` | persist 60s | user, 60s | `analytics`, `jobs`, `translations` |
| content translation | `content:page` | `/translations/content`, `/translations/content/all`, detail GETs | persist 60s; bounded pages only | user, 60s | `content`, `translations`, `jobs`, `languages` |
| string translation | `strings:page` | `/system-translate/domains`, `/themes`, `/strings`, string detail GETs | persist 60s; bounded pages only | user, 60s | `strings`, `translations`, `jobs`, `languages` |
| translate posts | `posts:page` | `/translations/content`, `/posts` | persist 60s; bounded pages only | user, 60s | `posts`, `translations`, `jobs`, `languages` |
| history | `history:completed-page` | `/translate-jobs` | persist completed rows 30s; active rows memory-only | NEVER cache active progress | `jobs`, `translations` |
| assignments | `assignments:mine` | `/workflow/assignments`, assignment history | persist 30s, user namespace mandatory | user, 30s | `workflow`, `assignments` |
| workflow dashboard | `workflow:metrics` | `/workflow/stats` | persist 30s | user, 30s | `workflow`, `assignments`, `jobs` |
| team dashboard | `team:overview` | `/team/time-entries` | persist 60s | user, 60s | `team`, `assignments` |
| onboarding | `onboarding:plans` | `/onboarding/plans` | persist 5m | user, 5m | `onboarding`, `license` |
| licensing | `license:live` | `/license`, `/license/usage`, `/onboarding/paypal-session/*` | memory only; always revalidate | NEVER | `license`, `payment` |
| migration | `migration:live` | `/mpz/v1/migration/backup`, `/preflight`, `/process`, `/start`, `/verify` | memory only; always revalidate | NEVER | `migration` |

Pages with no direct data ownership (`analytics.js`, `translations.js`, wrapper dashboards) delegate stores to their sections/components; do not duplicate stores at page layer.

## 9. Rollout

1. **Wave 0 — evidence + exact inventory:** baseline measurements; exact endpoint/store/mutation table; verify realistic data volume; identify main-thread-dominant routes.
2. **Wave 1 — foundations:** Vitest + jsdom; DataStore; registry; PageShell; Region; Skeleton presets; PHP RestResponseCache + generation invalidation; identity localization. Unit/PHP tests green.
3. **Wave 2 — beachhead:** migrate `languages.js` (simple mutation-heavy) and `content-translate.js` (largest route). Measure cold/warm behavior. Do not fan out until budgets pass and interaction state tests pass.
4. **Wave 3 — page fan-out:** migrate remaining independent routes. Each task owns exactly its page/section and dedicated SCSS partial. Shared registry changes are serialized through one integration task; page agents return requested registry entries rather than editing shared file concurrently.
5. **Wave 4 — motion:** serialized keyframe/token consolidation after page SCSS settles.
6. **Wave 5 — full verification and cleanup:** delete obsolete cache/loading code only after zero-reference proof; build; unit/PHP; remote E2E; visual/performance comparison; warning-free gates.

If beachhead shows shared primitives do not improve measured bottleneck, STOP fan-out and revise design from evidence. This is an architecture gate, not user preference.

## Testing and release gates

Add Vitest + jsdom and `npm test` in `admin/package.json`. Tests MUST cover TTL, version bust, LRU/quota behavior, user/site namespace isolation, promise dedupe, abort/late-response suppression, storage-event invalidation, optimistic rollback, PageShell teardown, Region keyed patch, and interaction deferral.

PHP tests MUST cover hit/miss, valid empty payload caching, generation invalidation, user/site separation, nonce rejection, non-success rejection, transient fallback, and cache-failure live fallback. Verify an actual second-call cache HIT in dev WordPress without Redis/Memcached.

Playwright MUST run through `~/.claude/bin/e2e-remote`; NEVER launch local browser/dev-server pair. Add outcome tests for:

- cold load: shaped skeleton per missing region → data;
- warm load: cached rows appear before network settles, no skeleton over cached region;
- cache isolation between two users;
- language mutation invalidates browser/server caches;
- background refresh preserves dirty settings, selected rows, focus, scroll, open dropdown, and modal;
- route change aborts old updates;
- reduced motion disables shimmer/transitions;
- before/after performance budgets from Wave 0.

Run existing journey E2E suite unchanged as regression gate. Build command: `cd admin && npm run build`.

## Non-goals

- No framework migration, service worker, visual redesign, or cache-wide flush.
- No persistence of live progress, license/payment/session state.
- No virtualization without Wave 0 evidence.
- No server schema redesign unrelated to measured latency or bounded payloads.

## Architecture decisions

- Keep DataStore, DataRegion, and PageShell separate: cache lifecycle, data-region state/reconciliation, and route ownership each hide distinct complexity. Keep keyed reconciler private inside DataRegion; a public standalone Region would be shallow.
- Add RegionPresets: table/select/form/stat/list/chart patterns repeat across 3+ owners and must compose existing shared components rather than create parallel markup.
- Make AdminDataPolicy canonical: PHP REST cache and browser invalidation consume one policy; duplicated scope/TTL/tag tables would drift.
- Use ObjectCache: source confirms transient fallback at `includes/Performance/ObjectCache.php:176-217`; advisor claim that fallback was absent was incorrect.
- Use generation tokens: ObjectCache has no safe prefix enumeration; broad flush is forbidden.
- Default all caches to user scope: small duplication cost is preferable to cross-user disclosure.
- Cache legitimate empty results: truthiness-based cache semantics would create repeated misses and incorrect behavior.
- Require measured beachhead before fan-out: avoids optimizing network while main-thread rendering dominates.
- The old `Cache.js` was deleted in `9f5d447c` after repository searches proved zero source, test, Webpack-entry, dynamic-import, and built-runtime consumers.


### Final cache seam decision

Response-level caching at `rest_dispatch_request` is permanent. It covers every eligible controller through `AdminDataPolicy`, preserves response status, headers, and valid empty payloads, and leaves mutations/live state at the REST boundary. A `LanguageManager::remember` wrapper was rejected because it caches one domain below that boundary and cannot preserve or safely replay the complete response contract. The dispatch filter runs after permissions; tests assert that permission precedes hit replay and that mutation responses remain live.

The immutable seven-row performance contract is a measured subset of the 15 runtime `AdminDataPolicy` descriptors. A deterministic crosswalk validates that each measured key has one compatible policy descriptor; the other eight policy-only descriptors do not alter the historical baseline or final receipt.

## Implemented contract

`AdminDataPolicy` is the sole 15-descriptor runtime policy. Its first seven
entries are the immutable performance measurement subset in
`admin/performance/admin-snappy-route-contract.json`; the remaining eight are
runtime-only and are covered by policy/consumer tests and the all-pages browser
inventory. `#/translations/posts` selects `posts:page` and calls the canonical
`/translations/content?postType=post` endpoint.

The response cache reads at `rest_dispatch_request` only after WordPress route
matching and permission evaluation, and invalidates writes from
`rest_request_after_callbacks`. Full REST response status, headers, and empty
bodies are replayed without bypassing permission or rate-limit allowance.

TTL-zero DataRegions await their live mount fetch and preserve painted data with
accessible retry on a failed revalidation. The dashboard places Quick Actions
immediately after its overview header and contains no System Status/Health
card. No shipped plugin CSS, JS, or localized PHP implements dark mode.

The final browser lane uses Playwright 1.62.0 through the remote controller for
Chromium and Firefox at `http://100.126.128.50:8081`; no localhost target is
used. `admin/src/utils/Cache.js` is deleted and production references are zero.
