-
Notifications
You must be signed in to change notification settings - Fork 41
example window activity
Every desktop window carries an activity phase — idle, pending, saving, saved, failed — that the framework moves for you on every wp.os.fetch, and that you can drive by hand for anything else.
The title bar renders it as the status ring: the leading mark, in the position the app icon used to hold. That icon was a copy of the window's own dock tile a few hundred pixels below it, and a title bar has room for one mark of that size — this one changes.
| Phase | Ring | Gesture |
|---|---|---|
idle |
white outline | — |
pending / saving
|
accent outline | breathes, 1.6s |
saved |
accent fill, white check | overshoots and settles; the glyph fades up just behind the fill |
failed |
open red outline, red bang — persists until the next request starts, because a failure that fades out is a failure the user misses | two decaying swells, then stops |
Only success fills. Colour alone is not a distinction every user can make, so the two outcomes differ in shape as well as hue: filled versus open, check versus bang. The gestures are emphasis rather than information, so prefers-reduced-motion drops all three and every colour, fill and glyph stays.
None of that needs any code from you. It is already happening on every window in the shell — including iframe windows, whose admin pages do their own jQuery and XHR calls: the chromeless bridge brackets each one and the parent moves the ring.
Only writes report on that automatic path. GET, HEAD, OPTIONS and QUERY are excluded — a read changed nothing, so nothing can have failed to change, and an admin page fires reads constantly on its own. (QUERY carries a body, so a payload test would classify it backwards; it is still a read.) WordPress Heartbeat is excluded too, POST or not: a poll the user never asked for would otherwise light every window every 15 seconds.
wp.os.fetch is the deliberate path and doesn't filter by method — a GET you route through it does move the phase, because you chose to report it. Pass silent: true to opt one call out.
Themeable from a desktop theme or a stylesheet of your own:
| Token | What it paints |
|---|---|
--os-titlebar-activity-color |
The ring while a request is in flight. |
--os-titlebar-activity-saved-color |
The fill on success. |
--os-titlebar-activity-failed-color |
The ring on failure. |
--os-titlebar-activity-size |
Ring diameter (default 16px). |
--os-titlebar-activity-idle-color |
The resting ring — white, and the same value focused or not. |
Reduced motion drops the breath and both outcome gestures: movement is emphasis, the colour and the fill are the state.
The framework's ring claims no private channel — it is found by data-os-activity-indicator, and so is yours. Every matching element in the title bar is driven by the same paint call:
const host = document.createElement( 'span' );
host.className = 'os-window__activity';
const dot = document.createElement( 'os-save-status' );
dot.setAttribute( 'mode', 'dot' );
dot.setAttribute( 'animation', 'modem' );
dot.setAttribute( 'phase', 'idle' );
dot.setAttribute( 'data-os-activity-indicator', '' );
host.appendChild( dot );
// …render `host` into an after-title slot; the window finds it by that
// attribute and drives its `phase` and `error` from here on.That one is the modem LED: while work is in flight it blinks like a 1990s data modem; on success it briefly fills in green; on failure it goes solid red with the error message as a tooltip. variant="ring" gets you the treatment the title bar wears instead.
The ring is invisible to assistive technology, so the title bar also carries a visually-hidden live region that the framework writes the outcome into: Saved politely, Not saved. <error> assertively. The in-flight phase is deliberately not announced — telling someone that the save they just started is still going interrupts them to say nothing.
Status:
wp.os.fetchis Stable;Window.trackActivity,Window.markActivity, and<os-save-status>are Experimental.
Use wp.os.fetch instead of the global fetch:
// Before:
const res = await fetch( '/wp-json/myplugin/v1/save', { method: 'POST' } );
// After:
const res = await wp.os.fetch( '/wp-json/myplugin/v1/save', { method: 'POST' } );That's it. The window is saving for the round-trip, saved on success, failed on failure (carrying the error message). No CSS, no DOM, no per-window plumbing — the title bar's ring breathes, fills with a check, or goes red on its own.
"Failure" means the outcome, not the promise. Native fetch resolves for 4xx/5xx, but the indicator doesn't: a response with ok: false settles the phase as failed with Request failed (HTTP 500 Internal Server Error). as its tooltip. Your side of the call is unaffected — wp.os.fetch hands back the native promise, so it still resolves with the error response and your own if ( ! res.ok ) branch runs as before. Only a genuinely successful (2xx) response fills the ring.
By default, wp.os.fetch attributes the request to the focused window at the moment of the call. Most fetches happen inside event handlers — clicks, key presses, form submits — and the click already focused the window. So in 95% of cases the default attribution is correct.
For the 5% where focus isn't your friend, pass an explicit attribution:
// You have the window's id (most native-window bundles know their own id):
wp.os.fetch( url, init, { windowId: 'my-plugin/inbox' } );
// You have a Window instance in scope:
wp.os.fetch( url, init, { window: ctx.window } );
// Don't move the phase for this fetch (background polls, prefetches):
wp.os.fetch( url, init, { silent: true } );Wrap the bundle's fetch helper once, then every call inherits the attribution:
// my-plugin/rest.js
function shellFetch( input, init ) {
if ( window.wp?.os?.fetch ) {
return wp.os.fetch( input, init, { windowId: 'my-plugin/inbox' } );
}
return fetch( input, init );
}
export async function fetchInbox() {
return ( await shellFetch( '/wp-json/myplugin/v1/inbox' ) ).json();
}
export async function archive( id ) {
return shellFetch( `/wp-json/myplugin/v1/inbox/${ id }/archive`, {
method: 'POST',
} );
}Every call site (fetchInbox, archive) now moves the inbox window's activity phase, with no per-call adoption.
When the operation isn't a single fetch — a postMessage handshake, an IndexedDB write, a BroadcastChannel round-trip, a long client-side computation — reach for Window.trackActivity( promise ):
const win = wp.os.windowManager.getById( 'my-plugin/dashboard' );
// Single Promise:
await win.trackActivity( indexedDbWrite( record ) );
// Sequence:
await win.trackActivity( ( async () => {
const a = await load();
const b = await transform( a );
await commit( b );
} )() );Returns the Promise unchanged so callers can chain. The minimum 1.2s saving-display floor still applies, so even a 100ms operation shows a full modem cycle.
For activity that doesn't map to a single Promise — an SSE stream, a WebSocket, a chained subscription — drive the phase manually with Window.markActivity():
win.markActivity( 'saving' );
const sse = new EventSource( '/wp-json/myplugin/v1/stream' );
sse.addEventListener( 'data', applyChunk );
sse.addEventListener( 'end', () => {
sse.close();
win.markActivity( 'saved' );
} );
sse.addEventListener( 'error', ( err ) => {
sse.close();
win.markActivity( 'failed', { error: 'Connection lost' } );
} );Phases:
| Phase | Visual | Auto-clears |
|---|---|---|
'idle' |
Always-on hollow ring (accent color). | — |
'pending' / 'saving'
|
Filled, modem-blink with soft glow. | No |
'saved' |
Brief green fill. | After 2.2s |
'failed' |
Solid red. opts.error → tooltip. |
After 6s |
markActivity() is idempotent — setting the same phase twice is a no-op except for resetting the auto-clear timer.
Window.trackActivity is reference-counted, so concurrent operations on the same window don't fight:
// Two fetches in parallel — dot stays lit until the LAST one settles.
await Promise.all( [
wp.os.fetch( urlA ),
wp.os.fetch( urlB ),
] );The terminal phase reflects the burst as a whole — if any tracked operation in the burst failed, the indicator settles on "failed" (with the most recent error as the tooltip), even when the last operation succeeded. A burst of 5 successful fetches followed by 1 error reads "failed" — surface the bad news; the user wants to know.
Minimum 1.2s saving display — even a 50ms fetch holds the saving phase for ~1.2s so the modem-blink animation has time to register. Concurrent fetches that re-start within the floor cancel any deferred settle, so chained operations keep blinking smoothly without dropping into "saved" between calls.
Always-on idle ring — at rest, the dot is a 12px hollow circle with a 2px border tinted by the user's accent (color-mix(in srgb, var(--wp-admin-theme-color) 55%, transparent)). It looks like a real modem's "ready" LED — quietly present, not flashing, not invisible. That "always on" is why the framework no longer mounts one in the title bar of its own accord: a ready LED is right on a surface a user chose to put it on, and wrong as a fixture on every window ever opened. Set --os-ui-save-status-idle-color: transparent on the host if you want a dot that only appears while work is in flight.
Drift-by-design animation — the modem stutter cycles at 1.8s, the soft-glow halo at 2.4s; the offset periods mean the combined pattern only truly repeats every 7.2s, so it never reads as a metronome.
Reduced-motion — users with prefers-reduced-motion: reduce get a calm solid-on dot during saving (no animation, same affordance).
Wrap them in a Promise and hand to Window.trackActivity:
function trackedXhr( url, body, win ) {
return win.trackActivity( new Promise( ( resolve, reject ) => {
const xhr = new XMLHttpRequest();
xhr.open( 'POST', url );
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve( xhr.response )
: reject( new Error( `${ xhr.status } ${ xhr.statusText }` ) );
xhr.onerror = () => reject( new Error( 'Network error' ) );
xhr.send( body );
} ) );
}fetch covers the vast majority of cases; this pattern is the escape hatch.
-
docs/javascript-reference.md— full API surface. -
<os-save-status>— the standalone component the title-bar indicator uses. Drop one anywhere (panel headers, plugin own settings forms, custom toolbars) — it auto-listens to a configurable CustomEvent and renders the same modem dot.
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