-
Notifications
You must be signed in to change notification settings - Fork 41
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 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 donenpm 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; 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) and8891(tests instance,.wp-env.tests.json). 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.
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, which is the screen the shell boots on. Work that defers part of that same chain measures as approximately zero on such an instance and as several megabytes without it. 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 includes/plugins-window/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.
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`).
├── recycle-bin/ # Feature windows — one directory per
├── posts-window/ # window, each compiled to its own
├── plugins-window/ # lazy Vite bundle (see the `build:*`
├── comments-window/ # scripts in package.json).
├── my-wordpress/
├── content-graph/
├── ai-assistant/
├── 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.
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. -
src/settings/sections/*— OpenStation Preferences internals. -
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. Today three handles have populated bundles —openstation(the main shell),os-posts-window, anddesktop-mode-recycle-bin; seebin/build-i18n.shfor the handle to source-prefix map.
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),docs/plans/is excluded, anddocs/assets/is copied verbatim. 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. -
.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.
-
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
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- 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
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- 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
- 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
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- 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
- 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
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- 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