-
Notifications
You must be signed in to change notification settings - Fork 41
example layout primitives
The shell ships a small set of layout components that compose into the usual native-window shapes without anyone hand-rolling a padding/gap/grid recipe. The recommended stack, outermost to innermost:
-
<os-body>— fills the window, owns padding + vertical gap + scroll. -
<os-panel>— a grouped section inside the body (think settings card). -
<os-row>— a 12-column grid for horizontal layouts. Children declare width withcol="N". -
Any element (
<os-*>,<div>, third-party custom elements) — the leaf controls.
None of these are mandatory — mix and match as the window's UI dictates.
<os-row> is Bootstrap-style: 12 equal tracks, children declare their width via col="N" (1..12). The col attribute lives on the child, not on <os-row>, so any element type works:
<os-row>
<os-text-field col="6" label="First name"></os-text-field>
<os-text-field col="6" label="Last name"></os-text-field>
</os-row>
<os-row>
<os-select col="4" label="Currency">…</os-select>
<os-number-field col="8" label="Amount"></os-number-field>
</os-row>
<os-row>
<div col="3">sidebar</div>
<div col="9">main</div>
</os-row>Children without col span the full row — matching the intuition that a lone child shouldn't shrink to 1/12th.
| Attribute | Default | What it does |
|---|---|---|
gap |
12 |
Pixel gap between children on both axes. |
column-gap |
inherits gap
|
Override just the horizontal gap. |
row-gap |
inherits gap
|
Override just the vertical gap (when children wrap). |
<os-body> is the outermost container inside a native-window render. It sets up the common shape so plugin authors don't re-derive it every time:
| Attribute | Default | What it does |
|---|---|---|
gap |
12 |
Vertical gap between top-level children. |
padding |
16 |
Inset around all children. Pass padding="0" for edge-to-edge canvas content. |
scroll |
off | When present, overflow scrolls within the body rather than the window frame. |
<os-body scroll>
<os-panel>…</os-panel>
<os-panel>…</os-panel>
</os-body>Short answer: body wraps the whole window, panels group sections inside the body.
-
<os-body>fills the window, owns the scroll region, sets the outer padding. One per native window. -
<os-panel>is a grouped section — think settings card. Zero-to-many per body. Panels compose with their owngapandpaddingthat's independent of the body's.
You can use <os-panel> directly inside a render callback without a body — that works too. The body just codifies the "I want the default native-window layout" case.
openstation_register_window( 'converter', array(
'title' => __( 'Unit Converter', 'my-plugin' ),
'width' => 420,
'height' => 320,
'script' => 'converter-render',
'template' => function () {
?>
<os-body scroll>
<os-panel>
<os-row>
<os-select
col="6"
label="<?php esc_attr_e( 'From', 'my-plugin' ); ?>"
data-role="from"
></os-select>
<os-select
col="6"
label="<?php esc_attr_e( 'To', 'my-plugin' ); ?>"
data-role="to"
></os-select>
</os-row>
<os-row>
<os-number-field
col="8"
label="<?php esc_attr_e( 'Amount', 'my-plugin' ); ?>"
data-role="amount"
value="0"
></os-number-field>
<os-display
col="4"
data-role="result"
size="xl"
>0</os-display>
</os-row>
</os-panel>
</os-body>
<?php
},
) );Render callback wires the inputs; the layout is zero hand-rolled CSS:
window.openStationNativeWindows.converter = function ( body ) {
const from = body.querySelector( '[data-role="from"]' );
const to = body.querySelector( '[data-role="to"]' );
const amt = body.querySelector( '[data-role="amount"]' );
const out = body.querySelector( '[data-role="result"]' );
from.items = UNITS;
to.items = UNITS;
from.setAttribute( 'value', 'm' );
to.setAttribute( 'value', 'km' );
const recompute = () => {
out.textContent = convert(
Number( amt.getAttribute( 'value' ) || '0' ),
from.getAttribute( 'value' ),
to.getAttribute( 'value' ),
);
};
from.addEventListener( 'os-pick', recompute );
to.addEventListener( 'os-pick', recompute );
amt.addEventListener( 'os-input-change', recompute );
};| Want… | Use |
|---|---|
| Two fields on the same line, equal width |
<os-row> + col="6" twice |
| Sidebar + main content |
<os-row> + col="3" / col="9"
|
| Three thirds |
col="4" three times |
| Uniform cell grid (calculator keypad, photo thumbnails) |
<os-grid columns="4" gap="8"> — not <os-row>
|
| A stack of full-width cards |
<os-stack gap="12"> — the common case, no col math needed |
| Single column with padding + scroll around the window body | <os-body scroll> |
| Grouped section with its own rhythm | <os-panel gap="8"> |
<os-row> is the right reach for mixed-width horizontal layouts. For uniform grids (every cell the same size), <os-grid> is simpler. For vertical stacking, <os-stack> costs nothing.
openstation_component() accepts style as either the usual string value or an associative array of CSS-property → value pairs. The array form serializes to a single style="…" attribute with auto-unit for length-shaped properties.
openstation_component( 'os-stack', array(
'gap' => 12,
'style' => array(
'padding' => 0,
'background' => 'rgba(0,0,0,0.04)',
'border-radius' => 8,
),
), $children );
// → <os-stack gap="12" style="padding: 0; background: rgba(0,0,0,0.04); border-radius: 8px">The array form is the ergonomic path — it mirrors the React/Vue style prop. Dynamic styling composes naturally: 'padding' => $dense ? 0 : 16, 'color' => $isError ? '#d63638' : null (null/false entries are dropped).
openstation_component( 'os-stack', array(
'style' => 'padding: 0; margin-top: 16px',
), $children );Bare integers on length-shaped properties (padding, margin, width, height, gap, border-width, border-radius, positional insets, …) auto-unit to pixels. Everything else passes through verbatim.
| Value in PHP | Serialized |
|---|---|
'padding' => 16 |
padding: 16px |
'padding' => 0 |
padding: 0 (CSS treats 0 as dimensionless on any property) |
'padding' => '1rem' |
padding: 1rem |
'padding' => 'calc(1em + 4px)' |
padding: calc(1em + 4px) |
'z-index' => 5 |
z-index: 5 (non-length, no unit) |
'opacity' => 0.5 |
opacity: 0.5 (non-length, no unit) |
'margin' => null |
(entry dropped) |
'margin' => false |
(entry dropped) |
Inline HTML keeps working the same as any other HTML element — <os-stack style="padding: 0"> sets the host's inline style, which beats the component's shadow-CSS default via specificity. Use the PHP helper's style array when you want programmatic composition; use the raw style="…" attribute when you're writing markup by hand.
<os-body>, <os-panel>, and <os-stack> still declare their own padding prop that routes through a CSS custom property (--os-ui-body-padding, --os-ui-panel-padding, --os-ui-stack-padding). Both paths coexist:
-
<os-body padding="0">— uses the component's own prop, sets--os-ui-body-padding: 0px. -
<os-body style="padding: 0">(or'style' => [ 'padding' => 0 ]) — sets inlinestyle="padding: 0", wins via specificity.
Either works. The prop form is the older convention; the style array is the generic mechanism that works across every <os-*> regardless of whether it declared a matching prop.
Plain HTML class="foo bar" works natively on every <os-*> component (they're all HTMLElements). For JS-driven styling where an array of conditional classes is already in hand, each component has a classNames property:
const card = document.querySelector( 'os-panel' );
card.classNames = [ 'brand', 'is-active', 'is-focused' ];
// → <os-panel class="brand is-active is-focused">
card.classNames = [ 'dense' ];
// → <os-panel class="dense"> (replaces, doesn't merge)
card.classNames = null;
// → class attribute removed entirely
// Getter returns an array of currently-applied classes.
card.classNames; // ['dense']The classes go on the host element, which lives in light DOM. That means external plugin CSS enqueued via wp_enqueue_style() targets the host directly — the shadow boundary doesn't block class selectors on the host itself. Useful for branding a shell component with a plugin-owned accent colour or typography setting:
/* In the plugin's enqueued stylesheet */
os-panel.brand {
--wp-admin-theme-color: #ff00ff;
font-family: 'Marvelous Sans';
}Passing a string also works — it's split on whitespace the same way class="…" parses:
card.classNames = 'brand dense';
// → <os-panel class="brand dense">The classes you apply don't automatically penetrate the shadow root. CSS custom properties (the --foo kind) DO inherit through, so setting --wp-admin-theme-color on the host via a plugin class propagates to everything inside.
Input components (<os-text-field>, <os-number-field>, <os-select>) auto-generate a deterministic id on the host based on their DOM ancestry:
os-<window>-<tab-path>-<label-slug>
Example: a <os-select label="From unit"> inside <os-tabpanel for="convert"> inside <div id="wp-window-calculator"> gets id="os-calculator-tab-convert-from-unit" automatically. The inner control gets the same id with a suffix — __input for <os-text-field> / <os-number-field>, __trigger for <os-select>'s combobox button — and the component's <label> uses for= to pair them — clicking the label focuses the control.
Same ancestry + same label always produces the same id. Plugin authors can document.getElementById( 'os-calculator-tab-convert-from-unit' ) and know they're reaching the same element across rebuilds.
Pass a custom id attribute and the auto-id machinery steps aside:
<os-select id="my-brand-picker" label="Currency">
<os-option value="eur">Euro</os-option>
<os-option value="usd">US Dollar</os-option>
</os-select>The host keeps id="my-brand-picker", the inner combobox button gets id="my-brand-picker__trigger", and <label for="my-brand-picker__trigger"> pairs correctly. Auto-id only fires when the caller didn't set one.
-
<os-body>,<os-panel>,<os-row>,<os-grid>are pure-layout elements with no implicit role. They don't affect the accessibility tree; children retain whatever role they declared. -
<os-row>uses CSS grid under the hood — standard browser behaviour for keyboard navigation and screen readers applies to its children. - Auto-id guarantees that input controls have a stable
idand a real<label for>pairing inside the shadow root — both silence Chrome's "form field needs an id or name" warning and give screen readers a proper accessible name.
A 45° banner that wraps a corner of its parent — the classic "FEATURED / NEW / BETA / SALE" stamp on a card. The component owns its own clipping geometry; consumers only need to make the parent a positioned containing block.
<article class="my-card" style="position: relative;">
<os-ribbon>Featured</os-ribbon>
<h3>Card title</h3>
<p>Card body…</p>
</article>
⚠️ Parent must be positioned.<os-ribbon>usesposition: absoluteon its host. Withoutposition: relative(orabsolute/fixed/sticky) on the parent, the ribbon anchors to the next positioned ancestor up the tree — usually the window body — and floats over the wrong thing entirely.
| Attribute | Values | Default | Notes |
|---|---|---|---|
placement |
top-end · top-start · bottom-end · bottom-start
|
top-end |
Logical end/start, so LTR/RTL flip for free. The 45° rotation sign also flips under [dir='rtl']. |
tone |
primary · success · warning · danger · info · neutral
|
primary |
Background tint. primary uses --wp-admin-theme-color so the ribbon picks up the active color scheme automatically. Matches <os-badge>'s palette so the two surfaces feel like a set. |
<os-ribbon placement="bottom-start" tone="success">New</os-ribbon>
<os-ribbon placement="top-start" tone="warning">Beta</os-ribbon>The host honours these custom properties for per-instance tuning without touching the component source. Set them on the host (or on any ancestor) to retheme:
| Variable | Default | What it controls |
|---|---|---|
--os-ui-ribbon-size |
90px |
Square clipping window edge. Smaller cards usually want 60px–70px. |
--os-ui-ribbon-banner-width |
140px |
Width of the rotated strip (before clipping). |
--os-ui-ribbon-banner-offset |
20px |
Distance from the corner to the strip's perpendicular centerline. |
--os-ui-ribbon-banner-pull |
-36px |
How far the strip overhangs the clip edge along the inline axis. |
--os-ui-ribbon-bg |
var(--wp-admin-theme-color, #2271b1) |
Banner background — overrides tone when set. |
--os-ui-ribbon-fg |
#fff |
Banner text color. |
--os-ui-ribbon-shadow |
0 2px 4px rgba(0,0,0,0.2) |
Drop shadow under the banner. |
--os-ui-ribbon-padding |
4px 0 |
Vertical padding of the strip. |
--os-ui-ribbon-font |
700 10px/1.4 system-ui |
Banner text typography shorthand. |
--os-ui-ribbon-tracking |
0.06em |
Letter-spacing. |
--os-ui-ribbon-z |
2 |
Stacking order relative to other absolutely-positioned children of the parent. |
The rotated strip is exposed as the banner shadow part, so consumers can apply CSS that shadow-DOM --os-ui-ribbon-* variables don't cover (e.g. a gradient background, a custom font face):
my-card os-ribbon::part(banner) {
background: linear-gradient(135deg, #ff6a00, #ee0979);
}The ribbon is decorative — there is no implicit role and pointer-events: none is set on the host, so it never steals clicks. If the label carries meaningful information that screen readers shouldn't miss, surface it elsewhere in the card body (e.g. a visually-hidden span repeating the status), since rotated text inside a decorative shadow boundary isn't a reliable a11y surface.
-
<os-stack>,<os-cluster>,<os-grid>— other layout primitives. - Native window with tabs — tab auto-swap pattern that composes with the layout stack.
-
<os-select>,<os-text-field>,<os-number-field>— the form primitives used in the example above.
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