Multisite: support the network admin, and scope desktops per site - #704
Conversation
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>
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).
|
Took this over and fixed the remaining issues. Root cause: the network admin shared the main site's session blob, and the two desktops derive the same window ids from different admins ( Fix (b614425): the network admin now has its own session key ( Re-verified the PR's test plan on a local subdirectory multisite: network dock opens network screens, site rows open browser tabs, per-site desktops stay independent, exit/re-enter round-trips to the right admin. |
* 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…
* 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
…
What it does
Makes the shell work on a multisite network.
wp-admin/network/admin.php?page=openstation. Its dock is the network menu, and its screens open in windows there like a site's do.Why are these changes being made?
None of it worked before. Switching to OpenStation from
/wp-admin/network/landed you on the main site's dashboard, because the target allowlist only resolved site filenames and quietly dropped the rest.Testing Instructions
This cannot be tested on the Playground link. Playground serves a single-site install, and every path below needs a network. Use a local multisite:
{ "multisite": true }to.wp-env.override.json, runnpm run env:start, then create two sites:admin/passwordand turn OpenStation on.The network admin
/wp-admin/network/and click Switch to OpenStation in the admin bar. Make sure you land on the network desktop, not the main site's, and that the address iswp-admin/network/admin.php?page=openstation.Leaving an admin
/wp-admin/prefix with the network admin, so it is the case that regressed twice.)The dock tile
manage_network(create an editor on one site and log in as them), make sure there is no tile at all.Per-site desktops
/site2/wp-admin/).Single site, unchanged