Skip to content

Apps: Posts, Pages, Users, User Edit, Plugins and Comments become App Framework apps - #757

Merged
AllTerrainDeveloper merged 6 commits into
trunkfrom
port/list-windows-to-app-framework
Sep 4, 2026
Merged

Apps: Posts, Pages, Users, User Edit, Plugins and Comments become App Framework apps#757
AllTerrainDeveloper merged 6 commits into
trunkfrom
port/list-windows-to-app-framework

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Posts, Pages, Users, User Edit, Plugins and Comments are now App Framework apps under apps/. The three legacy bundles (src/posts-window, src/plugins-window, src/comments-window), the six includes/*-window/ modules and the three window stylesheets are deleted. Every window keeps its frozen id, title, size, gate, opt-in setting and URL remap; every query-args filter, REST field, REST route, AJAX handler and JS hook-bus filter keeps its name and signature. Styling is verbatim, phone layouts included.

The framework grew what the six windows had each hand-rolled, so the next list window gets it for free:

  • openstation_app_rest() / openstation_app_rest_page() — in-process Core REST with _fields and _embed (+ code, openstation_app_rest_page_is_out_of_range()), so a data() serves the exact rows a browser fetched and every third-party REST field / rest_post_query filter keeps working.
  • The reopen lifecycle action + per-session params — the deep-link contract that replaces the three shared-store hand-offs (user-edit-target, post-filter, tab-target). Remaps pass params; openUserEditWindow() moved to src/open-targets/user-edit-window.ts.
  • The own-echo skip: an app's announce is tagged with its window, and that window's watch() never answers it with a second round trip.
  • App::config( callable ) — per-viewer config resolved when the manifest is built.
  • ctx.windowId, ctx.host.toast( { duration } ), and the list-window furniture in @openstation/app: statusControl(), pager(), mountMenuCheckboxes(), createListTableSync(); the shared .os-app-list__* layout (with its @container / phone rules) in app-runtime.css.
  • <os-user-profile> as one companion bundle (openstation-user-profile) both the Users and User Edit windows load, instead of a copy compiled into each.

A second review pass (five reviewers + knip + a CSS-usage scan) fed a cleanup round: the Plugins multisite status literal (network-active), the User Edit personal-options meta registration (an init hook added during init never fired), profile.php on a live window, the Users root doubling as a note→post drop target, per-row COUNT queries in data() replaced by grouped ones (Users 60 → 2 per paint, Comments 20 → 1), a shared term-canvas base for the Categories mind map and the Tags cloud (~700 duplicated lines gone), the Posts cells split per column family (Pages no longer ships the tag/category pickers), the Plugins parts split at their seams with one reviews / action-button / busy module, wp.org HTML parsed inert, and every dead export, config key and stylesheet rule the tools found removed. Two detectors are now in the repo: npm run lint:unused (knip) and npm run lint:css:unused (bin/unused-css.sh).

The open is preloaded. The old bundles held the shell overlay until their first REST fetch, and the ported apps inherited a worse version of that beat: bundles downloaded on the click, the frame waited for a whole mount request, and the dock's hover prewarm stood down on native tiles. App::prefetch() was the only early-paint path and it runs data() on every boot — wrong for windows whose data() is queries. Three layers now, each usable alone:

  • placeholder in defineApp() — the app's stand-in data, painted from the declared state the moment the window opens, with the new ctx.loading set until mount answers (one mounted(), no second mount). Posts, Pages, Users and Plugins paint their frame with the table in its skeleton; Comments paints bare tabs and ghost rows. Deep links still wait.
  • Bundle prefetch — once a sync settles and the browser is idle, a <link rel="prefetch"> per deferred native window's bundle, companion scripts and styles (nothing executes; Save-Data and 2G skip it; one hint per URL), so a first open's <script> comes from cache.
  • Hover prewarm for native windows — with "Prewarm windows on hover" on, the dock's dwell on a system tile, an icon launcher or a remapped menu URL calls the new wp.os.prewarmWindow( id ): bundles into the tab, then the runtime sends the window's first mount silently (wp.os.apps.prewarm) and holds the answer for 30 s; the open takes it instead of fetching. Iframe tiles keep the speculative window.

Lines: old vs new

Old = the deleted files at trunk (wc -l). New = the six app directories minus *.test.ts, plus the framework's net growth (git diff --numstat against trunk).

Window(s) Old (TS + PHP + CSS) New (apps/<dir>, incl. CSS)
Posts + Pages 10,248 + 1,331 + 1,591 = 13,170 8,112 + 305 = 8,417
Users + User Edit 4,671 + 2,526 + ~210 = 7,407 4,378 + 771 = 5,149
Plugins 7,478 + 2,327 + 1,120 = 10,925 7,747
Comments 2,092 + 1,333 + 642 = 4,067 3,503
Framework additions (net) 921
Product total 35,359 25,737 (−9,622, −27%)
Tests removed / added 2,247 5,998 (app vitest 2,829 + app PHPUnit 2,444 + framework 725)

Every file is under 700 lines. Bundles (minified): posts 225 KB, pages 163 KB, plugins 138 KB (was 203), users 47 KB (was 128), user-edit 7 KB (was 109), user-profile 111 KB (shared), comments 33 KB.

Behaviour notes

  • os.plugin.changed now carries source: 'plugins-app' and the changed ids; a Pages trash announces page (was post).
  • openstation_comments_window_after_bulk also fires from the Comments app's moderate action (same function as the route). The Comments reply gate is edit_posts, as the route always was. openstation_comments_window_reply_editor is gone (the composer is a textarea; the filter never selected anything).
  • The Users bulk bar gained Delete / Remove-from-site (with a reassign picker on single site) and a real column sort; the Users and Comments default _fields drop the fields the apps now compute in grouped queries (all stay registered).
  • Deep links: desktop-mode-user-edit takes { userId } (0 = the viewer, which is what profile.php sends), desktop-mode-comments { post }, desktop-mode-plugins { tab }; a live singleton retargets through reopen.
  • The Plugins ?openstation_force_refresh=1 flag is gone (Refresh is the reload action); one dm_pwsz_map size transient replaces the per-plugin ones.
  • A plugin delete dispatch on a network is refused like the admin-ajax half always was — openstation_plugins_window_caps() is the screen gate, and a super admin holds delete_plugins. The update transient is read straight from Core (no memo, no *_site_transient_update_plugins hooks — Plugin Check reads those names as a self-hosted updater).
  • New public surface: wp.os.prewarmWindow( id ) (Experimental), wp.os.apps.prewarm( id ), RuntimeHost.fetch( …, { silent } ), ViewContext.loading, defineApp( { placeholder } ).

Full list in docs/migration-list-apps.md; hook docs, JS reference, API index, examples and the REST route index updated.

Verification

  • npm run build, npm run lint (0 errors), npm run typecheck, npm run lint:php — clean.
  • npm run test:js — 471 files, 5,857 tests passed.
  • npm run test:php — full suite green, single-site and multisite; wp plugin check — 0 findings.
  • npm run lint:css:unused — every selector emitted; npm run lint:unused — no dead exports left under apps/.
  • Manual QA on :8889: the open path verified (frame at once, rows from the hover prewarm on click). Still to walk: each window on a phone-width viewport (status picker, card list, bottom bulk bar, folded Categories/Tags editors); profile.php, user-edit.php?user_id=N, edit-comments.php?p=N, plugin-install.php deep links into open and closed windows; the Users Profile tab and the User Edit window sharing the profile bundle; a multisite install for the network-active plugin rows.

🤖 Generated with Claude Code

Open WordPress Playground Preview

… Framework apps

The six native list windows are now `.os.php` apps under `apps/`, and the
three legacy bundles (`src/posts-window`, `src/plugins-window`,
`src/comments-window`), the six `includes/*-window/` modules and the three
window stylesheets are gone. Every window keeps its frozen id, gate, opt-in
setting, URL remap, query-args filters, REST fields and routes, AJAX
handlers and JS hook-bus filters; the styling is verbatim, phone layouts
included. 35,359 lines of product code become 26,548.

The framework absorbed what each window had hand-rolled: an in-process
Core REST proxy (`openstation_app_rest()` / `_page()`) so a `data()`
serves the exact rows a browser fetched; the `reopen` lifecycle and
per-session params, which replace the three shared-store deep-link
hand-offs; `App::config( callable )` for per-viewer config; the
list-window furniture (`statusControl()`, `pager()`,
`mountMenuCheckboxes()`) and the shared `.os-app-list__*` layout with its
phone rules. See docs/migration-list-apps.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper marked this pull request as ready for review September 3, 2026 21:27
@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude were the framework additions needed? Why were those added and what are their purpose?

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Claude finished @AllTerrainDeveloper's task in 48s —— View job


Task

Answering: "were the framework additions needed? Why were those added and what are their purpose?"

  • Read trigger comment and PR description
  • Inspect the framework additions (openstation_app_rest(), reopen lifecycle action, App::config(), statusControl()/pager()/mountMenuCheckboxes(), shared list CSS)
  • Check how each is actually used across the ported apps
  • Post answer

Answer

Yes — each of the five additions is real shared infrastructure that at least two of the six ported apps needed, not speculative abstraction. Breakdown:

1. openstation_app_rest() / openstation_app_rest_page()includes/framework/wordpress.php:564-630
In-process wrapper around Core's REST dispatch that (unlike calling rest_do_request() directly) still runs the request through rest_post_dispatch, so _fields projection and _embed expansion — and any third-party rest_field/rest_post_query filter — behave exactly as they would for a browser-issued request. Without this, each app's data() would either duplicate that dispatch-replay logic or silently diverge from what a real REST client sees.
Used directly by Posts, Users, Plugins, Comments; Pages picks it up for free by reusing apps/posts/parts/query.php wholesale. Only User Edit (single-record, not a collection) doesn't need it. 5/6 apps.

2. reopen lifecycle action + per-session paramsincludes/framework/class-app.php:961-970, src/app-runtime/index.ts:347-368, src/open-targets/user-edit-window.ts
Replaces three separate ad hoc wp.os.createSharedStore hand-off modules that existed on trunk before this PR: src/posts-window/user-edit-target.ts, src/comments-window/post-filter.ts, src/plugins-window/tab-target.ts. Each solved the same problem differently — "open target X inside an already-open singleton window" — by stashing state in a module store the receiver polled. reopen makes that a first-class lifecycle action with params riding the dispatch itself.
Declared by User Edit, Plugins, and Comments; Users is a caller of the new opener contract for User Edit deep links. 3 declare it, a 4th calls it — and it demonstrably deleted three duplicate mechanisms rather than adding a fourth pattern.

3. App::config( callable )includes/framework/class-app.php:747-768
Lets per-viewer facts (nonces, current_user_can() checks, current user id) be resolved lazily when the manifest is built for that request/viewer, instead of baked in at file-load time. Every app needs viewer-scoped facts somewhere.
Used by all 6 apps — the most universally-needed of the five additions.

4. statusControl() / pager() / mountMenuCheckboxes() (src/app-runtime/list-ui.ts, new 272-line file) and 5. the shared .os-app-list__* CSS (assets/css/app-runtime.css, +285 lines)
The file's own header comment says it plainly: "the first native list built all three by hand; the second copied them." This pair is scoped to the list-table shape specifically — Posts (+Pages via reuse) and Users. Plugins (a card/grid browser) and Comments (a conversation-rail view) correctly don't use it, and neither does User Edit (a form). So it's 3/6 apps, but importantly it wasn't forced onto the other 3 just for consistency — it was kept narrow to where the duplication actually existed.

Bottom line: all five were needed by ≥2 apps each (three of them by a majority or all six), and the framing in the PR body ("the next list window gets it for free") holds up — openstation_app_rest, App::config(), and reopen are already broad-use primitives, while the list-UI helpers/CSS were deliberately kept to the apps that share that specific shape rather than over-generalized.


AllTerrainDeveloper and others added 5 commits September 4, 2026 09:26
…, cost

Five reviewers, knip and a CSS-usage scan went over the port; this is
everything they found, applied.

Framework: a window's announce is tagged with its id and its own
`watch()` skips the echo (no second round trip after a mutation);
`ctx.windowId`; `ctx.host.toast( { duration } )`;
`createListTableSync()` for the preserved-table dance every list did by
hand; `App::config( callable )` documented as `function ( App )`;
`openstation_app_rest()` reports a single resource as one, sends its
page defaults, and returns the error `code` with
`openstation_app_rest_page_is_out_of_range()`; the pager is an
`<os-select>`; nonce refresh rewrites both levels of a blob; the i18n
map can attribute one source tree to several handles;
`profile.php` retargets a live User Edit window to the viewer.

Apps: the Plugins multisite status literal (`network-active`); the User
Edit personal-options meta registration (an `init` hook added during
`init` never fired); the Users root no longer doubles as the note→post
drop target; per-row COUNTs in `data()` replaced by grouped queries
(Users 60 → 2 per paint, Comments 20 → 1, one size transient for every
plugin); the Categories mind map and the Tags cloud on one term-canvas
base (~700 duplicated lines gone, one CSS block); the Posts cells split
per column family so the Pages bundle stops shipping the pickers; the
profile surface as one companion bundle both the Users and User Edit
windows load; the Plugins parts split at their seams with shared
reviews / action-button / busy modules, inert wp.org HTML parsing and
no duplicate kit components; the Comments rail no longer collapses
after Load more, its composer clears on a conversation change, its
reply gate matches the route; every dead export, config key, filter
and stylesheet rule the tools found removed; tests backfilled.

Two detectors join the repo: `npm run lint:unused` (knip) and
`npm run lint:css:unused` (`bin/unused-css.sh`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…take the network persona

Three multisite-only failures, two causes.

`openstation_plugins_window_caps()` is the Plugins window's screen
gate: Core's SITE plugins screen offers no install, upload or delete —
those live in the network admin — so the map reports them false on a
network however capable the viewer is. The admin-ajax half enforced
that in its guard; the `delete` action did not, and went straight to
Core's REST controller, which lets a super admin through. The window
hid a button the server would still have honoured. `mutate()` now
applies the same gate to every row mutation it performs.

Editing someone else is `manage_network_users` on a network
(`map_meta_cap`, `edit_user`), so the User Edit suite's acting user is
a super admin there — the persona the window is built for, and the one
`usersApp.php` already takes. Its PUT-through-core and destroy-sessions
cases were asserting a single-site rule on a network install.

Full suite green both ways: 2,925 tests single-site, 2,925 multisite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HdbgKfaTNgh6W3twXHzXA
Plugin Check refuses a wp.org-hosted plugin that looks like it ships a
self-hosted updater, and it decides that by scanning raw PHP for the
`update_plugins` transient's own set/delete hook names. The window's
per-request snapshot listened on that pair purely to drop itself when
the transient changed under it, which is the one thing those hooks are
NOT for here — but the scanner cannot tell the difference, and the
build failed on it.

The snapshot now listens where an intra-request rewrite actually comes
from: `upgrader_process_complete` and `deleted_plugin`, the pair that
already invalidates the size map beside it. The forced refresh drops
the snapshot itself, right after it repopulates the transient, so the
Refresh button keeps reading its own fresh answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HdbgKfaTNgh6W3twXHzXA
…f our own

The memo the previous commit kept had to be invalidated whenever
anything rewrote the transient, and the obvious listeners for that are
the two hook names Plugin Check reads as a self-hosted updater. Losing
them left the memo stale: a second read inside one request answered
from the first, which the icon-resolution test caught the moment it
primed a fixture twice.

So the memo goes. `get_site_transient()` is already an in-memory read
after the first call of a request — core caches the option behind it —
so priming once and reading through is the same cost without a
coherence problem to solve. The non-persistent group registration moves
beside the one memo left, the local-icon probe, whose answer genuinely
cannot change inside a request.

Full suite green both ways (2,925 single-site, 2,925 multisite) and
`wp plugin check` reports no updater finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HdbgKfaTNgh6W3twXHzXA
…er prewarm

The six ported windows had no instant-open path: the first paint waited
behind the shell overlay for a whole `mount` request, the bundles
downloaded on the click, and the dock's hover prewarm stood down on
every native tile. `App::prefetch()` was the only way to paint early,
and it runs `data()` on every shell boot — wrong for windows whose
`data()` is queries.

Three layers, each usable on its own:

- A client `placeholder` in `defineApp()`: the app's stand-in data
  (an empty list, zero counts) painted from the declared state the
  moment the window opens, with the new `ctx.loading` set until the
  `mount` answer replaces it — one `mounted()`, no second mount. Posts,
  Pages, Users and Plugins paint their frame with the table in its
  skeleton; Comments paints bare tabs and six ghost rows. Deep links
  still wait for `mount`.
- `<link rel="prefetch">` for every deferred native window's bundle,
  companion scripts and companion styles once a sync has settled and
  the browser is idle — nothing executes, Save-Data and 2G skip it, one
  hint per URL — so a first open's `<script>` comes from cache.
- Hover-intent prewarm for native windows: with "Prewarm windows on
  hover" on, the dock's dwell on a system tile, an icon launcher or a
  remapped menu URL calls the new `wp.os.prewarmWindow( id )`, which
  loads the bundles and has the runtime send the window's first `mount`
  now (`wp.os.apps.prewarm`), silently, holding the answer for 30 s. The
  open takes it instead of fetching. Iframe tiles keep the speculative
  window.

`RuntimeHost.fetch` gains `silent`; `nativeWindows.prewarmById` and
`prewarmWindowById` join the facade. Docs: the client-view section of
app-framework.md ("The first paint"), javascript-reference.md (the new
API, `wp.os.apps.prewarm`, the setting's note), api-index.md,
architecture.md, hooks-reference.md, the migration note, and the
Preferences toggle's description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 9f07fcf into trunk Sep 4, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the port/list-windows-to-app-framework branch September 4, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant