Skip to content

App Framework — a window in one PHP file (.osx.php + optional .os.ts), Code Blue & WP Explorer rebuilt on it - #722

Merged
AllTerrainDeveloper merged 19 commits into
trunkfrom
feature/openstation-app-framework-code-bred
Sep 1, 2026
Merged

App Framework — a window in one PHP file (.osx.php + optional .os.ts), Code Blue & WP Explorer rebuilt on it#722
AllTerrainDeveloper merged 19 commits into
trunkfrom
feature/openstation-app-framework-code-bred

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Proposal

An OpenStation app is a window declared in one PHP file. App::define() covers everything a native window can do today — title, size, icon, title-bar buttons, ⋯-menu rows, tabs, per-window theme/controls/slots, desktop icon, gate — plus a typed state schema, actions, and a view painted from <os-*> components. No JavaScript per app: one shared runtime mounts it, sends os-action triggers to PHP, and morphs the re-rendered body back in.

When an interaction must be instant (a filter over rows the browser already holds), the app adds a client view — a .os.ts beside the .os.php — with local actions and a view( { state, data } ) rendered by the kit's own html tag. Same state model, same dispatch contract, same effects; os-bind writes and local actions never leave the tab.

Full contract: docs/app-framework.md. Recipe: docs/examples/os-app.md.

What's in the box

  • Framework core (includes/framework/): App, State, Runtime, Registry, Os, Effects, Html — calls no WordPress function. Six contracts (Auth, Settings, Hooks, Cache, Env, Store) with WordPress and standalone adapters; define( 'OPENSTATION_STANDALONE', true ) runs the same framework and the same app files on bare PHP (Runtime::describe() returns "the whole window" as a value).
  • WordPress host (includes/framework/wordpress.php): loads apps/ (+ openstation_apps_directories), registers every allowed app through openstation_register_window() / _tab() / _icon(), one route POST desktop-mode/v1/apps/<id>/dispatch.
  • Client runtime (src/app-runtime/, 19 kB minified / 7.1 kB gzipped): keyed DOM morph, the attribute vocabulary (os-action, os-bind, os-arg-*, os-on for every kit event, os-keys, os-debounce, os-confirm*, os-poll, os-key, os-preserve, os-prop-* for property-driven components), effects (toast, title, close, open, open_url, badge, announce, context menu, channel send), tabs, channels, lifecycle actions, wp.os.apps.{dispatch,local,session}.
  • @openstation/app for .os.ts files: defineApp(), html, i18n. apps/*/*.os.ts are discovered by Vite and built by npm run build:apps.
  • <os-histogram>: the stacked time chart with a toggle legend joins the component kit.
  • Code Blue rebuilt as apps/code-blue/ (same window id, gate and openstation_code_blue_* hooks). Removed surface — the /code-blue/* REST routes and the window_args / icon_args / template_html filters — is written up in docs/migration-code-blue-app.md. Filters are instant (client view); reading a log, switching source and clearing are one request each.
  • My WordPress rebuilt (My WordPress: WP Explorer rebuilt on the App Framework — 8,169 lines against 32,238, Agents wizard included #727, merged here) as apps/my-wordpress/ — WP Explorer as an App Framework app: the section grids, the dossiers, the Agents wizard and the WooCommerce surfaces, with the legacy desktop-mode-my-wordpress native window, its bundle and its build target deleted whole. The full story is in that PR; migration notes in docs/migration-wp-explorer-app.md.

The ledger

Source lines only, tests and docs excluded. What the framework costs once, what each app costs on top of it, and what the old implementations gave back. (Numbers include the hardening pass below — the framework grew by the services both apps were re-implementing, and both apps shrank while gaining behaviour.)

New code:

PHP TypeScript CSS Total
App Framework — includes/framework/, src/app-runtime/, runtime CSS 3,952 2,204 65 +6,221
Code Blue app — apps/code-blue/ 606 356 269 +1,231
My WordPress app — apps/my-wordpress/ 2,730 5,383 1,588 +9,701

Removed:

PHP TypeScript CSS Total
Old Code Blue — includes/code-blue/, src/code-blue/, its stylesheet 981 1,726 528 −3,235
Old My WordPress (WP Explorer) — the src/my-wordpress/ bundle deleted whole, plus PHP registration trims 274 15,823 18 −16,115

Read together: the two apps' 10,932 lines replace 19,350 removed ones, and the framework's 6,221 are paid once — every further port lands at the apps' ratio, not its own. <os-histogram> and <os-stat> (880 lines) are counted with neither: they joined the general component kit. On the My WordPress side, ~7,100 lines deliberately survive as shared infrastructure (the WooCommerce bundle, the shared explorer stylesheet, the cross-bundle leaf modules) — itemised in #727.

Is it actually easier to write an app now? — an honest comparison

The line counts say a third of the code; here is what that means at the desk, task by task, old implementation against new — including what got harder.

Registering a window. Old Code Blue needed five PHP files: window.php (registration args, the icon, a kses'd template callback, three filters, an init priority matched by convention), rest.php, assets.php, bootstrap.php, plus a per-app Vite build target and the lazy-load sync wiring. Old My WordPress's window.php alone was 359 lines. New: one App::define() chain in one file — the template, the dispatch route, the gate, the stylesheet and the client bundle are all derived by convention (npm run build:apps discovers every apps/*/*.os.ts).

Painting UI. The old bundles were imperative: renderCodeBlue() built every control with document.createElement, attached an addEventListener per control, and choreographed its own paint functions and repaints — 26 imperative DOM calls in old Code Blue's entry, 274 across old My WordPress's. The new view is a function of ( state, data ): new Code Blue contains zero createElement/addEventListener calls — the whole window is a template — and new My WordPress contains 18, of which 15 live in wire.ts, the deliberately imperative edge (drag-out, drop targets, the hover card).

Talking to the server. Old: every operation was its own REST route (register_rest_route + permission_callback + args schema) and its own client function in a hand-rolled REST module (src/code-blue/rest.ts, 118 lines; src/my-wordpress/rest.ts, 1,101 lines) reading window config, joining URLs, carrying nonces, handling errors. New: an operation is one ->action() closure; the wire, the nonce, request serialisation, the local-write-survives-echo race handling, busy states and error toasts are the runtime's. One-off reads go through ctx.fetch. And there is one capability gate per app instead of a permission_callback per route — fewer places to get authorization wrong.

The behaviour every list window needs. Old: each window rebuilt pagination, selection, marquee, stat tiles and formatters for itself — that is a large part of how the old My WordPress bundle reached 15,707 lines. New: createPagedList(), applySelection(), createMarquee(), <os-stat>, formatBytes/formatDate, Os::page()/Os::facts() — an app composes them instead of writing them.

Testing. mockViewContext() mounts an app view in jsdom in one call and local actions are pure reducers; the old bundles needed a DOM rig and fetch stubs per module. The Code Blue suite pins the whole app at under half its old size.

What is honestly harder, or new:

  1. There is a model to learn. Server action vs local action vs os-bind — you must decide which side every interaction lives on, and the wrong default is slow: a server-view interaction is a full WordPress request (~235 ms on the local Docker). The framework makes the right choice possible, not automatic.
  2. The attribute vocabulary and effects are a DSL. Until os-action/os-arg-*/os-confirm/os-poll are muscle memory, the reference stays open in a tab.
  3. State is a schema. Undeclared keys are silently dropped and types are coerced — deliberate and documented, but the first "why doesn't the client remember this?" hour is real.
  4. Debugging crosses more layers — trigger → binding → dispatch wire → State coercion → data() → render — than "click handler mutates DOM" ever did.
  5. Shadow-DOM components change test idioms. textContent does not see into <os-stat>; assertions read props/attributes (several were converted during the port).
  6. Client views are not third-party-consumable yet. @openstation/app is a Vite alias into src/ — outside plugin authors get server views today; the instant path is ours until the package ships.
  7. The imperative edge remains. My WordPress's wire.ts is still ~700 lines of drag/drop/hover glue in mounted/updated. The framework shrank that corner; it does not — and should not — pretend it away.

Net: for the shapes admin windows actually take — lists, forms, dashboards, detail panes — an app is about a third of the code, and roughly a working day of registration/REST/paint ceremony per window is simply gone. The price is a learning curve and a wire between the author and the DOM. Code Blue demonstrates the floor (an entire window with zero imperative DOM); My WordPress demonstrates the ceiling (the hardest 15% still needs hands, just fewer of them).

How an app opens — click to painted

Nothing about a Code Blue window is in the tab until you ask for it. The
runtime is registered at boot and never enqueued; the client view, both
stylesheets and the log itself arrive on the first open, in this order:

sequenceDiagram
    autonumber
    actor U as User
    participant SH as Shell · window manager
    participant CV as apps/code-blue.min.js
    participant RT as app-runtime.min.js
    participant API as REST · dispatch route
    participant AP as Runtime + code-blue.os.php
    participant LR as log-reader.php

    Note over SH,LR: PHP already ran. init@10 loaded apps/*/*.os.php into the Registry.<br/>init@20 registered the native window, the desktop icon, the Refresh button and the ⋯ Clear row,<br/>and shipped openStationWindowConfig — osApp true, the endpoint, a nonce, the declared state.<br/>Not one byte of this window's JS or CSS is in the tab.

    Note over U,SH: 1 · The ask
    U->>SH: click the dock tile or the desktop icon
    SH->>SH: manager.open() — window frame in the DOM, loading overlay up
    SH->>SH: cloneTemplate() — the server-rendered mount root paints its os-spinner

    Note over SH,CV: 2 · First open only — companion assets, in contract order
    SH->>SH: inject app-runtime.css and code-blue.css
    SH->>CV: load the built .os.ts bundle
    CV-->>SH: defineApp() publishes window.openStationApps[id]
    SH->>RT: load the one shared runtime
    RT-->>SH: registerApps() publishes openStationNativeWindows[id]<br/>and registers the title-bar and ⋯ chrome

    Note over SH,RT: 3 · Mount
    SH->>RT: invoke the render callback — resolved from the registry after the load
    RT->>RT: build the wp.os host, apply window theme, controls and slots
    RT->>RT: createSession per mount root, wire hide / show / resize / channels
    RT->>API: dispatch mount — state, args, params, viewport, X-WP-Nonce

    Note over API,LR: 4 · One round trip, all of it server-side
    API->>AP: permission — logged in, app exists, App::allows()
    AP->>AP: can_use() — manage_options AND Developer mode, both filterable
    AP->>AP: new State(defaults, incoming) — undeclared keys dropped, types coerced
    AP->>AP: run_mount(), then compute_data()
    AP->>LR: sources() → current_source() → read()
    LR->>LR: tail() the last 1 MB
    LR->>LR: parse() → entries filter → cap
    LR-->>AP: entries, sources, environment, scanned bytes
    AP-->>API: ok, state, html, data, effects<br/>html is empty — Code Blue has no server view
    API-->>RT: 200 JSON

    Note over RT,U: 5 · Paint
    RT->>CV: client.render() — view(state, data) through the kit's html tag
    CV-->>RT: diffed into the live root, nodes kept
    RT->>RT: finishRender() — os-prop-* props, lazy-load missing os-* components, reconcile os-poll timers
    RT->>RT: mounted() fires once, reading a finished DOM — then run effects
    RT-->>SH: the mount promise resolves
    SH->>U: loading overlay drops — the window is open

    Note over U,LR: Steady state. Range, search, sort, legend chips and row expansion are local actions:<br/>runLocal() reduces the state, repaints, finishRender() — every paint, server or local, ends in the same pass.<br/>Only Refresh, switching source and Clear log come back to the dispatch route — each exactly one request.
Loading

Three things the shape is meant to make obvious:

  • The window's cost is paid on open, not on boot. Steps 5–9 happen once
    per tab. Every admin page that never opens Code Blue pays nothing for it.
  • The client view loads before the runtime. That is the companion-script
    contract in native-windows.ts, and it is load-bearing here: the runtime's
    render callback calls clientAppFor( id ), which can only find what
    defineApp() already published.
  • mount is the only request between clicking and reading the log. The
    old window needed the bundle plus /code-blue/sources plus
    /code-blue/entries.

The hardening pass — from experiment to the shipping framework

This started as an experiment; with two apps landed it is the framework we ship, and a full framework-vs-apps audit (every capability inventoried against what each app actually uses, plus a duplication sweep) drove a hardening pass. The rule it enforced: anything both apps re-implement is the framework's job, and an app carries only what makes it that app.

What moved into the framework:

  • ViewContext grew the client services apps were hand-rolling: ctx.dispatch( …, { confirm } ) (the shell's confirm dialog on imperative dispatches — fixing a real hole where Move to Trash from the context menu ran unconfirmed while the same action's button confirmed), ctx.ui( factory ) + ctx.repaint() (client-only per-view state with an explicit re-render, replacing a WeakMap bag and a no-op reducer), ctx.fetch() (REST root + nonce + window spinner attribution supplied by the framework), and ctx.host (the typed shell surface).
  • createPagedList() — the infinitely scrolled server-paginated list (accumulation, the one-page-per-scroll-gesture sentinel protocol, skeleton sizing, the short-list deadlock guard): ~120 lines every list window needed, now @openstation/app's. applySelection() (click/Ctrl/Shift selection math) beside it.
  • refresh joined set as a built-in action — both apps had declared an empty PHP handler just to get "recompute data() and re-render".
  • Os::page() (the paged-list envelope, hand-assembled five times) and Os::facts() (the drop-empty-facts idiom, four times) as pure statics — the framework core stays WordPress-free.
  • formatBytes / formatDate exported from @openstation/app (five app-side date helpers and a third formatBytes copy deleted; the footprint's month callout no longer shifts a month in negative-UTC timezones).
  • <os-stat> joined the kit (three surfaces drew the same stat tile with three stylesheets), and the data-tone--os-app-tone tone contract moved into app-runtime.css scoped to .os-app — it had been document-scoped in Code Blue's sheet, retinting any data-tone element in the tab.
  • mockViewContext() (src/app-runtime/testing.ts) — one blessed test context instead of four drifting stubs.
  • Plus the couplings the audit flagged: the Agents pane strip is the kit's <os-tabs> (the hand-rolled tablist had no roving focus), the context-menu clamp is the shell's clampToViewport, the duplicated hover-card CSS is served once by the shared explorer sheet, and os-preserve is documented as the server-view morph's contract (client views guard imperative DOM with stamps, which is what actually protected it).

Still open, deliberately: tabs/channels/server-view surfaces have no consumer app yet — the next port should be chosen to exercise them.

Tests

npm run build, lint, typecheck, test:js (5,338), full PHPUnit (2,722), PHPCS clean. New: tests/phpunit/tests/appFramework.php (including Os::page/Os::facts and the built-in refresh), tests/phpunit/tests/codeBlue.php, tests/vitest/app-runtime-*.test.ts (one scans the component kit and fails if the runtime's event list falls behind; app-runtime-session pins the new context services; app-runtime-paged-list pins the scroll-gesture protocol itself, which the app suites never covered; app-runtime-format pins the formatters), apps/code-blue/code-blue.test.ts, the apps/my-wordpress/** suites, src/ui/components/os-histogram/os-histogram.test.ts, src/ui/components/os-stat/os-stat.test.ts.

Review round 2

@epeicher's four passes are addressed in 4bcc7ed:

  • Built app bundles + TypeScript in the zip. assets/js/apps/ is gitignored on its own line (the /assets/js/*.js pattern only matches the top level), the two committed bundles are untracked, apps/**/*.ts is export-ignored, and bin/package.sh now walks apps/*/*.os.ts for the bundles it splices in — so the zip no longer depends on git-archive shipping them, and the unminified dev build stops shipping. Verified against a built zip: apps/code-blue/{code-blue.os.php,code-blue.css,log-reader.php} + assets/js/apps/code-blue.min.js, no .ts, no code-blue.js.
  • Doc claims. client( $path ) now says client views are not third-party-usable yet and why (@openstation/app is a Vite alias into src/); the standalone section says the seam is real but has no shipped bootstrap and that Code Blue's __() keeps it on WordPress; the runtime figure above is corrected to 19 kB / 7.1 kB gzip.
  • $os->toast( $message, $tone ). The parameter is gone rather than faked — the shell has no toast severity at all (wp.os.showToast() takes none), so implementing it would have meant a new <os-toast> variant, which is its own change.
  • The default gate, the unescaped server view, state depth. New "The gate is the only authorization there is" section in docs/app-framework.md, with the state-depth limit pinned by a test. The gate default is left matching openstation_register_window()'s (any logged-in user) rather than changed unilaterally — a per-action capability declaration is the open design question, happy to add it if you'd rather it be structural than documented.
  • Smaller items. The morph now morphs children before assigning a <select>'s value, and spends a duplicated os-key on first use; os-range-change joins the default-debounced events; the three dropped Code Blue tests are back; Code Blue's remember() cache is removed (the filters ran inside the callback and parse() bakes localized labels in — a log reader's product is freshness).

CI, both jobs red before: PHPUnit failed because the desktop-icon registry is process-scoped and the icons these tests register leaked into Tests_OpenStation_FilesStore's auto-place counts; Plugin Check failed because it recognises exactly four direct-access guard spellings and the framework's dual ABSPATH/OPENSTATION_STANDALONE guard is not one of them (23 files read as unguarded), and because it runs PHPCS under its own ruleset where phpcs.xml.dist's customEscapingFunctions do not exist.

Also, per @AllTerrainDeveloper: the app file is now <name>.os.php beside <name>.os.ts — one extension family per app.

Manual check

Developer mode on → open Code Blue. Range, search, sort, legend chips and row expansion respond with no request; Refresh / source switch / Clear log show the busy state once. Compare against the previous window: cards, legend row, toolbar are 1:1.

🤖 Generated with Claude Code

Open WordPress Playground Preview

…onal instant client view (.os.ts); Code Blue rebuilt on it

A window is now one file. `OpenStation\App::define()` declares title,
size, icon, title-bar buttons, ⋯-menu rows, tabs, per-window chrome,
a typed state schema, actions, and either a server-rendered view
(zero JavaScript) or a `data()` plus a `.os.ts` client view for the
interactions that must never wait for a WordPress request.

- includes/framework: host-agnostic core (App, State, Runtime,
  Registry, Os, Effects, Html) behind six contracts (Auth, Settings,
  Hooks, Cache, Env, Store) with WordPress and standalone adapters;
  the WordPress host loads apps/*/*.osx.php, registers them as native
  windows and serves one dispatch route.
- src/app-runtime: the one shared client bundle — mount, dispatch,
  keyed DOM morph, os-action / os-bind / os-arg / os-poll / os-prop /
  os-confirm vocabulary covering every kit component and event,
  effects (toast, title, close, open, open_url, badge, announce,
  menu, send), tabs, channels, lifecycle actions; `@openstation/app`
  gives an .os.ts `defineApp()` with local actions and a view
  rendered by the kit's html tag.
- <os-histogram>: the chart moves into the component kit.
- Code Blue is rebuilt as apps/code-blue (same id, gate and hooks;
  the /code-blue/* REST routes and window_args/icon_args/template_html
  filters are gone — docs/migration-code-blue-app.md): 1,269 lines
  instead of 3,235, filters instant, one request to read the log.

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

Copy link
Copy Markdown
Collaborator

Reviewed against trunk across four passes (security, architecture, backwards compatibility, performance). The bet is sound and the execution is disciplined: no confirmed exploitable vulnerability, boot cost does not regress (the runtime is registered but never eagerly enqueued), and the open path drops from 26.7 kB gzip to 12.9 kB. Registering through openstation_register_window() means live refresh works with no new payload key, which is the right call.

Before merge

1. Built app bundles are committed. git ls-files assets/js/apps/ returns code-blue.js and code-blue.min.js. The .gitignore pattern /assets/js/*.js only matches the top level, so the new subdirectory slipped through (assets/js/app-runtime.js is correctly ignored). This is the "assets/js is dist/" rule, and it will produce minified diffs in every future PR.

2. TypeScript source ships to wp.org. git archive HEAD apps/ includes code-blue.os.ts and code-blue.test.ts. .gitattributes export-ignores /src/ and /tests/ but nothing under apps/.

These two need fixing together: bin/package.sh's fileBase sed only matches single-quoted literals and cannot see the app targets' template-literal path (vite.config.js:704), so the zip currently depends on git-archive shipping the committed bundles. Teach package.sh about the app targets first, then gitignore the output. That also stops the unminified dev bundle shipping.

3. Two doc claims outrun the code. docs/app-framework.md:89 tells third parties to pass App::client( $path ), but @openstation/app is a Vite alias to src/app-runtime/client.ts, which imports ../i18n and ../ui/core/html. No package, no global export, so only server-view apps are third-party-usable today. Same for standalone mode: the seam is genuinely clean (verified zero WP calls in the core), but nothing boots it without WordPress in CI, and Code Blue calls __() nine times so it cannot run there. Also the "10 kB" runtime figure measures 19,319 bytes minified / 7,321 gzip.

4. $os->toast( $message, $tone ) silently drops the tone. src/app-runtime/index.ts:75 forwards only { message } and ToastOptions has no tone field, so every success renders as an error. Implement it or drop the parameter before the surface freezes. The session test asserts against a stub host, which is why this passed CI.

Worth deciding while the surface is still Experimental

  • The default gate is any logged-in user. class-app.php:366-379: with no capabilities() or can(), allows() returns true for any authenticated user, and there is no per-action authorization. On a WooCommerce site every customer clears that, so an author who writes ->action( 'delete_all', ... ) without a gate has exposed it to all of them.
  • An unescaped server view is more than XSS. The dispatch HTML path is deliberately not kses-filtered, and the runtime wires every os-action / os-poll it finds in returned markup, so a smuggled <span os-poll="250" os-action="destructive"> fires on a timer with no user interaction. Code Blue avoids this by rendering client-side, but the docs recommend server views for forms and settings without the warning.
  • State typing stops at the top level. State::accept() coerces scalars only. An array() default accepts any JSON object, any depth, arbitrary keys, so toggle_item() and contains() can be handed a nested map. Worth a doc warning plus a test.

Smaller, non-blocking

  • The morph assigns a <select> value before morphing in its options (syncFormValue at morph.ts:114, morphChildList at :115), so selecting a newly added option fails silently. Duplicate os-keys also re-match and re-move the same node with no warning.
  • The default 250 ms debounce covers only four text events, and os-range-change is not one, so a slider in a server view queues one request per drag tick with no coalescing.
  • Three Code Blue tests were dropped (entries filterable, entry cap keeping newest, level mapping). Those cover public filters the migration note says are unchanged.
  • Code Blue's new remember() cache runs the entries / max_bytes / max_entries filters inside the callback, and keys localized labels without a locale, so on Redis or Memcached sites filter changes lag and admins in different locales can see each other's labels for up to 5 minutes.

@epeicher epeicher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proposal looks good to me @AllTerrainDeveloper. I like the idea of having one file of each type for the apps, instead of needing so many lines of code. I have left the output of a thorough review from my agents from different perspectives, as I wanted to analyze it deeply.

AllTerrainDeveloper and others added 5 commits August 31, 2026 23:18
…d close the review

The file format is now `<name>.os.php` beside `<name>.os.ts` — one
extension family for one app, instead of `.osx.php` next to `.os.ts`.

CI:

- Plugin Check recognises exactly four direct-access guard spellings,
  and `if ( ! defined( 'ABSPATH' ) && ! defined( 'OPENSTATION_STANDALONE' ) )`
  is not one of them: 23 framework and app files read as unguarded.
  Rewritten as `if ( ! defined( 'ABSPATH' ) ) { defined(
  'OPENSTATION_STANDALONE' ) || exit; }`, which keeps the standalone
  seam and matches the pattern. The shape is load-bearing; AGENTS.md
  says so now.
- Plugin Check runs PHPCS under its own ruleset, so the
  `customEscapingFunctions` in `phpcs.xml.dist` are invisible to it and
  `Html\esc()`-escaped exception messages still tripped
  `EscapeOutput.ExceptionNotEscaped`. Scoped `phpcs:ignore` with the
  reason on the four `throw`s, and on `is_writable()` in the log model.
- `Tests_OpenStation_FilesStore` failed on PHP 8.3/8.4 because the
  desktop-icon registry is process-scoped: the icons `appFramework` and
  `codeBlue` register through `openstation_apps_register_windows()`
  survived into every later test's auto-place count. Both tear_downs
  unregister them.

Review:

- `assets/js/apps/*.js` was committed — `/assets/js/*.js` only matches
  the top level. Gitignored on its own line, untracked, and
  `bin/package.sh` now walks `apps/*/*.os.ts` for the bundles it
  splices in (their vite `fileBase` is a template literal its `fileBase`
  sed cannot see), so the zip no longer depends on git-archive shipping
  them — and the unminified dev build stops shipping.
- `apps/**/*.ts` is `export-ignore`d: TypeScript source no longer ships
  to wp.org.
- `$os->toast( $message, $tone )` dropped the tone silently, because the
  shell has no toast severity. The parameter is gone rather than
  faked.
- The morph assigned a `<select>`'s value before morphing its options in,
  so selecting a newly added option failed silently; children are
  morphed first. A duplicated `os-key` re-matched the same live node —
  the key is now spent on first use.
- `os-range-change` joins the default-debounced events: a slider drag in
  a server view queued one request per tick.
- Code Blue's `read()` is no longer cached. The `entries` / `max_bytes` /
  `max_entries` filters ran inside the cached callback and `parse()`
  bakes localized labels in, so on a persistent object cache a filter
  change lagged and two admins in different locales could read each
  other's labels. A log reader's product is freshness.
- Restored the three dropped Code Blue tests (filterable entries, the
  entry cap keeping the newest, the label→severity map).
- Docs stop over-promising: client views are not third-party-usable yet
  (`@openstation/app` is a Vite alias into `src/`), standalone mode has
  no shipped bootstrap and Code Blue's `__()` calls keep it on
  WordPress. New "The gate is the only authorization there is" section
  covers the logged-in-by-default gate, the unfiltered server view
  (a smuggled `os-poll` fires with no interaction), and state typing
  stopping at the top level — the last with a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gnored

`openstation_apps_client_bundle()` reports an app's client view only
when `assets/js/apps/<name>[.min].js` is on disk. Untracking those
bundles removed the file the PHPUnit job had been reading by accident,
so `Tests_OpenStation_CodeBlue::test_host_ships_the_client_view_with_the_window`
failed on both PHP versions.

The job now runs `npm run build:apps` — two vite runs, well under a
second — rather than the test asserting against source instead of the
artifact a user installs. The assertion names the command when it fails
locally, and DEVELOPMENT.md says to build once on a fresh clone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stat

Three simplifications the sequence diagram made visible:

- `session.ts`: every paint now ends in the same `finishRender()` —
  props, lazy component load, poll reconciliation. Before, a client
  app's server response walked the DOM twice (`paintClient()` ran
  applyProps + reconcilePolls and `apply()` ran them again), while a
  LOCAL paint never ran `ensureComponents()` at all, so a local action
  that rendered a kit component not yet in the tab left it inert until
  the next server round trip. `mounted()` keeps running after the
  finished pass, so an imperative hook reads a complete DOM.
- `class-app.php`: the "definition file's name without .os.php" rule
  was spelled out in both `style_path()` and `client_source()`; it is
  now one `file_base()` helper — the convention is a single fact.
- `wordpress.php`: the client bundle was resolved (an `is_file()` stat)
  twice per app per request — once for the companion script, once for
  the config's `client` flag. `openstation_apps_client_config()` now
  takes the already-resolved path.

No contract changes: same wire shape, same attribute vocabulary, same
`App` surface. build, lint, typecheck, test:js (5390), test:php (2668),
lint:php all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hooks reference still said the parsed result was cached through
$os->cache; the review pass removed that cache (the filters ran inside
the callback and the labels are localized). The doc now says why.

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

@epeicher epeicher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the latest commits, I passed the same agents with previous context using Fable and thoser are the comments. This is good to go! :shipit:

Re-reviewed at a50bf071, verifying each item against the code rather than the commit messages. Everything holds: good to merge.

The four pre-merge fixes

  • Bundles: /assets/js/apps/ gitignored; package.sh now walks apps/*/*.os.ts itself and mkdir -ps the archive dir, so the packaging interaction is handled. CI builds the views before PHPUnit.
  • Zip: apps/**/*.ts export-ignore confirmed with git archive: only .os.php, log-reader.php and CSS ship, and only the .min.js is spliced in, so the dev bundle no longer ships either.
  • Docs: client views marked "inside this repo only, for now"; standalone is now "the shape of the contract rather than a supported install mode"; bundle figure corrected to 19 kB.
  • Toast tone: parameter removed from Effects::toast() / Os::toast(), with <os-notice tone> documented as the alternative. Clean.

The three decisions: made and documented rather than dodged. The new "The gate is the only authorization there is" section covers all three findings: the default-allow gate (justified as matching openstation_register_window()'s own default, with the instruction to declare a gate on every app), the os-poll trigger-injection escalation, and top-level-only state typing, now pinned by test_state_typing_is_top_level_only_for_array_keys.

Smaller items: both morph edges got real fixes (children morph before syncFormValue; a matched os-key is spent so duplicates insert fresh nodes), os-range-change joined the default debounce set, all three dropped Code Blue tests are back, and the log cache was removed outright with the freshness/filter/locale reasoning written into the code.

Two things beyond the review, both positive: the .os.php rename is complete with zero leftovers, and unifying the paint paths into one finishRender() fixed a real gap where a local action rendering a not-yet-loaded kit component left it inert. CI is green across PHP 8.3/8.4, Vitest, build, and Plugin Check, which settles the one item the first pass left unverified.

AllTerrainDeveloper and others added 11 commits September 1, 2026 16:00
…against 32,238, Agents wizard included (#727)

* Docs: Code Blue reads are uncached — drop the stale cache sentence

The hooks reference still said the parsed result was cached through
$os->cache; the review pass removed that cache (the filters ran inside
the callback and the labels are localized). The doc now says why.

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

* My WordPress: the full explorer surface as one server-view app, and what the framework had to grow

The app (`apps/my-wordpress/`, zero JavaScript):

- Root folder grid: the four builtin sections PLUS every eligible
  custom post type, folded into plugin-group folders — discovered by
  calling the SAME `openstation_my_wordpress_*` helpers WP Explorer
  uses (`eligible_post_types`, `post_type_icon`, `post_type_group`,
  `collect_groups`), so both windows always agree on what the site
  contains and the existing CPT filters shape both. Counts on every
  tile, `N folders` in the status bar, back chevron.
- Two-pane section view: searchable, sortable (per-kind sort menu),
  paged list beside the selected item's dossier. Media renders as a
  thumbnail grid at 48/page. Row gestures nest three triggers —
  click opens the pane, double-click opens the editor, right-click
  pops the actions menu — one wrapper per event.
- Multi-select with a bulk bar: per-row checkboxes accumulate into
  `selected` via `State::toggle_item()`, bulk trash honours
  `delete_post` per item and announces only what actually moved.
- Dossiers: post facts, user footprint (roles, posts, comments),
  media facts plus WP Explorer's own "used in" scan
  (`openstation_my_wordpress_media_usage_build`), edit / trash with
  confirm.
- Sections filterable at render time
  (`openstation_my_wordpress_app_sections`) — nothing frozen at
  registration, unlike the `init` 99 snapshot the old window takes.

What porting a complex app forced into the framework:

- `App::watch( ...$types )` — re-render when watched content changes
  anywhere on the desktop, `'*'` for any content change (the explorer
  cannot enumerate its types at define time). Runtime subscribes to
  the `os.<type>.changed` broadcasts, coalesces bursts, marks a
  minimized window stale and catches up on restore. The read half of
  the `$os->announce()` pair.
- `Auth::can( $capability, ...$args )` — meta-capabilities need their
  object (`can( 'delete_post', $id )`). WordPress adapter forwards to
  `current_user_can()`; standalone answers from the name.

Tests: 22 PHPUnit cases end to end through dispatch (discovery,
groups, sort, search, panes, selection, bulk authorization, menus,
effects), 5 new vitest cases for watch (exact, wildcard, coalescing,
stale-on-pause, unsubscribe). The app-registering suites' tear_downs
now unregister EVERY app icon, so the process-scoped icon registry
cannot leak into the files-store auto-place counts again.

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

* My WordPress: the full explorer — every blocker solved with the framework's own client view

The gaps called unportable are now in the window, each through a seam
that already existed:

- **Drag rows out to the desktop** — the client view lifts rows into
  `wp.os.dragManager` with the same shortcut payload the desktop's
  drop targets already accept; a multi-selection drags as a stack.
- **Marquee selection** — click, Ctrl/Cmd toggle, Shift range and a
  drawn marquee, all local reducers over `state.selected`; the same
  ids feed the server's bulk-trash authorization untouched.
- **Infinite scroll** — an IntersectionObserver dispatches `more`;
  pages accumulate per-number client-side, so appending never
  duplicates, a watch refresh replaces exactly the page it re-fetched,
  and a new section/query/sort starts clean.
- **Plugin preview actions** — the SAME pipeline as WP Explorer: PHP
  descriptors from `openstation_my_wordpress_preview_actions`
  (capability-gated server-side), the SAME
  `os.my-wordpress.preview-actions` JS filter applied through
  `wp.os.hooks`, buttons in the pane and rows in the context menu.
  An action registered for WP Explorer appears here unchanged.
- **Context menu, media zoom, copy links, Escape chain** — client
  state that never leaves the tab; `<os-context-menu>` at the
  pointer, full-size zoom overlay, clipboard links for the selection.
- **Rendered post preview** — `data()` ships the post through Core's
  `the_content` pipeline; the client injects it into an `os-preserve`
  slot the diff never touches.
- **Edit locks** — WP Explorer's lock payload feeds row badges and a
  dossier notice.

The split is the framework's own: `my-wordpress.os.php` stays the
truth (sections + CPT discovery + groups, WP_Query/WP_User_Query,
per-item `can( 'delete_post', $id )`, trash / bulk-trash / edit,
dossier payloads incl. the media usage scan) and `my-wordpress.os.ts`
paints it — 757 + 926 lines + 426 CSS against the old module's
32,230.

Tests: 21 PHPUnit cases assert the data payloads, state and effects
end to end; 12 vitest cases pin the selection math, the page
accumulator, preview-action scoping (section id, post-type slug,
wildcard, MIME fail-closed, the shared JS filter) and four full
renders of the view.

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

* My WordPress: pixel parity with WP Explorer — tile grids, real breadcrumbs, the always-there preview pane

Side-by-side against WP Explorer, three things did not match. Now
they do:

- **Lists are icon-tile grids, not rows** — every section paints the
  SAME `<os-tile>` element the wallpaper and WP Explorer use, flowed
  as a grid (the wallpaper's absolute positioning switched off, the
  grid owns layout): section icon or thumbnail as the visual, label
  beneath, the DRAFT/pending/private corner ribbon from the kit's own
  `<os-ribbon>`, selection ring via the canonical
  `.os-file-tile--selected`, a 🔒 overlay for edit locks.
- **Breadcrumbs are the desktop-files shape** — a round back chevron,
  ancestor crumbs as accent-coloured links, the current segment plain
  bold text, `›` separators. The search box moves to its own band
  under the header (`Search posts…`, section label lowercased),
  exactly where WP Explorer puts it.
- **The preview pane is always there** — "Select an entry to preview
  it here." until a tile is clicked, then the dossier. Navigating
  into an entry is one click, like the original.

Also matched: the two-sided status bar (`24 of 576 items` /
`Page 1 of 24`, loaded-of-total from the accumulated pages), and the
sort menu moves off the toolbar into the canvas right-click menu
(Sort by — Newest/Oldest/Title, plus Refresh), which is where the
original's icon-canvas menu keeps it. The bulk bar only exists while
a selection does.

Client tests grow to 13 (tile attributes, ribbon, lock tooltip,
selection attribute, both pane states, the crumb trail, the status
line); 5,408 vitest and the full PHPUnit suite green.

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

* My WordPress: the real context menu, ribbon anchoring, tamed infinite scroll — and the invented bulk bar removed

Three fixes from side-by-side review, one uninvention:

- **The context menu is WP Explorer's, entries and order**: Open in
  editor, Navigate into, Edit…, Publish (unpublished items only),
  Copy link, Move to Trash — then the item's preview actions, then
  every plugin entry appended by the SAME
  `os.my-wordpress.tile-context-menu` JS filter the original runs,
  which is where the agents' "Send to <agent>" rows come from: one
  registration, both windows. An action on a selected item applies to
  the whole selection (Copy link copies every link, Move to Trash
  bulk-trashes). Edit… opens a quick-edit `<os-modal>` — Status +
  Comments over the selection — backed by a new `quick-edit` server
  action that re-checks `edit_post` (and `publish_post`) per item and
  announces `updated`.
- **The bulk bar is gone.** Selecting never opens a toolbar — the
  original has no such chrome; selection actions live in the context
  menu. A test now pins its absence.
- **The DRAFT ribbon sits on the tile again**: the tile was flattened
  to `position: static` for the grid, which re-anchored its
  absolutely-positioned `<os-ribbon>` to the grid cell. `relative`
  keeps the tile in flow AND keeps it the ribbon's containing block.
  The lock overlay gets a tile-hugging wrapper for the same reason.
- **Infinite scroll is one page per scroll gesture**: the sentinel
  disarms when it fires and only a scroll on the canvas re-arms it,
  so a window parked at the bottom no longer chain-loads every page;
  the incoming page paints as shimmering skeleton tiles (WP
  Explorer's placeholders) sized to the page's real footprint. Tiles
  sit at a fixed 104px pitch — resizing changes how many fit per
  row, never how wide a tile is.

16 client tests (menu order, Publish gating, user verbs, no-bulk-bar
pin) + 23 PHPUnit (quick-edit authorization both ways); 5,411 vitest
and the full PHP suite green.

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

* My WordPress: Navigate into is the real detail folder, Edit… is the full modal, infinite scroll self-heals — verified in the browser

Three gaps closed, each found by driving the window side-by-side with
WP Explorer in a live session:

- **Navigate into = the detail FOLDER view.** A post opens as a
  folder: relation tiles on the left — Author, Contributors,
  Comments · N, Categories · N, Tags · N, Attached media,
  Revisions — with live counts, the rendered article on the right,
  `N folders / <status>` in the status bar. Each folder drills into
  its rows (author/contributor user cards, comment excerpts, term
  counts, attachment thumbnails, revision titles via
  `wp_post_revision_title_expanded`), and double-clicking a row opens
  its editor through a `sub-open` action that recomputes the URL
  server-side — never from the client. Contributors reuse WP
  Explorer's own cap-gated payload. New state: `into` + `relation`,
  threaded through back/crumbs.
- **Edit… is the original's modal**: Status, Author (site authors),
  Comments, Sticky, Add categories (term checkboxes), Add tags —
  applied per item with `edit_post` / `publish_post` /
  `edit_others_posts` checks, sticky via stick_post/unstick_post,
  terms appended, one `updated` announce.
- **Infinite scroll cannot stall.** The gesture-per-page rule
  deadlocked when the first batch fit the viewport: no scrollbar → no
  scroll → never re-armed. `updated()` now re-arms while the canvas
  has no overflow, so short viewports fill until they scroll, then
  gestures take over. Watched it live: Users walked 24 → 138 of 138,
  one page per gesture, skeletons in between.
- **Found while testing: plugin CPT folders were flat in dispatches.**
  `openstation_track_type_registrants` defaults to `is_admin()`, and
  a dispatch is REST — the CPT→plugin map was empty, so Woo/ACF/
  MailPoet types rendered loose while WP Explorer grouped them. The
  host now tracks registrants on dispatch requests
  (`openstation_apps_is_dispatch_request()`, URI-sniffed because the
  answer is needed during init). Verified: both windows now show
  identical folders.

Browser-verified end to end on :8889 (context menu incl. every
"Send to <agent>" row, Publish gating on drafts, ribbons, folder
navigation, modal, scroll). 37 client tests, 27 PHPUnit cases, 5,413
vitest and the full PHP suite green.

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

* My WordPress: the sub-list dossier panes, and image icons masked to the tile colour

Two parity gaps from manual side-by-side testing:

- **Selecting a sub-list row paints WP Explorer's own dossier.** The
  panes consume the SAME stats the original renders — the term-stats,
  user-stats and comment-stats REST callbacks invoked in-process with
  a synthetic request, their filters included. A category or tag gets
  the full card: name + taxonomy badge + View archive, the
  POSTS / COMMENTS / AUTHORS stat tiles ("10 · 5 published"), the
  12-month activity bars (zero months included), first/last post, and
  the clickable recent-posts list (each opens its editor through a
  cap-gated `sub-open-post`). An author or contributor gets the user
  dossier plus the user-stats activity and recent posts; a comment
  gets its author, date, rendered body and an "Open the post" button;
  attached media gets the media dossier with the usage scan; a
  revision gets a rendered preview — refused when the row is a
  revision of a DIFFERENT post. Selecting reuses the `open` action
  and the canonical tile selection ring.
- **Image icons are masked to the current colour** the way the
  shell's renderIcon() paints them — Woo's black brand W rendered as
  a black bitmap on the dark tile grid while every neighbour was
  monochrome. `glyph()` now paints non-dashicon icons as a
  CSS-masked span; photos (thumbnails) stay real images.

Browser-verified against WP Explorer: the Notes category pane is
pixel-for-pixel the original's, and both windows now show the same
white W. 20 client tests, 30 PHPUnit cases; 5,415 vitest and the full
PHP suite green.

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

* My WordPress: deterministic pagination, calm page arrivals, the Send-to heading, and the term pane's last two sections

Four refinements from the user's manual pass:

- **Pagination is deterministic.** Demo and imported content routinely
  shares one post_date to the second, and rows equal on the primary
  sort have NO defined order — each page's query may resort the whole
  set differently, which is exactly the "tiles reorder as pages land"
  report. Every query now carries an ID tiebreak (posts/media DESC,
  users ASC alongside display_name/registered), pinned by a test that
  creates thirty same-second posts and asserts disjoint pages in
  strict ID order.
- **A page arrival paints once.** Ghost placeholders were keyed on the
  in-flight flag, so the render that delivered page N also flashed a
  ghost block for page N+1 until the finally-repaint removed it — a
  two-paint shuffle on every page. Ghosts now key on the page number
  being fetched (`ui.loadingPage`) and vanish in the same paint that
  brings the rows.
- **The Send-to group sits behind an inert `SEND TO` heading.** The
  agents' rows (recognised by their `agent-send-to-` id contract) are
  regrouped behind a non-interactive `<os-context-menu-option
  heading>`; other plugins' filter entries stay where the filter put
  them, and a filter that reordered the list is respected verbatim.
- **The term pane gains its last two sections**: Top contributors
  (avatars + post counts from the payload's `topAuthors`) and "Often
  paired with" (co-term chips that open that term's own pane). Both
  were already in the reused term-stats payload; they self-hide when
  empty.

Browser-verified: the heading renders between Move to Trash and the
agent rows, pages land without reshuffling, the Notes pane unchanged.
21 client tests, 31 PHPUnit cases; 5,416 vitest and the full PHP
suite green.

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

* My WordPress: Finder clicks on the folder canvases, and the context menu paints in place

Two behaviours from the user's manual pass:

- **Single click selects, double click opens** — WP Explorer's Finder
  semantics, restored on both folder canvases: the root's section and
  group tiles, and the detail view's relation folders. A click paints
  the selection ring and nothing else; double click (or Enter, for
  the keyboard) navigates. Entity tiles inside a section keep
  click-to-preview — that IS the original's list behaviour, where
  selecting an entry fills the pane. Pinned by a test that clicks a
  root tile and asserts no dispatch until the double click.
- **The context menu no longer flashes at the raw pointer position.**
  The shell's own placement pattern, adopted: the menu paints
  `visibility: hidden`, is measured on the next frame, clamped inside
  the viewport with an 8px margin, and revealed — one paint, no jump,
  and a right-click near an edge opens fully on-screen.

Browser-verified: Posts tile selects purple on click and opens on
double click, and the menu (SEND TO heading included) appears already
placed. 22 client tests; 5,417 vitest and the full PHP suite green.

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

* html core: reconcile arrays across length changes — an appended page no longer remounts the whole list

The kit's html renderer diffed arrays in place ONLY when the length
matched; any length change disposed every entry and remounted the
array from scratch. That is exactly what an infinite scroll does —
append a page, add or remove ghost placeholders — so every arrival
destroyed and recreated the entire tile canvas: custom elements
re-upgraded, masks and images re-decoded, and the whole container
visibly blinked.

Reconciliation is now prefix-stable (still positional, not keyed —
the file's stated v1 design): shared slots update in place, a shorter
array disposes only the tail (entries AND their anchors, mirroring
the array branch of disposeChildState), a longer one appends fresh
entries before the array's end anchor so ordering and trailing
sibling content hold. Every consumer of the kit's html tag benefits —
Code Blue's issue list stops rebuilding on filter changes too.

Four new core tests pin the contract: prefix identity across growth,
survivor identity across shrink, sibling order after growth, plus the
existing equal-length identity test. Full run: 5,420 vitest across
433 files (every component renders through this path), PHP suite and
PHPCS green.

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

* My WordPress: wear the explorer's theme tokens — tiles were unreadable on light surfaces

The shared `.os-file-tile` is born on the WALLPAPER, where its label
is white over a dark gradient. WP Explorer retones it by re-scoping
the `--os-tile-*` tokens on its window root; the port never did, so
on a light scheme the tiles kept wallpaper-white labels on a white
window — invisible.

`.os-mywp` now carries the SAME recipe, on the SAME
`--os-my-wordpress-*` window-token family WP Explorer wears — so a
desktop theme that repaints one explorer repaints both — with the
palette chain underneath and the pre-brand literals as the floor:
window fg/bg, `--os-tile-fg` / `-fg-muted` / `-hover-bg`, and the
light-context label rendering (no shadow, 500 weight, antialiased,
themed color). Font family joins the window stack too.

Full vitest (5,420 — the palette-discipline suites included) and
PHPCS green.

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

* Lint: the file-length nudge — twin warning rules for TS and PHP

Past 1,000 lines a file now gets ONE encouraging warning asking for a
split toward the 300–600-line comfort zone: local-rules/os-file-length
on the ESLint side, OpenStation.Files.FileLength (a house PHPCS sniff
under tools/phpcs/, wired via installed_paths — restating the vendor
standards, which the ruleset value replaces rather than appends) on the
PHP side. Warnings by design, never a gate: a long file is a smell, not
a defect, and the right moment to split is a judgement call.

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

* My WordPress: the Agents section 1:1, composed from parts, wearing the shell's tokens

The section WP Explorer carries, whole: the cast grid with the
breathing faces, the off-state preview crew above the enable bar, the
Define / Tools / Triggers panes, and the five-step wizard — Describe
(starters + the AI draft through the openstation_agent_draft seam),
Meet (the 12-candidate Mio strip), Powers, Summon, Launch — plus drops
onto the cast cards, drag-out to the desktop, the face backfill, and
the os.agents.roster-changed signal that re-warms WP Explorer's
"Send to" cache across bundles. The mechanics moved the framework's
way: catalogues settle with the data, the wizard's cast is declared
app state the server drafts into and creates from, and the mutations
run as actions over the same store functions the REST routes wrap.

Both halves are now compositions over focused parts/ — six PHP parts,
eight TS parts, every source file in the 90–591-line range, pinned by
the suite and documented as the framework's split recipe in
docs/app-framework.md. And the surface wears the shell's own tokens:
selection through --os-tile-selected-bg / --os-tile-focus-ring (the
canonical pair — reading --os-ui-accent painted raw Pulse where WP
Explorer follows the admin scheme), links through --os-link, hovers
through --os-hover, skeletons through --os-skeleton-*, the sparkline
and stat values through --wp-admin-theme-color, all guarded by a
tokenization test. The Agents root tile renders its robot portrait as
the image it is instead of a masked disc.

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

* My WordPress: the preview pane's Explore details button — the door into the detail folder

WP Explorer's article footer offers two verbs, and the port had kept
only one: Open in editor was there, but Explore details — the way into
the author / comments / categories / tags / media / revisions folder —
was reachable from the context menu alone. Same secondary button, same
tooltip, same seat beside the editor button, dispatching the existing
`into` action. Pinned in the dossier render test.

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

* My WordPress: the Edit… modal wears the original's controls — and modals get readable notices

The port's quick-edit had flattened WP Explorer's bulk modal into raw
HTML: a checkbox column for categories, a comma-separated text input
for tags, and no hint. Now it is the original, control for control:
the info notice up top, <os-category-picker> with the term tree
(parent shipped in the choices), and <os-tag-input> — creatable, with
suggestions filtered from a tag list the data payload already holds,
so a keystroke never costs a request and a brand-new token needs no
id minted: the server takes names and appends.

The notice was unreadable in there, and the fix belongs to the
component layer: themes pin the notice INK for light windows (Legacy:
1d2327), and that global outranks the notice's own fg chain on
os-modal's deliberately dark dialog. os-modal now re-points
--os-ui-notice-color through --os-ui-modal-text — the same sanctioned
opt-out shape as its other text colours, allowlisted in the
reachability guard — so every modal's notices read, both explorers
included.

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

* My WordPress: the tile hover card — WP Explorer's tooltip, tokens and all

Hovering an entry tile now summons the original's floating card: bold
title, the lock banner when someone else is editing, the thumbnail in
its neutral well, and the excerpt clamped at 240 characters (users and
media, which have none, show their subtitle line instead). Same class
names and the same palette-level --os-my-wordpress-card-* chains, so a
theme that repaints one explorer's card repaints both. Appended to
document.body because the window clips; hidden the moment a press
means a click, a drag-out or the context menu. The excerpt ships with
the list payload; the tiles drop their native title= attribute so the
browser tooltip never doubles the card.

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

* My WordPress: the shared plugin seams — preview-extras slots, tile decorations, list bands

A plugin that extends WP Explorer now extends this window with the
same registration. The preview article grew the original's named
slots (header / meta / footer, plus meta on the navigate-into
article), and os.my-wordpress.preview-extras fires over them once per
item — the AllTerrain Work board facts and its footer button land
exactly where they do next door. os.my-wordpress.list-tile fires per
rendered tile, after it is in the DOM. And the
os.my-wordpress.list-bands filter folds a section's grid into the
banded layout: declared order, sticky frosted headings, tone tints,
count chips, the unlabelled tail band — WP Explorer's contract,
verbatim, with shift-selection extending across the visual order.

Subscribers read their facts off REST rows, so post-kind list rows
now carry the REST-visible fields: registered show_in_rest meta under
`meta`, and one term-id array per REST-exposed taxonomy keyed by its
rest_base. A band assigner or extras painter written for the original
works here without edits.

House rules held twice along the way: the test file crossed 1,000
lines and split along the parts/ seam (agents tests now live beside
the agents parts), and the size budget was re-argued to 9,000 with
tests excluded from the count — the original's side was never counted
with its tests either.

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

* My WordPress: contain plugin seams — a throwing subscriber must not break the app's own wiring

The preview-extras and list-tile fires moved to the END of the
after-render pass and each is try/caught individually: subscriber
code is plugin code, and one exception was positioned to silently
kill the infinite-scroll re-aim, the content injection and the other
slots behind a pane that still painted fine. The seams survival test
now also pins that what a subscriber painted outlives a repaint.

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

* My WordPress: the WooCommerce surface — one bundle, one set of rules, both windows

The app grows the last WP Explorer parity gap: a shop now reads like a
shop here too. A new parts/woocommerce.php serves the Orders and
Customers sections (the two surfaces a post-type folder cannot: HPOS
keeps orders out of wp_posts, and customers are a ranking over users),
band-orders the Products and Coupons queries off the same cached plans
that order them for WP Explorer, and puts the same openstation_woo /
openstation_woo_customer facts on every row. Deliberately thin: every
rule — which band a product is in, how customers rank, what an order
row says — stays in the existing integration helpers, called behind
function_exists guards. Inert without WooCommerce.

The client half is not ported at all, which is the point: the app now
fires the four seams it still owed (group-extras over an open plugin
folder, user-activate on a person's double-click, the
user-preview-actions row, the user-dossier-sections fact filter), and
the existing os-my-wordpress-woocommerce bundle decorates both windows
from one registration. It rides the app through one new framework
seam, openstation_app_window_args — the filter a companion plugin uses
to append script/style handles to an app window it doesn't own, loaded
on first open and never sooner.

Orders are flat — a new section flag meaning "rows are not posts": no
Navigate into, no Edit… modal, no Trash (client menu and server
actions both refuse, since an order id may collide with a post id
under legacy storage), and double-click opens the real WC order screen
via get_edit_order_url().

Tests: 7 PHPUnit cases (inert-without-Woo, section decoration, the
flat guards both ways, the window-args seam) and 12 vitest cases (each
seam's payload and stamping, containment, the flat menu/pane rules).
The size budget moves 9,000 → 9,500 with the honest accounting: the
like-for-like original's Woo surface is ~7,600 lines; the app reaches
it for ~1,000.

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

* My WordPress: the QA pass — six parity fixes, one of them a framework bug

The batch a side-by-side session against WP Explorer surfaced:

- The preview pane takes the original's split: the list leads at
  minmax(240px, 1fr) and the pane holds 430px until the window truly
  cannot afford it, growing to ~40% on wide windows — instead of a
  fixed 320px flex basis. Grid, not flex, with min-block-size: 0 on
  both panes so the canvases keep their own scrolling.

- A FRAMEWORK bug, fixed for every app: the session adopted a dispatch
  response's echoed state wholesale, but the echo is the state as of
  when that request was SENT. A keystroke typed while a watch refresh
  (or a previous keystroke's own dispatch) was on the wire got visibly
  reverted — the search box snapping back mid-word — and then lost,
  because the next queued dispatch read the stomped state. apply() now
  diffs the live state against the request's own snapshot and keeps
  every locally-newer key; the serialisation chain carries it up on
  the next dispatch. Pinned end to end: the typed value survives the
  echo AND the queued search sends it.

- The user pane paints WP Explorer's full dossier: role badge +
  Author archive under the name, the four stat tiles (Posts with its
  published note, Pages, Comments received, Comments left), the
  12-month activity bars, Member since / First / Last published,
  Recent posts, Top categories & tags — one line of server reuse (the
  same aggregated blob /user-stats/<id> serves), every block gated by
  the shared user-dossier-sections filter, so the Woo Customers pane
  still strips to bio.

- <os-text-field> grows `clearable`: a kit-owned clear (x) at the
  inline end while the field holds a value — appearance:none took
  WebKit's native search-X with it and Firefox never had one.
  Clearing emits os-input-change AND os-input-commit (an explicit
  clear must not wait out a keystroke debounce) and refocuses. The
  app's search band wears it.

- "Edit profile" opens the shared desktop-mode-user-edit profile
  window — not a raw user-edit.php iframe titled by its URL. The
  opener (store target → singleton with session params → relations
  announce) moved into the dependency-free user-edit-target leaf
  module; WP Explorer's own copy now delegates to it, and the app
  routes its pane button, its context-menu row, and — parity too —
  the unclaimed double-click now opens the activity footprint, the
  original's built-in answer to "open this person".

- The app's built-in Users folder stays money-free, by design and
  documented as a deliberate divergence: its rows no longer carry
  openstation_woo_customer, and the shared bundle now treats the
  facts' presence as the opt-in for any people surface outside the
  Customers section — which stays opted in by id, so WP Explorer and
  the Customers section are unchanged.

- The root grid's icon well matches the original's 48px box / 32px
  glyph pair, so the Agents portrait (and every brand mark) reads at
  WP Explorer's size instead of 40px.

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

* My WordPress: enhance search band styling with border for better separation

* My WordPress IS WP Explorer: the legacy app deleted whole, the last surface ported

The port stops being a sibling. The app reclaims the "WP Explorer"
name, the folder-mark icon and the pinned launcher slot (both read
from the module's own helpers — one source each), and the legacy
native window is deleted as a whole: its 15,707-line bundle, its
registration, template and config emit, its build target, and the
5,249 lines of suites that exercised it. Net for this commit:
+1,826 / −22,050.

What had to move first, so nothing the shell does lost its door:

- The ACTIVITY FOOTPRINT — the one surface the app never had — is
  ported 1:1 into `parts/footprint.ts`: hero, stat strip, the
  GitHub-style year heatmap, the rhythm charts, the month callout,
  the timeline and the action footer, same class names so the
  original stylesheet and plugin CSS keep applying, one cached
  round-trip to `/user-footprint/<id>`, the status bar's two lines
  included. Escape and Back pop it; the breadcrumb carries the
  person.
- "Open this object in the explorer" became the shared open target
  (`src/my-wordpress/explorer-open.ts`): the desktop tiles' Navigate
  into, the wallpaper preview's Explore details and the Corkboard's
  Open in <site> stash the object and open the app, which consumes
  the target on mount and on subscription — the same cold-start-safe
  contract the footprint and agent-editor targets already used (both
  now retarget to the app too).
- The Recycle Bin no longer needs a cross-bundle API to trash a
  dropped row: shortcut payloads carry their section's `restPath`,
  the bin DELETEs against it (`rest-trash.ts`) and announces the
  standard `os.<type>.changed` so every watcher drops the tile.
- The "Send to <agent>" intake registers from the app's bundle, reads
  the shell's REST config instead of the dead window blob, and gates
  its warm-up on the payload's `agentsEnabled` flag.
- The shared explorer stylesheet stays — it also paints the desktop
  folder windows — and rides the app as a companion style. Its 18
  hard-coded admin blues (the footprint hero's wash, the avatar
  well, the role chips, the calendar intensity ramp) now resolve
  through color-mix over `--wp-admin-theme-color`, so themes and the
  accent picker finally repaint the footprint header; pinned by a
  regression test.

`wp.os.myWordpress` (openDetail / openMedia / openUserFootprint /
registerEntityKind / trashEntity) is removed; the entities filter
stays as an inert compatibility surface. The breaking change ships
with its note — docs/migration-wp-explorer-app.md — and the hooks /
JS references now document the replacements instead of the corpse.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The audit of framework-vs-apps found the same primitives written
twice or three times, plus one leak. This lands the shared homes:

- The data-tone → status-colour mapping moves from code-blue.css
  (where it was document-scoped and retinted any data-tone element
  in the tab once injected) into app-runtime.css, scoped to
  .os-app and published as --os-app-tone — the tone contract every
  app can read. Code Blue's swatch and row border consume it.
- .os-app__spacer joins the runtime sheet; both apps' private
  flex-1 spacer classes are gone.
- @openstation/app now exports formatBytes (the file-drop
  formatter, one source of truth instead of a third copy) and
  formatDate (short/long/month/datetime/iso — replaces five
  near-copies across footprint.ts, dossier-views.ts and
  code-blue.os.ts, and fixes the footprint callout reading a
  YYYY-MM month at UTC midnight, which shifted a month in
  negative-offset timezones).
- The Agents detail pane strip is the kit's <os-tabs> instead of a
  hand-rolled tablist — the hand-rolled one had no roving focus and
  no arrow keys.
- My WordPress's context-menu clamp is the shell's own
  clampToViewport instead of a fourth ad-hoc viewport flip.

Docs: the tone contract, the spacer and the formatting exports in
docs/app-framework.md. Tests: tests/vitest/app-runtime-format.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The capability audit found My WordPress re-implementing, outside the
framework, four things every next app would need again. They are the
framework's now, and the app consumes them:

- ctx.dispatch( action, args, { confirm } ) — an imperative dispatch
  can ask the shell's confirm dialog, the same one the declarative
  os-confirm attribute uses. Fixes a real inconsistency: Move to
  Trash from the context menu ran unconfirmed while the identical
  action behind the preview pane's button confirmed.
- ctx.ui( factory ) + ctx.repaint() — client-only per-view state with
  an explicit re-render. Replaces the app's WeakMap-keyed UI bag and
  its no-op `repaint` reducer (dispatching a reducer that did nothing
  was the only way to ask for a paint).
- ctx.fetch( path, init ) — REST with the root, the nonce and the
  window's spinner attribution supplied by the framework. The
  footprint's fetch loses its hand-built X-WP-Nonce header, and
  restRoot/restNonce leave the app's data payload (the config blob
  now carries restRoot, pinned in appFramework.php).
- ctx.host — the typed RuntimeHost, so the copy-links toast stops
  reaching through window.wp.os.

New: src/app-runtime/testing.ts — mockViewContext(), one blessed test
context instead of the four drifting stubs the suites had grown; all
app suites now build contexts through it. Session tests pin the ui
bag, repaint, fetch resolution + nonce, and confirm gating. Docs:
the client-view contract section in docs/app-framework.md.

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

The paged-list envelope (items/total/pages/page/perPage) is the shape
the client runtime's page accumulation understands, and the first app
hand-assembled it five times. It is Os::page() now. The detail-pane
facts idiom — drop the rows whose value came back empty, reindex —
appeared four times as the same array_values(array_filter(...))
closure; it is Os::facts(). Both are pure statics, so the framework
core stays WordPress-free.

Also one app-side dedupe the audit flagged: the WP_User list row was
built field-for-field twice (Users section and WooCommerce Customers,
differing only in the capability check). One user_row() in lists.php,
with the check passed in.

Pinned in appFramework.php; documented in the $os table of
docs/app-framework.md.

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

The single most reusable thing in the first app was the ~120 lines
implementing "an infinitely scrolled server-paginated list": page
accumulation keyed by list identity, the IntersectionObserver
sentinel, the one-page-per-scroll-gesture arming protocol, the
short-list deadlock guard, and skeletons sized to the incoming page.
The second list window would have copied all of it. It is
createPagedList() in @openstation/app now — accumulate() in the view,
sync() from updated(), dispose() on teardown — and the ghost/hasMore
arithmetic moved inside with it. Selection math (plain click
replaces, Ctrl toggles, Shift ranges from the anchor) is
applySelection() beside it.

My WordPress consumes both: nine UiState fields collapse into one
PagedList, the app's observer wiring and afterRender re-aim/re-arm
blocks are one sync() call, and ListPage is now an alias of the
framework's PageEnvelope. The accumulate/applySelection suites move
to tests/vitest/app-runtime-paged-list.test.ts, which also pins the
gesture protocol itself — armed/disarmed, ghosts, the deadlock guard
— which the app suites never covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Big value, small uppercase label, optional footnote in a bordered
box" existed three times: the dossier's statTile(), the footprint's
statCard(), and Code Blue's inline os-card tiles — three markups,
three stylesheets. It is one kit component now: <os-stat
value label caption>, with a `swatch` attribute that renders a
severity chip coloured through the app runtime's data-tone contract,
and every colour/size reachable through --os-ui-stat-* tokens (Code
Blue keeps its neutral value colour with a three-line override
instead of a twenty-line tile).

The dossier tiles and Code Blue's strip consume it; their private
tile CSS is deleted. The footprint's statCard deliberately does not
move: its class names are the ported-1:1 fidelity contract with
plugin CSS written against WP Explorer's footprint, and the same
holds for its bar chart. Tests updated to read value/label/caption
off the element (shadow DOM does not surface in light textContent),
plus the component's own suite; documented in
docs/components-reference.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both apps declared an empty PHP action for the same reason: a Refresh
button needs a server round trip whose entire meaning is "recompute
data() and re-render", and the runtime refused undeclared action
names. The runtime now treats a bare `refresh` exactly like `set`
with a recompute — no handler needed — while a declared handler still
wins (Code Blue keeps its error-clearing one). My WordPress's empty
declaration is gone, along with its `paginate` action, dead code left
from the page-based approach the infinite scroll replaced.

Also closes the audit's os-preserve finding: the attribute is honoured
by the server-view morph only, and both the docs and My WordPress's
comments claimed it protected client-view DOM it never touched — the
real guards there are the app's data-mywp stamps. The attribute table
in docs/app-framework.md now says so.

Pinned: appFramework.php test for the bare and the declared `refresh`.

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

The app stylesheet carried a near-verbatim copy of the shared
explorer sheet's hover-card rules (~90 lines: card, title, lock
banner, thumb well, excerpt) — and the shared sheet already rides the
app window as a declared companion style, so both sets were live at
once. The copy is gone; one set of rules paints both explorers'
cards, which is what the ported-1:1 class-name contract wanted all
along.

Docs: the os-app example and the client-view walkthrough in
docs/app-framework.md stop declaring an empty refresh action — the
built-in is the whole lesson now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dl/dt/dd fact loop appeared four times in dossier-views.ts (the
user dossier's milestones, the detail pane, the shared sub-pane, the
term pane's hand-built pair) and the media "Used in" block twice.
factList() and usedInList() are the single definitions now;
dossierFacts() composes them, and the term pane builds its milestone
rows as data instead of markup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last content-agnostic piece of list machinery still living in an
app: ~57 lines of marquee wiring (press on empty canvas, fixed box on
the body, per-move rect intersection over data-item-id rows, plain
press clears / modified press keeps, teardown). It is
createMarquee() in @openstation/app now, with a default
.os-app__marquee style in the runtime sheet wearing the canonical
selection tokens; My WordPress passes its own class so the app sheet
keeps painting it with WP Explorer's chain, and its wire() shrinks to
one call. Pinned in app-runtime-paged-list.test.ts; docs and the API
index carry the whole client-service surface (ViewContext, the list
machinery, Os::page/facts, the built-in refresh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing one shortcut

Dragging a 4-post selection onto a folder created one shortcut. The
drop side was fine (dragShortcutItems() and fileShortcutEntities()
handle sets); the payload never carried the set, because the app's
drag-out listener lives in mounted() — which runs once — and the
context it captured froze state as a mount-time SNAPSHOT. Its
`ctx.state.selected` was the empty mount selection forever, so the
multi-item branch never fired. The same staleness silently affected
every long-lived listener wire() installs: the Escape chain's
navigation reads and the drag payload's section routing.

The fix is the framework's, not a per-listener workaround: the view
context now serves `state` and `data` through live getters, so a
captured context always answers with the current values. The
interface marks both readonly; docs say "live" out loud; and the
session suite pins the exact failure shape — a context captured at
mount must see a selection made four dispatches later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AllTerrainDeveloper AllTerrainDeveloper changed the title EXPERIMENT/PROPOSAL: App Framework — a window in one PHP file (.osx.php + optional .os.ts), Code Blue rebuilt on it App Framework — a window in one PHP file (.osx.php + optional .os.ts), Code Blue & WP Explorer rebuilt on it Sep 1, 2026
AllTerrainDeveloper and others added 2 commits September 1, 2026 20:08
The plugin-check CI job failed with
missing_direct_file_access_protection on my-wordpress.os.php — a file
that HAS the sanctioned dual ABSPATH/OPENSTATION_STANDALONE guard.
Why it was invisible: Plugin Check's AST pass only walks top-level
nodes, so a guard inside a `namespace` is never found; every
namespaced file is judged by the regex fallback instead, and that
fallback reads ONLY the first 50 raw lines. This file's 48-line
docblock pushed the guard to line 56 — present, correct, and out of
frame. (Code Blue's identical guard sits at line 24, which is the
whole reason it passed.) The guard cannot move above the docblock
because `namespace` must be the first statement, so the fix is a
tighter header: same content, condensed, guard now at line 50 —
verified by replaying Plugin Check's exact slice-and-regex locally
against both apps.

The trap is written down in AGENTS.md next to the guard-shape rule:
in a namespaced file the guard's POSITION is load-bearing too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit b122192 into trunk Sep 1, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the feature/openstation-app-framework-code-bred branch September 1, 2026 18:23
AllTerrainDeveloper added a commit that referenced this pull request Sep 2, 2026
* Shell boot: a target is one-shot and must be a real page, so a reload stops opening an empty window (#720)

Every F5 on the shell screen opened a window showing nothing. Two
defects compounded, and either one alone is worth fixing.

`wp-admin/admin.php` is core's plugin-screen bootstrap. Without a
`page` arg there is nothing to dispatch to: it falls through the last
`else` in that file, fires two back-compat `load-*` hooks, and answers
200 with an empty body, having required neither `admin-header.php` nor
`admin-footer.php`. The URL still resolves, is still same-origin, and
`admin.php` is still on the target allowlist — it has to be, since
every plugin screen in the admin lives there. The allowlist matches
filenames and never sees the query, so a page-less `admin.php` passed
validation and became a window.

Worse, it arrived flagged as intent. A plain GET to that URL is not
the shell screen, so it reached the one-hop route in
`openstation_redirect_plain_admin_to_portal`, which forwards with
`intent` hardcoded true — "the user asked for this by name." The shell
obeyed.

And `target` / `intent` never left the address bar. They are an
instruction, not an address: PHP reads them once, on the request that
carries them, and hands the answer to the shell as `currentPage` and
`fromPortalIntent`; nothing on the JS side reads them from the URL.
Left in place they stop being one-shot — every reload re-read the same
target and re-opened it on top of the restored session, for the life
of the tab and past it, through a bookmark or a browser session
restore. `openstation_shell_boot_target()` has always documented that a
reload of the bare screen URL re-resolves against the live session;
nothing made the address bar hold that URL.

So: `openstation_url_is_page_less_admin_php()` joins
`openstation_url_is_shell_screen()` as a URL that resolves but must not
become a target, and `openstation_sanitize_portal_target()` refuses
both. One point covers the portal handler, the frozen-flag alias, the
one-hop route and the screen's own read; each already treats '' as
"fall back to the entry resolver" — the session's focused window, else
the default window, else the Dashboard. A plugin extending the
allowlist gets the same treatment, since the check is on the resolved
URL rather than the list.

On the shell side, `shellUrlWithoutBootArgs()` in `src/shell-url.ts`
strips the consumed args and `init()` replaces the history entry with
the result, before anything can throw. This is deliberately not the
`/openstation/` normalisation reverted earlier: that route costs an
HTTP redirect, and an address-bar flash, on every reload. Dropping two
args stays on the same screen and the same route.

The PHPUnit case that pins the redirect was written first and failed
on the target it emitted, `/wp-admin/admin.php`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Feat: Add close all windows keybinds. (#719)

* Workspaces / grid snap: a design pass on the surfaces

Five changes to how the proposal looks and reads, on top of #718.
Behaviour is unchanged except where the gutter moves a window by 4px.

Grid snap

- Windows placed on the grid are inset half a gutter on each side, so
  two on neighbouring cells sit 8px apart instead of sharing an edge.
  The cells stay contiguous: spanRect still tiles the area exactly and
  a GridSpan is still whole cells, so the responsive story is
  untouched. The inset lives in a new placementRect(), which the
  preview and the landing both read.
- The "cols x rows" readout moves to the middle of the target. In the
  corner it could sit several hundred pixels from the pointer on a
  large span, which is a readout nobody reads.

The wizard

- The primary button is last in the footer on every step, with Next
  and Customize before it. It was sitting second from the right with a
  secondary after it, so the rightmost seat changed meaning from step
  to step.
- The pane is 320px rather than 390px. Three of the six steps are a
  heading and one switch until that switch is on, and at the old
  height they read as a dialog that had failed to load.
- The Windows step no longer offers to "capture the windows you have
  open" when creating, where that button does not exist. It is an
  edit-mode option.

Overview

- A tile reserves one action row rather than two, until some desk on
  the bar actually has something to restore. Edit is on every desk so
  one row is always paid for; the second was charged to everyone,
  including users with no workspaces at all.

* Workspaces: the trail, the slot, and one tone for the cards

Four surface changes, following the agent wizard's treatment where it
already answered the same question.

- The step trail wears the instrument voice: mono uppercase labels,
  22px chips, and a step you have not reached is an outline rather
  than a filled dot. Six filled chips carried no state at all, so
  current, done and not-yet all looked alike and only the label weight
  said where you were.
- The "+" is the size of a desk again. It sits in the same wrapper a
  tile does, above an empty actions block, and carries the same
  preview band and label strip so its height comes from the tile's own
  rules rather than a number copied out of them. Its glyph centres in
  the whole slot.
- The Start step drops its subtitle. The cards already say what a
  template is and that a blank desk is one click.
- Every card glyph is one muted tone, and the chosen card lights its
  own. They used to wear each preset's colour, which is the product's
  hex, so the step opened on a purple cart, a green cap and a red pen.
  The colour still shows where it earns its place, on the desk's
  overview tile.

* Add Fleet OAuth authorization server (#724)

* Revert "Add Fleet OAuth authorization server (#724)"

This reverts commit 2bde4ce5de0fa24ee50afaa68aebb11954082a15.

* os-steps: size the chip border-box so an outline matches a fill

`--os-ui-step-chip-border` is the hook a trail uses to draw the steps
you have not reached as outlines, but the chip was sizing content-box,
so that 1px landed outside the declared size. An unreached chip came
out 22px + 2, and its whole grid row grew with it: a trail where the
step you are on is the smallest circle in the line.

Fixed in the component rather than in either wizard's stylesheet. The
agent wizard's trail uses the same hook and had the same 2px. With the
default border of 0 the two box models agree, so a trail that has not
opted into the outline is unchanged.

The guard is a source assertion: jsdom runs no style engine, and this
is a failure that reads as correct in a diff.

* Chore(deps): Bump anthropics/claude-code-action from 1.0.183 to 1.0.210 (#726)

Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.183 to 1.0.210.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/be7b93b1907a4abad570368f3c74b6fe3807510b...a874e9ecd7bb36efdad65429c6b35815f5a08f10)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.210
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Multisite: support the network admin, and scope desktops per site (#704)

* Multisite: support the network admin, and scope desktops per site

Gives the network admin its own shell screen, adds a Network Admin dock
tile, opens cross-admin links in a browser tab, and gives each site its
own saved session.

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

* Multisite: give the network admin its own session

The network admin shared the main site's session blob, and the two
desktops derive the same window ids from different admins — index-php
is the site dashboard on one and the network dashboard on the other.
So whichever desktop you opened second restored the other admin's
dashboard window under a colliding id, and the dock's Dashboard tile
focused the wrong admin's screen: from the network desktop, Dashboard
opened the site's dashboard.

The network admin now persists under its own meta key. The session REST
route runs in the main site's blog context whichever desktop is saving,
so the network screen's sessionUrl carries network=1 and the handlers
honour it only alongside manage_network. Both read and write filter
windows to the session's own admin scope, so a blob written before the
keys split heals on the next load instead of leaking across.

Also fixes the two phpcs errors the branch carried (doc param order in
openstation_resolve_admin_target, array formatting in the multisite
payload).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Roberto Aranda <roberto.aranda@automattic.com>

* App Framework — a window in one PHP file (.osx.php + optional .os.ts), Code Blue & WP Explorer rebuilt on it (#722)

* App Framework: declare a whole window in PHP (.osx.php), with an optional instant client view (.os.ts); Code Blue rebuilt on it

A window is now one file. `OpenStation\App::define()` declares title,
size, icon, title-bar buttons, ⋯-menu rows, tabs, per-window chrome,
a typed state schema, actions, and either a server-rendered view
(zero JavaScript) or a `data()` plus a `.os.ts` client view for the
interactions that must never wait for a WordPress request.

- includes/framework: host-agnostic core (App, State, Runtime,
  Registry, Os, Effects, Html) behind six contracts (Auth, Settings,
  Hooks, Cache, Env, Store) with WordPress and standalone adapters;
  the WordPress host loads apps/*/*.osx.php, registers them as native
  windows and serves one dispatch route.
- src/app-runtime: the one shared client bundle — mount, dispatch,
  keyed DOM morph, os-action / os-bind / os-arg / os-poll / os-prop /
  os-confirm vocabulary covering every kit component and event,
  effects (toast, title, close, open, open_url, badge, announce,
  menu, send), tabs, channels, lifecycle actions; `@openstation/app`
  gives an .os.ts `defineApp()` with local actions and a view
  rendered by the kit's html tag.
- <os-histogram>: the chart moves into the component kit.
- Code Blue is rebuilt as apps/code-blue (same id, gate and hooks;
  the /code-blue/* REST routes and window_args/icon_args/template_html
  filters are gone — docs/migration-code-blue-app.md): 1,269 lines
  instead of 3,235, filters instant, one request to read the log.

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

* App Framework: rename the app file to `.os.php`, fix the CI gates, and close the review

The file format is now `<name>.os.php` beside `<name>.os.ts` — one
extension family for one app, instead of `.osx.php` next to `.os.ts`.

CI:

- Plugin Check recognises exactly four direct-access guard spellings,
  and `if ( ! defined( 'ABSPATH' ) && ! defined( 'OPENSTATION_STANDALONE' ) )`
  is not one of them: 23 framework and app files read as unguarded.
  Rewritten as `if ( ! defined( 'ABSPATH' ) ) { defined(
  'OPENSTATION_STANDALONE' ) || exit; }`, which keeps the standalone
  seam and matches the pattern. The shape is load-bearing; AGENTS.md
  says so now.
- Plugin Check runs PHPCS under its own ruleset, so the
  `customEscapingFunctions` in `phpcs.xml.dist` are invisible to it and
  `Html\esc()`-escaped exception messages still tripped
  `EscapeOutput.ExceptionNotEscaped`. Scoped `phpcs:ignore` with the
  reason on the four `throw`s, and on `is_writable()` in the log model.
- `Tests_OpenStation_FilesStore` failed on PHP 8.3/8.4 because the
  desktop-icon registry is process-scoped: the icons `appFramework` and
  `codeBlue` register through `openstation_apps_register_windows()`
  survived into every later test's auto-place count. Both tear_downs
  unregister them.

Review:

- `assets/js/apps/*.js` was committed — `/assets/js/*.js` only matches
  the top level. Gitignored on its own line, untracked, and
  `bin/package.sh` now walks `apps/*/*.os.ts` for the bundles it
  splices in (their vite `fileBase` is a template literal its `fileBase`
  sed cannot see), so the zip no longer depends on git-archive shipping
  them — and the unminified dev build stops shipping.
- `apps/**/*.ts` is `export-ignore`d: TypeScript source no longer ships
  to wp.org.
- `$os->toast( $message, $tone )` dropped the tone silently, because the
  shell has no toast severity. The parameter is gone rather than
  faked.
- The morph assigned a `<select>`'s value before morphing its options in,
  so selecting a newly added option failed silently; children are
  morphed first. A duplicated `os-key` re-matched the same live node —
  the key is now spent on first use.
- `os-range-change` joins the default-debounced events: a slider drag in
  a server view queued one request per tick.
- Code Blue's `read()` is no longer cached. The `entries` / `max_bytes` /
  `max_entries` filters ran inside the cached callback and `parse()`
  bakes localized labels in, so on a persistent object cache a filter
  change lagged and two admins in different locales could read each
  other's labels. A log reader's product is freshness.
- Restored the three dropped Code Blue tests (filterable entries, the
  entry cap keeping the newest, the label→severity map).
- Docs stop over-promising: client views are not third-party-usable yet
  (`@openstation/app` is a Vite alias into `src/`), standalone mode has
  no shipped bootstrap and Code Blue's `__()` calls keep it on
  WordPress. New "The gate is the only authorization there is" section
  covers the logged-in-by-default gate, the unfiltered server view
  (a smuggled `os-poll` fires with no interaction), and state typing
  stopping at the top level — the last with a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* CI: build the app client views before PHPUnit, now that they are gitignored

`openstation_apps_client_bundle()` reports an app's client view only
when `assets/js/apps/<name>[.min].js` is on disk. Untracking those
bundles removed the file the PHPUnit job had been reading by accident,
so `Tests_OpenStation_CodeBlue::test_host_ships_the_client_view_with_the_window`
failed on both PHP versions.

The job now runs `npm run build:apps` — two vite runs, well under a
second — rather than the test asserting against source instead of the
artifact a user installs. The assertion names the command when it fails
locally, and DEVELOPMENT.md says to build once on a fresh clone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* App runtime: one post-render pass, one naming convention, one bundle stat

Three simplifications the sequence diagram made visible:

- `session.ts`: every paint now ends in the same `finishRender()` —
  props, lazy component load, poll reconciliation. Before, a client
  app's server response walked the DOM twice (`paintClient()` ran
  applyProps + reconcilePolls and `apply()` ran them again), while a
  LOCAL paint never ran `ensureComponents()` at all, so a local action
  that rendered a kit component not yet in the tab left it inert until
  the next server round trip. `mounted()` keeps running after the
  finished pass, so an imperative hook reads a complete DOM.
- `class-app.php`: the "definition file's name without .os.php" rule
  was spelled out in both `style_path()` and `client_source()`; it is
  now one `file_base()` helper — the convention is a single fact.
- `wordpress.php`: the client bundle was resolved (an `is_file()` stat)
  twice per app per request — once for the companion script, once for
  the config's `client` flag. `openstation_apps_client_config()` now
  takes the already-resolved path.

No contract changes: same wire shape, same attribute vocabulary, same
`App` surface. build, lint, typecheck, test:js (5390), test:php (2668),
lint:php all green.

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

* Docs: Code Blue reads are uncached — drop the stale cache sentence

The hooks reference still said the parsed result was cached through
$os->cache; the review pass removed that cache (the filters ran inside
the callback and the labels are localized). The doc now says why.

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

* My WordPress: WP Explorer rebuilt on the App Framework — 8,169 lines against 32,238, Agents wizard included (#727)

* Docs: Code Blue reads are uncached — drop the stale cache sentence

The hooks reference still said the parsed result was cached through
$os->cache; the review pass removed that cache (the filters ran inside
the callback and the labels are localized). The doc now says why.

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

* My WordPress: the full explorer surface as one server-view app, and what the framework had to grow

The app (`apps/my-wordpress/`, zero JavaScript):

- Root folder grid: the four builtin sections PLUS every eligible
  custom post type, folded into plugin-group folders — discovered by
  calling the SAME `openstation_my_wordpress_*` helpers WP Explorer
  uses (`eligible_post_types`, `post_type_icon`, `post_type_group`,
  `collect_groups`), so both windows always agree on what the site
  contains and the existing CPT filters shape both. Counts on every
  tile, `N folders` in the status bar, back chevron.
- Two-pane section view: searchable, sortable (per-kind sort menu),
  paged list beside the selected item's dossier. Media renders as a
  thumbnail grid at 48/page. Row gestures nest three triggers —
  click opens the pane, double-click opens the editor, right-click
  pops the actions menu — one wrapper per event.
- Multi-select with a bulk bar: per-row checkboxes accumulate into
  `selected` via `State::toggle_item()`, bulk trash honours
  `delete_post` per item and announces only what actually moved.
- Dossiers: post facts, user footprint (roles, posts, comments),
  media facts plus WP Explorer's own "used in" scan
  (`openstation_my_wordpress_media_usage_build`), edit / trash with
  confirm.
- Sections filterable at render time
  (`openstation_my_wordpress_app_sections`) — nothing frozen at
  registration, unlike the `init` 99 snapshot the old window takes.

What porting a complex app forced into the framework:

- `App::watch( ...$types )` — re-render when watched content changes
  anywhere on the desktop, `'*'` for any content change (the explorer
  cannot enumerate its types at define time). Runtime subscribes to
  the `os.<type>.changed` broadcasts, coalesces bursts, marks a
  minimized window stale and catches up on restore. The read half of
  the `$os->announce()` pair.
- `Auth::can( $capability, ...$args )` — meta-capabilities need their
  object (`can( 'delete_post', $id )`). WordPress adapter forwards to
  `current_user_can()`; standalone answers from the name.

Tests: 22 PHPUnit cases end to end through dispatch (discovery,
groups, sort, search, panes, selection, bulk authorization, menus,
effects), 5 new vitest cases for watch (exact, wildcard, coalescing,
stale-on-pause, unsubscribe). The app-registering suites' tear_downs
now unregister EVERY app icon, so the process-scoped icon registry
cannot leak into the files-store auto-place counts again.

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

* My WordPress: the full explorer — every blocker solved with the framework's own client view

The gaps called unportable are now in the window, each through a seam
that already existed:

- **Drag rows out to the desktop** — the client view lifts rows into
  `wp.os.dragManager` with the same shortcut payload the desktop's
  drop targets already accept; a multi-selection drags as a stack.
- **Marquee selection** — click, Ctrl/Cmd toggle, Shift range and a
  drawn marquee, all local reducers over `state.selected`; the same
  ids feed the server's bulk-trash authorization untouched.
- **Infinite scroll** — an IntersectionObserver dispatches `more`;
  pages accumulate per-number client-side, so appending never
  duplicates, a watch refresh replaces exactly the page it re-fetched,
  and a new section/query/sort starts clean.
- **Plugin preview actions** — the SAME pipeline as WP Explorer: PHP
  descriptors from `openstation_my_wordpress_preview_actions`
  (capability-gated server-side), the SAME
  `os.my-wordpress.preview-actions` JS filter applied through
  `wp.os.hooks`, buttons in the pane and rows in the context menu.
  An action registered for WP Explorer appears here unchanged.
- **Context menu, media zoom, copy links, Escape chain** — client
  state that never leaves the tab; `<os-context-menu>` at the
  pointer, full-size zoom overlay, clipboard links for the selection.
- **Rendered post preview** — `data()` ships the post through Core's
  `the_content` pipeline; the client injects it into an `os-preserve`
  slot the diff never touches.
- **Edit locks** — WP Explorer's lock payload feeds row badges and a
  dossier notice.

The split is the framework's own: `my-wordpress.os.php` stays the
truth (sections + CPT discovery + groups, WP_Query/WP_User_Query,
per-item `can( 'delete_post', $id )`, trash / bulk-trash / edit,
dossier payloads incl. the media usage scan) and `my-wordpress.os.ts`
paints it — 757 + 926 lines + 426 CSS against the old module's
32,230.

Tests: 21 PHPUnit cases assert the data payloads, state and effects
end to end; 12 vitest cases pin the selection math, the page
accumulator, preview-action scoping (section id, post-type slug,
wildcard, MIME fail-closed, the shared JS filter) and four full
renders of the view.

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

* My WordPress: pixel parity with WP Explorer — tile grids, real breadcrumbs, the always-there preview pane

Side-by-side against WP Explorer, three things did not match. Now
they do:

- **Lists are icon-tile grids, not rows** — every section paints the
  SAME `<os-tile>` element the wallpaper and WP Explorer use, flowed
  as a grid (the wallpaper's absolute positioning switched off, the
  grid owns layout): section icon or thumbnail as the visual, label
  beneath, the DRAFT/pending/private corner ribbon from the kit's own
  `<os-ribbon>`, selection ring via the canonical
  `.os-file-tile--selected`, a 🔒 overlay for edit locks.
- **Breadcrumbs are the desktop-files shape** — a round back chevron,
  ancestor crumbs as accent-coloured links, the current segment plain
  bold text, `›` separators. The search box moves to its own band
  under the header (`Search posts…`, section label lowercased),
  exactly where WP Explorer puts it.
- **The preview pane is always there** — "Select an entry to preview
  it here." until a tile is clicked, then the dossier. Navigating
  into an entry is one click, like the original.

Also matched: the two-sided status bar (`24 of 576 items` /
`Page 1 of 24`, loaded-of-total from the accumulated pages), and the
sort menu moves off the toolbar into the canvas right-click menu
(Sort by — Newest/Oldest/Title, plus Refresh), which is where the
original's icon-canvas menu keeps it. The bulk bar only exists while
a selection does.

Client tests grow to 13 (tile attributes, ribbon, lock tooltip,
selection attribute, both pane states, the crumb trail, the status
line); 5,408 vitest and the full PHPUnit suite green.

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

* My WordPress: the real context menu, ribbon anchoring, tamed infinite scroll — and the invented bulk bar removed

Three fixes from side-by-side review, one uninvention:

- **The context menu is WP Explorer's, entries and order**: Open in
  editor, Navigate into, Edit…, Publish (unpublished items only),
  Copy link, Move to Trash — then the item's preview actions, then
  every plugin entry appended by the SAME
  `os.my-wordpress.tile-context-menu` JS filter the original runs,
  which is where the agents' "Send to <agent>" rows come from: one
  registration, both windows. An action on a selected item applies to
  the whole selection (Copy link copies every link, Move to Trash
  bulk-trashes). Edit… opens a quick-edit `<os-modal>` — Status +
  Comments over the selection — backed by a new `quick-edit` server
  action that re-checks `edit_post` (and `publish_post`) per item and
  announces `updated`.
- **The bulk bar is gone.** Selecting never opens a toolbar — the
  original has no such chrome; selection actions live in the context
  menu. A test now pins its absence.
- **The DRAFT ribbon sits on the tile again**: the tile was flattened
  to `position: static` for the grid, which re-anchored its
  absolutely-positioned `<os-ribbon>` to the grid cell. `relative`
  keeps the tile in flow AND keeps it the ribbon's containing block.
  The lock overlay gets a tile-hugging wrapper for the same reason.
- **Infinite scroll is one page per scroll gesture**: the sentinel
  disarms when it fires and only a scroll on the canvas re-arms it,
  so a window parked at the bottom no longer chain-loads every page;
  the incoming page paints as shimmering skeleton tiles (WP
  Explorer's placeholders) sized to the page's real footprint. Tiles
  sit at a fixed 104px pitch — resizing changes how many fit per
  row, never how wide a tile is.

16 client tests (menu order, Publish gating, user verbs, no-bulk-bar
pin) + 23 PHPUnit (quick-edit authorization both ways); 5,411 vitest
and the full PHP suite green.

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

* My WordPress: Navigate into is the real detail folder, Edit… is the full modal, infinite scroll self-heals — verified in the browser

Three gaps closed, each found by driving the window side-by-side with
WP Explorer in a live session:

- **Navigate into = the detail FOLDER view.** A post opens as a
  folder: relation tiles on the left — Author, Contributors,
  Comments · N, Categories · N, Tags · N, Attached media,
  Revisions — with live counts, the rendered article on the right,
  `N folders / <status>` in the status bar. Each folder drills into
  its rows (author/contributor user cards, comment excerpts, term
  counts, attachment thumbnails, revision titles via
  `wp_post_revision_title_expanded`), and double-clicking a row opens
  its editor through a `sub-open` action that recomputes the URL
  server-side — never from the client. Contributors reuse WP
  Explorer's own cap-gated payload. New state: `into` + `relation`,
  threaded through back/crumbs.
- **Edit… is the original's modal**: Status, Author (site authors),
  Comments, Sticky, Add categories (term checkboxes), Add tags —
  applied per item with `edit_post` / `publish_post` /
  `edit_others_posts` checks, sticky via stick_post/unstick_post,
  terms appended, one `updated` announce.
- **Infinite scroll cannot stall.** The gesture-per-page rule
  deadlocked when the first batch fit the viewport: no scrollbar → no
  scroll → never re-armed. `updated()` now re-arms while the canvas
  has no overflow, so short viewports fill until they scroll, then
  gestures take over. Watched it live: Users walked 24 → 138 of 138,
  one page per gesture, skeletons in between.
- **Found while testing: plugin CPT folders were flat in dispatches.**
  `openstation_track_type_registrants` defaults to `is_admin()`, and
  a dispatch is REST — the CPT→plugin map was empty, so Woo/ACF/
  MailPoet types rendered loose while WP Explorer grouped them. The
  host now tracks registrants on dispatch requests
  (`openstation_apps_is_dispatch_request()`, URI-sniffed because the
  answer is needed during init). Verified: both windows now show
  identical folders.

Browser-verified end to end on :8889 (context menu incl. every
"Send to <agent>" row, Publish gating on drafts, ribbons, folder
navigation, modal, scroll). 37 client tests, 27 PHPUnit cases, 5,413
vitest and the full PHP suite green.

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

* My WordPress: the sub-list dossier panes, and image icons masked to the tile colour

Two parity gaps from manual side-by-side testing:

- **Selecting a sub-list row paints WP Explorer's own dossier.** The
  panes consume the SAME stats the original renders — the term-stats,
  user-stats and comment-stats REST callbacks invoked in-process with
  a synthetic request, their filters included. A category or tag gets
  the full card: name + taxonomy badge + View archive, the
  POSTS / COMMENTS / AUTHORS stat tiles ("10 · 5 published"), the
  12-month activity bars (zero months included), first/last post, and
  the clickable recent-posts list (each opens its editor through a
  cap-gated `sub-open-post`). An author or contributor gets the user
  dossier plus the user-stats activity and recent posts; a comment
  gets its author, date, rendered body and an "Open the post" button;
  attached media gets the media dossier with the usage scan; a
  revision gets a rendered preview — refused when the row is a
  revision of a DIFFERENT post. Selecting reuses the `open` action
  and the canonical tile selection ring.
- **Image icons are masked to the current colour** the way the
  shell's renderIcon() paints them — Woo's black brand W rendered as
  a black bitmap on the dark tile grid while every neighbour was
  monochrome. `glyph()` now paints non-dashicon icons as a
  CSS-masked span; photos (thumbnails) stay real images.

Browser-verified against WP Explorer: the Notes category pane is
pixel-for-pixel the original's, and both windows now show the same
white W. 20 client tests, 30 PHPUnit cases; 5,415 vitest and the full
PHP suite green.

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

* My WordPress: deterministic pagination, calm page arrivals, the Send-to heading, and the term pane's last two sections

Four refinements from the user's manual pass:

- **Pagination is deterministic.** Demo and imported content routinely
  shares one post_date to the second, and rows equal on the primary
  sort have NO defined order — each page's query may resort the whole
  set differently, which is exactly the "tiles reorder as pages land"
  report. Every query now carries an ID tiebreak (posts/media DESC,
  users ASC alongside display_name/registered), pinned by a test that
  creates thirty same-second posts and asserts disjoint pages in
  strict ID order.
- **A page arrival paints once.** Ghost placeholders were keyed on the
  in-flight flag, so the render that delivered page N also flashed a
  ghost block for page N+1 until the finally-repaint removed it — a
  two-paint shuffle on every page. Ghosts now key on the page number
  being fetched (`ui.loadingPage`) and vanish in the same paint that
  brings the rows.
- **The Send-to group sits behind an inert `SEND TO` heading.** The
  agents' rows (recognised by their `agent-send-to-` id contract) are
  regrouped behind a non-interactive `<os-context-menu-option
  heading>`; other plugins' filter entries stay where the filter put
  them, and a filter that reordered the list is respected verbatim.
- **The term pane gains its last two sections**: Top contributors
  (avatars + post counts from the payload's `topAuthors`) and "Often
  paired with" (co-term chips that open that term's own pane). Both
  were already in the reused term-stats payload; they self-hide when
  empty.

Browser-verified: the heading renders between Move to Trash and the
agent rows, pages land without reshuffling, the Notes pane unchanged.
21 client tests, 31 PHPUnit cases; 5,416 vitest and the full PHP
suite green.

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

* My WordPress: Finder clicks on the folder canvases, and the context menu paints in place

Two behaviours from the user's manual pass:

- **Single click selects, double click opens** — WP Explorer's Finder
  semantics, restored on both folder canvases: the root's section and
  group tiles, and the detail view's relation folders. A click paints
  the selection ring and nothing else; double click (or Enter, for
  the keyboard) navigates. Entity tiles inside a section keep
  click-to-preview — that IS the original's list behaviour, where
  selecting an entry fills the pane. Pinned by a test that clicks a
  root tile and asserts no dispatch until the double click.
- **The context menu no longer flashes at the raw pointer position.**
  The shell's own placement pattern, adopted: the menu paints
  `visibility: hidden`, is measured on the next frame, clamped inside
  the viewport with an 8px margin, and revealed — one paint, no jump,
  and a right-click near an edge opens fully on-screen.

Browser-verified: Posts tile selects purple on click and opens on
double click, and the menu (SEND TO heading included) appears already
placed. 22 client tests; 5,417 vitest and the full PHP suite green.

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

* html core: reconcile arrays across length changes — an appended page no longer remounts the whole list

The kit's html renderer diffed arrays in place ONLY when the length
matched; any length change disposed every entry and remounted the
array from scratch. That is exactly what an infinite scroll does —
append a page, add or remove ghost placeholders — so every arrival
destroyed and recreated the entire tile canvas: custom elements
re-upgraded, masks and images re-decoded, and the whole container
visibly blinked.

Reconciliation is now prefix-stable (still positional, not keyed —
the file's stated v1 design): shared slots update in place, a shorter
array disposes only the tail (entries AND their anchors, mirroring
the array branch of disposeChildState), a longer one appends fresh
entries before the array's end anchor so ordering and trailing
sibling content hold. Every consumer of the kit's html tag benefits —
Code Blue's issue list stops rebuilding on filter changes too.

Four new core tests pin the contract: prefix identity across growth,
survivor identity across shrink, sibling order after growth, plus the
existing equal-length identity test. Full run: 5,420 vitest across
433 files (every component renders through this path), PHP suite and
PHPCS green.

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

* My WordPress: wear the explorer's theme tokens — tiles were unreadable on light surfaces

The shared `.os-file-tile` is born on the WALLPAPER, where its label
is white over a dark gradient. WP Explorer retones it by re-scoping
the `--os-tile-*` tokens on its window root; the port never did, so
on a light scheme the tiles kept wallpaper-white labels on a white
window — invisible.

`.os-mywp` now carries the SAME recipe, on the SAME
`--os-my-wordpress-*` window-token family WP Explorer wears — so a
desktop theme that repaints one explorer repaints both — with the
palette chain underneath and the pre-brand literals as the floor:
window fg/bg, `--os-tile-fg` / `-fg-muted` / `-hover-bg`, and the
light-context label rendering (no shadow, 500 weight, antialiased,
themed color). Font family joins the window stack too.

Full vitest (5,420 — the palette-discipline suites included) and
PHPCS green.

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

* Lint: the file-length nudge — twin warning rules for TS and PHP

Past 1,000 lines a file now gets ONE encouraging warning asking for a
split toward the 300–600-line comfort zone: local-rules/os-file-length
on the ESLint side, OpenStation.Files.FileLength (a house PHPCS sniff
under tools/phpcs/, wired via installed_paths — restating the vendor
standards, which the ruleset value replaces rather than appends) on the
PHP side. Warnings by design, never a gate: a long file is a smell, not
a defect, and the right moment to split is a judgement call.

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

* My WordPress: the Agents section 1:1, composed from parts, wearing the shell's tokens

The section WP Explorer carries, whole: the cast grid with the
breathing faces, the off-state preview crew above the enable bar, the
Define / Tools / Triggers panes, and the five-step wizard — Describe
(starters + the AI draft through the openstation_agent_draft seam),
Meet (the 12-candidate Mio strip), Powers, Summon, Launch — plus drops
onto the cast cards, drag-out to the desktop, the face backfill, and
the os.agents.roster-changed signal that re-warms WP Explorer's
"Send to" cache across bundles. The mechanics moved the framework's
way: catalogues settle with the data, the wizard's cast is declared
app state the server drafts into and creates from, and the mutations
run as actions over the same store functions the REST routes wrap.

Both halves are now compositions over focused parts/ — six PHP parts,
eight TS parts, every source file in the 90–591-line range, pinned by
the suite and documented as the framework's split recipe in
docs/app-framework.md. And the surface wears the shell's own tokens:
selection through --os-tile-selected-bg / --os-tile-focus-ring (the
canonical pair — reading --os-ui-accent painted raw Pulse where WP
Explorer follows the admin scheme), links through --os-link, hovers
through --os-hover, skeletons through --os-skeleton-*, the sparkline
and stat values through --wp-admin-theme-color, all guarded by a
tokenization test. The Agents root tile renders its robot portrait as
the image it is instead of a masked disc.

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

* My WordPress: the preview pane's Explore details button — the door into the detail folder

WP Explorer's article footer offers two verbs, and the port had kept
only one: Open in editor was there, but Explore details — the way into
the author / comments / categories / tags / media / revisions folder —
was reachable from the context menu alone. Same secondary button, same
tooltip, same seat beside the editor button, dispatching the existing
`into` action. Pinned in the dossier render test.

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

* My WordPress: the Edit… modal wears the original's controls — and modals get readable notices

The port's quick-edit had flattened WP Explorer's bulk modal into raw
HTML: a checkbox column for categories, a comma-separated text input
for tags, and no hint. Now it is the original, control for control:
the info notice up top, <os-category-picker> with the term tree
(parent shipped in the choices), and <os-tag-input> — creatable, with
suggestions filtered from a tag list the data payload already holds,
so a keystroke never costs a request and a brand-new token needs no
id minted: the server takes names and appends.

The notice was unreadable in there, and the fix belongs to the
component layer: themes pin the notice INK for light windows (Legacy:
1d2327), and that global outranks the notice's own fg chain on
os-modal's deliberately dark dialog. os-modal now re-points
--os-ui-notice-color through --os-ui-modal-text — the same sanctioned
opt-out shape as its other text colours, allowlisted in the
reachability guard — so every modal's notices read, both explorers
included.

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

* My WordPress: the tile hover card — WP Explorer's tooltip, tokens and all

Hovering an entry tile now summons the original's floating card: bold
title, the lock banner when someone else is editing, the thumbnail in
its neutral well, and the excerpt clamped at 240 characters (users and
media, which have none, show their subtitle line instead). Same class
names and the same palette-level --os-my-wordpress-card-* chains, so a
theme that repaints one explorer's card repaints both. Appended to
document.body because the window clips; hidden the moment a press
means a click, a drag-out or the context menu. The excerpt ships with
the list payload; the tiles drop their native title= attribute so the
browser tooltip never doubles the card.

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

* My WordPress: the shared plugin seams — preview-extras slots, tile decorations, list bands

A plugin that extends WP Explorer now extends this window with the
same registration. The preview article grew the original's named
slots (header / meta / footer, plus meta on the navigate-into
article), and os.my-wordpress.preview-extras fires over them once per
item — the AllTerrain Work board facts and its footer button land
exactly where they do next door. os.my-wordpress.list-tile fires per
rendered tile, after it is in the DOM. And the
os.my-wordpress.list-bands filter folds a section's grid into the
banded layout: declared order, sticky frosted headings, tone tints,
count chips, the unlabelled tail band — WP Explorer's contract,
verbatim, with shift-selection extending across the visual order.

Subscribers read their facts off REST rows, so post-kind list rows
now carry the REST-visible fields: registered show_in_rest meta under
`meta`, and one term-id array per REST-exposed taxonomy keyed by its
rest_base. A band assigner or extras painter written for the original
works here without edits.

House rules held twice along the way: the test file crossed 1,000
lines and split along the parts/ seam (agents tests now live beside
the agents parts), and the size budget was re-argued to 9,000 with
tests excluded from the count — the original's side was never counted
with its tests either.

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

* My WordPress: contain plugin seams — a throwing subscriber must not break the app's own wiring

The preview-extras and list-tile fires moved to the END of the
after-render pass and each is try/caught individually: subscriber
code is plugin code, and one exception was positioned to silently
kill the infinite-scroll re-aim, the content injection and the other
slots behind a pane that still painted fine. The seams survival test
now also pins that what a subscriber painted outlives a repaint.

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

* My WordPress: the WooCommerce surface — one bundle, one set of rules, both windows

The app grows the last WP Explorer parity gap: a shop now reads like a
shop here too. A new parts/woocommerce.php serves the Orders and
Customers sections (the two surfaces a post-type folder cannot: HPOS
keeps orders out of wp_posts, and customers are a ranking over users),
band-orders the Products and Coupons queries off the same cached plans
that order them for WP Explorer, and puts the same openstation_woo /
openstation_woo_customer facts on every row. Deliberately thin: every
rule — which band a product is in, how customers rank, what an order
row says — stays in the existing integration helpers, called behind
function_exists guards. Inert without WooCommerce.

The client half is not ported at all, which is the point: the app now
fires the four seams it still owed (group-extras over an open plugin
folder, user-activate on a person's double-click, the
user-preview-actions row, the user-dossier-sections fact filter), and
the existing os-my-wordpress-woocommerce bundle decorates both windows
from one registration. It rides the app through one new framework
seam, openstation_app_window_args — the filter a companion plugin uses
to append script/style handles to an app window it doesn't own, loaded
on first open and never sooner.

Orders are flat — a new section flag meaning "rows are not posts": no
Navigate into, no Edit… modal, no Trash (client menu and server
actions both refuse, since an order id may collide with a post id
under legacy storage), and double-click opens the real WC order screen
via get_edit_order_url().

Tests: 7 PHPUnit cases (inert-without-Woo, section decoration, the
flat guards both ways, the window-args seam) and 12 vitest cases (each
seam's payload and stamping, containment, the flat menu/pane rules).
The size budget moves 9,000 → 9,500 with the honest accounting: the
like-for-like original's Woo surface is ~7,600 lines; the app reaches
it for ~1,000.

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

* My WordPress: the QA pass — six parity fixes, one of them a framework bug

The batch a side-by-side session against WP Explorer surfaced:

- The preview pane takes the original's split: the list leads at
  minmax(240px, 1fr) and the pane holds 430px until the window truly
  cannot afford it, growing to ~40% on wide windows — instead of a
  fixed 320px flex basis. Grid, not flex, with min-block-size: 0 on
  both panes so the canvases keep their own scrolling.

- A FRAMEWORK bug, fixed for every app: the session adopted a dispatch
  response's echoed state wholesale, but the echo is the state as of
  when that request was SENT. A keystroke typed while a watch refresh
  (or a previous keystroke's own dispatch) was on the wire got visibly
  reverted — the search box snapping back mid-word — and then lost,
  because the next queued dispatch read the stomped state. apply() now
  diffs the live state against the request's own snapshot and keeps
  every locally-newer key; the serialisation chain carries it up on
  the next dispatch. Pinned end to end: the typed value survives the
  echo AND the queued search sends it.

- The user pane paints WP Explorer's full dossier: role badge +
  Author archive under the name, the four stat tiles (Posts with its
  published note, Pages, Comments received, Comments left), the
  12-month activity bars, Member since / First / Last published,
  Recent posts, Top categories & tags — one line of server reuse (the
  same aggregated blob /user-stats/<id> serves), every block gated by
  the shared user-dossier-sections filter, so the Woo Customers pane
  still strips to bio.

- <os-text-field> grows `clearable`: a kit-owned clear (x) at the
  inline end while the field holds a value — appearance:none took
  WebKit's native search-X with it and Firefox never had one.
  Clearing emits os-input-change AND os-input-commit (an explicit
  clear must not wait out a keystroke debounce) and refocuses. The
  app's search band wears it.

- "Edit profile" opens the shared desktop-mode-user-edit profile
  window — not a raw user-edit.php iframe titled by its URL. The
  opener (store target → singleton with session params → relations
  announce) moved into the dependency-free user-edit-target leaf
  module; WP Explorer's own copy now delegates to it, and the app
  routes its pane button, its context-menu row, and — parity too —
  the unclaimed double-click now opens the activity footprint, the
  original's built-in answer to "open this person".

- The app's built-in Users folder stays money-free, by design and
  documented as a deliberate divergence: its rows no longer carry
  openstation_woo_customer, and the shared bundle now treats the
  facts' presence as the opt-in for any people surface outside the
  Customers section — which stays opted in by id, so WP Explorer and
  the Customers section are unchanged.

- The root grid's icon well matches the original's 48px box / 32px
  glyph pair, so the Agents portrait (and every brand mark) reads at
  WP Explorer's size instead of 40px.

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

* My WordPress: enhance search band styling with border for better separation

* My WordPress IS WP Explorer: the legacy app deleted whole, the last surface ported

The port stops being a sibling. The app reclaims the "WP Explorer"
name, the folder-mark icon and the pinned launcher slot (both read
from the module's own helpers — one source each), and the legacy
native window is deleted as a whole: its 15,707-line bundle, its
registration, template and config emit, its build target, and the
5,249 lines of suites that exercised it. Net for this commit:
+1,826 / −22,050.

What had to move first, so nothing the shell does lost its door:

- The ACTIVITY FOOTPRINT — the one surface the app never had — is
  ported 1:1 into `parts/footprint.ts`: hero, stat strip, the
  GitHub-style year heatmap, the rhythm charts, the month callout,
  the timeline and the action footer, same class names so the
  original stylesheet and plugin CSS keep applying, one cached
  round-trip to `/user-footprint/<id>`, the status bar's two lines
  included. Escape and Back pop it; the breadcrumb carries the
  person.
- "Open this object in the explorer" became the shared open target
  (`src/my-wordpress/explorer-open.ts`): the desktop tiles' Navigate
  into, the wallpaper preview's Explore details and the Corkboard's
  Open in <site> stash the object and open the app, which consumes
  the target on mount and on subscription — the same cold-start-safe
  contract the footprint and agent-editor targets already used (both
  now retarget to the app too).
- The Recycle Bin no longer needs a cross-bundle API to trash a
  dropped row: shortcut payloads carry their section's `restPath`,
  the bin DELETEs against it (`rest-trash.ts`) and announces the
  standard `os.<type>.changed` so every watcher drops the tile.
- The "Send to <agent>" intake registers from the app's bundle, reads
  the shell's REST config instead of the dead window blob, and gates
  its warm-up on the payload's `agentsEnabled` flag.
- The shared explorer stylesheet stays — it also paints the desktop
  folder windows — and rides the app as a companion style. Its 18
  hard-coded admin blues (the footprint hero's wash, the avatar
  well, the role chips, the calendar intensity ramp) now resolve
  through color-mix over `--wp-admin-theme-color`, so themes and the
  accent picker finally repaint the footprint header; pinned by a
  regression test.

`wp.os.myWordpress` (openDetail / openMedia / openUserFootprint /
registerEntityKind / trashEntity) is removed; the entities filter
stays as an inert compatibility surface. The breaking change ships
with its note — docs/migration-wp-explorer-app.md — and the hooks /
JS references now document the replacements instead of the corpse.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* App Framework: hoist what the first two apps re-implemented

The audit of framework-vs-apps found the same primitives written
twice or three times, plus one leak. This lands the shared homes:

- The data-tone → status-colour mapping moves from code-blue.css
  (where it was document-scoped and retinted any data-tone element
  in the tab once injected) into app-runtime.css, scoped to
  .os-app and published as --os-app-tone — the tone contract every
  app can read. Code Blue's swatch and row border consume it.
- .os-app__spacer joins the runtime sheet; both apps' private
  flex-1 spacer classes are gone.
- @openstation/app now exports formatBytes (the file-drop
  formatter, one source of truth instead of a third copy) and
  formatDate (short/long/month/datetime/iso — replaces five
  near-copies across footprint.ts, dossier-views.ts and
  code-blue.os.ts, and fixes the footprint callout reading a
  YYYY-MM month at UTC midnight, which shifted a month in
  negative-offset timezones).
- The Agents detail pane strip is the kit's <os-tabs> instead of a
  hand-rolled tablist — the hand-rolled one had no roving focus and
  no arrow keys.
- My WordPress's context-menu clamp is the shell's own
  clampToViewport instead of a fourth ad-hoc viewport flip.

Docs: the tone contract, the spacer and the formatting exports in
docs/app-framework.md. Tests: tests/vitest/app-runtime-format.test.ts.

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

* App Framework: the view context grows the services apps worked around

The capability audit found My WordPress re-implementing, outside the
framework, four things every next app would need again. They are the
framework's now, and the app consumes them:

- ctx.dispatch( action, args, { confirm } ) — an imperative dispatch
  can ask the shell's confirm dialog, the same one the declarative
  os-confirm attribute uses. Fixes a real inconsistency: Move to
  Trash from the context menu ran unconfirmed while the identical
  action behind the preview pane's button confirmed.
- ctx.ui( factory ) + ctx.repaint() — client-only per-view state with
  an explicit re-render. Replaces the app's WeakMap-keyed UI bag and
  its no-op `repaint` reducer (dispatching a reducer that did nothing
  was the only way to ask for a paint).
- ctx.fetch( path, init ) — REST with the root, the nonce and the
  window's spinner attribution supplied by the framework. The
  footprint's fetch loses its hand-built X-WP-Nonce header, and
  restRoot/restNonce leave the app's data payload (the config blob
  now carries restRoot, pinned in appFramework.php).
- ctx.host — the typed RuntimeHost, so the copy-links toast stops
  reaching through window.wp.os.

New: src/app-runtime/testing.ts — mockViewContext(), one blessed test
context instead of the four drifting stubs the suites had grown; all
app suites now build contexts through it. Session tests pin the ui
bag, repaint, fetch resolution + nonce, and confirm gating. Docs:
the client-view contract section in docs/app-framework.md.

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

* App Framework: Os::page() and Os::facts() — the envelopes apps hand-rolled

The paged-list envelope (items/total/pages/page/perPage) is the shape
the client runtime's page accumulation understands, and the first app
hand-assembled it five times. It is Os::page() now. The detail-pane
facts idiom — drop the rows whose value came back empty, reindex —
appeared four times as the same array_values(array_filter(...))
closure; it is Os::facts(). Both are pure statics, so the framework
core stays WordPress-free.

Also one app-side dedupe the audit flagged: the WP_User list row was
built field-for-field twice (Users section and WooCommerce Customers,
differing only in the capability check). One user_row() in lists.php,
with the check passed in.

Pinned in appFramework.php; documented in the $os table of
docs/app-framework.md.

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

* App Framework: createPagedList() and applySelection() — the list machinery

The single most reusable thing in the first app was the ~120 lines
implementing "an infinitely scrolled server-paginated list": page
accumulation keyed by list identity, the IntersectionObserver
sentinel, the one-page-per-scroll-gesture arming protocol, the
short-list deadlock guard, and skeletons sized to the incoming page.
The second list window would have copied all of it. It is
createPagedList() in @openstation/app now — accumulate() in the view,
sync() from updated(), dispose() on teardown — and the ghost/hasMore
arithmetic moved inside with it. Selection math (plain click
replaces, Ctrl toggles, Shift ranges from the anchor) is
applySelection() beside it.

My WordPress consumes both: nine UiState fields collapse into one
PagedList, the app's observer wiring and afterRender re-aim/re-arm
blocks are one sync() call, and ListPage is now an alias of the
framework's PageEnvelope. The accumulate/applySelection suites move
to tests/vitest/app-runtime-paged-list.test.ts, which also pins the
gesture protocol itself — armed/disarmed, ghosts, the deadlock guard
— which the app suites never covered.

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

* Components: <os-stat> — the tile three surfaces were drawing by hand

"Big value, small uppercase label, optional footnote in a bordered
box" existed three times: the dossier's statTile(), the footprint's
statCard(), and Code Blue's inline os-card tiles — three markups,
three stylesheets. It is one kit component now: <os-stat
value label caption>, with a `swatch` attribute that renders a
severity chip coloured through the app runtime's data-tone contract,
and every colour/size reachable through --os-ui-stat-* tokens (Code
Blue keeps its neutral value colour with a three-line override
instead of a twenty-line tile).

The dossier tiles and Code Blue's strip consume it; their private
tile CSS is deleted. The footprint's statCard deliberately does not
move: its class names are the ported-1:1 fidelity contract with
plugin CSS written against WP Explorer's footprint, and the same
holds for its bar chart. Tests updated to read value/label/caption
off the element (shadow DOM does not surface in light textContent),
plus the component's own suite; documented in
docs/components-reference.md.

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

* App Framework: `refresh` joins `set` as a built-in action

Both apps declared an empty PHP action for the same reason: a Refresh
button needs a server round trip whose entire meaning is "recompute
data() and re-render", and the runtime refused undeclared action
names. The runtime now treats a bare `refresh` exactly like `set`
with a recompute — no handler needed — while a declared handler still
wins (Code Blue keeps its error-clearing one). My WordPress's empty
declaration is gone, along with its `paginate` action, dead code left
from the page-based approach the infinite scroll replaced.

Also closes the audit's os-preserve finding: the attribute is honoured
by the server-view morph only, and both the docs and My WordPress's
comments claimed it protected client-view DOM it never touched — the
real guards there are the app's data-mywp stamps. The attribute table
in docs/app-framework.md now says so.

Pinned: appFramework.php test for the bare and the declared `refresh`.

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

* My WordPress: one hover card stylesheet, and docs teach the built-in refresh

The app stylesheet carried a near-verbatim copy of the shared
explorer sheet's hover-card rules (~90 lines: card, title, lock
banner, thumb well, excerpt) — and the shared sheet already rides the
app window as a declared companion style, so both sets were live at
once. The copy is gone; one set of rules paints both explorers'
cards, which is what the ported-1:1 class-name contract wanted all
along.

Docs: the os-app example and the client-view walkthrough in
docs/app-framework.md stop declaring an empty refresh action — the
built-in is the whole lesson now.

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

* My WordPress: one fact list, one used-in block

The dl/dt/dd fact loop appeared four times in dossier-views.ts (the
user dossier's milestones, the detail pane, the shared sub-pane, the
term pane's hand-built pair) and the media "Used in" block twice.
factList() and usedInList() are the single definitions now;
dossierFacts() composes them, and the term pane builds its milestone
rows as data instead of markup.

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

* App Framework: createMarquee() — the drawn selection joins the framework

The last content-agnostic piece of list machinery still living in an
app: ~57 lines of marquee wiring (press on empty canvas, fixed box on
the body, per-move rect intersection over data-item-id rows, plain
press clears / modified press keeps, teardown). It is
createMarquee() in @openstation/app now, with a default
.os-app__marquee style in the runtime sheet wearing the canonical
selection tokens; My WordPress passes its own class so the app sheet
keeps painting it with WP Explorer's chain, and its wire() shrinks to
one call. Pinned in app-runtime-paged-list.test.ts; docs and the API
index carry the whole client-service surface (ViewContext, the list
machinery, Os::page/facts, the built-in refresh).

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

* App Framework: ctx.state / ctx.data are live — fixes multi-drag creating one shortcut

Dragging a 4-post selection onto a folder created one shortcut. The
drop side was fine (dragShortcutItems() and fileShortcutEntities()
handle sets); the payload never carried the set, because the app's
drag-out listener lives in mounted() — which runs once — and the
context it captured froze state as a mount-time SNAPSHOT. Its
`ctx.state.selected` was the empty mount selection forever, so the
multi-item branch never fired. The same staleness silently affected
every long-lived listener wire() installs: the Escape chain's
navigation reads and the drag payload's section routing.

The fix is the framework's, not a per-listener workaround: the view
context now serves `state` and `data` through live getters, so a
captured context always answers with the current values. The
interface marks both readonly; docs say "live" out loud; and the
session suite pins the exact failure shape — a context captured at
mount must see a selection made four dispatches later.

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

* My WordPress: put the direct-access guard inside Plugin Check's window

The plugin-check CI job failed with
missing_direct_file_access_protection on my-wordpress.os.php — a file
that HAS the sanctioned dual ABSPATH/OPENSTATION_STANDALONE guard.
Why it was invisible: Plugin Check's AST pass only walks top-level
nodes, so a guard inside a `namespace` is never found; every
namespaced file is judged by the regex fallback instead, and that
fallback reads ONLY the first 50 raw lines. This file's 48-line
docblock pushed the guard to line 56 — present, correct, and out of
frame. (Code Blue's identical guard sits at line 24, which is the
whole reason it passed.) The guard cannot move above the docblock
because `namespace` must be the first statement, so the fix is a
tighter header: same content, condensed, guard now at line 50 —
verified by replaying Plugin Check's exact slice-and-regex locally
against both apps.

The trap is written down in AGENTS.md next to the guard-shape rule:
in a namespaced file the guard's POSITION is load-bearing too.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* App Framework: blunt the honest costs — dev guards, a dispatch trace, (#729)

shadow-piercing test text, and third-party client views

The merged PR names seven things that are honestly harder with the
framework. This lands an answer to five of them:

- The two silent first-hour failures now warn, once, with the fix in
  the message: a rendered os-action that names no server action, no
  local reducer and no built-in warns AT PAINT TIME (a typo used to
  no-op until the click 400d), and a write to a state key App::state()
  does not declare — via os-bind or a local reducer — warns that the
  next round trip will drop it and points at ->state() / ctx.ui().
  An older config blob without the action list disables the trigger
  guard rather than crying wolf.
- wp.os.apps.debug( windowId | '*' ) — the dispatch trace: one
  collapsed console group per dispatch with the action, args, elapsed
  ms, exactly which state keys changed and to what, and the effects;
  local actions log a debug line; failures log the error with timing.
  One place to look instead of five layers to guess at.
- renderedText( node ) joins src/app-runtime/testing.ts — the text a
  user would read, walked through shadow roots and slots, because
  textContent stops at a shadow boundary and a view painted with
  <os-stat> reads as a hole without it.
- Client views are third-party-consumable: the runtime publishes the
  client API (defineApp, html, i18n, the list/format helpers) on
  wp.os.apps and drains window.openStationAppsPending — the classic
  async queue, so a companion script that loads before the runtime
  queues a callback and the same snippet works in any load order.
  App::client( $path ) already served arbitrary prebuilt scripts;
  now there is a supported way to write one. The "not third-party-
  ready" callout in docs/app-framework.md becomes a recipe.
- A "Where does an interaction live?" decision table in the docs — the
  one choice every interaction needs, with the slow wrong-default
  called out — plus a "Debugging a dispatch" section.

Pinned in tests/vitest/app-runtime-dx.test.ts (guards fire once and
stay quiet on old blobs, the trace obeys its switch, renderedText
pierces, the queue drains/serves late pushes/contains a throwing
view). API index updated.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Trash: the Recycle Bin rebuilt on the App Framework — 659 lines against 1,869, the frozen id claimed, the legacy window deleted whole (#731)

* Trash: the Recycle Bin ported to the App Framework, legacy kept beside it

A 1:1 port of the desktop-mode-recycle-bin native window as
apps/trash/ — same toolbar, same <os-table>, same empty state, same
confirm copy, same real-time channels — running over the SAME store
(includes/recycle-bin/), so it is one trash with two windows. The
legacy window is untouched and stays installed for side-by-side
comparison; the app takes the next dock slot (dock_order 41).

Pixel parity by construction: the table's cell renderers (type
badge, title stack, row buttons and the columns builder, with their
hard-won shadow-DOM constraints) move to src/recycle-bin/
table-visuals.ts and BOTH bins import them; the chunked Empty Trash
driver (empty-loop.ts) and the chromeless-postMessage + Heartbeat
subscriber (realtime.ts) are reused wholesale.

What the framework absorbed, piece by piece: the five REST routes
and their client (two ->action()s + data() + ctx.fetch), the
localized config blob (the dispatch wire), the per-type broadcast
subscriptions (watch('*')), the l…
AllTerrainDeveloper added a commit that referenced this pull request Sep 2, 2026
* Workspaces: a desktop that knows what it is for (proposal)

A virtual desktop is a box for windows. This adds an optional profile
alongside it, so a desk can also carry which apps show on it, which
widgets sit on it, what it looks like, what it opens with, and how
those windows are arranged.

Three templates ship — Woo, Sensei and Longreads — picked from an
<os-select> in the overview top bar, beside the desktop tiles that
already rename, close and create Spaces.

The rule the whole thing rests on: a workspace is a VIEW, never a
write. The rails are recomputed with extra 'hidden' placements, the
widget column is mounted and unmounted, and the appearance is layered
over the user's settings and lifted on exit. navPlacement, the
enabled-widget list and desktop_mode_os_settings are never touched, so
a workspace the user deletes costs them nothing. Saving Preferences
while standing on an overridden desk writes their own values back for
every key they did not change.

Templates name what they are ABOUT ('post_type=product', 'sensei')
rather than nav ids, matched as substrings against the live
navigation, so the Woo desk on a site without WooCommerce is a smaller
desk instead of four permission errors.

Also here: `columns` and `focus` arrangements on the window manager,
`/workspace` in the command palette, a Restore button under each
workspace tile, and `openstation_workspace_presets` so a plugin can
add or drop a template from PHP alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces: name the templates for the job, not the plugin

Woo / Sensei / Longreads become Commerce / Learning / Publishing.

A desk called "Woo" is wrong on a store running something else, and
wrong again the day the product is renamed — but the work is commerce
either way. The products are still what the templates reach for: the
token lists name WooCommerce and Sensei directly, so on a site that
has them, Commerce IS a WooCommerce desk in everything but its label.
On a site that does not, it degrades to the core menus its tokens
still match rather than promising a product that isn't there.

The preset ids move with the labels (`commerce`, `learning`,
`publishing`) — they are the PHP↔JS contract and appear in a stored
profile's `preset` field, and renaming them is free while nothing has
shipped. WooCommerce purple and Sensei green stay as each desk's
accent; the products are what the desks are built around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces: one door — the + opens a wizard; Edit joins Restore under each tile

The overview bar had a dropdown beside the +. Two doors to the same
room: the dropdown created desks from templates, the + created a blank
one without asking, and a user had to know which did what. The
dropdown is gone. The + is the obvious place to press, so it is the
only one, and it opens a wizard.

The wizard's first step is the escape hatch. Blank desktop is a card,
preselected, and Create desktop is the focused button: + then Enter is
a plain new desk, the same two gestures it was before. A template card
plus Create makes a desk from it exactly as the dropdown did — from the
preset id, so the profile filter still runs. Customize is the only way
into the remaining steps (Name, Apps, Widgets, Look, Windows), and
Create stays in the footer on every one of them: the wizard can be left
at any point with whatever has been set so far. A blank start the user
never customized creates a plain Space — no profile at all.

Edit is the same wizard without Start, with Save where Create was and
Delete in the corner. It sits under each tile below Restore, revealed
on hover and keyboard focus like rename and close, and is offered on
plain Spaces too: for one of those it is how it becomes a workspace.

The Look step is a real picker now — wallpaper swatches with the same
previews Preferences paints, accent swatches, dock behaviour — on top
of "Use the look I have now". The Name step picks a glyph and a colour.

The lazy bundle is renamed workspace-editor → workspace-wizard along
with its loader, entry and config key; nothing has shipped, so the
rename is free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces wizard: fixed height, named live wallpaper tiles, a real Windows step

The pane keeps one generous height across every step. A wizard that
grew and shrank with each step moved its own footer under the pointer
between clicks — Next in one place, then Next somewhere else.

Wallpaper tiles carry their name and, for canvas wallpapers, the same
live preview Preferences paints, through the same manager: the
registry and the per-wallpaper settings both live in a shared store,
so the wizard bundle sees the shell's defs. Previews are disposed the
moment the Look step leaves the screen; WebGL contexts are scarce.

The Windows step can now add a window: a picker of every app that
opens something (an admin menu's url, a native window's id — controls
are not offered), appending to the launch list as a chip. "Open them
now" and "Arrange now" are gone: both acted on a desk hidden behind
the modal, where the result was invisible until the modal closed and
looked, from inside it, like a button that did nothing. Restore under
the tile is that action, done where it can be seen.

The glyph and colour rows wear the kit's 28px accent chips in a row
grid — the previous markup named a swatch size that does not exist and
fell through to the wallpaper form, which also gave the modal a
horizontal scrollbar. The pane now clips horizontally and boxes every
child, so no step can push past the dialog edge again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces wizard: centre the glyph chips, keep selection rings inside the pane

The icon component sizes its host to the glyph, and inset: 0 on a 16px
box pins it to a corner rather than stretching it; the chip now owns
the box and lets the component centre inside it. The pane's inline
padding pays for a selected chip's 4px outer ring — with a matching
negative margin so content still lines up with the dialog's edges —
instead of clipping the leading edge off every first chip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces wizard: 170px shorter

560px reserved more than the tallest step uses at the dialog's width;
390px still holds the Start cards and the Look pickers without
scrolling, and the footer no longer sits under an empty third of the
dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Grid snap: Option/Alt while dragging lands a window on a 6×6 span; a shake restarts it

Hold Option (Alt on Windows and Linux) while dragging a window and the
work area becomes a 6×6 grid. The cell under the pointer when the key
went down is the anchor; the cell under it now is the cursor; the
window lands on the rectangle spanning the two. The span is a bounding
box, so dragging from (2,2) to (1,1) makes the same 2×2 as the other
way round. Shake the pointer and the anchor moves to the cell the
shake happened in — a placement restarted without letting go.

The grid is never stored in pixels: a cell is a fraction of the work
area, resolved against the live rect on every move, so it is the same
six columns at the same proportions on a laptop and a 5K display, and
follows a dock that folds away mid-drag. Boundaries are rounded per
edge so adjacent cells share one and the sixth reaches the end. It is
laid over the work area rather than the whole desk, because a cell is
a landing zone the user picks by pointing at it, and one hidden under
the dock is one they cannot point at.

The shake is a pointer gesture the platform does not have, so it is
published as one: `os-pointer-shake` on the dragged element and the
`os.pointer.shake` action, whether or not anything acts on it. The
detector (`src/window/shake.ts`) is pure and per-gesture. It counts
reversals after at least 14px of travel and reports at five of them,
never more than 320ms apart, over at least one second — amplitude
rejects jitter, count rejects an overshoot correction, gap rejects two
wiggles a pause apart, duration rejects a flick.

Drag integration is one `onDragGesture` callback on the window with a
discriminated detail (`modifier` / `shake`), so a new gesture is a new
variant, not a new field. The modifier is read from both the pointer
event and keydown/keyup, funnelled through one setter, so it can
change while the pointer is still. While a grid snap is armed the edge
zones stay quiet; the release lands the span and fires the generic
move / resize / drag-end hooks too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Grid snap: a placement stays true to the desk, and the held window goes translucent

A grid placement is a fraction of the work area, so it has to stay one
after the work area changes. The commit remembered pixels and forgot
the cells; a browser resize left a 2×2 at (1,1) as a 2×2 of the OLD
desk. The window now keeps its span — anchor, cursor, cols, rows — and
`reflowGridSpans` puts every grid-snapped window back on its cells
whenever the work-area store reports a change: a browser resize (the
desktop area resizing), a dock that moves or folds, a layout switch.
One `os.grid-snap.reflowed` per pass rather than a move and a resize
per window; the per-window change event still fires so the session
saves the new pixels.

The span rides through the session too. `snapshot()` writes it next to
the pixels, `session.php` sanitizes it (integers, a grid within 1×1 to
24×24, both cells inside it — anything else drops it and the window
restores on its pixels, as one written before grid snap does), and on
restore the cells outrank the clamped pixels: the window is born on
its cells of THIS desk rather than landing on stale pixels and jumping
a frame later.

A free drag, a hand resize, or a state change takes the window off
the grid — each is the user saying it is theirs to place again.
Minimize is a state change too, deliberately: a minimized window
returns to the pixels it left, and those are still on its cells unless
the desk changed meanwhile, which the reflow keeps current.

While the grid is up, the window being held wears
`os-window--grid-snapping`: translucent, so the grid and the wallpaper
read through the one thing in the way of seeing where it will land.
Opacity only — a filter on a live iframe is a per-frame cost the drag
cannot afford.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces: "Keep this desk", and a widget's × works on a workspace desk

/keep-desk (also a row in /workspace, and wp.os.workspaces.saveDesk())
makes the workspace open the way the desk is now: the open windows and
WHERE they are, the mounted widgets, the apps on the rails. The
arrangement becomes `free`, because the positions are the arrangement
now and an algorithm re-laying them out would undo the thing just
kept. A plain Space becomes a workspace by being saved.

Where each window is comes along in a form that survives a resized
browser or a different display: a grid-snapped window keeps its cells,
a free one becomes fractions of the work area. Provisioning places a
launched window from either, resolved against the live area — a
native window's through the `os-window-opened` it lands with, since
its opener has no promise to await.

The bug: on a workspace desk, a widget the profile mounted was one the
user's enabled list had never heard of, so `remove()` looked for it
there, found nothing, and returned before unmounting — the × did
nothing at all. The layer now knows when a workspace's column is in
force: add and remove act on THIS desk (mount / unmount, fire the
hook, and the shell records it on the profile), the user's list,
geometry and docked heights stay exactly as they were, and a plugin
widget arriving late lands only on a desk that names it. That is what
keeps "a workspace never writes the user's settings" true even for a
write the user makes from inside one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Grid snap: the other windows recede, the one in hand stays solid

The grid is for placing this window among the others, so the others
are what should read through — 'will this land on top of Posts?' is
the question the grid is there to answer. The area wears
os-area--grid-snapping and dims every window to 45%; the held window's
own class now exempts it from that rule instead of dimming it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces / grid snap: three review findings

1. Every arrangement takes its windows off the grid. `_gridSpan` was
   only cleared by a state change, and a window already `normal`
   passes through none of those in `prepareForArrange` — so a
   workspace that landed a window on its cells and then applied
   Columns or Focus left the span behind, and the next browser resize
   put the window straight back on its cells, undoing the arrangement
   the workspace had just set up. Cascade and Tile had the same gap
   for any stale span. All four now clear it for every window they
   place.

2. An in-place edit to an overridden object-valued setting survives a
   save. The workspace override compared by reference identity, and
   the wallpaper settings editor merges into `wallpaperSettings[ id ]`
   rather than replacing it — an edit that never changed a reference
   read as "untouched" and was reverted to the user's pre-workspace
   value on save. The state and the kept-aside patch now hold
   separate copies, and the comparison is by value.

3. "Keep this desk" cannot promise more than the server keeps. The
   capture stops at the same 12 the sanitizer stops at (a constant
   mirrored on both sides), and when the desk held more the toast
   says "the top 12 of 15" rather than a number the next reload would
   not honour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Workspaces / grid snap: a design pass on the surfaces

Five changes to how the proposal looks and reads, on top of #718.
Behaviour is unchanged except where the gutter moves a window by 4px.

Grid snap

- Windows placed on the grid are inset half a gutter on each side, so
  two on neighbouring cells sit 8px apart instead of sharing an edge.
  The cells stay contiguous: spanRect still tiles the area exactly and
  a GridSpan is still whole cells, so the responsive story is
  untouched. The inset lives in a new placementRect(), which the
  preview and the landing both read.
- The "cols x rows" readout moves to the middle of the target. In the
  corner it could sit several hundred pixels from the pointer on a
  large span, which is a readout nobody reads.

The wizard

- The primary button is last in the footer on every step, with Next
  and Customize before it. It was sitting second from the right with a
  secondary after it, so the rightmost seat changed meaning from step
  to step.
- The pane is 320px rather than 390px. Three of the six steps are a
  heading and one switch until that switch is on, and at the old
  height they read as a dialog that had failed to load.
- The Windows step no longer offers to "capture the windows you have
  open" when creating, where that button does not exist. It is an
  edit-mode option.

Overview

- A tile reserves one action row rather than two, until some desk on
  the bar actually has something to restore. Edit is on every desk so
  one row is always paid for; the second was charged to everyone,
  including users with no workspaces at all.

* Workspaces: the trail, the slot, and one tone for the cards

Four surface changes, following the agent wizard's treatment where it
already answered the same question.

- The step trail wears the instrument voice: mono uppercase labels,
  22px chips, and a step you have not reached is an outline rather
  than a filled dot. Six filled chips carried no state at all, so
  current, done and not-yet all looked alike and only the label weight
  said where you were.
- The "+" is the size of a desk again. It sits in the same wrapper a
  tile does, above an empty actions block, and carries the same
  preview band and label strip so its height comes from the tile's own
  rules rather than a number copied out of them. Its glyph centres in
  the whole slot.
- The Start step drops its subtitle. The cards already say what a
  template is and that a blank desk is one click.
- Every card glyph is one muted tone, and the chosen card lights its
  own. They used to wear each preset's colour, which is the product's
  hex, so the step opened on a purple cart, a green cap and a red pen.
  The colour still shows where it earns its place, on the desk's
  overview tile.

* os-steps: size the chip border-box so an outline matches a fill

`--os-ui-step-chip-border` is the hook a trail uses to draw the steps
you have not reached as outlines, but the chip was sizing content-box,
so that 1px landed outside the declared size. An unreached chip came
out 22px + 2, and its whole grid row grew with it: a trail where the
step you are on is the smallest circle in the line.

Fixed in the component rather than in either wizard's stylesheet. The
agent wizard's trail uses the same hook and had the same 2px. With the
default border of 0 the two box models agree, so a trail that has not
opted into the outline is unchanged.

The guard is a source assertion: jsdom runs no style engine, and this
is a failure that reads as correct in a diff.

* Wizard: cards read on the dark dialog, trail chips stay aligned

Two rendering fixes for the New desktop wizard.

os-modal now re-points the <os-card> surface set onto its own dark
modal tokens. A card's tokens chain through --os-ui-surface, so the
brand palette already reached them — until a desktop theme pins one
flat (Legacy writes --os-ui-card-bg: #fff), and then a light card
landed in the dark dialog carrying the dialog's light text. The
wizard opened on four white cards with white titles.

os-steps re-stamps `trail` on its children on slotchange, not only on
connect and re-render. The wizard rebuilds its whole trail on each
step, so from the second step on the chips were never stamped and sat
top-aligned beside centred labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Workspaces: a reload restores the desk's apps and widgets

A workspace's launch-list windows and its widget column are part of
what the desk is, not a one-time suggestion, so a reload restores
that definition: any window or widget you had closed comes back.

reopenWorkspaceWindows() runs on boot after session restore and
reopens only the launch windows the restore did not already bring
back — never a window that is open, never re-running the layout, never
re-stamping `provisioned`. The widget column is re-asserted in the
same beat. A closed widget is no longer recorded as an edit to the
desk, so it survives the round trip; permanent removal stays the
wizard's Edit step, mirroring how apps work.

Switching away and back within a session still leaves the desk as you
left it — the reload is the reset, Restore is the on-demand one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Workspaces / grid snap: a design pass on the surfaces (#723)

* Shell boot: a target is one-shot and must be a real page, so a reload stops opening an empty window (#720)

Every F5 on the shell screen opened a window showing nothing. Two
defects compounded, and either one alone is worth fixing.

`wp-admin/admin.php` is core's plugin-screen bootstrap. Without a
`page` arg there is nothing to dispatch to: it falls through the last
`else` in that file, fires two back-compat `load-*` hooks, and answers
200 with an empty body, having required neither `admin-header.php` nor
`admin-footer.php`. The URL still resolves, is still same-origin, and
`admin.php` is still on the target allowlist — it has to be, since
every plugin screen in the admin lives there. The allowlist matches
filenames and never sees the query, so a page-less `admin.php` passed
validation and became a window.

Worse, it arrived flagged as intent. A plain GET to that URL is not
the shell screen, so it reached the one-hop route in
`openstation_redirect_plain_admin_to_portal`, which forwards with
`intent` hardcoded true — "the user asked for this by name." The shell
obeyed.

And `target` / `intent` never left the address bar. They are an
instruction, not an address: PHP reads them once, on the request that
carries them, and hands the answer to the shell as `currentPage` and
`fromPortalIntent`; nothing on the JS side reads them from the URL.
Left in place they stop being one-shot — every reload re-read the same
target and re-opened it on top of the restored session, for the life
of the tab and past it, through a bookmark or a browser session
restore. `openstation_shell_boot_target()` has always documented that a
reload of the bare screen URL re-resolves against the live session;
nothing made the address bar hold that URL.

So: `openstation_url_is_page_less_admin_php()` joins
`openstation_url_is_shell_screen()` as a URL that resolves but must not
become a target, and `openstation_sanitize_portal_target()` refuses
both. One point covers the portal handler, the frozen-flag alias, the
one-hop route and the screen's own read; each already treats '' as
"fall back to the entry resolver" — the session's focused window, else
the default window, else the Dashboard. A plugin extending the
allowlist gets the same treatment, since the check is on the resolved
URL rather than the list.

On the shell side, `shellUrlWithoutBootArgs()` in `src/shell-url.ts`
strips the consumed args and `init()` replaces the history entry with
the result, before anything can throw. This is deliberately not the
`/openstation/` normalisation reverted earlier: that route costs an
HTTP redirect, and an address-bar flash, on every reload. Dropping two
args stays on the same screen and the same route.

The PHPUnit case that pins the redirect was written first and failed
on the target it emitted, `/wp-admin/admin.php`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Feat: Add close all windows keybinds. (#719)

* Workspaces / grid snap: a design pass on the surfaces

Five changes to how the proposal looks and reads, on top of #718.
Behaviour is unchanged except where the gutter moves a window by 4px.

Grid snap

- Windows placed on the grid are inset half a gutter on each side, so
  two on neighbouring cells sit 8px apart instead of sharing an edge.
  The cells stay contiguous: spanRect still tiles the area exactly and
  a GridSpan is still whole cells, so the responsive story is
  untouched. The inset lives in a new placementRect(), which the
  preview and the landing both read.
- The "cols x rows" readout moves to the middle of the target. In the
  corner it could sit several hundred pixels from the pointer on a
  large span, which is a readout nobody reads.

The wizard

- The primary button is last in the footer on every step, with Next
  and Customize before it. It was sitting second from the right with a
  secondary after it, so the rightmost seat changed meaning from step
  to step.
- The pane is 320px rather than 390px. Three of the six steps are a
  heading and one switch until that switch is on, and at the old
  height they read as a dialog that had failed to load.
- The Windows step no longer offers to "capture the windows you have
  open" when creating, where that button does not exist. It is an
  edit-mode option.

Overview

- A tile reserves one action row rather than two, until some desk on
  the bar actually has something to restore. Edit is on every desk so
  one row is always paid for; the second was charged to everyone,
  including users with no workspaces at all.

* Workspaces: the trail, the slot, and one tone for the cards

Four surface changes, following the agent wizard's treatment where it
already answered the same question.

- The step trail wears the instrument voice: mono uppercase labels,
  22px chips, and a step you have not reached is an outline rather
  than a filled dot. Six filled chips carried no state at all, so
  current, done and not-yet all looked alike and only the label weight
  said where you were.
- The "+" is the size of a desk again. It sits in the same wrapper a
  tile does, above an empty actions block, and carries the same
  preview band and label strip so its height comes from the tile's own
  rules rather than a number copied out of them. Its glyph centres in
  the whole slot.
- The Start step drops its subtitle. The cards already say what a
  template is and that a blank desk is one click.
- Every card glyph is one muted tone, and the chosen card lights its
  own. They used to wear each preset's colour, which is the product's
  hex, so the step opened on a purple cart, a green cap and a red pen.
  The colour still shows where it earns its place, on the desk's
  overview tile.

* Add Fleet OAuth authorization server (#724)

* Revert "Add Fleet OAuth authorization server (#724)"

This reverts commit 2bde4ce5de0fa24ee50afaa68aebb11954082a15.

* os-steps: size the chip border-box so an outline matches a fill

`--os-ui-step-chip-border` is the hook a trail uses to draw the steps
you have not reached as outlines, but the chip was sizing content-box,
so that 1px landed outside the declared size. An unreached chip came
out 22px + 2, and its whole grid row grew with it: a trail where the
step you are on is the smallest circle in the line.

Fixed in the component rather than in either wizard's stylesheet. The
agent wizard's trail uses the same hook and had the same 2px. With the
default border of 0 the two box models agree, so a trail that has not
opted into the outline is unchanged.

The guard is a source assertion: jsdom runs no style engine, and this
is a failure that reads as correct in a diff.

* Chore(deps): Bump anthropics/claude-code-action from 1.0.183 to 1.0.210 (#726)

Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.183 to 1.0.210.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/be7b93b1907a4abad570368f3c74b6fe3807510b...a874e9ecd7bb36efdad65429c6b35815f5a08f10)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.210
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Multisite: support the network admin, and scope desktops per site (#704)

* Multisite: support the network admin, and scope desktops per site

Gives the network admin its own shell screen, adds a Network Admin dock
tile, opens cross-admin links in a browser tab, and gives each site its
own saved session.

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

* Multisite: give the network admin its own session

The network admin shared the main site's session blob, and the two
desktops derive the same window ids from different admins — index-php
is the site dashboard on one and the network dashboard on the other.
So whichever desktop you opened second restored the other admin's
dashboard window under a colliding id, and the dock's Dashboard tile
focused the wrong admin's screen: from the network desktop, Dashboard
opened the site's dashboard.

The network admin now persists under its own meta key. The session REST
route runs in the main site's blog context whichever desktop is saving,
so the network screen's sessionUrl carries network=1 and the handlers
honour it only alongside manage_network. Both read and write filter
windows to the session's own admin scope, so a blob written before the
keys split heals on the next load instead of leaking across.

Also fixes the two phpcs errors the branch carried (doc param order in
openstation_resolve_admin_target, array formatting in the multisite
payload).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Roberto Aranda <roberto.aranda@automattic.com>

* App Framework — a window in one PHP file (.osx.php + optional .os.ts), Code Blue & WP Explorer rebuilt on it (#722)

* App Framework: declare a whole window in PHP (.osx.php), with an optional instant client view (.os.ts); Code Blue rebuilt on it

A window is now one file. `OpenStation\App::define()` declares title,
size, icon, title-bar buttons, ⋯-menu rows, tabs, per-window chrome,
a typed state schema, actions, and either a server-rendered view
(zero JavaScript) or a `data()` plus a `.os.ts` client view for the
interactions that must never wait for a WordPress request.

- includes/framework: host-agnostic core (App, State, Runtime,
  Registry, Os, Effects, Html) behind six contracts (Auth, Settings,
  Hooks, Cache, Env, Store) with WordPress and standalone adapters;
  the WordPress host loads apps/*/*.osx.php, registers them as native
  windows and serves one dispatch route.
- src/app-runtime: the one shared client bundle — mount, dispatch,
  keyed DOM morph, os-action / os-bind / os-arg / os-poll / os-prop /
  os-confirm vocabulary covering every kit component and event,
  effects (toast, title, close, open, open_url, badge, announce,
  menu, send), tabs, channels, lifecycle actions; `@openstation/app`
  gives an .os.ts `defineApp()` with local actions and a view
  rendered by the kit's html tag.
- <os-histogram>: the chart moves into the component kit.
- Code Blue is rebuilt as apps/code-blue (same id, gate and hooks;
  the /code-blue/* REST routes and window_args/icon_args/template_html
  filters are gone — docs/migration-code-blue-app.md): 1,269 lines
  instead of 3,235, filters instant, one request to read the log.

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

* App Framework: rename the app file to `.os.php`, fix the CI gates, and close the review

The file format is now `<name>.os.php` beside `<name>.os.ts` — one
extension family for one app, instead of `.osx.php` next to `.os.ts`.

CI:

- Plugin Check recognises exactly four direct-access guard spellings,
  and `if ( ! defined( 'ABSPATH' ) && ! defined( 'OPENSTATION_STANDALONE' ) )`
  is not one of them: 23 framework and app files read as unguarded.
  Rewritten as `if ( ! defined( 'ABSPATH' ) ) { defined(
  'OPENSTATION_STANDALONE' ) || exit; }`, which keeps the standalone
  seam and matches the pattern. The shape is load-bearing; AGENTS.md
  says so now.
- Plugin Check runs PHPCS under its own ruleset, so the
  `customEscapingFunctions` in `phpcs.xml.dist` are invisible to it and
  `Html\esc()`-escaped exception messages still tripped
  `EscapeOutput.ExceptionNotEscaped`. Scoped `phpcs:ignore` with the
  reason on the four `throw`s, and on `is_writable()` in the log model.
- `Tests_OpenStation_FilesStore` failed on PHP 8.3/8.4 because the
  desktop-icon registry is process-scoped: the icons `appFramework` and
  `codeBlue` register through `openstation_apps_register_windows()`
  survived into every later test's auto-place count. Both tear_downs
  unregister them.

Review:

- `assets/js/apps/*.js` was committed — `/assets/js/*.js` only matches
  the top level. Gitignored on its own line, untracked, and
  `bin/package.sh` now walks `apps/*/*.os.ts` for the bundles it
  splices in (their vite `fileBase` is a template literal its `fileBase`
  sed cannot see), so the zip no longer depends on git-archive shipping
  them — and the unminified dev build stops shipping.
- `apps/**/*.ts` is `export-ignore`d: TypeScript source no longer ships
  to wp.org.
- `$os->toast( $message, $tone )` dropped the tone silently, because the
  shell has no toast severity. The parameter is gone rather than
  faked.
- The morph assigned a `<select>`'s value before morphing its options in,
  so selecting a newly added option failed silently; children are
  morphed first. A duplicated `os-key` re-matched the same live node —
  the key is now spent on first use.
- `os-range-change` joins the default-debounced events: a slider drag in
  a server view queued one request per tick.
- Code Blue's `read()` is no longer cached. The `entries` / `max_bytes` /
  `max_entries` filters ran inside the cached callback and `parse()`
  bakes localized labels in, so on a persistent object cache a filter
  change lagged and two admins in different locales could read each
  other's labels. A log reader's product is freshness.
- Restored the three dropped Code Blue tests (filterable entries, the
  entry cap keeping the newest, the label→severity map).
- Docs stop over-promising: client views are not third-party-usable yet
  (`@openstation/app` is a Vite alias into `src/`), standalone mode has
  no shipped bootstrap and Code Blue's `__()` calls keep it on
  WordPress. New "The gate is the only authorization there is" section
  covers the logged-in-by-default gate, the unfiltered server view
  (a smuggled `os-poll` fires with no interaction), and state typing
  stopping at the top level — the last with a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* CI: build the app client views before PHPUnit, now that they are gitignored

`openstation_apps_client_bundle()` reports an app's client view only
when `assets/js/apps/<name>[.min].js` is on disk. Untracking those
bundles removed the file the PHPUnit job had been reading by accident,
so `Tests_OpenStation_CodeBlue::test_host_ships_the_client_view_with_the_window`
failed on both PHP versions.

The job now runs `npm run build:apps` — two vite runs, well under a
second — rather than the test asserting against source instead of the
artifact a user installs. The assertion names the command when it fails
locally, and DEVELOPMENT.md says to build once on a fresh clone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* App runtime: one post-render pass, one naming convention, one bundle stat

Three simplifications the sequence diagram made visible:

- `session.ts`: every paint now ends in the same `finishRender()` —
  props, lazy component load, poll reconciliation. Before, a client
  app's server response walked the DOM twice (`paintClient()` ran
  applyProps + reconcilePolls and `apply()` ran them again), while a
  LOCAL paint never ran `ensureComponents()` at all, so a local action
  that rendered a kit component not yet in the tab left it inert until
  the next server round trip. `mounted()` keeps running after the
  finished pass, so an imperative hook reads a complete DOM.
- `class-app.php`: the "definition file's name without .os.php" rule
  was spelled out in both `style_path()` and `client_source()`; it is
  now one `file_base()` helper — the convention is a single fact.
- `wordpress.php`: the client bundle was resolved (an `is_file()` stat)
  twice per app per request — once for the companion script, once for
  the config's `client` flag. `openstation_apps_client_config()` now
  takes the already-resolved path.

No contract changes: same wire shape, same attribute vocabulary, same
`App` surface. build, lint, typecheck, test:js (5390), test:php (2668),
lint:php all green.

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

* Docs: Code Blue reads are uncached — drop the stale cache sentence

The hooks reference still said the parsed result was cached through
$os->cache; the review pass removed that cache (the filters ran inside
the callback and the labels are localized). The doc now says why.

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

* My WordPress: WP Explorer rebuilt on the App Framework — 8,169 lines against 32,238, Agents wizard included (#727)

* Docs: Code Blue reads are uncached — drop the stale cache sentence

The hooks reference still said the parsed result was cached through
$os->cache; the review pass removed that cache (the filters ran inside
the callback and the labels are localized). The doc now says why.

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

* My WordPress: the full explorer surface as one server-view app, and what the framework had to grow

The app (`apps/my-wordpress/`, zero JavaScript):

- Root folder grid: the four builtin sections PLUS every eligible
  custom post type, folded into plugin-group folders — discovered by
  calling the SAME `openstation_my_wordpress_*` helpers WP Explorer
  uses (`eligible_post_types`, `post_type_icon`, `post_type_group`,
  `collect_groups`), so both windows always agree on what the site
  contains and the existing CPT filters shape both. Counts on every
  tile, `N folders` in the status bar, back chevron.
- Two-pane section view: searchable, sortable (per-kind sort menu),
  paged list beside the selected item's dossier. Media renders as a
  thumbnail grid at 48/page. Row gestures nest three triggers —
  click opens the pane, double-click opens the editor, right-click
  pops the actions menu — one wrapper per event.
- Multi-select with a bulk bar: per-row checkboxes accumulate into
  `selected` via `State::toggle_item()`, bulk trash honours
  `delete_post` per item and announces only what actually moved.
- Dossiers: post facts, user footprint (roles, posts, comments),
  media facts plus WP Explorer's own "used in" scan
  (`openstation_my_wordpress_media_usage_build`), edit / trash with
  confirm.
- Sections filterable at render time
  (`openstation_my_wordpress_app_sections`) — nothing frozen at
  registration, unlike the `init` 99 snapshot the old window takes.

What porting a complex app forced into the framework:

- `App::watch( ...$types )` — re-render when watched content changes
  anywhere on the desktop, `'*'` for any content change (the explorer
  cannot enumerate its types at define time). Runtime subscribes to
  the `os.<type>.changed` broadcasts, coalesces bursts, marks a
  minimized window stale and catches up on restore. The read half of
  the `$os->announce()` pair.
- `Auth::can( $capability, ...$args )` — meta-capabilities need their
  object (`can( 'delete_post', $id )`). WordPress adapter forwards to
  `current_user_can()`; standalone answers from the name.

Tests: 22 PHPUnit cases end to end through dispatch (discovery,
groups, sort, search, panes, selection, bulk authorization, menus,
effects), 5 new vitest cases for watch (exact, wildcard, coalescing,
stale-on-pause, unsubscribe). The app-registering suites' tear_downs
now unregister EVERY app icon, so the process-scoped icon registry
cannot leak into the files-store auto-place counts again.

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

* My WordPress: the full explorer — every blocker solved with the framework's own client view

The gaps called unportable are now in the window, each through a seam
that already existed:

- **Drag rows out to the desktop** — the client view lifts rows into
  `wp.os.dragManager` with the same shortcut payload the desktop's
  drop targets already accept; a multi-selection drags as a stack.
- **Marquee selection** — click, Ctrl/Cmd toggle, Shift range and a
  drawn marquee, all local reducers over `state.selected`; the same
  ids feed the server's bulk-trash authorization untouched.
- **Infinite scroll** — an IntersectionObserver dispatches `more`;
  pages accumulate per-number client-side, so appending never
  duplicates, a watch refresh replaces exactly the page it re-fetched,
  and a new section/query/sort starts clean.
- **Plugin preview actions** — the SAME pipeline as WP Explorer: PHP
  descriptors from `openstation_my_wordpress_preview_actions`
  (capability-gated server-side), the SAME
  `os.my-wordpress.preview-actions` JS filter applied through
  `wp.os.hooks`, buttons in the pane and rows in the context menu.
  An action registered for WP Explorer appears here unchanged.
- **Context menu, media zoom, copy links, Escape chain** — client
  state that never leaves the tab; `<os-context-menu>` at the
  pointer, full-size zoom overlay, clipboard links for the selection.
- **Rendered post preview** — `data()` ships the post through Core's
  `the_content` pipeline; the client injects it into an `os-preserve`
  slot the diff never touches.
- **Edit locks** — WP Explorer's lock payload feeds row badges and a
  dossier notice.

The split is the framework's own: `my-wordpress.os.php` stays the
truth (sections + CPT discovery + groups, WP_Query/WP_User_Query,
per-item `can( 'delete_post', $id )`, trash / bulk-trash / edit,
dossier payloads incl. the media usage scan) and `my-wordpress.os.ts`
paints it — 757 + 926 lines + 426 CSS against the old module's
32,230.

Tests: 21 PHPUnit cases assert the data payloads, state and effects
end to end; 12 vitest cases pin the selection math, the page
accumulator, preview-action scoping (section id, post-type slug,
wildcard, MIME fail-closed, the shared JS filter) and four full
renders of the view.

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

* My WordPress: pixel parity with WP Explorer — tile grids, real breadcrumbs, the always-there preview pane

Side-by-side against WP Explorer, three things did not match. Now
they do:

- **Lists are icon-tile grids, not rows** — every section paints the
  SAME `<os-tile>` element the wallpaper and WP Explorer use, flowed
  as a grid (the wallpaper's absolute positioning switched off, the
  grid owns layout): section icon or thumbnail as the visual, label
  beneath, the DRAFT/pending/private corner ribbon from the kit's own
  `<os-ribbon>`, selection ring via the canonical
  `.os-file-tile--selected`, a 🔒 overlay for edit locks.
- **Breadcrumbs are the desktop-files shape** — a round back chevron,
  ancestor crumbs as accent-coloured links, the current segment plain
  bold text, `›` separators. The search box moves to its own band
  under the header (`Search posts…`, section label lowercased),
  exactly where WP Explorer puts it.
- **The preview pane is always there** — "Select an entry to preview
  it here." until a tile is clicked, then the dossier. Navigating
  into an entry is one click, like the original.

Also matched: the two-sided status bar (`24 of 576 items` /
`Page 1 of 24`, loaded-of-total from the accumulated pages), and the
sort menu moves off the toolbar into the canvas right-click menu
(Sort by — Newest/Oldest/Title, plus Refresh), which is where the
original's icon-canvas menu keeps it. The bulk bar only exists while
a selection does.

Client tests grow to 13 (tile attributes, ribbon, lock tooltip,
selection attribute, both pane states, the crumb trail, the status
line); 5,408 vitest and the full PHPUnit suite green.

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

* My WordPress: the real context menu, ribbon anchoring, tamed infinite scroll — and the invented bulk bar removed

Three fixes from side-by-side review, one uninvention:

- **The context menu is WP Explorer's, entries and order**: Open in
  editor, Navigate into, Edit…, Publish (unpublished items only),
  Copy link, Move to Trash — then the item's preview actions, then
  every plugin entry appended by the SAME
  `os.my-wordpress.tile-context-menu` JS filter the original runs,
  which is where the agents' "Send to <agent>" rows come from: one
  registration, both windows. An action on a selected item applies to
  the whole selection (Copy link copies every link, Move to Trash
  bulk-trashes). Edit… opens a quick-edit `<os-modal>` — Status +
  Comments over the selection — backed by a new `quick-edit` server
  action that re-checks `edit_post` (and `publish_post`) per item and
  announces `updated`.
- **The bulk bar is gone.** Selecting never opens a toolbar — the
  original has no such chrome; selection actions live in the context
  menu. A test now pins its absence.
- **The DRAFT ribbon sits on the tile again**: the tile was flattened
  to `position: static` for the grid, which re-anchored its
  absolutely-positioned `<os-ribbon>` to the grid cell. `relative`
  keeps the tile in flow AND keeps it the ribbon's containing block.
  The lock overlay gets a tile-hugging wrapper for the same reason.
- **Infinite scroll is one page per scroll gesture**: the sentinel
  disarms when it fires and only a scroll on the canvas re-arms it,
  so a window parked at the bottom no longer chain-loads every page;
  the incoming page paints as shimmering skeleton tiles (WP
  Explorer's placeholders) sized to the page's real footprint. Tiles
  sit at a fixed 104px pitch — resizing changes how many fit per
  row, never how wide a tile is.

16 client tests (menu order, Publish gating, user verbs, no-bulk-bar
pin) + 23 PHPUnit (quick-edit authorization both ways); 5,411 vitest
and the full PHP suite green.

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

* My WordPress: Navigate into is the real detail folder, Edit… is the full modal, infinite scroll self-heals — verified in the browser

Three gaps closed, each found by driving the window side-by-side with
WP Explorer in a live session:

- **Navigate into = the detail FOLDER view.** A post opens as a
  folder: relation tiles on the left — Author, Contributors,
  Comments · N, Categories · N, Tags · N, Attached media,
  Revisions — with live counts, the rendered article on the right,
  `N folders / <status>` in the status bar. Each folder drills into
  its rows (author/contributor user cards, comment excerpts, term
  counts, attachment thumbnails, revision titles via
  `wp_post_revision_title_expanded`), and double-clicking a row opens
  its editor through a `sub-open` action that recomputes the URL
  server-side — never from the client. Contributors reuse WP
  Explorer's own cap-gated payload. New state: `into` + `relation`,
  threaded through back/crumbs.
- **Edit… is the original's modal**: Status, Author (site authors),
  Comments, Sticky, Add categories (term checkboxes), Add tags —
  applied per item with `edit_post` / `publish_post` /
  `edit_others_posts` checks, sticky via stick_post/unstick_post,
  terms appended, one `updated` announce.
- **Infinite scroll cannot stall.** The gesture-per-page rule
  deadlocked when the first batch fit the viewport: no scrollbar → no
  scroll → never re-armed. `updated()` now re-arms while the canvas
  has no overflow, so short viewports fill until they scroll, then
  gestures take over. Watched it live: Users walked 24 → 138 of 138,
  one page per gesture, skeletons in between.
- **Found while testing: plugin CPT folders were flat in dispatches.**
  `openstation_track_type_registrants` defaults to `is_admin()`, and
  a dispatch is REST — the CPT→plugin map was empty, so Woo/ACF/
  MailPoet types rendered loose while WP Explorer grouped them. The
  host now tracks registrants on dispatch requests
  (`openstation_apps_is_dispatch_request()`, URI-sniffed because the
  answer is needed during init). Verified: both windows now show
  identical folders.

Browser-verified end to end on :8889 (context menu incl. every
"Send to <agent>" row, Publish gating on drafts, ribbons, folder
navigation, modal, scroll). 37 client tests, 27 PHPUnit cases, 5,413
vitest and the full PHP suite green.

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

* My WordPress: the sub-list dossier panes, and image icons masked to the tile colour

Two parity gaps from manual side-by-side testing:

- **Selecting a sub-list row paints WP Explorer's own dossier.** The
  panes consume the SAME stats the original renders — the term-stats,
  user-stats and comment-stats REST callbacks invoked in-process with
  a synthetic request, their filters included. A category or tag gets
  the full card: name + taxonomy badge + View archive, the
  POSTS / COMMENTS / AUTHORS stat tiles ("10 · 5 published"), the
  12-month activity bars (zero months included), first/last post, and
  the clickable recent-posts list (each opens its editor through a
  cap-gated `sub-open-post`). An author or contributor gets the user
  dossier plus the user-stats activity and recent posts; a comment
  gets its author, date, rendered body and an "Open the post" button;
  attached media gets the media dossier with the usage scan; a
  revision gets a rendered preview — refused when the row is a
  revision of a DIFFERENT post. Selecting reuses the `open` action
  and the canonical tile selection ring.
- **Image icons are masked to the current colour** the way the
  shell's renderIcon() paints them — Woo's black brand W rendered as
  a black bitmap on the dark tile grid while every neighbour was
  monochrome. `glyph()` now paints non-dashicon icons as a
  CSS-masked span; photos (thumbnails) stay real images.

Browser-verified against WP Explorer: the Notes category pane is
pixel-for-pixel the original's, and both windows now show the same
white W. 20 client tests, 30 PHPUnit cases; 5,415 vitest and the full
PHP suite green.

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

* My WordPress: deterministic pagination, calm page arrivals, the Send-to heading, and the term pane's last two sections

Four refinements from the user's manual pass:

- **Pagination is deterministic.** Demo and imported content routinely
  shares one post_date to the second, and rows equal on the primary
  sort have NO defined order — each page's query may resort the whole
  set differently, which is exactly the "tiles reorder as pages land"
  report. Every query now carries an ID tiebreak (posts/media DESC,
  users ASC alongside display_name/registered), pinned by a test that
  creates thirty same-second posts and asserts disjoint pages in
  strict ID order.
- **A page arrival paints once.** Ghost placeholders were keyed on the
  in-flight flag, so the render that delivered page N also flashed a
  ghost block for page N+1 until the finally-repaint removed it — a
  two-paint shuffle on every page. Ghosts now key on the page number
  being fetched (`ui.loadingPage`) and vanish in the same paint that
  brings the rows.
- **The Send-to group sits behind an inert `SEND TO` heading.** The
  agents' rows (recognised by their `agent-send-to-` id contract) are
  regrouped behind a non-interactive `<os-context-menu-option
  heading>`; other plugins' filter entries stay where the filter put
  them, and a filter that reordered the list is respected verbatim.
- **The term pane gains its last two sections**: Top contributors
  (avatars + post counts from the payload's `topAuthors`) and "Often
  paired with" (co-term chips that open that term's own pane). Both
  were already in the reused term-stats payload; they self-hide when
  empty.

Browser-verified: the heading renders between Move to Trash and the
agent rows, pages land without reshuffling, the Notes pane unchanged.
21 client tests, 31 PHPUnit cases; 5,416 vitest and the full PHP
suite green.

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

* My WordPress: Finder clicks on the folder canvases, and the context menu paints in place

Two behaviours from the user's manual pass:

- **Single click selects, double click opens** — WP Explorer's Finder
  semantics, restored on both folder canvases: the root's section and
  group tiles, and the detail view's relation folders. A click paints
  the selection ring and nothing else; double click (or Enter, for
  the keyboard) navigates. Entity tiles inside a section keep
  click-to-preview — that IS the original's list behaviour, where
  selecting an entry fills the pane. Pinned by a test that clicks a
  root tile and asserts no dispatch until the double click.
- **The context menu no longer flashes at the raw pointer position.**
  The shell's own placement pattern, adopted: the menu paints
  `visibility: hidden`, is measured on the next frame, clamped inside
  the viewport with an 8px margin, and revealed — one paint, no jump,
  and a right-click near an edge opens fully on-screen.

Browser-verified: Posts tile selects purple on click and opens on
double click, and the menu (SEND TO heading included) appears already
placed. 22 client tests; 5,417 vitest and the full PHP suite green.

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

* html core: reconcile arrays across length changes — an appended page no longer remounts the whole list

The kit's html renderer diffed arrays in place ONLY when the length
matched; any length change disposed every entry and remounted the
array from scratch. That is exactly what an infinite scroll does —
append a page, add or remove ghost placeholders — so every arrival
destroyed and recreated the entire tile canvas: custom elements
re-upgraded, masks and images re-decoded, and the whole container
visibly blinked.

Reconciliation is now prefix-stable (still positional, not keyed —
the file's stated v1 design): shared slots update in place, a shorter
array disposes only the tail (entries AND their anchors, mirroring
the array branch of disposeChildState), a longer one appends fresh
entries before the array's end anchor so ordering and trailing
sibling content hold. Every consumer of the kit's html tag benefits —
Code Blue's issue list stops rebuilding on filter changes too.

Four new core tests pin the contract: prefix identity across growth,
survivor identity across shrink, sibling order after growth, plus the
existing equal-length identity test. Full run: 5,420 vitest across
433 files (every component renders through this path), PHP suite and
PHPCS green.

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

* My WordPress: wear the explorer's theme tokens — tiles were unreadable on light surfaces

The shared `.os-file-tile` is born on the WALLPAPER, where its label
is white over a dark gradient. WP Explorer retones it by re-scoping
the `--os-tile-*` tokens on its window root; the port never did, so
on a light scheme the tiles kept wallpaper-white labels on a white
window — invisible.

`.os-mywp` now carries the SAME recipe, on the SAME
`--os-my-wordpress-*` window-token family WP Explorer wears — so a
desktop theme that repaints one explorer repaints both — with the
palette chain underneath and the pre-brand literals as the floor:
window fg/bg, `--os-tile-fg` / `-fg-muted` / `-hover-bg`, and the
light-context label rendering (no shadow, 500 weight, antialiased,
themed color). Font family joins the window stack too.

Full vitest (5,420 — the palette-discipline suites included) and
PHPCS green.

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

* Lint: the file-length nudge — twin warning rules for TS and PHP

Past 1,000 lines a file now gets ONE encouraging warning asking for a
split toward the 300–600-line comfort zone: local-rules/os-file-length
on the ESLint side, OpenStation.Files.FileLength (a house PHPCS sniff
under tools/phpcs/, wired via installed_paths — restating the vendor
standards, which the ruleset value replaces rather than appends) on the
PHP side. Warnings by design, never a gate: a long file is a smell, not
a defect, and the right moment to split is a judgement call.

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

* My WordPress: the Agents section 1:1, composed from parts, wearing the shell's tokens

The section WP Explorer carries, whole: the cast grid with the
breathing faces, the off-state preview crew above the enable bar, the
Define / Tools / Triggers panes, and the five-step wizard — Describe
(starters + the AI draft through the openstation_agent_draft seam),
Meet (the 12-candidate Mio strip), Powers, Summon, Launch — plus drops
onto the cast cards, drag-out to the desktop, the face backfill, and
the os.agents.roster-changed signal that re-warms WP Explorer's
"Send to" cache across bundles. The mechanics moved the framework's
way: catalogues settle with the data, the wizard's cast is declared
app state the server drafts into and creates from, and the mutations
run as actions over the same store functions the REST routes wrap.

Both halves are now compositions over focused parts/ — six PHP parts,
eight TS parts, every source file in the 90–591-line range, pinned by
the suite and documented as the framework's split recipe in
docs/app-framework.md. And the surface wears the shell's own tokens:
selection through --os-tile-selected-bg / --os-tile-focus-ring (the
canonical pair — reading --os-ui-accent painted raw Pulse where WP
Explorer follows the admin scheme), links through --os-link, hovers
through --os-hover, skeletons through --os-skeleton-*, the sparkline
and stat values through --wp-admin-theme-color, all guarded by a
tokenization test. The Agents root tile renders its robot portrait as
the image it is instead of a masked disc.

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

* My WordPress: the preview pane's Explore details button — the door into the detail folder

WP Explorer's article footer offers two verbs, and the port had kept
only one: Open in editor was there, but Explore details — the way into
the author / comments / categories / tags / media / revisions folder —
was reachable from the context menu alone. Same secondary button, same
tooltip, same seat beside the editor button, dispatching the existing
`into` action. Pinned in the dossier render test.

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

* My WordPress: the Edit… modal wears the original's controls — and modals get readable notices

The port's quick-edit had flattened WP Explorer's bulk modal into raw
HTML: a checkbox column for categories, a comma-separated text input
for tags, and no hint. Now it is the original, control for control:
the info notice up top, <os-category-picker> with the term tree
(parent shipped in the choices), and <os-tag-input> — creatable, with
suggestions filtered from a tag list the data payload already holds,
so a keystroke never costs a request and a brand-new token needs no
id minted: the server takes names and appends.

The notice was unreadable in there, and the fix belongs to the
component layer: themes pin the notice INK for light windows (Legacy:
1d2327), and that global outranks the notice's own fg chain on
os-modal's deliberately dark dialog. os-modal now re-points
--os-ui-notice-color through --os-ui-modal-text — the same sanctioned
opt-out shape as its other text colours, allowlisted in the
reachability guard — so every modal's notices read, both explorers
included.

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

* My WordPress: the tile hover card — WP Explorer's tooltip, tokens and all

Hovering an entry tile now summons the original's floating card: bold
title, the lock banner when someone else is editing, the thumbnail in
its neutral well, and the excerpt clamped at 240 characters (users and
media, which have none, show their subtitle line instead). Same class
names and the same palette-level --os-my-wordpress-card-* chains, so a
theme that repaints one explorer's card repaints both. Appended to
document.body because the window clips; hidden the moment a press
means a click, a drag-out or the context menu. The excerpt ships with
the list payload; the tiles drop their native title= attribute so the
browser tooltip never doubles the card.

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

* My WordPress: the shared plugin seams — preview-extras slots, tile decorations, list bands

A plugin that extends WP Explorer now extends this window with the
same registration. The preview article grew the original's named
slots (header / meta / footer, plus meta on the navigate-into
article), and os.my-wordpress.preview-extras fires over them once per
item — the AllTerrain Work board facts and its footer button land
exactly where they do next door. os.my-wordpress.list-tile fires per
rendered tile, after it is in the DOM. And the
os.my-wordpress.list-bands filter folds a section's grid into the
banded layout: declared order, sticky frosted headings, tone tints,
count chips, the unlabelled tail band — WP Explorer's contract,
verbatim, with shift-selection extending across the visual order.

Subscribers read their facts off REST rows, so post-kind list rows
now carry the REST-visible fields: registered show_in_rest meta under
`meta`, and one term-id array per REST-exposed taxonomy keyed by its
rest_base. A band assigner or extras painter written for the original
works here without edits.

House rules held twice along the way: the test file crossed 1,000
lines and split along the parts/ seam (agents tests now live beside
the agents parts), and the size budget was re-argued to 9,000 with
tests excluded from the count — the original's side was never counted
with its tests either.

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

* My WordPress: contain plugin seams — a throwing subscriber must not break the app's own wiring

The preview-extras and list-tile fires moved to the END of the
after-render pass and each is try/caught individually: subscriber
code is plugin code, and one exception was positioned to silently
kill the infinite-scroll re-aim, the content injection and the other
slots behind a pane that still painted fine. The seams survival test
now also pins that what a subscriber painted outlives a repaint.

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

* My WordPress: the WooCommerce surface — one bundle, one set of rules, both windows

The app grows the last WP Explorer parity gap: a shop now reads like a
shop here too. A new parts/woocommerce.php serves the Orders and
Customers sections (the two surfaces a post-type folder cannot: HPOS
keeps orders out of wp_posts, and customers are a ranking over users),
band-orders the Products and Coupons queries off the same cached plans
that order them for WP Explorer, and puts the same openstation_woo /
openstation_woo_customer facts on every row. Deliberately thin: every
rule — which band a product is in, how customers rank, what an order
row says — stays in the existing integration helpers, called behind
function_exists guards. Inert without WooCommerce.

The client half is not ported at all, which is the point: the app now
fires the four seams it still owed (group-extras over an open plugin
folder, user-activate on a person's double-click, the
user-preview-actions row, the user-dossier-sections fact filter), and
the existing os-my-wordpress-woocommerce bundle decorates both windows
from one registration. It rides the app through one new framework
seam, openstation_app_window_args — the filter a companion plugin uses
to append script/style handles to an app window it doesn't own, loaded
on first open and never sooner.

Orders are flat — a new section flag meaning "rows are not posts": no
Navigate into, no Edit… modal, no Trash (client menu and server
actions both refuse, since an order id may collide with a post id
under legacy storage), and double-click opens the real WC order screen
via get_edit_order_url().

Tests: 7 PHPUnit cases (inert-without-Woo, section decoration, the
flat guards both ways, the window-args seam) and 12 vitest cases (each
seam's payload and stamping, containment, the flat menu/pane rules).
The size budget moves 9,000 → 9,500 with the honest accounting: the
like-for-like original's Woo surface is ~7,600 lines; the app reaches
it for ~1,000.

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

* My WordPress: the QA pass — six parity fixes, one of them a framework bug

The batch a side-by-side session against WP Explorer surfaced:

- The preview pane takes the original's split: the list leads at
  minmax(240px, 1fr) and the pane holds 430px until the window truly
  cannot afford it, growing to ~40% on wide windows — instead of a
  fixed 320px flex basis. Grid, not flex, with min-block-size: 0 on
  both panes so the canvases keep their own scrolling.

- A FRAMEWORK bug, fixed for every app: the session adopted a dispatch
  response's echoed state wholesale, but the echo is the state as of
  when that request was SENT. A keystroke typed while a watch refresh
  (or a previous keystroke's own dispatch) was on the wire got visibly
  reverted — the search box snapping back mid-word — and then lost,
  because the next queued dispatch read the stomped state. apply() now
  diffs the live state against the request's own snapshot and keeps
  every locally-newer key; the serialisation chain carries it up on
  the next dispatch. Pinned end to end: the typed value survives the
  echo AND the queued search sends it.

- The user pane paints WP Explorer's full dossier: role badge +
  Author archive under the name, the four stat tiles (Posts with its
  published note, Pages, Comments received, Comments left), the
  12-month activity bars, Member since / First / Last published,
  Recent posts, Top categories & tags — one line of server reuse (the
  same aggregated blob /user-stats/<id> serves), every block gated by
  the shared user-dossier-sections filter, so the Woo Customers pane
  still strips to bio.

- <os-text-field> grows `clearable`: a kit-owned clear (x) at the
  inline end while the field holds a value — appearance:none took
  WebKit's native search-X with it and Firefox never had one.
  Clearing emits os-input-change AND os-input-commit (an explicit
  clear must not wait out a keystroke debounce) and refocuses. The
…
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