-
Notifications
You must be signed in to change notification settings - Fork 41
example window reveal
Status: Experimental
A window reveal is the transition that uncovers a window's content
once it has finished loading. OpenStation paints an opaque surface
over the window body for the duration of the load, then animates that
surface's clip-path away. The user picks one in OpenStation Preferences →
Effects → "Window reveal", and sets a global speed next to it. The
twelve built-ins — sweep, rise, diagonal, iris, diamond,
curtain, shutter, blinds, slats, mosaic, radar,
obturator — are registered through exactly the API below.
The surface is a sibling of the window's <iframe> in the shell's own
DOM. Nothing is injected into the page being revealed, and the content
is never clipped itself — so a reveal cannot swallow a click, break a
plugin's layout, or interfere with whatever is loading.
wp.os.ready( () => {
wp.os.registerWindowReveal( {
id: 'acme/rise',
label: 'Rise',
// The surface starts covering everything…
from: 'inset( 0% 0% 0% 0% )',
// …and ends inset 100% from the bottom: nothing left.
to: 'inset( 0% 0% 100% 0% )',
owner: 'my-plugin-reveals',
} );
} );Enqueue that as a normal admin script and the reveal appears in the selector on the next load.
owner should be your script handle. Today it is only a tag: the
live-unregister sweep on plugin deactivation is not wired for reveals
yet (same known gap as palettes), so a deactivated plugin's reveal
stays in the selector until the next reload. Set it anyway — the moment
the sweep lands, your reveal starts being cleaned up live, with no def
change on your side.
CSS animates a clip-path only between values that use the same
shape function. For polygon() it additionally requires the same
vertex count and the same fill rule.
A pair that breaks those rules is not an error the browser reports. It simply jumps from one value to the other at the halfway mark — which, on a window that has just finished loading, reads as a flicker rather than as a broken animation, and is easy to ship without noticing.
registerWindowReveal() throws when the two endpoints use different
shape functions:
// Throws: `inset()` and `circle()` cannot interpolate.
wp.os.registerWindowReveal( {
id: 'acme/broken',
label: 'Broken',
from: 'inset( 0% )',
to: 'circle( 0% )',
} );Vertex counts it cannot check for you, because both values are individually valid. Build both endpoints from one function so the ring structure cannot drift:
/**
* Surface with a growing rectangular hole in the middle. Vertex count
* is fixed by the shape, so any two calls interpolate.
*
* @param {number} h Hole half-height, 0 (covered) to 52 (uncovered).
*/
function band( h ) {
return `polygon(
0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
-2% ${ 50 - h }%, -2% ${ 50 + h }%,
102% ${ 50 + h }%, 102% ${ 50 - h }%,
-2% ${ 50 - h }%, 0% 0%
)`.replace( /\s+/g, ' ' );
}
wp.os.registerWindowReveal( {
id: 'acme/band',
label: 'Band',
from: band( 0 ),
to: band( 52 ),
owner: 'my-plugin-reveals',
} );Two things about that polygon are worth copying:
-
The hole is wound backwards relative to the outer ring, and the
path returns to
0% 0%before and after it. That is what punches a hole under the defaultnonzerofill rule — noevenoddkeyword, which would otherwise have to match on both endpoints too. -
The hole overshoots the box (
-2%/102%) on the axis it spans fully, so no hairline of surface survives at fractional window sizes.
The shipped reveals are built the same way — see
src/reveals/shapes.ts for
irisSurface(), curtainSurface() and blindsSurface(), and
tests/vitest/window-reveal-shapes.test.ts for the invariant asserted
as a test.
A reveal can be drawn as two layers: the surface you described, and
behind it an edge painted in --os-window-reveal-edge.
The edge runs your same from → to keyframes over a slightly
longer duration, so it is permanently a little less far along and peeks
out past the surface as a band hugging the clip boundary.
You never describe an edge shape. A time lag follows any geometry, so
your reveal gets a correctly-shaped edge for free — the same mechanism
that gives blinds six thin lines, iris an opening ring, and radar
a rotating spoke.
It is off unless a theme turns it on. The colour token ships as
transparent, and while it computes that way the shell drops the layer
rather than animating something invisible — so an edge costs nothing
until someone asks for one. Turning it on is pure CSS:
.os-shell {
--os-window-reveal-edge: #7c5cff;
/* Fraction of the reveal's travel — holds its apparent width at
any speed. `70ms` would pin it to time instead. */
--os-window-reveal-edge-thickness: 12%;
}Thickness declared that way overrides your edgeLag, because it
belongs to the theme's look rather than to any one reveal. Your
edgeLag is what applies when no theme has an opinion:
wp.os.registerWindowReveal( {
id: 'acme/edgeless',
label: 'Edgeless',
from: band( 0 ),
to: band( 52 ),
edgeLag: 0, // this reveal never gets an edge, themed or not
} );edgeLag is in ms and defaults to 70 (clamped to 0–600). A longer
lag means a wider band.
wp.os.registerWindowReveal( {
id: 'acme/slow-iris',
label: 'Slow iris',
description: 'Opens out from the centre, unhurried.',
from: band( 0 ),
to: band( 52 ),
duration: 900, // ms; clamped to 80–4000
easing: 'cubic-bezier( 0.2, 0, 0, 1 )',
owner: 'my-plugin-reveals',
} );description shows under the selector while your reveal is the active
one — a good place for a one-line description of the motion.
Your duration is the lowest-priority of three, because the user's
own preference has to win:
- The user's OS-Settings speed — Effects → Reveal speed. When they pick anything other than "Default (per reveal)", it overrides every reveal, including yours.
-
--os-window-reveal-duration— a desktop theme's house pace. Undeclared by default; accepts620ms,0.62s, or a bare620. - Your def's
duration.
Whichever wins, edgeLag is scaled by the same ratio, so your edge
band keeps its apparent width at any speed — band width is a fraction
of the travel, not a span of time. Nothing to handle on your side.
Three theme tokens, all overridable from a desktop theme's tokens
block or any stylesheet:
| Token | Role | Default |
|---|---|---|
--os-window-reveal-surface |
The covering surface | white |
--os-window-reveal-edge |
The trailing edge band |
transparent — no edge |
--os-window-reveal-edge-thickness |
Band width: %/fraction of travel, or a time |
undeclared — the def's edgeLag
|
--os-window-reveal-duration |
House pace for every reveal | undeclared — the def's duration
|
.os-shell {
--os-window-reveal-surface: linear-gradient(
135deg,
#12122a,
#241f4d
);
--os-window-reveal-edge: #7c5cff;
--os-window-reveal-edge-thickness: 12%;
--os-window-reveal-duration: 620ms;
}Because the animation clips the layers rather than fading them, a
gradient or image works as well as a flat colour. Setting either to
transparent turns that layer off — the shell drops it instead of
animating something invisible.
One clip-path is one region, so anything it leaves uncovered is
uncovered. If your effect depends on pieces overlapping — a
mechanism rather than a shape — supply layers instead of from/to.
The uncovered area then becomes whatever all the layers leave
uncovered:
wp.os.registerWindowReveal( {
id: 'acme/split-doors',
label: 'Split doors',
edgeLag: 0,
layers: [
{ from: 'inset( 0% 50% 0% 0% )', to: 'inset( 0% 100% 0% 0% )', color: '#3a3a47' },
{ from: 'inset( 0% 0% 0% 50% )', to: 'inset( 0% 0% 0% 100% )', color: '#4a4a59' },
],
} );Each layer keeps the same interpolation contract and shares the reveal's duration and easing.
Give neighbouring layers different colors. It is the only thing
that makes an overlap visible. Layers of one colour composite into a
single silhouette however you shape them — the part on top is
indistinguishable from the part beneath, so the lying-across that makes
a mechanism a mechanism never renders. With different tones, every
overlap draws itself: the upper layer's tone wins, and its boundary
across the lower one is the seam.
Don't reach for the trailing edge to do this. Every edge layer paints
behind every surface layer, so an edge can only ever show
union( edges ) − union( surfaces ) — one band around the uncovered
area, never per-part seams. Set edgeLag: 0 on a multi-layer reveal.
Some effects a stack of clipped boxes simply cannot express. render
hands you the DOM instead:
wp.os.registerWindowReveal( {
id: 'acme/iris',
label: 'Iris',
edgeLag: 0, // a rendered reveal has no trailing-edge layer
render: () => {
const element = buildMySvg();
return {
element,
play: ( { duration, easing, delay } ) =>
bladesOf( element ).map( ( blade ) =>
blade.animate(
[ { transform: 'rotate(0deg)' }, { transform: 'rotate(48deg)' } ],
{ duration, easing, delay, fill: 'both' },
),
),
};
},
} );The shell still owns the timing — when it plays, how long it runs, the
user's speed setting, the spinner hand-off, reduced-motion, teardown.
You own the element and the animations, and return every Animation
so the shell can wait on them and cancel them if the window reloads
mid-reveal.
The built-in obturator is why this exists. A lens iris has a
cyclic overlap — every leaf over the next, and the last back under
the first. That is a circular dependency; paint order is a linear one,
so no stack of layers can represent it. Built from layers, the last one
has nothing drawn over it and keeps a visibly disproportionate share of
the area, which reads as one flat region exactly where a seam belongs.
As SVG it dissolves: six equilateral wedges tile a hexagon over the
window and each slides tangentially, under a <mask> built from the
same paths. Nothing restacks — every frame is one translate per wedge
plus mask compositing. See
src/reveals/obturator.ts.
A def can carry its own surface paint with surfaceColor, overriding
the token:
wp.os.registerWindowReveal( {
id: 'acme/noir',
label: 'Noir',
from: band( 0 ),
to: band( 52 ),
surfaceColor: '#0b0b0e',
} );Almost no reveal should. A reveal is normally a shape, and the
site decides what colour that shape is; hard-coding paint takes that
away from every theme your plugin will ever run under. The one shipped
exception is obturator (Camera shutter), whose near-black blades are
what make it a camera shutter rather than a hexagon. Reach for it only
when the same is true of yours.
edgeColor works the same way, and a multi-layer reveal usually needs
it: set it darker than your surface, or the overlapping parts read
as a single mass with no visible seams between them.
A desktop theme can also recommend a reveal and a speed through its
manifest, applied once on the user's first activation — see
docs/desktop-themes.md.
- The reveal always plays. The loading spinner has a 120 ms entry delay, so fast loads never paint one — the reveal still runs, just without waiting for a fade-out that never happened.
- It replays on every load edge, matching the spinner: reload, in-window navigation, tab switch. Not only on first open.
-
prefers-reduced-motionis honoured — the content is uncovered directly, with no animation. - A window mid-load keeps the reveal that armed it. If the user switches reveals while a window is still loading, that window finishes with the pair it started with rather than animating between two unrelated shapes.
- Teardown waits for the edge, which by design lands after the surface — so the band is never yanked off screen mid-travel. With no edge (the default) it waits for the surface instead.
wp.os.listWindowReveals(); // every registered reveal
wp.os.unregisterWindowReveal( 'acme/rise' );
wp.os.getOsSettings().windowReveal; // the user's pick, or 'none'
wp.os.getOsSettings().windowRevealDuration; // ms, or 0 for per-revealThe raw os.window-reveals JS filter receives the registry
array on every read — use it to reorder, remove, or conditionally swap
reveals:
wp.hooks.addFilter(
'os.window-reveals',
'my-plugin/only-calm-reveals',
( reveals ) => reveals.filter( ( r ) => r.id !== 'blinds' ),
);Registration is JS-only. There is no
openstation_register_window_reveal_script() PHP companion yet, so a
reveal shipped by a plugin the user activates mid-session shows up in
the selector only after a page reload. Reveals from plugins that were
already active work normally. The same gap applies to palettes.
-
docs/javascript-reference.md—registerWindowRevealreference and the fullWindowRevealDeftable -
docs/examples/custom-unfocus-effect.md— the sibling registry, for unfocused windows -
docs/desktop-themes.md— theming the reveal surface
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