-
Notifications
You must be signed in to change notification settings - Fork 42
example register widget
Widgets are passive cards in the right-side column — or floating freely on
the desktop if movable: true. They are for glanceable, persistent content:
a clock, a comment queue, a stats chart. Not launchers, not interactive tools.
The smallest complete example. A PHP file registers the widget metadata and script handle; a TypeScript file declares the mount callback.
my-plugin.php
<?php
/** Plugin Name: My Widget Plugin */
defined( 'ABSPATH' ) || exit;
function myplugin_register_hello_widget_assets() {
$version = '1.0.0';
// Register CSS eagerly — the JS loads lazily but the stylesheet needs to
// be in the DOM before the first render to avoid a flash of unstyled content.
wp_register_style(
'myplugin-hello-widget',
plugin_dir_url( __FILE__ ) . 'assets/js/widget-hello.min.css',
array(),
$version
);
// Register JS — do NOT enqueue it directly. The shell's server-sync
// loads this bundle lazily when the widget picker opens or the widget
// mounts. Using wp_enqueue_script() here would load it on every admin page.
wp_register_script(
'myplugin-hello-widget',
plugin_dir_url( __FILE__ ) . 'assets/js/widget-hello.min.js',
array(),
$version,
true
);
}
add_action( 'init', 'myplugin_register_hello_widget_assets', 5 );
// Eagerly enqueue the CSS on OpenStation shell pages only.
function myplugin_enqueue_hello_widget_styles() {
if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled() ) {
return;
}
if ( function_exists( 'openstation_is_chromeless_request' ) && openstation_is_chromeless_request() ) {
return;
}
wp_enqueue_style( 'myplugin-hello-widget' );
}
add_action( 'admin_enqueue_scripts', 'myplugin_enqueue_hello_widget_styles', 20 );
// Announce the widget to OpenStation so it appears in the picker.
function myplugin_register_hello_widget() {
if ( ! function_exists( 'openstation_register_widget' ) ) {
return;
}
openstation_register_widget( 'myplugin/hello', array(
'label' => __( 'Hello Widget', 'myplugin' ),
'description' => __( 'A simple greeting.', 'myplugin' ),
'icon' => 'dashicons-smiley',
'script' => 'myplugin-hello-widget',
'movable' => true,
'resizable' => true,
'min_width' => 200,
'min_height' => 100,
'default_width' => 260,
'default_height' => 140,
) );
}
add_action( 'init', 'myplugin_register_hello_widget', 6 );src/plugins/hello-widget/index.ts
import './styles.css';
import type { WidgetContext, WidgetTeardown } from '../../widgets/types';
// Must match the id passed to openstation_register_widget() in PHP exactly.
// Do not rename this after users have the widget enabled — it is the
// localStorage key for their preference and renaming it resets everyone.
const WIDGET_ID = 'myplugin/hello';
const mount = (
container: HTMLElement,
_ctx: WidgetContext,
): WidgetTeardown => {
const p = document.createElement( 'p' );
p.className = 'my-hello__text';
p.textContent = 'Hello from my widget!';
container.appendChild( p );
// Return a teardown — always required even with nothing to clean up.
return () => undefined;
};
const w = window as unknown as {
openStationWidgets?: Record< string, typeof mount >;
};
w.openStationWidgets = w.openStationWidgets ?? {};
w.openStationWidgets[ WIDGET_ID ] = mount;Add a Vite target in vite.config.js inside the TARGETS object:
'widget-hello': {
entry: 'src/plugins/hello-widget/index.ts',
fileBase: 'widget-hello',
iifeName: 'openStationHelloWidget',
},Add a build script in package.json:
"build:widget-hello": "OPENSTATION_TARGET=widget-hello vite build --mode development && OPENSTATION_TARGET=widget-hello vite build --mode production"Then build:
npm run build:widget-helloThe widget appears in the picker immediately after the next page load.
Fetches the latest post title on mount and refreshes every minute.
Always use trackedFetch from ../../tracked-fetch — never raw fetch().
The repo's ESLint config bans raw fetch() calls. trackedFetch routes
requests through the framework (loading spinner, activity bus) and injects
the REST nonce automatically — no manual X-WP-Nonce header needed.
import { trackedFetch } from '../../tracked-fetch';
import type { WidgetContext, WidgetTeardown } from '../../widgets/types';
const WIDGET_ID = 'myplugin/latest-post';
const mount = async (
container: HTMLElement,
_ctx: WidgetContext,
): Promise< WidgetTeardown > => {
// Declare destroyed at the very top. Check it after every await —
// the widget may be removed while a network request is in flight.
let destroyed = false;
const body = document.createElement( 'p' );
body.textContent = 'Loading\u2026';
container.appendChild( body );
const root = ( window as unknown as { wpApiSettings?: { root?: string } } )
.wpApiSettings?.root ?? '/wp-json/';
const refresh = async () => {
if ( destroyed ) return;
try {
const res = await trackedFetch(
root.replace( /\/$/, '' ) + '/wp/v2/posts?per_page=1&_fields=title',
{ credentials: 'same-origin' },
{ source: 'myplugin/latest-post', silent: true },
);
if ( destroyed ) return; // check after every await
if ( ! res.ok ) return;
const posts = await res.json() as Array< { title: { rendered: string } } >;
if ( destroyed ) return; // check after the second await too
body.textContent = posts[ 0 ]?.title.rendered ?? 'No posts found.';
} catch {
if ( ! destroyed ) body.textContent = 'Could not load data.';
}
};
await refresh();
// Poll — but pause while the tab is hidden. Nobody sees the
// repaint and the requests still hit the server, so stop the
// timer on visibilitychange → hidden and restart on reveal,
// catching up immediately only when the data has gone stale
// (a quick tab flip shouldn't cost a request).
const POLL_MS = 60_000;
let intervalId: ReturnType< typeof setInterval > | null = null;
let lastRunMs = Date.now();
const poll = () => {
lastRunMs = Date.now();
void refresh();
};
const startPolling = () => {
if ( intervalId === null ) intervalId = setInterval( poll, POLL_MS );
};
const stopPolling = () => {
if ( intervalId !== null ) {
clearInterval( intervalId );
intervalId = null;
}
};
const onVisibilityChange = () => {
if ( document.hidden ) {
stopPolling();
return;
}
if ( Date.now() - lastRunMs >= POLL_MS ) poll();
startPolling();
};
document.addEventListener( 'visibilitychange', onVisibilityChange );
if ( ! document.hidden ) startPolling();
return () => {
destroyed = true;
stopPolling();
document.removeEventListener( 'visibilitychange', onVisibilityChange );
};
};
const w = window as unknown as {
openStationWidgets?: Record< string, typeof mount >;
};
w.openStationWidgets = w.openStationWidgets ?? {};
w.openStationWidgets[ WIDGET_ID ] = mount;ctx.storage is a namespaced localStorage wrapper. Keys are scoped to
your widget id automatically so two widgets can both use 'preferences'
without colliding.
const mount = async ( container: HTMLElement, ctx: WidgetContext ) => {
// get() returns null when the key does not exist yet.
// Always provide a fallback — storage may be unavailable
// (private browsing, quota exceeded).
const count = ctx.storage.get< number >( 'clicks' ) ?? 0;
const btn = document.createElement( 'button' );
btn.textContent = `Clicked ${ count } times`;
btn.addEventListener( 'click', () => {
const next = ( ctx.storage.get< number >( 'clicks' ) ?? 0 ) + 1;
ctx.storage.set( 'clicks', next );
btn.textContent = `Clicked ${ next } times`;
} );
container.appendChild( btn );
return () => undefined;
};Values round-trip through JSON.stringify / JSON.parse. Plain objects,
arrays, and primitives work. Class instances, Date, and Map do not —
convert them first (date.toISOString(), Array.from( map )).
Use ResizeObserver to trigger the initial draw and all subsequent redraws.
Check entry.contentRect is non-zero before drawing — the canvas may not
have layout yet when mount first runs. Never use setTimeout as a
workaround for waiting on layout.
const mount = async ( container: HTMLElement, _ctx: WidgetContext ) => {
let destroyed = false;
let ro: ResizeObserver | null = null;
const wrap = document.createElement( 'div' );
wrap.style.cssText = 'flex:1; min-height:0;';
const canvas = document.createElement( 'canvas' );
canvas.style.cssText = 'display:block; width:100%; height:100%;';
wrap.appendChild( canvas );
container.appendChild( wrap );
const draw = () => {
const rect = canvas.getBoundingClientRect();
if ( rect.width === 0 || rect.height === 0 ) return; // not laid out yet
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round( rect.width * dpr );
canvas.height = Math.round( rect.height * dpr );
const ctx = canvas.getContext( '2d' );
if ( ! ctx ) return;
ctx.scale( dpr, dpr );
// ... your drawing code here
};
// ResizeObserver fires as soon as the element has real layout dimensions.
// This handles both the initial draw and any subsequent resizes.
ro = new ResizeObserver( ( entries ) => {
if ( destroyed ) return;
const entry = entries[ 0 ];
if ( entry && entry.contentRect.width > 0 ) draw();
} );
ro.observe( wrap );
return () => {
destroyed = true;
ro?.disconnect();
};
};All sizes are pixels, passed to openstation_register_widget():
| Arg | Effect |
|---|---|
min_width |
Smallest width the user can drag the card to |
min_height |
Smallest height the user can drag the card to |
max_width |
Optional ceiling on user-driven resize |
max_height |
Optional ceiling on user-driven resize |
default_width |
Starting width when first added as a floating widget |
default_height |
Starting height when first added as a floating widget |
movable: true lets the user drag the widget off the column.
resizable: true adds resize handles — movable: true gives all 8 corner
and edge handles; column-docked widgets get a bottom-edge handle only.
Your stylesheet gets five custom properties from the shell. Read them and your widget follows the desktop theme and the accent the user picked, with no extra code:
| Token | Role |
|---|---|
--os-ui-color-text |
Primary text — body copy, headings |
--os-ui-color-text-subtle |
Muted text — labels, timestamps, metadata |
--os-ui-color-border |
Divider lines and borders |
--os-ui-color-accent |
Buttons, highlights, links |
--os-ui-color-surface |
The card's glass background (rarely needed) |
.my-widget__label {
color: var( --os-ui-color-text-subtle, rgba( 255, 255, 255, 0.7 ) );
border-bottom: 1px solid var( --os-ui-color-border, rgba( 255, 255, 255, 0.12 ) );
}Pick a fallback that reads on dark. The card is a dark glass panel in
every theme, Legacy included, so the first four resolve to on-dark
values. A light-theme grey like #6b7280 in the var() fallback looks
fine while the palette is present and turns near-invisible the moment it
isn't — the failure is silent, because an undeclared custom property just
yields to the fallback with no error.
-
Hooks reference —
openstation_register_widget()— full argument reference, error codes, and lifecycle actions. -
JavaScript reference —
wp.os.registerWidget()— the client-side equivalent. - Starter widget source — a heavily commented skeleton covering every pattern above in a single working widget. It only appears in the add-widget picker when the current user has "Enable developer mode" turned on (OpenStation Preferences → Features) — regular users don't see it.
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
- 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