-
Notifications
You must be signed in to change notification settings - Fork 41
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.
-
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
- 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