Repository navigation
DEVELOPMENT
This file is for people working on openstation — the plugin itself, not plugins that extend it. If you want to extend the shell, start with docs/getting-started.md.
npm install # one-time
npm run dev # watch: rebuilds assets/js/desktop.js on save
npm run lint # ESLint — our CI runs this
npm run test:js # Vitest — the full JS suite (jsdom)
npm run test:js:watch # Vitest in watch mode
npm run build # produces both assets/js/desktop{,.min}.js
# PHPUnit and PHPCS run inside a dedicated wp-env instance (requires Docker):
npm run env:start:tests # first run pulls WP + MariaDB images
npm run test:php:install # composer install inside the tests instance (once)
npm run test:php # the PHPUnit run itself
npm run test:php:multisite # same suite against a network install
npm run lint:php # PHPCS, errors only — this is what CI gates on
npm run lint:php:all # PHPCS including advisory warnings
npm run lint:php:fix # PHPCBF — applies every auto-fixable rule
npm run env:stop:tests # when you're done
# Dead-code detectors (advisory; neither gates CI):
npm run lint:unused # knip — unused files, exports and types under src/ and apps/
npm run lint:css:unused # class selectors in the app + runtime sheets nothing emitsOne PHPUnit test reads build output rather than source: an app's client view is only reported when assets/js/apps/<name>[.min].js exists on disk, and that directory is gitignored. On a fresh clone run npm run build (or just npm run build:apps) once before npm run test:php — the assertion names the command if you forget.
npm run env:start spins up a self-contained WordPress + MariaDB stack with this checkout bind-mounted as the plugin: the manual QA environment on http://localhost:8890/wp-admin/ (admin / password). PHPUnit runs in a second, independent instance defined by .wp-env.tests.json (port 8891), started with npm run env:start:tests; npm run test:php:multisite reuses that instance with WP_MULTISITE=1, so the same suite also runs against a network install (the test library reinstalls the database either way — the two runs never taint each other); the test:php* scripts target it via wp-env's --config flag, so QA state and test runs never share a database. See Manual QA and per-worktree instances for how the mount works and how to run one instance per git worktree.
- The
"wp-content/plugins/desktop-mode": "."mapping in.wp-env.jsonbind-mounts the directory you runwp-env startfrom straight into the container. PHP edits are live on the next request; JS changes appear afternpm run build, because the site serves whatever is inassets/js/right now. The enqueued bundles'?ver=cache-buster is filemtime-based, so a normal browser reload picks up fresh builds. - wp-env keys everything to the start directory plus the config file: it hashes them and gives each combination its own containers, database, and WordPress volume under
~/.wp-env/<hash>/. Same directory + same config, same instance, every time. That's how.wp-env.json(QA) and.wp-env.tests.json(PHPUnit) run as two fully isolated stacks from one checkout. - Ports:
8890(QA instance,.wp-env.json),8891(tests instance,.wp-env.tests.json) and8892(the member instance of a local OpenStation network,.wp-env.member.json, see below). They are remapped from wp-env's defaults so the stacks coexist with a Core checkout's environment (see the PHPUnit section ofAGENTS.md). -
bin/sync-to-wp-develop.shdoes not feed this instance. It mirrors the tree into a wordpress-develop checkout, which is a different environment entirely. The wp-env instance always serves the start directory live through the mount; there is no copy step to forget.
Because the instance identity includes the start directory, every git worktree can run its own fully isolated stack in parallel. The only knob is ports, since each worktree inherits 8890/8891 from the tracked config files. WP_ENV_PORT applies to whichever instance the command starts:
cd <path-to-worktree>
npm install # worktrees start bare
npm run build
WP_ENV_PORT=8894 npm run env:start # QA instance
WP_ENV_PORT=8895 npm run env:start:tests # PHPUnit instance (if needed)http://localhost:8894/wp-admin/ now serves that worktree's code while the main checkout's instance keeps serving :8890. Databases, uploads, and user state are all per-instance.
Notes:
-
Skipping the port override fails loudly, not subtly. If another running instance already holds
8890, Docker refuses to bind ("port is already allocated") andwp-env startaborts; nothing silently cross-connects to the other site. Instances only conflict while running, so a stopped main instance frees8890for a worktree. -
Prefer override files for long-lived worktrees. Drop
{ "port": 8894 }in a git-ignored.wp-env.override.json(and{ "port": 8895 }in.wp-env.tests.override.jsonif you run PHPUnit there) and a plainnpm run env:start/env:start:testsdoes the right thing from then on. TheWP_ENV_PORTenv var wins over the override file when both are set. -
Run wp-env commands from the worktree's directory.
env:start,env:stop,env:destroy, their:testsvariants, andtest:phpall resolve the instance from the current directory. -
Cost. Each instance is three containers (WordPress, CLI, database).
npm run env:stop/env:stop:testsparks an instance and keeps its data;npm run env:destroy/env:destroy:testsdeletes it. -
npm run test:phpin a worktree runs inside that worktree's own tests instance, so PHPUnit is isolated per worktree too.
network.md pairs separate installs into one switcher. To try it on one machine, a third instance acts as the member:
npm run env:start # the hub, :8890 (a multisite; pair from its network admin)
npm run env:start:member # the member, :8892 (a single site, admin / password)Inside a container localhost is the container itself, so both setup scripts drop bin/wp-env-network-dev.sh's mu-plugin into their instance: it rewrites localhost:<port> onto host.docker.internal through the openstation_network_request_url filter, and plain HTTP is allowed because wp-env sets WP_ENVIRONMENT_TYPE to local. Then, in the hub's network shell, open Network and add http://localhost:8892; in the member's shell, open Network and join http://localhost:8890. Reload either shell and open Workspaces: the same row on both. npm run env:stop:member when done.
bin/boot-cost.mjs answers one question deterministically: what does the shell's boot document actually cost, and what changed between two builds. It logs into a local WordPress, fetches one document, then fetches every <script src> and <link rel=stylesheet> the server printed into it, and reports request count plus raw and gzipped bytes grouped by owner.
Measuring the server's output rather than the browser's behaviour is the whole point. DevTools' footer totals (N requests / X MB transferred / Finish: Y) move with cache state, with how long the tab sat there polling, and with how many windows you opened, so two recordings of the same build routinely disagree by more than the change being measured. Same code in, same numbers out.
It needs a running instance (see Manual QA and per-worktree instances); it will not start one for you.
npm run perf:boot-cost -- --label trunk --out /tmp/trunk.json
npm run perf:boot-cost -- --diff /tmp/trunk.json /tmp/branch.jsonDefaults are the QA instance (http://localhost:8890, admin / password) and the shell boot document (/wp-admin/, which redirects into the portal). --base points at another port, --path at another document, so --path '/wp-admin/edit.php?openstation_chromeless=1' measures what a page opened inside a window costs. --out writes the per-asset detail that --diff consumes, and --diff prints the delta table plus the list of files that left or joined the document.
Use one instance and switch the code under it. The mount serves the start directory live, so a branch switch is enough for PHP, and assets/js/ is committed, so the bundles switch with it:
git switch trunk
npm run perf:boot-cost -- --label trunk --out /tmp/trunk.json
git switch my-branch
npm run perf:boot-cost -- --label my-branch --out /tmp/branch.json
npm run perf:boot-cost -- --diff /tmp/trunk.json /tmp/branch.jsonRunning two wp-env instances instead is the obvious alternative and it is a trap: each keeps its own database, so they disagree about active plugins, theme and content, and the gap between the two sites will swamp the gap between the two branches.
Four things that will bite you:
-
SCRIPT_DEBUGdecides whether you are measuring production. It is on by default in wp-env, which serves unminified core assets and unminified plugin bundles; numbers taken that way have the right shape but run roughly 3x the production figure. For a number destined for a PR description,wp config set SCRIPT_DEBUG false --rawinside the instance first and set it back afterwards. Turning it off also switches core to concatenatedload-scripts.phpbundles, so request counts change shape as well as size. -
What else is active can change the answer completely.
bin/setup-wp-env.shinstalls and activates Gutenberg on every fresh instance, and Gutenberg's Dashboard page (build/pages/dashboard/page-wp-admin.php) enqueues the entire editor package chain on the Dashboard screen. The shell boots from its own screen (admin.php?page=openstation, which/wp-admin/redirects to), so that chain no longer reaches the boot document — it reaches the Dashboard window, which is the point of the screen and the reason--path '/wp-admin/'measured with Gutenberg active is a stable number. The trap still applies to a window: work that defers part of a chain some other plugin enqueues on the same screen measures as approximately zero there. When a boot-cost change looks far smaller than expected, find out what else on the page enqueues the same handles before concluding the change did nothing. - Deferral moves cost, it does not delete it. Do not open windows during a run. A deferral is supposed to make the boot document cheaper and the first open more expensive, so measure the two separately or the second effect hides the first.
- Only compare like with like. Absolute totals from a Gutenberg-active instance and a Gutenberg-inactive one are not comparable to each other. Only the trunk-versus-branch delta within one configuration means anything.
phpcs.xml.dist inherits the full WordPress standard. It scans PHP only — the extensions arg is load-bearing, because without it PHPCS applies its CSS and JS sniffs to assets/, walks ~70 minified bundles and exhausts a 1GB memory limit on any checkout where npm run build has run.
The ruleset separates two things that the standard reports identically:
-
Errors gate CI.
npm run lint:phprunsphpcs -nand must exit clean. Anything that fails here is a defect or a deviation nobody has argued for yet. -
Warnings are advisory.
npm run lint:php:allreports them and they show up in review, but they never fail a build. Each downgrade has its reasoning inline inphpcs.xml.dist— the short version:-
WordPress.DB.DirectDatabaseQuery. The plugin owns eight custom tables (see the frozen-values section ofAGENTS.md);$wpdbis the only way to reach them. The caching advice still matters for the aggregate stats underincludes/my-wordpress/, which do read core tables, so the sniff reports rather than being excluded. -
WordPress.DB.PreparedSQL.InterpolatedNotPrepared. Table names cannot be placeholders below WordPress 6.2, which introduced%i. The plugin supports 6.0, so custom-table queries interpolate{$tables['…']}and pass values throughprepare(). Revisit if the minimum ever moves to 6.2. -
Docblock coverage. 1186 of the 1248 functions under
includes/carry one, so the standard matches the house style — the gap is a tail to close, not a convention to abandon. Holding CI red until it is closed would only teach everyone to ignore the job.
-
Before reaching for a phpcs:ignore, check that the finding is genuinely not a defect, and put the reason on the same line. Prefer a scoped disable/enable pair over a file-wide disable: the AJAX handlers in apps/plugins/parts/ajax.php verify their nonce inside a shared guard function, which the sniff cannot follow, but the exemption is scoped to the $_POST reads so a handler that forgets the guard still trips.
npm run lint:php:fix runs PHPCBF. It is safe on formatting but it has one known rough edge: its Squiz.PHP.EmbeddedPhp fix splits multi-line inline comments inside templates and leaves the continuation lines misaligned. Skim the diff for comments before committing.
Extensions under extensions/ are excluded here and scanned against their own rulesets — they ship as separate plugins with their own prefixes and text domains.
ESLint catches an unused import or local inside a file; it cannot see an export nothing imports, a file nothing reaches, or a stylesheet rule nothing emits. Two scripts cover that ground and are meant to be run before a refactor PR, not on every save:
-
npm run lint:unusedruns knip with the repo'sknip.json(the Vite/Vitest plugins are off there —vite.config.jsis CommonJS and knip's loader trips on it — so every bundle entry is listed by hand). It reports files nothing reaches, exports used only inside their own file (drop theexport), and exported types nothing imports. Read the "unused exports" list with the file open: a symbol used in its own file is over-exported, a symbol with no other reference is dead. -
npm run lint:css:unused(bin/unused-css.sh) greps every.classselector of the app sheets and the runtime sheet over the TypeScript and PHP that could emit it, and lists the misses; pass a sheet path to check one. The markup here is built in templates, so the HTML-driven CSS pruners have nothing to look at; this literal scan is the honest first pass. A hit proves nothing (a longer name contains a shorter one); a miss is a rule to look at — the cells rendered inside<os-table>'s shadow root, for one, can never be reached by a document sheet.
PHP has no equivalent worth wiring: a WordPress plugin calls functions by string name from hooks, so the static tools (psalm --find-unused-code, PHPStan's unused rules) report every hook callback as dead. A grep for the function name is the reliable test.
Two twin rules keep an eye on file size, one per language, both warnings by design:
-
TypeScript:
local-rules/os-file-length(eslint-local-rules/os-file-length.cjs), shown bynpm run lint. -
PHP:
OpenStation.Files.FileLength(tools/phpcs/OpenStation/), hidden by the errors-only gate, shown bynpm run lint:php:alland in editors.
Past 1,000 total lines a file gets one encouraging warning: split it along its natural seams and aim for modules of ~300–600 lines — small enough to hold in one head, one review and one test file. A long file is a smell, not a defect, which is why neither rule ever fails a build; the right moment to split is a judgement call. When the file is an App Framework app, the split recipe is documented: app-framework.md → "Splitting a large app".
src/
├── public-api.ts # Barrel: re-exports every plugin-author-facing
│ # type / enum / helper. New author-facing
│ # symbol? Add it here too.
├── desktop.ts # Shell entry — boots the window manager,
│ # dock, widget layer, wallpaper layer, and
│ # exposes `window.wp.os`.
├── hooks.ts # @wordpress/hooks bridge + the typed HOOKS
│ # enum that names every event we fire.
├── types.ts # Window / session / config interfaces.
├── shared-store.ts # Cross-bundle reactive state primitive
│ # (`wp.os.createSharedStore`).
├── tracked-fetch.ts # Cross-bundle bridge to `wp.os.fetch`.
├── window/ # Window class + its pointer / chrome / tabs
│ # / iframe-bridge / menu helpers.
├── window-manager/ # WindowManager + desktops + arrange + snap
│ # + overview helpers.
├── window-system/ # Lazy window-system bundle (entry + loader);
│ # `WindowManager.open()` awaits it before
│ # constructing any Window.
├── window-chrome/ # Window-chrome customization framework
│ # (themes, controls, slots).
├── shell-overlays/ # Lazy bundle for toasts, confirm dialogs,
│ # and context menus (entry + loader).
├── ui/components/ # The `<os-*>` kit. `entry.ts` + `loader.ts`
│ # also build it as a lazy bundle behind
│ # `wp.os.loadComponents()`, for plugins
│ # that can't import at build time.
├── commands/ # Command registration: server-sync, shell
│ # harvester, iframe bridge.
├── presence/ # Presence store (`wp.os.presence`).
├── pwa/ # PWA: install, notify, service worker.
├── desktop-files/ # Files/folders on the wallpaper
│ # (`wp.os.files`), the Recycle Bin's drop
│ # targets and closed-tile art.
├── open-targets/ # Cross-bundle "open the app on X" hand-offs
│ # (params-based doors for `apps/`, e.g.
│ # `openUserEditWindow()`).
├── content-graph/ # Feature windows — one directory per
├── ai-assistant/ # window, each compiled to its own lazy
│ # Vite bundle (see the `build:*` scripts
│ # in package.json). Whole windows built
│ # on the App Framework — Posts, Pages,
│ # Users, User Edit, Plugins, Comments,
│ # Trash, WP Explorer, Code Blue, Station
│ # Home, Preferences — live under `apps/`,
│ # not here.
├── wallpapers/ # Registry, layer, built-ins, types, vendor
│ # script loader.
├── widgets/ # Registry, layer, picker, frame
│ # (movable/resizable chrome), state.
├── settings/ # OpenStation Preferences panel: state, sections,
│ # media REST client.
├── ui/
│ ├── core/ # The tagged-template renderer + base
│ │ # Component class + css` helper.
│ └── components/ # <os-*> web components (one folder per
│ # tag, each with .ts / .styles.ts / .test.ts).
├── modules/ # Vendor-script registry (PixiJS today,
│ # more later). Used by canvas wallpapers.
├── plugins/ # Built-in plugins that use the public API —
│ # animated-logo-wallpaper is the reference
│ # example for third-party authors.
├── dock.ts # The dock rail (icons, tooltips, submenu
│ # popover, instance rail; bottom by
│ # default, left/right per layout).
├── toast.ts # Toast queue (wraps <os-toast-container>).
├── utils.ts # urlMatchKey, deriveWindowId, sanitize*.
└── i18n.ts # Thin wrapper around window.wp.i18n.
The tree above is curated, not exhaustive — src/ holds many more
single-purpose modules and feature directories (drag bridge, devtools,
pinned notes, …). Run ls src/ for the full picture; the shipped
bundles (and the TS entry behind each) are the build:* scripts in
package.json, resolved via OPENSTATION_TARGET in vite.config.js.
App client views are the exception to that list: every
apps/<dir>/<name>.os.ts is discovered by vite.config.js as the target
app:<name> and built by npm run build:apps (part of npm run build; a shared element two apps mount, such as <os-user-profile>, is a fixed target of its own — npm run build:user-profile)
into assets/js/apps/<name>[.min].js — see
app-framework.md.
Anything re-exported from src/public-api.ts is public. We promise
backwards compatibility within a major version — renamed fields, tightened
types, and removed symbols need a deprecation path.
Anything not re-exported from public-api.ts is internal, even if
the file itself is tracked. In particular:
-
src/window/tabs.ts,menus.ts,pointer.ts,iframe-bridge.ts,dom.ts— package-private helpers of theWindowclass. -
src/window-manager/desktops.ts,arrange.ts,overview.ts,snap.ts,geometry.ts— package-private helpers of theWindowManagerclass. -
apps/os-settings/parts/*— OpenStation Preferences internals (the app's pages). -
src/widgets/frame.ts,state.ts— widget-layer internals.
Class fields prefixed with _ (e.g. _externalTabs, _activeDesktopId)
are package-internal. They're public in TypeScript so sibling helper
modules can reach them, but a plugin author touching them is knowingly
off-road.
When adding a new internal symbol, mark it with a JSDoc @internal tag
so editors and typedoc can hide it from completion lists:
/** @internal */
public _privateField: Map< string, unknown > = new Map();-
Name it. Convention:
os.<domain>.<event>(JS) oropenstation_<domain>_<event>(PHP). Add the constant to theHOOKSenum insrc/hooks.tswith a JSDoc describing payload + timing. -
Fire it.
doAction( HOOKS.NEW_THING, payload )for actions orapplyFilters( HOOKS.NEW_THING, value, context )for filters. -
Document it. Add a row to
docs/javascript-reference.md(JS hooks) or a full section todocs/hooks-reference.md(PHP hooks), with status label (Stable / Experimental / Planned). -
Test it. At minimum, a Vitest assertion that the action fires
with the expected payload — see
tests/vitest/window-lifecycle-hooks.test.tsfor patterns. -
Example it. If the hook is non-trivial, add a recipe to
docs/examples/(seearrange-action.md,window-lifecycle.mdas templates).
Everything on window.wp.os lives in the OpenStationPublicApi
interface in src/desktop.ts. To add a method:
- Add the field to the interface with a JSDoc.
- Wire it up inside the
window.wp.os = { … }assignment. - Re-export whatever types it uses from
src/public-api.ts. - Document it in
docs/javascript-reference.md.
-
TS: strict mode, tabs,
snake_casefor PHP /camelCasefor JS. Preferconstoverlet. Noany; useunknown+ type-narrow. -
CSS: custom properties for theming. BEM-ish
.os-{component}__{element}--{modifier}. -
PHP: WordPress standards (tabs, Yoda conditions,
snake_case),defined( 'ABSPATH' ) || exit;at the top of every file. - Comments: the "why", not the "what". If a workaround exists for a browser quirk or a subtle invariant, note it inline. Otherwise let the code speak.
Strings flow through three files per locale in languages/:
-
desktop-mode.pot— extracted from PHP and TS sources. Regenerate withnpm run extract:i18n(wrapswp i18n make-potand thenmsgmerge-es the refreshed POT into every existingdesktop-mode-{locale}.po). -
desktop-mode-{locale}.po/.mo— translator output, one pair per shipped locale. -
desktop-mode-{locale}-{handle}.json— JS translation bundles. WordPress'swp_set_script_translations()looks up these files by the script handle, NOT by source-file hash, because we pass a path argument fromincludes/assets.php(and, for an app's client view, fromincludes/framework/wordpress.phpunder the handleopenstation-app-<id>-client). Seebin/build-i18n.shfor the handle to source-prefix map — everyapps/<dir>/has an entry.
Project-Id-Version is derived by make-pot from the plugin header
in desktop-mode.php (Plugin Name plus Version). Nothing pins it in
the extraction script, and nothing should: pinning is how it goes
stale.
Report-Msgid-Bugs-To points translators at
https://wordpress.org/support/plugin/desktop-mode. That slug is the
published wp.org slug and is frozen, so it keeps reading
desktop-mode even though the plugin is now called OpenStation. See
AGENTS.md, "desktop_mode_* values are frozen".
The two-step pipeline is:
npm run extract:i18n # source -> .pot, then msgmerge into every .po
# (translate the .po files)
npm run build:i18n # .po -> per-handle JSON bundlesRe-run extract:i18n whenever a translatable string is added or
changed in PHP or TS source. Re-run build:i18n whenever a .po
file is updated. build:i18n invokes wp i18n make-json --extensions=ts under the hood and merges the per-source JSONs into
one file per script handle.
npm run i18n is a convenience alias that runs both stages
back-to-back. Use it when you have just edited translatable strings
in source and want every artifact refreshed in one shot.
bin/release.sh runs npm run i18n automatically as the first step
of a release (before bump-version.sh), so the version-bump commit
also carries the refreshed .pot, .po, and JSON bundles. The diff
prints to stdout — if the language-file changes look wrong, Ctrl-C
before the bump commits anything.
Pass --skip-i18n for hotfixes where you do not want translation-
file churn in the release commit:
./bin/release.sh 1.1.4 --skip-i18nThe repository's GitHub wiki is a generated mirror of docs/ — never
edit it through the wiki UI; the next sync overwrites it. Doc changes go
through pull requests against docs/ like any other change.
The pipeline is two pieces:
-
bin/build-wiki.mjsflattensdocs/into the wiki's flat page namespace:docs/README.mdbecomesHome,docs/examples/README.mdbecomesExamples, every example page gets anexample-prefix (which is also what prevents basename collisions such asdesktop-host.mdexisting in both directories), any other subdirectory maps to dash-joined page names (docs/screenshots/native-workspaces/README.mdbecomesscreenshots-native-workspaces, listed under More in the sidebar),docs/plans/is excluded, and every non-markdown file (docs/assets/, screenshot folders, …) is copied verbatim at its docs-relative path so image links keep working. Relative.mdlinks are rewritten to wiki page names (anchors preserved); links escapingdocs/into the source tree become absolute GitHubblob/trunkURLs. It also generates the_Sidebar.mdnavigation and a_Footer.mdprovenance note. Unresolved relative links are printed as warnings — runnode bin/build-wiki.mjs /tmp/wiki-outlocally to preview a sync or check links. The only hard failure is two sources mapping to the same page name;tests/vitest/build-wiki.test.tsruns the script against the realdocs/tree so a new folder cannot break the sync unnoticed. Mermaid fences render natively in the wiki; a chart that must look the same everywhere ships as an SVG underdocs/assets/. -
.github/workflows/wiki.ymlruns the script on every push totrunkthat touchesdocs/**(plusworkflow_dispatchfor manual runs) and pushes the output to<repo>.wiki.git— a wiki is itself a git repository — using the workflow'sGITHUB_TOKEN. Deletes and renames propagate; the sync is authoritative.
One-time prerequisite: GitHub only creates the wiki repository when a first page is saved through the UI. If the sync job fails with "Could not clone", enable the wiki, save any page (its content will be replaced), and re-run the workflow.
Any live site can run an unreleased build without touching FTP or wp-cli:
the OpenStation Beta companion plugin (extensions/openstation-beta/,
own zip attached to every GitHub release) installs the built
zip of any open PR, the trunk build, or the latest stable release over
the desktop-mode plugin in place. See
extensions/openstation-beta/README.md for how
it discovers builds (PR preview artifacts + trunk-build.yml on the
ci-artifacts release) and its guard rails.
It is a separate plugin on purpose — the GitHub-installer machinery
must never ship in the wp.org distribution, and the switcher has to
survive a branch build that breaks OpenStation itself. Package it
locally with npm run package:beta.
-
Vitest —
tests/vitest/*.test.ts+ colocatedsrc/**/*.test.ts. Runs in jsdom. -
PHPUnit —
tests/phpunit/tests/*.php. Tagged@group openstation. Runs inside the dedicated wp-env tests instance (PHPUnit 9.6 + phpunit-polyfills). Configured in.wp-env.tests.json+composer.json. - E2E — planned (Playwright). Nothing landed yet.
-
Circular imports between
src/window/helpers — fine at runtime with function exports but TypeScript's module ordering can complain. Keep side-effect-free type exports separate from function exports. -
jsdom gaps —
scrollIntoView,CSSStyleSheet.replaceSync,ResizeObserverneed mocks. Checktests/vitest/helpers/before adding a fresh one. -
Vite IIFE means dynamic
import()flattens into the main bundle. Vendor scripts (PixiJS) ship separately and inject vialoadVendorScripton first use — don't code-split insidesrc/.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- The App Framework — a window in one PHP file
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Data model — where OpenStation keeps its data
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Window-scoped MIO
- Mio
- Mobile — the phone layer
- Multisite
- Native Windows & Framework Interop
- OpenStation Network
- Plugin compatibility layer
- Pulse Monitor
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
- Workspaces
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration — Code Blue becomes an App Framework app
- Migration — AI comment scoring leaves core
- Migration: window, wallpaper and widget bundles load on demand
- Migration — Posts, Pages, Users, User Edit, Plugins and Comments become App Framework apps
- Migration — the navigation model
- Migration — OpenStation Preferences becomes an App Framework app
- Performance settings move to Extended options
- Presence storage migration
- Migration — the Recycle Bin becomes an App Framework app
- Migration — the shell boots from its own screen
- Migration — Station Home becomes an App Framework app
- Migration: a native window's tabs move to the window chrome
- Migration — WP Explorer becomes the my-wordpress app
- Migration — WordPress package globals are no longer ambient
More
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- App layout recipes
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Edit a record with one form values map
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Repairable form edits with MIO
- Register a window companion
- Pin your app to the phone tab bar, and react to the mode
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- WP Explorer — add a column to the list view
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- Ship a window as an .os.php app
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Revisions in their own window — extend or redirect "View revisions"
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Observe stored-file cleanup failures
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config
- Place something where the user can reach it — wp.os.workArea
- Ship a workspace template