-
Notifications
You must be signed in to change notification settings - Fork 41
desktop themes
Status: Experimental
A desktop theme reskins the whole OpenStation shell at once —
every design token, the typeface, a texture on any surface the shell
paints, the window frame and its corners, and a complete iconset down
to the window control glyphs. It ships as a ZIP containing a
theme.json manifest plus images and fonts. Nothing else.
Desktop themes vs window themes. OpenStation also has window themes (
openstation_register_window_theme(),wp.os.registerWindowTheme), which restyle one window's chrome. Desktop themes restyle the entire OS. They are separate features with separate registries; everything on this page uses thedesktop_theme/desktopThemenaming to keep them apart.
- The security model
- The Legacy theme — start here
- ZIP layout
theme.json- Tokens
- Fonts
- Wallpapers
- Recommended OS settings
- Icons
- Textures
- Texturing your own surface
- Value grammar
- Fallback semantics
- Installing and activating
- Registering a theme from PHP
- Non-goals
A theme is data, never code. There is no author-supplied CSS and no author-supplied JavaScript, ever. What actually happens on upload:
- The archive is walked entry-by-entry before a single byte is
written. Traversal, absolute paths, backslashes, NUL bytes, and
any extension outside
json txt md png jpg jpeg gif webp avif svg woff2 woff ttf otfreject the whole upload. Entry-count and uncompressed-size caps apply. - The archive extracts into a staging directory.
-
theme.jsonis sanitized field by field. Every asset reference is resolved against the staging directory and must land inside it — against the image extension list for icons, textures and the preview, and against the font list forfonts. The two lists are disjoint, so an icon can never resolve to a font file or the other way round. - Every referenced SVG is parsed with DOMDocument and stripped of
scripts, embedding elements,
on*handlers, non-fragmenthrefs, andjavascript:/url()in style attributes. DTDs and entity declarations reject the upload outright. On a server without DOMDocument, SVGs are refused rather than shipped unexamined. -
Only the assets the sanitized manifest actually references are
moved into the live directory. Anything else in the ZIP is
discarded with the staging directory — including
txt/mdfiles, which no manifest field can reference and which exist in the allowlist purely so an archive may carry the licence notice a bundled font obliges you to ship. - PHP compiles a stylesheet from the sanitized manifest: one rule
of custom-property declarations, preceded by the
@font-facerules it generated itself. It writes everyurl()from arawurlencoded path.
@font-face is the only at-rule that ever appears, and no part of it
comes from you verbatim except the family name — which is restricted
to ^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$ precisely so that wrapping it
in double quotes is airtight. The format() hint is derived from the
file extension rather than read from the manifest.
The practical consequence for you: if a value isn't in the grammar below, it silently doesn't apply. That is deliberate, and it is why this feature can be open to site admins at all.
OpenStation ships one theme of its own, called Desktop Mode (Legacy), and it is both the way back to the old look and the fastest way to write your first theme.
Legacy is the shell's pre-brand defaults written down: every design
token the chrome and the <os-*> component kit read, at the value it
resolved to before the OpenStation
palette landed in
assets/css/variables.css. 465 declarations, in one file,
sorted — the WordPress-admin greys and blues, complete.
Two audiences, one file. If you liked the old look, pick it and you
have it back. If you are writing a theme, it saves you the archaeology
of finding out that --os-ui-fg-muted used to be #50575e, that the
dock glyph sits at 70% white, and that the unfocused close button is
rgba( 0, 0, 0, 0.45 ) — and it is a complete worked example of every
token the system exposes.
To write a theme, fork it. The manifest lives at
assets/desktop-themes/legacy/theme.json inside the plugin, and
npm run package:legacy-theme writes
dist/desktop-mode-legacy-theme.zip — a normal, installable theme
ZIP. Change the id, change the name, change the twenty values you
care about, delete the rest (whatever you delete keeps its default —
see Fallback semantics), and upload.
Legacy is a snapshot, frozen on purpose. When OpenStation's own defaults change — a warmer dock, a different hairline, a new accent — Legacy goes on declaring exactly what it declares today. Nothing regenerates it: not the build, not CI, not the plugin at runtime. PHP reads that one committed file and registers it, and that is the whole mechanism.
This is the promise the theme is for. Someone who picks Legacy is asking to keep the look they know, and a manifest that quietly tracked the code would take it away from them one release at a time — while also making the theme a no-op again for anyone forking it as a reference. If a later release drifts far enough to be worth capturing, that is a new snapshot theme under a new id, not a rewrite of this one.
The practical consequence for you: fork it and you have a stable floor. Values you keep will still mean what they meant, and values the shell changes underneath you are exactly the ones your fork is already pinning.
Adding a name it never had is a different act, and it is allowed.
The freeze protects values that were collected — those never move.
A token minted after the snapshot has no collected value to protect,
and leaving it out does not preserve the old look: it hands that name
to the palette, which after the brand means the brand. So the manifest
grows when the token surface grows, at the value the new token's
consuming rule falls back to, and Tests_OpenStation_DesktopThemesLegacy
holds the two halves apart — a count that may rise, and per-token
assertions that may not move.
Legacy is code-registered (from PHP) rather than uploaded, so it is present on every install and the delete route does not apply to it — there is no file to remove, and its card carries no delete button. A site that genuinely does not want it in the picker unregisters it:
add_action( 'init', function () {
openstation_unregister_desktop_theme( 'desktop-mode/legacy' );
}, 20 );Legacy declares what is fixed about the default look, not what is conditional about it. Three families stay out, each because naming a literal would make the theme differ from the unthemed shell rather than reproduce it:
| Left out | Why |
|---|---|
Anything the palette derives from the accent — the tab wash / bloom / edge, the focus rings, the holo glows, the dock divider, the title-bar activity dot, --os-ui-accent-dim itself |
The accent picker writes --os-ui-accent and --os-ui-accent-dim inline at runtime, and these follow. Pin one and the pick stops reaching it: the user chooses teal and that surface stays blue. |
| Derived sizes — the dock / icon / recycle badge families | They size themselves off the icon they decorate, so a literal freezes them against a large dock. |
Texture-slot properties (*-image, *-border-image-*) |
Those are written by textures, not by tokens. |
If you want one of them, name it in your own theme — Legacy leaving
it undeclared is a statement about defaults, not a restriction. But
understand what you are giving up on the first row: a theme that pins
--os-ui-tab-wash has opted its sidebar out of the accent picker, for
every user who wears it.
The rule underneath that first row is the one to take away. Look at how the palette declares a token before answering it:
-
A literal (
--os-tabs-bg-unfocused: #0c0b0f) is a colour, and a colour is yours to replace. Answer it. -
A derivation (
--os-ui-tab-wash: linear-gradient( 90deg, color-mix( in srgb, var( --os-ui-accent-dim ) 16%, … ) )) is not a colour — it is a rule, and it already resolves correctly under your theme, because the accent it reads is the one you declared. Answering it with a literal restores nothing and severs the chain.
One accent to declare, and everything the palette computes from it
follows. Tests_OpenStation_DesktopThemesLegacy asserts both halves —
that Legacy answers every palette literal, and that it answers none of
the accent-driven derivations.
Context-dependent tokens — --os-fg, --os-tooltip-bg / -fg —
used to be on that list and are not any more. The
idea was that one literal could not serve a token that reads light on
the desk and dark inside a window, so naming it would break one of the
two. What actually happens when a theme omits a name is not "the token
stays conditional" — it is the palette answers instead, and the
palette is one value too. Omitting decided nothing; it only moved the
decision to the brand. See Fallback semantics
for why an omitted token is not an open question.
The tooltip chip is still the same dark lozenge under Legacy, which was
always the intent — a chip is one line of text pinned to a control, not
a surface that belongs to a window. It is now dark because Legacy says
rgba( 0, 0, 0, 0.45 ) rather than because nobody said anything. The
WP Explorer hover card used to be painted
as one and looked wrong for exactly that reason — Obsidian, floating
over Legacy's white window. It derives from --os-my-wordpress-bg,
which Legacy also declares, so it comes back light.
It is a canonical snapshot, not a byte-for-byte one. A handful of
palette names were read with slightly different literals at different
sites as the codebase grew — --os-ui-hover at 4% in one stylesheet and
6% in another, --os-ui-surface-elevated as #f6f7f7 here and #fff
there. Legacy picks one value per name, which is what naming a palette
means; wearing it unifies those few near-duplicates rather than
reproducing each of them. Nothing moves and nothing changes contrast.
It does not take back your accent. The accent colour is a user
setting (OpenStation Preferences → Appearance), written as an inline style that
outranks every stylesheet, so it is yours rather than any theme's.
Legacy leaves it alone deliberately — pick WordPress Blue there if
you want the old accent back with the old palette.
Either shape works — the manifest may sit at the archive root, or one directory deep (what "Compress this folder" produces on macOS and Windows):
neon-glass.zip
├── theme.json
├── preview.png
├── LICENSE.txt <- validated, never installed
├── icons/
│ ├── close.svg
│ ├── settings.svg
│ └── trash.svg
├── fonts/
│ ├── neon-grotesk-400.woff2
│ └── neon-mono-400.woff2
└── textures/
├── titlebar.png
├── frame.png
└── desktop.jpg
__MACOSX/ folders and dotfiles are ignored, not rejected. The archive
must contain exactly one theme.json.
Re-uploading a theme with the same id is an update, not an
error: the old directory is dropped wholesale so removed assets don't
linger, and every user already wearing it sees the new version on
their next page load.
{
"manifestVersion": 2,
"id": "acme/neon-glass",
"name": "Neon Glass",
"version": "1.0.0",
"author": "Acme Design",
"description": "Deep indigo glass with a neon rim.",
"preview": "preview.png",
"tokens": {
"--os-titlebar-bg-focused": "#1a1a2e",
"--os-font": "\"Neon Grotesk\", system-ui, sans-serif"
},
"fonts": [
{ "family": "Neon Grotesk", "weight": "400", "display": "swap",
"src": [ "fonts/neon-grotesk-400.woff2" ] }
],
"iconColor": "currentColor",
"icons": {
"OS_SETTINGS": { "type": "image", "path": "icons/settings.svg" },
"WINDOW_CONTROL_CLOSE": { "type": "image", "path": "icons/close.svg" },
"APP:edit-php": { "type": "dashicon", "name": "dashicons-edit-large" }
},
"textures": {
"TITLEBAR": { "type": "image", "path": "textures/titlebar.png",
"repeat": "repeat-x", "size": "auto 100%" },
"WINDOW_FRAME": { "type": "border-image", "path": "textures/frame.png",
"slice": "24 fill", "width": "12px", "repeat": "round" },
"DESKTOP": { "type": "image", "path": "textures/desktop.jpg",
"size": "cover", "repeat": "no-repeat" }
},
"recommendedOsSettings": {
"dockSize": "large",
"desktopLayout": "unified"
}
}Get these wrong and the upload is rejected with a specific message:
| Field | Rule |
|---|---|
manifestVersion |
1 or 2. |
id |
^[a-z0-9_-]+(/[a-z0-9_-]+)?$, max 64 chars. Namespacing (vendor/name) is encouraged. |
name |
Non-empty. |
The storage slug is the id with / flattened to -
(acme/neon-glass → acme-neon-glass). It is what appears in the
compiled selector and the body class.
On manifestVersion. 2 says nothing about the shape of anything
else on this page — it exists so you can declare that your manifest
carries recommendedOsSettings, and so a
reader can tell a deliberate omission from an older file. Every v1
manifest keeps working untouched, and a v1 manifest that ships the
block anyway still has it honoured: dropping a valid, sanitized field
over a version number would contradict the drop-and-continue rule the
rest of the sanitizer follows. Write 2 when you recommend settings,
leave 1 alone otherwise.
Optional, and individually droppable — see Fallback semantics.
| Field | Notes |
|---|---|
version, author
|
Plain text, ≤32 / ≤120 chars. |
description |
Plain text, ≤500 chars. |
preview |
Path to an image shown on the theme card in OpenStation Preferences. |
iconColor |
Default fill for every icon — see Icon colour. |
wallpaper / wallpapers
|
One or more pickable wallpapers — see Wallpapers. |
recommendedOsSettings |
Layout preferences to seed on first activation — see Recommended OS settings. |
tokens, fonts, icons, textures
|
See below. |
A map of CSS custom property => value. Three namespaces are accepted; anything else is dropped, so a theme can never reach a property the shell didn't mean to expose.
Where the defaults live.
assets/css/variables.cssdeclares the shell's own palette — the OpenStation brand — scoped tobody.os-active. A theme's compiled selector matches the same element and prints after it, so every token below is yours to take whether or not the shell already has an opinion about it. Behind both, every rule in the tree still reads its token asvar( --token, <literal> ), and those literals are the pre-brand WordPress-admin values — which is what Legacy collects.That scope is also why an admin page inside an iframe window looks exactly as it does outside one: chromeless documents carry
body.os-chromeless, match none of it, and render on the literals. See Non-goals.
| Namespace | What it restyles |
|---|---|
--os-* |
The shell: dock, desktop, window frame, title bars, corners. |
--os-ui-* |
Window bodies: the <os-*> component kit and every feature stylesheet. |
--wp-admin-theme-color |
The WordPress admin accent. |
This is the one that matters most, and the one theme authors most
often miss: --os-* alone restyles the frame around a
window and nothing inside it. Window bodies — the Settings panel,
the Posts table, the Trash, file tiles, every dialog — read the
--os-ui-* palette. A theme that sets only the shell tokens produces a
dark frame around a white page.
| Token | Role |
|---|---|
--os-ui-surface |
Cards, panels, table rows |
--os-ui-surface-elevated |
Headers, raised strips |
--os-ui-surface-sunken |
Wells, recessed areas |
--os-ui-field-bg, --os-ui-field-border, --os-ui-field-fg
|
Inputs written in the light DOM — a plugin's own <input> / <select> / <textarea> inside a window body. The kit's own <os-text-field> and friends resolve their surface inside their shadow roots and don't read these. |
--os-ui-fg |
Body text |
--os-ui-fg-muted |
Secondary text, metadata |
--os-ui-fg-faint |
Disabled text |
--os-ui-fg-on-accent |
Text on a filled accent / danger surface. Not --os-ui-accent-text — that name doesn't exist, and because an undefined custom property just yields to the var() fallback, writing it produces no error and no visible clue. If a colour you set is being ignored, check the name against this table first. |
--os-ui-border |
Hairlines |
--os-ui-border-strong |
Emphasized dividers |
--os-ui-hover |
Row / tile hover wash |
--os-ui-scrim |
Modal + overlay backdrop |
--os-ui-accent |
Primary action |
--os-ui-accent-strong |
Its hover / active state |
--os-ui-danger, --os-ui-danger-hover
|
Destructive actions |
--os-ui-warning-fg, --os-ui-warning-bg, --os-ui-warning-border
|
Warning notices |
--os-ui-info-fg, --os-ui-info-bg
|
Info notices |
--os-ui-success-fg |
Success text |
Individual components expose finer-grained tokens on top of these
(--os-ui-button-bg, --os-ui-card-bg, --os-ui-table-header-bg, …) —
around 190 in all, each documented next to its component in
src/ui/components/<name>/<name>.styles.ts. Every one of them falls
through to the palette above, e.g.
background: var( --os-ui-card-bg, var( --os-ui-surface, #fff ) );so setting --os-ui-surface alone restyles cards, flyouts, menus,
tables and the rest. Reach for a component-local token only when you
want that one component to differ — it still wins when set. Any
--os-ui-* name is accepted by the manifest.
Both resolve at runtime, which is the trap: reading
--os-ui-badge-warning in devtools returns a colour, so it looks like
a palette token, and setting it looks like it worked. It isn't one.
It's declared inside <os-badge>, which means a theme that re-points
--os-ui-warning-fg — the palette token it falls through to — leaves
a plugin's badges on the old value, because the component-local
declaration is closer to the element and wins.
The test is one grep, and it is exact:
grep -n -- '--os-ui-badge-warning' assets/css/variables.css # palette?-
In
variables.css(declared underbody.os-active) → palette. Theme-settable, and every component chains through it. -
Not in
variables.css→ component-local, declared insrc/ui/components/<name>/<name>.styles.ts. Settable per component, but re-pointing the palette token it falls back to will not move it.
The tables above are the palette in full. Everything else is
component-local; the --os-ui-badge-* family straddles the line
(--os-ui-badge-warning-bg is palette, --os-ui-badge-warning is
not), which is exactly why the grep beats the guess.
These tokens have no default value. Every consuming site reads
them as var( --os-ui-x, <its own literal> ), which is why an unthemed
shell looks exactly as it always did and why one theme value retints
a whole family at once.
That includes the ones inside a component's shadow DOM. A component
declaring --os-ui-table-bg on its own :host would make the name
unreachable — a declaration on the element beats anything the element
inherits, and your theme declares on an ancestor — so components read
their public tokens into private aliases instead. The one exception is
<os-modal>, whose dialog surface is dark whatever the admin colour
scheme says. It re-points the foreground tokens (--os-ui-fg,
--os-ui-fg-muted, --os-ui-border, --os-ui-border-strong), the
surfaces (--os-window-bg, --os-ui-surface,
--os-ui-surface-elevated) and the washes over them (--os-ui-hover,
--os-ui-button-bg-hover) inside the dialog, and gives you a
--os-ui-modal-* name for each: -text, -text-muted, -border,
-border-strong, -field-bg, -surface, -surface-elevated,
-hover, -button-bg-hover.
Theme the --os-ui-modal-* set, not the tokens it re-points — a
--os-ui-surface your theme sets never reaches inside a dialog, by
design. The set covers foreground and surface deliberately: it used
to re-point only the text colours, which looked correct for as long as
the palette outside the dialog was dark too, and came apart the moment
a light theme was worn — <os-select> in a dialog painted a white
trigger under near-white text.
The palette is not limited to window bodies: the shell's own
body-mounted overlays — toasts, confirm dialogs, context menus, and
the command palette / Site Assistant (⌘K) — read it too, because
they render inside the body.os-desktop-theme-<slug> half
of the compiled selector. Set --os-ui-surface and the --os-ui-fg
family and the palette panel follows without any extra work.
A dark theme's minimum viable body palette:
"tokens": {
"--os-ui-surface": "#161634",
"--os-ui-surface-elevated": "#1e1c44",
"--os-ui-surface-sunken": "#101026",
"--os-ui-fg": "#e9e7ff",
"--os-ui-fg-muted": "#a5a1cc",
"--os-ui-fg-faint": "#6f6b99",
"--os-ui-border": "#2f2a63",
"--os-ui-border-strong": "#453e8c",
"--os-ui-hover": "rgba( 124, 92, 255, 0.16 )",
"--os-ui-scrim": "rgba( 6, 4, 24, 0.68 )"
}The kit has one more family, and it is the one that decides how a
control looks at the moment it is on. A switch that is on, a checked
checkbox, the selected segment, the filled part of a progress bar and
the step-number chip all paint --os-ui-holo-fill — by default the
brand's Holomesh, transcribed into CSS in --os-mesh-holo.
| Token | Role |
|---|---|
--os-ui-holo-fill |
What an on / selected / filled surface paints. |
--os-ui-holo-ink |
Glyphs and text on that fill. |
--os-ui-hero-mesh |
What a large decorative FIELD paints — a panel wash, an identity rail — as opposed to a control. An aurora by default, and built differently from the five meshes: its alpha is in the colour stops rather than applied to the layer, so the desk shows through and the hues keep their chroma at any strength. A mesh knocked back with opacity over a large area goes grey. Keep the two separate: a value chosen so a 40px chip reads as on is often within a unit or two of the surface behind it, which is invisible once it is stretched across a rail. Legacy answers this one with a flat WordPress-admin blue wash while leaving its control fill alone. |
--os-ui-holo-sheen |
The hover film over a surface that is not lit. |
--os-ui-holo-edge, --os-ui-holo-edge-quiet
|
The iridescent hairline, lit and at rest. |
--os-ui-holo-glow, --os-ui-holo-glow-strong
|
The bloom around a lit surface. |
--os-ui-holo-track |
The unlit half — switch tracks, empty progress. |
--os-ui-tab-edge |
The leading edge on the selected row of a vertical <os-tabs>. The flat accent by default. |
--os-ui-tab-wash, --os-ui-tab-bloom
|
That row's surface wash and the bloom the edge throws back across it. Both ambient, so both resolve through --os-ui-accent-dim. |
--os-ui-accent-dim |
The accent, one step back. Every ambient use of it — glows, washes, focus blooms — resolves through this, so it is the single knob for how loud a theme reads. |
--os-ui-focus-ring |
Focus on a target: buttons, switches, checkboxes, swatches. |
--os-ui-focus-ring-field |
Focus on a field: quieter, tightens the input's own border. |
--os-ui-holo-transition |
Duration for every holographic transition in the kit. |
And the motion scale, which is worth setting as a group or not at all — a theme that changes one duration gets a panel where the controls disagree about how fast they are:
| Token | Role |
|---|---|
--os-ui-motion-fast |
A state flip with no travel: a colour, an opacity, a tick. |
--os-ui-motion-slow |
Something crossing a distance: a thumb, a drawer, a card. |
--os-ui-motion-ambient |
An ambient loop — a shimmer, a drift. |
--os-ui-ease-spring |
Overshoots and settles. Wrong for anything that changes size. |
--os-ui-ease-out |
Decelerating. The default for anything arriving. |
--os-ui-ease-loop |
Symmetric, for a loop that returns where it started. |
Setting all three durations to 1ms is a supported way to build a still theme; every fragment in the kit already does exactly that under prefers-reduced-motion.
Setting --os-ui-holo-fill alone retints every one of those surfaces
at once, which is the shortest route to a theme that does not look
like OpenStation. A flat colour works:
"tokens": {
"--os-ui-holo-fill": "#7c5cff",
"--os-ui-holo-ink": "#ffffff"
}Change the ink when you change the fill. The default ink is Void, because every mesh in the brand is a light surface — a dark fill with the default ink is near-black on near-black, and it looks fine in a screenshot of the off state.
Do not answer a fill with none. Turning the iridescence off is a
reasonable thing to want, and for the overlay tokens it is exactly
right: --os-ui-holo-sheen and the two --os-ui-holo-edge* are a
::before film and an ::after stroke, so none removes a decoration
and leaves the control's own background alone.
--os-ui-holo-fill is not one of those. It is the surface — the rule
sets background-color: transparent and paints the whole control
through this image — so none gives you an invisible button, not a
flat one. The same is true of any token whose row in the table above
reads "what … paints" rather than "the film over" it:
--os-cn-row-fill (the constellation's selected row, whose ink flips
dark on top of it) and --os-ui-tab-wash / -bloom (the selected
settings tab). Answer those with a flat colour or a plain gradient:
"tokens": {
"--os-ui-holo-fill": "linear-gradient( 124deg, #f0f6fc 0%, #f6f7f7 100% )",
"--os-ui-holo-ink": "#1d2327"
}The distinction is not visible from the token's name — check whether
the consuming rule paints a surface or sits over one before switching
it off. Legacy does exactly this: the
sheen and the edges are none, the fills are pre-brand light surfaces.
The five meshes themselves (--os-mesh-holo, --os-mesh-pulse,
--os-mesh-auro, --os-mesh-star, --os-mesh-mio) are also settable,
and are the right lever when you want your own gradient everywhere the
brand's would have gone. --os-mesh-mio belongs to the mascot; retint
it and Mio changes with the rest of the station, which may or may not
be what you meant.
Consuming rules must read
--os-ui-holo-fill, not a mesh token. Setting a mesh tononeis how a theme opts out of iridescence — Legacy sets all five — and a rule that reads--os-mesh-holodirectly resolves tononethere. Thevar()literal does not rescue it: a fallback fires only when a property is undefined, and a mesh set tononeis very much defined. The surface paints nothing at all rather than a quieter version of itself. Route through--os-ui-holo-fill— or--os-ui-hero-meshif the surface is a field rather than a control — and the opt-out becomes a substitution.
--os-* names must match ^--os-[a-z0-9-]+$.
Read assets/css/variables.css for the full set.
Set
--os-backstopif your desk is light. It is the flat colour painted on the shell behind every other layer, including the wallpaper. Nothing is normally seen through it — it exists so that a frame in which some layer fails to paint shows the desk's own colour rather than the white admin page the shell sits over. The palette paints it Void#0c0b0f(#1d2327is only the pre-brand fallback literal in the consuming rules), so left unthemed it would read as a dark blink on a light theme.
The icon grid is a token set, and it is one grid.
--os-tile-w/--os-tile-h,--os-grid-gap-x/--os-grid-gap-yand--os-grid-paddingdescribe every surface that lays out placements: the wallpaper, folder windows, and each canvas in the WP Explorer. Widen a gap and all of them widen together — that is the point of them being one declaration.The cell pitch is derived, never declared:
cell = tile + gap. The layout maths can't read CSS, sosrc/desktop-files/grid.tsmirrors these numbers andtests/vitest/grid-metrics.test.tsparses the stylesheet to prove the mirror is faithful. A theme retuning them shifts the visual spacing; stored icon coordinates snap to the new pitch the next time a tile is dragged or the canvas is sorted, so expect a one-time reshuffle rather than an instant re-layout.--os-tile-w-large/--os-tile-h-largedo the same job for image-led sections (tileSize: 'large').
--os-window-radiusis not one of them in practice. The Window-corners preset in OpenStation Preferences writes that property as an inline style on the shell root, which outranks any stylesheet rule, so a theme declaring it intokenshas no effect on windows. The user's corner preference stays the user's. If your frame artwork needs a particular radius, ask for it throughrecommendedOsSettings.windowRadius— that sets their preference once, on first activation, and leaves it theirs to change.
"tokens": {
"--os-window-bg": "#12122a",
"--os-window-border": "#2b2b52",
"--os-titlebar-bg": "#171733",
"--os-titlebar-bg-focused": "#241f4d",
"--os-titlebar-color": "#a8a8c0",
"--os-dock-bg": "rgba( 12, 12, 30, 0.72 )",
"--wp-admin-theme-color": "#7c5cff"
}One token owns the surface a window's content is uncovered from once it finishes loading (OpenStation Preferences → Effects → "Window reveal"):
| Token | Role |
|---|---|
--os-window-reveal-surface |
Fill of the receding reveal surface. Obsidian #1a1721 by default; non-native windows override it to white |
--os-window-reveal-edge |
Fill of the band trailing the reveal's clip boundary. transparent by default |
--os-window-reveal-edge-thickness |
How wide that band is. Undeclared by default |
--os-window-reveal-duration |
How long a reveal runs. Undeclared by default |
"tokens": {
"--os-window-reveal-surface": "#12122a",
"--os-window-reveal-edge": "#7c5cff",
"--os-window-reveal-edge-thickness": "12%",
"--os-window-reveal-duration": "620ms"
}Surface defaults to the palette's Obsidian #1a1721, except on
iframe windows — the ones showing a real admin page — which override it
to white to match the page they uncover. It has to be opaque or there is
nothing to reveal from — the content would simply be visible the
whole time and the clip animation would paint nothing. Set it to
var( --os-window-bg ) to follow your window colour, or to a
brand colour, a gradient, or an image; the surface is a plain element
and the animation clips it rather than recolouring it. transparent is
also a legitimate value, meaning "no covering surface" — the shell then
skips the layer rather than animating something invisible.
One reveal ignores this token: Camera shutter paints its own near-black blades, because in any other colour it stops being a camera shutter.
Edge is the band that travels with the clip boundary and draws each
reveal's shape — six lines on Blinds, an opening ring on Iris, a
rotating spoke on Radar. It is transparent by default: the reveal
reads as the page arriving, and a hard graphic edge on top of that is a
deliberate look rather than the neutral one. Give it a colour and the
band turns on for every reveal the user might pick, yours or a
plugin's, with nothing else to configure — the band follows whatever
shape the active reveal has. While the token computes transparent the
shell skips the layer entirely, so leaving it alone costs nothing.
Gradients and images work as well as flat colours.
Edge thickness takes either a fraction of the reveal's travel —
12%, or the equivalent unitless 0.12 — or an absolute time like
70ms. Prefer the fraction: the band then holds its apparent width at
any reveal speed and any window size, because its width is a share of
how far the shape moves rather than a span of time. Undeclared by
default, in which case each reveal's own edgeLag decides; declare it
and it wins outright, since thickness is a property of your look rather
than of any one reveal. 0% suppresses the band while leaving the
colour in place.
Duration is undeclared by default and accepts 620ms, 0.62s,
or a bare 620 (read as ms). It sets the house pace for every reveal
the user might pick — but a user who has chosen a speed in OpenStation Preferences
→ Effects out-ranks it, the same way the window-corner preset out-ranks
a theme's --os-window-radius. Their choice stays theirs.
Two shell tokens own every tooltip chip — the dock tile tooltip and the content-graph satellite tooltip:
| Token | Role |
|---|---|
--os-tooltip-bg |
The tooltip chip surface |
--os-tooltip-fg |
Its primary text |
"tokens": {
"--os-tooltip-bg": "#1e1c44",
"--os-tooltip-fg": "#e9e7ff"
}They are worth setting explicitly. Without them, tooltips fall back
to colours borrowed from other families — --os-ui-scrim or
--os-ui-surface-elevated for the surface, --os-ui-fg-on-accent for the
text — and those pairings come apart under a custom palette. Set
--os-ui-scrim to a translucent wash for your modals and the dock
tooltip goes translucent with it; keep a light --os-ui-surface-elevated
next to a white --os-ui-fg-on-accent and the satellite tooltip renders
white text on a white chip. Neither was fixable from the palette alone,
because fixing it would have broken the modal backdrop or the text on
accent-filled buttons.
Both tokens are declared by the palette (#33303a over #fffbff),
so a theme that ignores them keeps the OpenStation tooltip; the
borrowed-colour fallbacks above are only the floor beneath the palette.
A chip is deliberately the same lozenge under every theme — one line of text pinned to a control. The WP Explorer hover card is a different object and has its own family, below.
Hovering a tile in WP Explorer summons a card with a title, a featured image and an excerpt. It is window furniture rather than a chip: it follows the theme that paints the window it was summoned from, and you get that for free.
| Token | Role | Derived from |
|---|---|---|
--os-my-wordpress-card-bg |
The card surface | --os-my-wordpress-bg |
--os-my-wordpress-card-fg |
Its primary text | --os-my-wordpress-fg |
--os-my-wordpress-card-fg-muted |
The excerpt and any secondary line | --os-ui-fg-muted |
--os-my-wordpress-card-border |
The edge that lifts it off the window | --os-ui-border |
--os-my-wordpress-card-shadow |
Its drop shadow | — (flat) |
--os-my-wordpress-card-thumb-bg |
The well behind the featured image | --os-media-tile-bg |
--os-my-wordpress-card-lock-bg |
The wash behind the "someone is editing" banner | --os-ui-badge-danger-bg |
Most themes should set none of these. Every one except the shadow
is derived from a token you are likely setting anyway — name
--os-my-wordpress-bg and --os-ui-border and the card arrives
matching your window, with a visible edge, without a line of extra
manifest. Set a -card- token only when you want the card to differ
from the window it belongs to.
"tokens": {
"--os-my-wordpress-card-bg": "#1e1c44",
"--os-my-wordpress-card-border": "rgba( 233, 231, 255, 0.16 )"
}Two things worth knowing if you do:
-
The card's surface is the window's surface, so the border and the
shadow are the only reason it reads as a separate object. If you
repoint
--os-my-wordpress-card-border, keep it distinct from--os-my-wordpress-card-bgor the card loses its edge entirely. - The shadow is flat, not derived. A drop shadow is cast light — dark under a light theme and dark under a dark one — so there is no window token for it to follow. It is named so you can still reach it.
--os-dock-bg repaints the strip. These four repaint what
sits on it:
| Token | Role |
|---|---|
--os-dock-icon-color |
The glyph at rest |
--os-dock-icon-color-hover |
The glyph on hover / peek |
--os-dock-item-bg-hover |
The wash behind a hovered tile |
--os-dock-item-outline |
The keyboard focus ring and the tile status indicator (active dot, all-minimized ring) |
"tokens": {
"--os-dock-bg": "rgba( 244, 243, 255, 0.86 )",
"--os-dock-icon-color": "rgba( 26, 22, 58, 0.72 )",
"--os-dock-icon-color-hover": "#12102b",
"--os-dock-item-bg-hover": "rgba( 26, 22, 58, 0.1 )",
"--os-dock-item-outline": "rgba( 26, 22, 58, 0.65 )"
}Set them whenever your dock is pale. The palette paints the glyph at 72% Starlight, brightening to full Starlight on hover, over an 18% Pulse wash; the focus ring follows the user's accent, and it also paints the status marks on the tile: the solid dot under the running or focused tile and the hollow ring under one whose windows are all minimized. That reads against the default translucent-dark strip and disappears the moment you give the dock a light background. Recolouring the strip alone is the most common way a first theme ends up with an invisible dock.
--os-dock-icon-color is a colour, not a fill, which
matters if your iconset uses
"iconColor": "currentColor": those icons are masked
with the glyph colour, so this single token drives dashicons, your own
artwork, and the hover transition together.
System tiles — OpenStation Preferences, the recycle bin, and their neighbours —
read the same --os-dock-icon-color. Unthemed they sit one
notch brighter than menu tiles; once you name a colour they join the
rest rather than staying stranded white.
This reaches plugin and custom-post-type artwork too, not just
dashicons and your own iconset. The dock has always flattened those
SVGs to one colour so a brand mark can't shout over its neighbours;
they are now flattened by a currentColor mask rather than by a
force-to-white filter, so they land on your glyph colour like
everything else. A URL the mask can't take — one carrying literal
quotes, spaces or parens — still falls back to the white filter.
All four are declared by the palette, so a theme that ignores them
keeps the OpenStation dock; the pre-brand white literals survive only
as the var() fallbacks in the consuming rules.
Native windows render in the parent shell, not in an iframe, so
wp-admin's own stylesheets reach into their content. Core styles a
few things with bare element selectors — h1, h2, h3, a,
code — which means a heading inside a themed window inherited
core's #1d2327 regardless of your palette.
The shell now re-points those at the palette (see the specificity
note in assets/css/window-chrome.css), so headings follow
--os-ui-fg, links follow --os-ui-accent, and <code> follows
--os-ui-surface-sunken. Nothing is needed from a theme author — but
it explains why a heading might once have looked "stuck".
Raw form controls (input, textarea, select) are the known
exception: core styles those with attribute selectors that carry real
specificity, and native windows use the <os-*> components instead.
If you build a native window with raw form controls, style them
yourself.
The framework's own raw controls are already handled — window bodies
get a tokenized override in assets/css/window-chrome.css, and the
command palette's search field gets one in
assets/css/ai-assistant.css. Both out-specify core's
input[type="…"] rules and fall back to core's values, so nothing
changes without a theme.
Four tokens carry the shell's typefaces, and the palette declares all
four — Geist for the chrome, title bars and window bodies, Geist Mono
for code — so an unthemed active shell renders the brand faces. Every
consuming rule still reads
font-family: var( --token, <the literal that was always there> ), so
the pre-brand WordPress-admin stacks remain the floor if the
stylesheet fails to load — and they are what the Legacy theme puts
back.
| Token | Applies to |
|---|---|
--os-font |
Shell chrome: dock labels, desktop icon labels, widgets, the overview |
--os-titlebar-font |
Window title bars. Falls back to --os-font
|
--os-ui-font |
Window bodies and the whole <os-*> component kit |
--os-ui-font-mono |
Code, hashes, file sizes, log output |
The chrome / body split is the one real decision here, and it is the one real desktop environments make: a display face on the title bars and dock, a comfortable text face inside windows.
"tokens": {
"--os-font": "\"Neon Grotesk\", system-ui, sans-serif",
"--os-titlebar-font": "\"Neon Grotesk\", system-ui, sans-serif",
"--os-ui-font": "\"Neon Grotesk\", system-ui, sans-serif",
"--os-ui-font-mono": "\"Neon Mono\", ui-monospace, monospace"
}Always end a stack with a generic family. If the bundled face fails
to load — a slow network, a unicodeRange that doesn't cover the
user's script — that fallback is what they read.
Setting these tokens does not require bundling a font. A stack of
system faces ("Iowan Old Style, Palatino, serif") is a complete,
zero-byte way to change how the OS reads. Bundle a font only when you
need one that isn't there.
The About journal is ordinary shell UI: its overview, cards and status
states follow the surface and text tokens, and its featured dispatch
paints --os-ui-hero-mesh over --os-ui-surface-sunken.
Colour that encodes meaning or is composed artwork is deliberately not themable: pinned-note paper, game palettes, and the content-graph node hues. Retinting those would destroy the signal they carry. The same applies to their typography — the note's handwriting face and the Inkfall serif are part of the artwork.
A theme may bundle typefaces. Each entry in fonts
becomes one @font-face rule, generated by PHP from your descriptor.
"fonts": [
{ "family": "Neon Grotesk", "weight": "400", "style": "normal",
"display": "swap",
"src": [ "fonts/neon-grotesk-400.woff2", "fonts/neon-grotesk-400.woff" ] },
{ "family": "Neon Grotesk", "weight": "700", "style": "normal",
"display": "swap",
"src": [ "fonts/neon-grotesk-700.woff2" ] }
]Declaring a face does not use it. Point a typography token at the family, or nothing changes — the browser downloads a face only when something asks for it, so an unreferenced face costs no bytes.
| Field | Rule |
|---|---|
family |
Required. ^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$. Letters, digits, spaces, _, -. Nothing else — see the security model. |
src |
Required. A path, or a list of paths in preference order. { "path": "…" } objects work too. Extensions: woff2, woff, ttf, otf. |
weight |
One or two of normal / bold / an integer 1–1000. Two values declare a variable-font range ("100 900"). |
style |
normal, italic, oblique. |
display |
auto, block, swap, fallback, optional. |
stretch |
A *-condensed / *-expanded keyword, normal, or one to two percentages. |
unicodeRange |
U+xxxx ranges, comma separated, up to 32. |
The format() hint is derived from the extension — don't supply one,
it is ignored.
16 faces per theme, 4 sources per face, 8 MB per file, 32 MB per
archive. Both face caps are filterable
(openstation_desktop_theme_font_caps).
-
Ship
woff2. Every browser OpenStation targets supports it, and it is roughly half the size ofwoff. Awoffsecond source is belt and braces;ttf/otfare accepted but rarely worth the bytes. -
Subset. A full Unicode face is megabytes. Latin-1 plus the
punctuation you actually use is usually 15–30 kB. Declare what you
subsetted to in
unicodeRangeso the browser can skip the download for text the face can't render. -
display: swappaints fallback text immediately and swaps when the face arrives. On admin chrome that is almost always right —blockhides your dock labels while the font loads. -
Licensing is yours. A font you bundle is redistributed to every
visitor of the site that installs the theme, and to whoever you hand
the ZIP to. Ship one whose licence permits that, and put the notice
in a
LICENSE.txtinside the archive (the allowlist acceptstxtandmdfor exactly this; they are never installed on the server). -
Fonts reach the shell and native windows, not iframes. An
iframe window renders a real
wp-admindocument in its own browsing context, which the theme stylesheet does not enter. Your typeface restyles the desk, the dock, every title bar, and every native window body; the admin page inside an iframe keeps the admin's font.
** A theme may ship wallpapers. Each one appears in
OpenStation Preferences → Wallpaper as an ordinary pick, labelled
<theme name> - (theme) — or <theme name>: <label> - (theme) when
the theme ships more than one.
"wallpapers": {
"deep-field": { "path": "wallpapers/deep-field.jpg",
"label": "Deep Field",
"description": "Violet light across a star field." },
"grid-horizon": { "path": "wallpapers/grid-horizon.jpg",
"label": "Grid Horizon" }
}Applying a theme does not change the user's wallpaper, which is where we deliberately part company with macOS and Windows.
A wallpaper here is a stored user preference. For theme activation to swap it, we would have to either overwrite that preference silently or keep a shadow record of "what they had before" to restore later — and both are worse than simply putting the theme's artwork where the user already goes to change wallpapers.
The upside is real: a theme's wallpaper can be worn without the theme, and wearing the theme never costs a user the wallpaper they chose. Every theme in the library contributes its wallpapers, not just the active one.
This is separate from the
DESKTOPtexture slot. That slot follows the theme and layers over whatever wallpaper is active; a wallpaper here is a picture the user selects.
"wallpaper": "wallpapers/desk.jpg"
"wallpaper": { "path": "wallpapers/desk.jpg", "size": "cover" }
"wallpapers": [ "a.jpg", { "path": "b.jpg", "label": "Dusk" } ]
"wallpapers": { "dusk": { "path": "b.jpg" } }wallpaper and wallpapers are interchangeable keys. A map's keys
become the wallpaper ids.
| Field | Rule |
|---|---|
path |
Required. Image inside your ZIP (absolute URL for a code theme). |
label |
Shown in the picker after the theme name. Recommended once you ship more than one. |
id |
Explicit id. See the stability note below. |
description |
Shown in OpenStation Preferences when this wallpaper is selected. Falls back to the theme's description. |
size |
cover (default), contain, auto, or lengths. |
repeat |
no-repeat (default) or any background-repeat keyword. |
position |
center center (default), keywords, lengths, or percentages. |
Twelve per theme, filterable via
openstation_desktop_theme_max_wallpapers.
The user's choice persists by id, so an id is derived from something
that survives an edit, never the array position: an explicit id,
then the map key, then a slug of label, then the image's filename.
Reordering your list is therefore safe. Renaming a file, or changing a
label that was supplying the id, is not — do that and anyone using
that wallpaper falls back to the default. Set id explicitly if you
expect either to change.
Some themes are designed against an arrangement, not just a palette: a
wide dock because the tiles carry artwork, a unified bottom bar
because the desktop is meant to read as one surface, square corners
because the frame texture has its own. recommendedOsSettings lets
that intent travel with the theme instead of living in a setup guide.
"recommendedOsSettings": {
"dockSize": "large",
"desktopLayout": "unified",
"windowRadius": "default",
"adminBarMode": "dynamic",
"dockRailRenderer": "default",
"windowReveal": "iris",
"windowRevealDuration": 620
}A theme arranges the desktop once — the first time that user activates it — and never again.
- Applied on activation, never on page load. There is no pass that re-asserts a theme's preferences behind the user's back.
- Applied once per user, per theme. The shell records the theme in
the user's
appliedThemeRecommendationsledger; a second activation of a theme they have already worn changes nothing. - A later change by the user always wins. Pick the theme, put the dock back to compact, re-pick the theme — it stays compact.
The way back is the user's to take: OpenStation Preferences → Themes shows an Apply <theme>'s recommended layout and effects button for the active theme when it recommends something, and that is the only path that applies a recommendation a second time. It sets the settings and nothing else — the dock resizing under the cursor is the feedback.
This is the same posture as wallpapers, for the same reason. Dock size and layout are stored user preferences, and a theme that could silently overwrite them on every load would be taking something the user chose.
Every field is optional. A field you don't name is not touched, and one you name with a value outside its set is dropped while the rest still apply.
| Field | Values |
|---|---|
dockSize |
compact, default, large
|
desktopLayout |
classic, unified
|
dockPlacement |
bottom, left, right — which edge the dock sits on. Read by unified; classic owns both of its edges and ignores it. |
windowRadius |
sharp, default, round
|
adminBarMode |
static, dynamic, hidden — how the WordPress admin bar presents above the shell. dynamic parks it off the top edge behind a peek strip that reveals on hover or keyboard focus; hidden removes it, leaving the dock's Exit OpenStation tile as the route back to classic admin. A theme wanting an edge-to-edge desk recommends one of the two. |
dockRailRenderer |
A registered dock rail renderer id. Core ships default; plugins register their own. |
windowReveal |
A registered window-reveal id — the transition that uncovers a window's content once it loads. Core ships twelve (sweep, rise, diagonal, iris, diamond, curtain, shutter, blinds, slats, mosaic, radar, obturator); none is always valid and means no transition. |
windowRevealDuration |
How long reveals run, in whole ms. Clamped to 80–4000. Omit it to leave the user's speed alone — recommending 0 is not a way to say "default". |
accent |
A registered accent-swatch id (OpenStation Preferences → Appearance). Core ships pulse, nebula, sirius, lagoon, wp-blue, indigo, teal, emerald, amber, rose; sites extend the list through openstation_accent_colors. |
accent is the one recommendation a theme cannot express any other
way, and most themes want it. The accent is a user setting written as
an inline style on the shell document, which outranks every stylesheet
— so a manifest can restyle the entire OS through tokens and still
leave WordPress blue on every focus ring, tab underline, sort arrow and
selection wash. Declaring --wp-admin-theme-color in tokens does not
help: it is dropped, because the accent belongs to the user rather than
to the theme. Recommending it is how a palette says "and this hue with
it", once, on first activation.
The built-in Desktop Mode (Legacy) theme is the worked example —
its whole recommendation block is { "accent": "wp-blue" }, which is
what makes the pre-brand chrome come back complete.
dockRailRenderer, windowReveal and accent are the fields validated in
two places: PHP checks the charset, and the shell checks — at apply
time — that something is actually registered under that id. Recommend
a renderer, reveal or swatch a site doesn't have and the key is
skipped; the rest of your recommendations still apply.
(windowReveal: "none" is exempt: it is the "no reveal" sentinel
rather than a registration.)
windowRevealDuration is the one numeric recommendation, and it is
clamped rather than dropped — a theme asking for something slower
than the shell will play is expressing "slow", and the nearest playable
duration is the honest reading of that.
Nothing else is reachable. The allow-list is presentation only, so a
manifest cannot flip a feature toggle, a capability-adjacent
preference, or another theme's activation. A site can widen the list
through openstation_desktop_theme_recommended_os_settings_schema
— { enum } for a closed set, { slug } for a registry id resolved
at apply time, { int => { min, max } } for a clamped number — and
even then the shell only writes a key that already exists and whose
current value has the same type as the one being recommended.
They pick your theme and the dock and layout move into the arrangement you designed. That movement is the whole feedback — the shell does not editorialize about it — and it does not happen again for that theme. Anything they change afterwards in Appearance is theirs and stays.
A map of slot => icon descriptor. Two descriptor shapes:
{ "type": "image", "path": "icons/close.svg" }
{ "type": "dashicon", "name": "dashicons-no-alt" }path must be a relative path inside your ZIP, with an image
extension. name must match ^dashicons-[a-z0-9-]+$.
| Group | Slots |
|---|---|
| Window controls |
WINDOW_CONTROL_MINIMIZE, WINDOW_CONTROL_MAXIMIZE, WINDOW_CONTROL_FULLSCREEN, WINDOW_CONTROL_FULLSCREEN_EXIT, WINDOW_CONTROL_CLOSE, WINDOW_CONTROL_MENU, WINDOW_CONTROL_RELOAD, WINDOW_CONTROL_DETACH
|
| System tiles |
OS_SETTINGS, RECYCLE_BIN, BUG_REPORT, EXIT_OPENSTATION, PWA_INSTALL
|
| Apps |
DEFAULT_APP_ICON, plus APP:<slug> for any individual dock tile, desktop icon, or native window id. These reach the dock and the desktop; the window title bar carries the status ring rather than an app icon, so it is not one of the surfaces they paint |
| Desktop files |
FOLDER, FILE_SHORTCUT, FILE_POST, FILE_ATTACHMENT, FILE_UPLOAD, FILE_USER, FILE_TERM, FILE_COMMENT, FILE_BOOKMARK, FILE_LINK, FILE_EMBED
|
| Recycle-bin row actions |
RECYCLE_RESTORE, RECYCLE_DELETE
|
APP:<slug> slugs are the tile ids the shell uses — APP:edit-php,
APP:upload-php, APP:my-plugin-dashboard. The quickest way to find
one is the browser console:
wp.os.getMenuItems().map( ( i ) => i.id );The single most useful line in a theme manifest:
"iconColor": "currentColor"It applies to every icon that doesn't set its own, and it does more than recolour — it changes how an image icon is painted:
color |
Image icons render as | Your artwork's colours |
|---|---|---|
| absent | plain <img>
|
kept as authored |
| set | a CSS mask filled with your colour | discarded — alpha only |
Which is why currentColor is the workhorse. Every surface in the
shell already sets a text colour that suits it: the dock dims its
glyphs and brightens them on hover, the title bar swaps on focus, file
tiles follow --os-tile-fg, a danger-hover goes red. A
masked icon filled with currentColor inherits all of that. One
monochrome silhouette set, drawn in any colour you like, reads
correctly everywhere.
Without it, a black-stroked iconset looks perfect in isolation and then disappears against your own dark dock — the single most common way a first theme goes wrong.
Per-slot overrides take a real colour, which is right when the colour is the meaning:
"iconColor": "currentColor",
"icons": {
"OS_SETTINGS": { "type": "image", "path": "icons/settings.svg" },
"EXIT_OPENSTATION": { "type": "image", "path": "icons/exit.svg",
"color": "#ff6b81" },
"BUG_REPORT": { "type": "image", "path": "icons/bug.svg",
"color": "#ffcf70" },
"APP:my-brand": { "type": "image", "path": "icons/brand.svg",
"color": "none" }
}"color": "none" is the opt-out: it keeps one multi-colour icon —
a brand mark, an app tile with real artwork — rendering as an <img>
inside an otherwise-tinted set, without giving up the manifest-wide
default.
Accepted values: currentColor, hex (3/4/6/8 digits), the functional
notations (rgb(), rgba(), hsl(), oklch(), …), a bare CSS
colour keyword, or none. Dashicons take the colour too — they are
font glyphs, so the tint is simply their color.
Two slot groups always mask, with or without a color:
WINDOW_CONTROL_* and the two RECYCLE_* row actions. They default
to currentColor so a themed close button still turns white on a
focused title bar and red on danger-hover. Naming an explicit colour
there is an opt-out of that state tinting — the glyph then holds one
colour throughout, which is occasionally what you want and usually
not.
Design consequence: if you are tinting, draw silhouettes. Only the alpha channel survives, so a stroke colour in the source SVG is irrelevant — pick whatever is easiest to see while you work.
Control buttons paint an image-type icon as a CSS mask tinted with
currentColor, not as an <img>. That is what keeps a themed close
button turning white on a focused title bar and red on danger-hover,
exactly like the built-in glyphs — an image would paint its own colours
and go deaf to the title bar's focused/unfocused state.
So: only the alpha channel of the source image is used. Design control glyphs as solid silhouettes. Colour in them is discarded.
The same applies to the two recycle-bin row-action slots. Those also
ignore dashicon-type descriptors entirely — <os-table> renders
into a shadow root the global Dashicons stylesheet can't reach, so a
dashicon would come out blank and the built-in SVG is used instead.
A map of slot => texture descriptor. Every slot is optional and individually droppable; a surface you don't mention keeps its colour.
Window chrome
| Slot | Type | Paints on |
|---|---|---|
TITLEBAR |
image |
Every window title bar |
TITLEBAR_FOCUSED |
image |
The focused window's title bar (falls back to TITLEBAR) |
WINDOW_FRAME |
border-image |
The window frame — replaces the 1px border |
WINDOW_FRAME_FOCUSED |
border-image |
The focused window's frame (falls back to WINDOW_FRAME) |
WINDOW_CORNER_NE / _NW / _SE / _SW
|
image |
Corner ornaments on the resize handles |
WINDOW_BODY |
image |
The window content area, behind native content |
TABBAR |
image |
The in-window submenu tab strip |
TITLEBAR_CONTROLS |
image |
The plate behind the window-control cluster |
TITLEBAR_BUTTON |
image |
The face of each individual control button |
Shell
| Slot | Type | Paints on |
|---|---|---|
DESKTOP |
image |
The wallpaper layer |
DOCK |
image |
The dock strip, layered over its background colour |
DOCK_ITEM |
image |
The face of a single dock tile |
ICON_TILE |
image |
The plate behind each desktop icon |
WIDGET |
image |
Desktop widget cards, over the frosted backdrop |
Component kit — these paint inside window bodies and in the
popovers that mount on <body>, so one slot reaches every instance
of that component anywhere in the OS.
| Slot | Type | Paints on |
|---|---|---|
MENU |
image |
<os-menu> and <os-context-menu> panels |
DIALOG |
image |
<os-modal> and <os-confirm-dialog> surfaces, and the command-palette / Site Assistant panel |
SCRIM |
image |
The backdrop behind a modal, and behind the command palette |
PANEL |
image |
<os-card>, <os-panel>, <os-flyout>
|
TOAST |
image |
<os-toast> notifications |
TABLE_HEADER |
image |
<os-table> header cells, sticky included |
BUTTON |
image |
<os-button> faces (except the link variant) |
Component textures tile across small surfaces, so subtle wins: a 2–4 px noise or hairline pattern reads as material, a 200 px illustration reads as a mistake.
By default the control cluster and every button face are
transparent, so a TITLEBAR texture runs edge to edge underneath
them and the glyphs float on your artwork. That is almost always what
a textured title bar wants, and it is why the default is transparent
rather than a plate.
If you want the opposite — controls in their own well, the way some desktop environments do it — the shape is yours to build:
| Token | Controls |
|---|---|
--os-titlebar-controls-bg |
Cluster background colour |
--os-titlebar-controls-radius |
Its corner radius |
--os-titlebar-controls-padding |
Inline padding (block padding stays 0 so the title-bar height can't shift) |
--os-titlebar-controls-gap |
Space between buttons |
--os-titlebar-meta-bg / -radius / -image
|
Same for the Screen Options / Help cluster; the radius falls through to the controls radius |
--os-ui-btn-bg |
Resting colour of one button face |
--os-ui-btn-radius |
Its corner radius |
--os-titlebar-divider |
The hairline between page chrome and window chrome. Set transparent to let your artwork carry the separation |
--os-titlebar-divider-unfocused |
Its unfocused counterpart |
The leading mark of the title bar is the status ring — the window's
activity phase, the one wp.os.fetch drives. It replaced the app icon
that used to sit there, which was a copy of the same window's dock
tile. Four states, and only success fills the ring.
| Token | Controls |
|---|---|
--os-titlebar-activity-idle-color |
The resting ring — white by default, and one value whether the window is focused or not |
--os-titlebar-activity-color |
The ring while a request is in flight |
--os-titlebar-activity-saved-color |
The fill when the request lands |
--os-titlebar-activity-failed-color |
The ring when it didn't. This one stays until the next request starts |
--os-titlebar-activity-size |
Ring diameter (default 16px) |
Retint them if your title-bar artwork would swallow one, but keep the outcomes distinguishable by more than hue — the built-in pair differs in fill and glyph as well as colour, which is what makes it readable for a user who can't separate the two.
"tokens": {
"--os-titlebar-controls-bg": "rgba( 10, 10, 26, 0.55 )",
"--os-titlebar-controls-radius": "8px",
"--os-titlebar-controls-padding": "4px",
"--os-ui-btn-bg": "rgba( 255, 255, 255, 0.06 )",
"--os-ui-btn-radius": "6px"
}Add TITLEBAR_CONTROLS or TITLEBAR_BUTTON for artwork instead of a
flat colour. Hover, focus and danger states override the button's
background colour only, so a face texture survives all of them. Every one of them layers over the surface's
background colour, which the palette tokens still control — a
translucent texture composes with the colour instead of replacing it.
{ "type": "image", "path": "textures/titlebar.png",
"repeat": "repeat-x", "size": "auto 100%", "position": "left center" }-
repeat— one ofrepeat,repeat-x,repeat-y,no-repeat,space,round. -
size—auto,cover,contain, or one-to-two components, eachautoor a number withpx/%/rem/em. -
position— one or two components: a keyword (left,right,top,bottom,center), a length, a percentage, or0. Negative offsets allowed.
Three combinations cover almost everything, and picking the wrong one is why a texture looks blurry or repetitive:
| Intent | size |
repeat |
Draw |
|---|---|---|---|
| Tile a detailed motif across any width |
auto or auto 100%
|
repeat / repeat-x
|
Seamless artwork at natural resolution |
| Stretch one image to fill the surface |
100% 100% or cover
|
no-repeat |
A gradient or a soft wash — detail smears |
| Place a fixed ornament | its pixel size | no-repeat |
Exactly the pixels you want, at 2× |
Tiling is what lets a texture be big and detailed. A window title
bar can be 400px or 2000px wide; stretching one image across both
destroys it. A seamless 256px strip tiled with repeat-x looks
identical at every width, at full resolution, for a few kilobytes.
Two rules make a strip seamless:
-
Match the edges. Whatever touches
x = 0must continue atx = width. For a wave, use a whole number of periods across the image; for scattered motifs, draw anything that crosses an edge twice, once on each side. -
Let one axis scale.
size: auto 100%scales the strip to the surface's height and keeps its aspect ratio, so the tile width follows automatically. Export at 2× the height you expect (a 72px strip for a 36px title bar) and it stays crisp on HiDPI.
position is what anchors the result. A repeating grid must be
positioned top left: with the default center, the lattice shifts
every time the window resizes and the grid appears to crawl. A
horizontal strip usually wants left center so its tiling origin is
the surface's leading edge rather than its midpoint.
The Neon Glass example theme
(docs/examples/register-desktop-theme.md)
shows both cases — a seamless 256×72 circuit-trace title bar that
tiles, and a 24px grid pinned to top left in the window body.
The four corner slots share one size token: whichever corner declares a
size first (in NE, NW, SE, SW order) sets it for all four.
Corner ornaments paint INSIDE the window's rounded box. The window
sets overflow: hidden — that is what clips iframe content to the
rounded corners — so anything hung off a corner is clipped by both the
window edge and the corner radius. Ornaments are therefore anchored
just inside the corner, clear of the arc.
Tune the distance with --os-window-corner-inset; it
defaults to the resize handle's overhang plus a share of
--os-window-radius, which is what keeps a large radius
from cutting into the artwork.
Top corners paint over the title bar. The resize handles sit at
z-index: 999 and the title bar at 21, so WINDOW_CORNER_NE and
_NW render above the chrome — NE over the close button, NW over
the status ring. They cannot be pushed underneath: the ornament is a
child of the z-999 handle, which is its own stacking context.
That makes the top two slots a different design problem from the bottom two. Diffuse, low-alpha light works — it reads as the title bar catching light and leaves every glyph legible. Hard edges do not: a saturated bracket up there lands straight through the close glyph.
Keep the falloff tight. A broad bloom is the subtler version of
the same mistake: it washes out whatever TITLEBAR texture you spent
the effort making seamless, precisely in the strip where the controls
live. Aim to be under ~3% alpha within 10px of the corner.
The Neon Glass example theme uses arcs and a bright node on the bottom
corners, and bloom only on the top.
If you want artwork that OVERHANGS the frame, use WINDOW_FRAME
instead: border-image paints in the border area, which overflow
does not clip, and its nine-slice corners are exactly the right tool
for a frame that extends past the window box.
{ "type": "border-image", "path": "textures/frame.png",
"slice": "24 fill", "width": "12px", "repeat": "round" }-
slice— one to four unitless numbers, optional trailingfill. -
width— one to four lengths (unitless allowed: multiples of the border width). -
repeat— one or two ofstretch,repeat,round,space.
-
DOCKpaints over--os-dock-bg, so a semi-transparent texture still gets the translucent wash underneath. -
DESKTOPpaints over the user's CSS wallpaper but under any canvas wallpaper. A theme that wants the user's wallpaper to stay visible should ship a texture with transparency; an opaque one takes the desk over. This is deliberate: a whole-OS reskin should be able to own the background, but a wallpaper the user actively chose and which animates should not be painted out. -
WINDOW_BODYshows behind native window content and behind an iframe whose page is transparent. A normalwp-adminpage inside an iframe paints its own opaque background over it. -
SCRIMandDIALOGare separate on purpose: the scrim is the dimmed backdrop, the dialog is the card on top of it.
The slot table above is data, not code. The compiler reads it and nothing else, which means a plugin can texture a surface OpenStation has never heard of without touching the framework.
Two steps. Register the slot with the custom property you want it written to:
add_filter( 'openstation_desktop_theme_texture_slots', function ( $slots ) {
$slots['ACME_SIDEBAR'] = array(
'type' => 'image', // or 'border-image'
'prop' => '--acme-sidebar-image', // the property base name
);
return $slots;
} );…then consume it in your own stylesheet, with the fallback that keeps an unthemed shell unchanged:
.acme-sidebar {
background-color: var( --acme-panel-bg, #fff );
background-image: var( --acme-sidebar-image, none );
background-repeat: var( --acme-sidebar-image-repeat, repeat );
background-size: var( --acme-sidebar-image-size, auto );
}A theme can now write:
"textures": {
"ACME_SIDEBAR": { "type": "image", "path": "textures/sidebar.png",
"repeat": "repeat-y" }
}| Key | Meaning |
|---|---|
type |
image or border-image. Decides which descriptor grammar the sanitizer applies and which properties get written. |
prop |
The custom-property base name. An image slot emits <prop>, <prop>-repeat, <prop>-size; a border-image slot emits <prop>-source, -slice, -width, -repeat. |
companions |
Set to false for a variant slot that should inherit another slot's repeat + size rather than declare its own — how TITLEBAR_FOCUSED works. |
sizeGroup |
A custom property shared by a family of slots that must render at one size. First slot to declare a size wins; the four window corners use this. |
A slot with no prop is accepted by the sanitizer and emits nothing.
That combination is a bug, not a feature — it exists only so a
malformed filter can't produce malformed CSS.
Slot names should be UPPER_SNAKE and namespaced by prefix
(ACME_SIDEBAR, not SIDEBAR) so two plugins can't collide.
The same trick works for icons via
openstation_desktop_theme_icon_slots, with one extra obligation:
the JS side resolves icons, so a new icon slot must also exist in
src/desktop-themes/slots.ts or the shell will never look it up.
Texture slots have no such twin — they are pure CSS.
Every token value is checked before it can reach the stylesheet:
- 1–256 characters.
- Characters limited to letters, digits, whitespace, and
# % . , ( ) / * + - _ ' ". That excludes;{}@\<>!and the backtick, which is what makes declaration escape, at-rule injection, and!importantoverrides impossible. - No CSS comment sequences (
/*,*/). - No
url(,image-set(,element(,attr(,var(, orexpression. External references are PHP's job — it generates theurl()s for your declared assets itself.var()is banned so a theme can't alias a property outside the exposed namespace. - Balanced parentheses.
Gradients, rgba(), calc(), and shorthand values all pass. If a
value of yours doesn't apply, run it past this list first.
Whatever your manifest doesn't say, the system default keeps saying. Concretely:
- A dropped token, icon, or texture entry leaves the built-in value in place. There is no "partially broken" state.
- Only structural fields (
manifestVersion,id,name) can fail the whole upload. Everything else drops the offending entry and installs the rest. - A slot you never mention is never consulted. With no theme active, icon resolution is a single null check — the shell pays nothing for this feature existing, no extra stylesheet, and no attribute on the shell root.
- If a user's selected theme is deleted, or the plugin registering it is deactivated, they degrade silently to the system default. No user meta is rewritten; the enqueue path existence-checks on every request.
The trap: a base colour does not cover its derived siblings. Read the sentence above literally — the system default keeps saying what it says. It does not stand aside for a related token you did name.
Several tokens are written in the stylesheets as a chain, so that a theme naming only the base still gets a coherent result:
background-color: var( --os-tabs-bg-unfocused, var( --os-tabs-bg, … ) );That inner fallback fires only when --os-tabs-bg-unfocused is
undeclared — and the palette declares it. Your theme compiles onto
body.os-desktop-theme-<slug>; a name you omit is not undeclared, it is
inherited from body.os-active. So the fallback never runs, and your
--os-tabs-bg is bypassed on exactly the state you did not name.
This is not hypothetical: it is why a light theme could paint the tab
strip #f6f7f7 and still watch it come back near-black the moment the
window lost focus.
So name both halves of a pair — when both halves are literals. The
families where this bites are the ones with a state or emphasis
modifier that the palette states outright: --os-tabs-bg /
-bg-unfocused, --os-titlebar-bg / -bg-focused, and the
--os-tabs-active-* set.
It does not apply to a sibling the palette computes — the focus
rings, the tab wash and bloom, the holo glows. Those read the accent,
so your --os-ui-accent already reaches them and naming them would
take them away from the accent picker. See
What it deliberately leaves out for
the literal-versus-derivation rule, which is the general form of both
halves of this.
If you fork Legacy you inherit a correct answer either way — every literal named, every derivation left alone — which is the main practical reason to start there rather than from an empty manifest.
Install: OpenStation Preferences → Themes → drop a .zip on the upload tile.
Requires manage_options by default (filterable via
openstation_desktop_theme_upload_capability).
Window controls. These are title-bar chrome, so they follow the title bar's own colours rather than the body palette, and each focus state is addressed separately.
Unfocused glyphs are derived for you: set
--os-titlebar-color and the shell mixes legible unfocused
controls out of it automatically. Override precisely with
--os-titlebar-btn-color, -color-hover, -bg-hover, and
-bg-active when you want exact control.
Focused glyphs cannot be derived — they sit on
--os-titlebar-bg-focused, which you may set to anything
from near-black to a pastel, and CSS has no contrast-safe function of
a background colour. So they stay white at 70% until you say
otherwise, through the mirror-image set:
| Token | Role |
|---|---|
--os-titlebar-btn-focused-color |
Glyph at rest |
--os-titlebar-btn-focused-color-hover |
Glyph on hover / press |
--os-titlebar-btn-focused-bg-hover |
Hover wash behind a control |
--os-titlebar-btn-focused-bg-active |
Pressed / active wash |
--os-titlebar-btn-focused-outline |
Keyboard focus ring |
"tokens": {
"--os-titlebar-bg-focused": "#ded9ff",
"--os-titlebar-color-focused": "#12102b",
"--os-titlebar-btn-focused-color": "rgba( 18, 16, 43, 0.7 )",
"--os-titlebar-btn-focused-color-hover": "#12102b",
"--os-titlebar-btn-focused-bg-hover": "rgba( 18, 16, 43, 0.12 )",
"--os-titlebar-btn-focused-bg-active": "rgba( 18, 16, 43, 0.18 )",
"--os-titlebar-btn-focused-outline": "rgba( 18, 16, 43, 0.65 )"
}A pale focused title bar is exactly the case to set them for: without
them the active window is the one window whose close button you cannot
see. The screen-meta buttons (Screen Options, Help) and the ⋯ menu
trigger sit in the same bar and read the same tokens, so one pass
covers every button in the title bar.
Close-button red is deliberately not in either set — it is semantic
signal, not chrome, and both states resolve it through --os-ui-danger.
Activate: every user picks their own theme on the same tab —
including users who cannot upload. The library is site-wide;
activation is per-user, stored as desktopTheme in the
desktop_mode_os_settings user meta.
The first card in the grid is OpenStation — the shell's own look,
stored as the empty string. It is not a theme in the registry (its
palette is assets/css/variables.css, not a manifest), but the picker
treats it as a peer of everything beside it: it carries the same
"Apply …'s recommended layout and effects" button, and what it
recommends is the accent its palette was drawn against (Pulse) and the
layout it was drawn for (unified, dock on the bottom edge).
The switch is live: no reload. The stylesheet swaps, the shell attribute and body class flip, and every themed icon repaints. On a fresh page load PHP stamps the attribute, prints the body class, and enqueues the stylesheet before the shell script runs, so there is no flash of the default palette.
If the theme ships
recommendedOsSettings, the user's first
activation of it also seeds those preferences — once — and the tab
grows an Apply <theme>'s recommended layout button for going
back to them later.
wp.os.desktopThemes.list(); // the library
wp.os.desktopThemes.getActive(); // slug, or null
wp.os.desktopThemes.resolveIcon( 'WINDOW_CONTROL_CLOSE' );
// Presentation only — does NOT persist. For a preview you'll revert:
wp.os.desktopThemes.setActive( 'acme-neon-glass' );
// Change it for real — persists AND applies:
wp.os.updateOsSettings( { desktopTheme: 'acme-neon-glass' } );
// What the theme recommends, and re-applying it (persists):
wp.os.desktopThemes.list()[ 0 ].recommendedOsSettings;
wp.os.desktopThemes.applyRecommendedOsSettings();See JavaScript reference for the event and filter surface.
A plugin can ship a theme without an upload. Same sanitizer, same compiler, same constraints — the only difference is that assets are absolute URLs you already serve instead of files in a ZIP.
add_action( 'init', function () {
openstation_register_desktop_theme( 'acme/neon-glass', array(
'name' => __( 'Neon Glass', 'acme' ),
'version' => '1.0.0',
'preview' => plugins_url( 'theme/preview.png', __FILE__ ),
'tokens' => array(
'--os-titlebar-bg-focused' => '#241f4d',
'--os-font' => '"Neon Grotesk", sans-serif',
),
'fonts' => array(
array(
'family' => 'Neon Grotesk',
'weight' => '400',
'display' => 'swap',
'src' => array( plugins_url( 'theme/neon.woff2', __FILE__ ) ),
),
),
'icons' => array(
'WINDOW_CONTROL_CLOSE' => array(
'type' => 'image',
'path' => plugins_url( 'theme/close.svg', __FILE__ ),
),
),
'textures' => array(
'TITLEBAR' => array(
'type' => 'image',
'path' => plugins_url( 'theme/titlebar.png', __FILE__ ),
'repeat' => 'repeat-x',
),
),
'recommendedOsSettings' => array(
'dockSize' => 'large',
'desktopLayout' => 'unified',
),
) );
} );Code themes have no compiled file, so the payload carries the compiled
stylesheet as text and PHP prints it via wp_add_inline_style().
Uploaded themes win on a slug collision — a site admin who installed a
theme by hand outranks a plugin that later claims the same slug.
How the library reaches the shell. The boot payload
(serverDesktopThemes) ships every entry slimmed: no cssText,
no tokens, cssDeferred: true. Nothing reads either at boot — the
active theme's stylesheet is server-delivered (link for uploads,
inline <style> for code themes), and an inactive theme's CSS
matters only at the moment the user picks it. That moment fetches the
full entries from GET desktop-mode/v1/desktop-themes — the same
builder and the same openstation_desktop_themes filter as the
payload, so it is not a second source of truth — via
ensureFullDesktopThemes(), and the apply path does this
automatically. The read gate is the shell's own (a desktop-enabled
user), not the manage capability: the route exposes exactly what the
boot payload used to. Bridge and probe payloads (live plugin
activation) keep full entries.
See examples/register-desktop-theme.md
for a complete plugin.
-
<os-icon>content icons. Icons inside window bodies (tables, toolbars, empty states) are not themable. Only chrome is. -
Letter badges. The generated initial-letter tiles for items with no icon stay generated; retint them with the
--os-tile-*tokens instead. -
Art-direction colour and type. Note paper, game palettes, and graph node hues — see "What stays fixed" above.
-
Layout. A theme changes how things look, not where they are. No spacing scale, no dock geometry, no window metrics beyond the radius and title-bar height the tokens already expose.
recommendedOsSettingsis not an exception to this: it seeds the user's own layout preferences once, as a suggestion they own from that moment on, rather than giving the theme any say in how the shell is arranged. -
Author CSS and JS. Still never, and this is the line that makes everything else safe.
@font-faceis generated for you from a constrained descriptor; it is not an opening. -
Inside iframe windows. A theme styles the shell and native windows. The
wp-adminpage inside an iframe is a separate document that the theme stylesheet does not reach.This is a guarantee, not just a limitation. The shell's own palette is scoped to
body.os-activeprecisely so it cannot leak in either:variables.cssis a dependency ofchromeless.cssand therefore loads inside every iframe, and a palette on:rootwould repaint WordPress's own UI in there —--wp-admin-theme-coloralone would move Core's primary buttons, links and focus rings on every admin screen. Admin pages render in their own colours, in or out of a window. -
Uninstall cleanup. The plugin has no
uninstall.php; thedesktop_mode_desktop_themesoption and the uploads directory survive plugin deletion today.
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