true );
while ( ! empty( $queue ) ) {
$file = array_pop( $queue );
$css = (string) file_get_contents( $file );
if ( ! preg_match_all( '/@import\s+url\(\s*["\']?([^"\')\s]+)["\']?\s*\)/i', $css, $matches ) ) {
continue;
}
foreach ( $matches[1] as $import ) {
$path = dirname( $file ) . '/' . $import;
if ( ! file_exists( $path ) || isset( $seen[ $path ] ) ) {
continue;
}
$seen[ $path ] = true;
$max = max( $max, (int) filemtime( $path ) );
$queue[] = $path;
}
}
return (string) $max;
}
/**
* Registers the OpenStation CSS and JS handles.
*/
function openstation_register_assets() {
$version = OPENSTATION_VERSION;
$suffix = openstation_asset_suffix();
// `filemtime`-stamped version for built bundles. The plugin-wide
// `OPENSTATION_VERSION` is bumped per release, but the bundles
// iterate faster — without a per-file mtime stamp, two clients
// loading the same `?ver=…` URL can be served different bytes
// (whichever build was on disk at upload time). Stamping with the
// file's modification time guarantees the URL changes whenever the
// file does, so "is my fix deployed?" is answerable from the
// network tab. Falls back to `$version` when the file is missing
// (test envs that import this file before the build runs).
$built_version = static function ( $relative ) use ( $version ) {
$path = OPENSTATION_DIR . $relative;
return file_exists( $path ) ? (string) filemtime( $path ) : $version;
};
// Styles.
// `filemtime`-stamped, NOT the plugin-wide `$version`. This file
// is the token catalogue every other sheet resolves `var()`s
// against, and it changes whenever the palette does — which is a
// lot more often than the plugin version is bumped. With a static
// stamp the browser holds the old palette until a hard reload,
// and the symptom is maddening: a themed shell where some
// surfaces update and others don't.
wp_register_style(
'os-variables',
OPENSTATION_URL . 'assets/css/variables.css',
array(),
$built_version( 'assets/css/variables.css' )
);
// `filemtime`-stamped so the `` URL matches the
// `` hint emitted by
// `openstation_print_preload_hints()` (which stamps with filemtime).
// Registering this with the plain `$version` instead produced two
// different `?ver=` query strings for the same file, so the browser
// never matched the preload to the stylesheet and logged "preloaded
// but not used within a few seconds from the window's load event".
wp_register_style(
'openstation',
OPENSTATION_URL . 'assets/css/desktop.css',
array( 'os-variables' ),
$built_version( 'assets/css/desktop.css' )
);
/*
* Window styles — one handle per sheet, chained by dependency.
*
* These used to be `@import url( … )`ed from `windows.css` under
* the single `os-windows` handle. That was a standing
* cache bug: an `@import` URL carries no `?ver=`, so a changed
* sub-sheet had no URL for the browser to invalidate. Stamping the
* PARENT with the subtree's max mtime (which is what
* `openstation_css_subtree_version()` was for) made the browser
* re-fetch `windows.css` and then request each sub-sheet at an
* unchanged URL — free to be served from its heuristic cache. The
* result was edits not landing until a hard refresh, and rules
* being relocated into `windows.css` purely to dodge it.
*
* Now every sheet is separately registered with its own
* `filemtime` stamp, so each has a real cache key.
*
* THE DEPENDENCY CHAIN IS LOAD-BEARING. WordPress prints
* dependencies before dependents, so chaining each sheet to the
* previous one reproduces the order the `@import` block had, and
* `os-windows` (which depends on the last link) still
* prints after them and still wins ties:
*
* window-chrome → window-states → effects → window-links
* → windows
*
* `window-overview` and `os-settings` are deliberately NOT in this
* chain — they are registered below, after `os-windows`,
* so they can load deferred. Adding a sheet here means splicing it
* into the chain, not appending an unrelated dependency: order is
* the contract.
*/
$window_sheets = array(
'os-window-chrome' => 'assets/css/window-chrome.css',
'os-window-states' => 'assets/css/window-states.css',
'os-effects' => 'assets/css/effects.css',
'os-window-links' => 'assets/css/window-links.css',
);
$previous = array( 'os-variables', 'dashicons' );
foreach ( $window_sheets as $handle => $relative ) {
wp_register_style(
$handle,
OPENSTATION_URL . $relative,
$previous,
$built_version( $relative )
);
$previous = array( $handle );
}
// Entry point. Depends on the tail of the chain above, so
// enqueuing this one handle still pulls in every critical window
// sheet — the behaviour callers had when they were `@import`s.
wp_register_style(
'os-windows',
OPENSTATION_URL . 'assets/css/windows.css',
$previous,
$built_version( 'assets/css/windows.css' )
);
// These two load DEFERRED (see `openstation_defer_non_critical_styles()`):
// the UI they style — the OS Settings panel and the window
// overview — is lazy-loaded JS that can never be on screen at
// first paint, so ~47 KB of CSS has no business blocking render.
// They depend on `os-windows` so they print after it,
// preserving the cascade position they had as `@import`s.
wp_register_style(
'os-window-overview',
OPENSTATION_URL . 'assets/css/window-overview.css',
array( 'os-windows' ),
$built_version( 'assets/css/window-overview.css' )
);
wp_register_style(
'os-settings',
OPENSTATION_URL . 'assets/css/os-settings.css',
array( 'os-windows' ),
$built_version( 'assets/css/os-settings.css' )
);
// Solo mode — one window, no desk around it. Loads last so it can
// hide surfaces the sheets above declared, and only enqueues on a
// solo request, so a normal shell never pays for it.
wp_register_style(
'os-solo',
OPENSTATION_URL . 'assets/css/solo.css',
array( 'os-windows' ),
$built_version( 'assets/css/solo.css' )
);
wp_register_style(
'os-dock',
OPENSTATION_URL . 'assets/css/dock.css',
array( 'os-variables', 'dashicons' ),
$built_version( 'assets/css/dock.css' )
);
wp_register_style(
'os-dock-peek',
OPENSTATION_URL . 'assets/css/dock-peek.css',
array( 'os-dock' ),
$built_version( 'assets/css/dock-peek.css' )
);
// The notch — the shell's top-centre voice and the site
// assistant's front door. Scoped to `.os-notch`.
wp_register_style(
'os-notch',
OPENSTATION_URL . 'assets/css/notch.css',
array( 'os-variables' ),
$built_version( 'assets/css/notch.css' )
);
// Keyboard-shortcuts window. Scoped to `.os-shortcuts`, so it is
// inert until the System menu opens the window, and unconditional
// for the same reason the layout sheet is: the window can be
// opened at any moment and a deferred sheet would paint it raw.
wp_register_style(
'os-shortcuts',
OPENSTATION_URL . 'assets/css/shortcuts.css',
array( 'os-variables' ),
$built_version( 'assets/css/shortcuts.css' )
);
// The OpenStation desktop layout — the core/plugin seam on the rail
// plus the constellation hover-submenu flyout. Both surfaces are
// scoped (`[data-os-layout="openstation"]` / `.os-constellation`),
// so the sheet is inert in every other layout and is loaded
// unconditionally rather than gated on the user's current pick:
// the layout is switchable live from OpenStation Preferences, and a
// conditional enqueue would leave the first switch unstyled.
wp_register_style(
'os-openstation-layout',
OPENSTATION_URL . 'assets/css/openstation-layout.css',
array( 'os-dock' ),
$built_version( 'assets/css/openstation-layout.css' )
);
// `filemtime`-stamped — the chromeless overrides iterate faster
// than the plugin-wide version bumps (per-page compat shims and
// page-title-action exceptions land in patches), and a stale
// cached copy means the user sees yesterday's rules. Without the
// stamp, the browser keeps `?ver=0.8.1` valid for the whole
// release cycle.
wp_register_style(
'os-chromeless',
OPENSTATION_URL . 'assets/css/chromeless.css',
array( 'openstation' ),
$built_version( 'assets/css/chromeless.css' )
);
// `filemtime`-stamped — the AI assistant surface (error states,
// inline affordances) iterates faster than plugin version bumps.
// Without an mtime stamp the browser keeps `?ver=`
// valid for the whole release cycle and the user keeps seeing
// yesterday's CSS even after a hard reload.
wp_register_style(
'desktop-mode-ai-assistant',
OPENSTATION_URL . 'assets/css/ai-assistant.css',
array( 'os-variables' ),
$built_version( 'assets/css/ai-assistant.css' )
);
wp_register_style(
'desktop-mode-bug-report',
OPENSTATION_URL . 'assets/css/bug-report.css',
array( 'os-variables' ),
$built_version( 'assets/css/bug-report.css' )
);
// `filemtime` instead of the plugin-wide `$version` for the
// recycle-bin CSS — this file iterates faster than the bundle
// and we never want a stale CSS cache to mask a real fix.
$recycle_bin_css = OPENSTATION_DIR . 'assets/css/recycle-bin.css';
wp_register_style(
'desktop-mode-recycle-bin',
OPENSTATION_URL . 'assets/css/recycle-bin.css',
array( 'os-variables', 'dashicons' ),
file_exists( $recycle_bin_css ) ? (string) filemtime( $recycle_bin_css ) : $version
);
// `filemtime` for the native Posts window CSS — same rationale as
// the recycle-bin CSS: bundle iterates faster than the plugin
// version and stale caches are worse than the cost of a 304.
$posts_window_css = OPENSTATION_DIR . 'assets/css/posts-window.css';
wp_register_style(
'os-posts-window',
OPENSTATION_URL . 'assets/css/posts-window.css',
array( 'os-variables', 'dashicons' ),
file_exists( $posts_window_css ) ? (string) filemtime( $posts_window_css ) : $version
);
// Native Plugins window CSS — same `filemtime`-cache-bust posture
// as the Posts/Recycle Bin styles.
$plugins_window_css = OPENSTATION_DIR . 'assets/css/plugins-window.css';
wp_register_style(
'os-plugins-window',
OPENSTATION_URL . 'assets/css/plugins-window.css',
array( 'os-variables', 'dashicons' ),
file_exists( $plugins_window_css ) ? (string) filemtime( $plugins_window_css ) : $version
);
// Native Comments window CSS — same `filemtime` posture.
$comments_window_css = OPENSTATION_DIR . 'assets/css/comments-window.css';
wp_register_style(
'os-comments-window',
OPENSTATION_URL . 'assets/css/comments-window.css',
array( 'os-variables', 'dashicons' ),
file_exists( $comments_window_css ) ? (string) filemtime( $comments_window_css ) : $version
);
// Files-on-the-Desktop tile + layer styles. `filemtime` for the
// same reason as the recycle-bin / posts-window CSS: this file
// iterates faster than the plugin version, and a stale cache
// would mask a real fix.
$desktop_files_css = OPENSTATION_DIR . 'assets/css/desktop-files.css';
wp_register_style(
'os-files',
OPENSTATION_URL . 'assets/css/desktop-files.css',
array( 'os-variables', 'dashicons' ),
file_exists( $desktop_files_css ) ? (string) filemtime( $desktop_files_css ) : $version
);
// Games hub window (launcher grid, scoreboard, challenges) +
// per-game styles. Same `filemtime` cache-bust posture as the
// other fast-iterating feature stylesheets.
$games_css = OPENSTATION_DIR . 'assets/css/games.css';
wp_register_style(
'desktop-mode-games',
OPENSTATION_URL . 'assets/css/games.css',
array( 'os-variables', 'dashicons' ),
file_exists( $games_css ) ? (string) filemtime( $games_css ) : $version
);
$game_inkfall_css = OPENSTATION_DIR . 'assets/css/game-inkfall.css';
wp_register_style(
'os-game-inkfall',
OPENSTATION_URL . 'assets/css/game-inkfall.css',
array( 'os-variables' ),
file_exists( $game_inkfall_css ) ? (string) filemtime( $game_inkfall_css ) : $version
);
$game_alphabet_soup_css = OPENSTATION_DIR . 'assets/css/game-alphabet-soup.css';
wp_register_style(
'os-game-alphabet-soup',
OPENSTATION_URL . 'assets/css/game-alphabet-soup.css',
array( 'os-variables' ),
file_exists( $game_alphabet_soup_css ) ? (string) filemtime( $game_alphabet_soup_css ) : $version
);
// Pinned-notes layer styles (paper, pushpin, pastel tokens, pin
// animations). Same `filemtime` cache-bust posture as the other
// fast-iterating feature stylesheets above. Depends on `os-files`
// because a pinned note dresses the canonical `.os-file-tile`
// chrome — anything that restyles a tile has to print after the
// file that declares one.
$notes_css = OPENSTATION_DIR . 'assets/css/notes.css';
wp_register_style(
'os-notes',
OPENSTATION_URL . 'assets/css/notes.css',
array( 'os-variables', 'dashicons', 'os-files' ),
file_exists( $notes_css ) ? (string) filemtime( $notes_css ) : $version
);
// Announcement-dialog styles (scrim, hero card, buttons). Registered
// always, enqueued only for a user who is actually owed the
// announcement — see the gate in `openstation_enqueue_assets()`. It
// is not lazy-loaded on top of that: on the one boot where it does
// go out, the dialog opens from the main bundle a second later, and
// a fetch racing that would show an unstyled card.
$announce_css = OPENSTATION_DIR . 'assets/css/announce.css';
wp_register_style(
'os-announce',
OPENSTATION_URL . 'assets/css/announce.css',
array( 'os-variables' ),
file_exists( $announce_css ) ? (string) filemtime( $announce_css ) : $version
);
// Scripts.
//
// `wp-hooks` — the shell exposes a WordPress-style filter/action
// API (`window.wp.hooks`) to third-party plugins.
// `wp-i18n` — the TS `__()` / `_x()` / `sprintf()` wrappers in
// `src/i18n.ts` delegate to `window.wp.i18n` for translation
// lookups. Both handles are core-shipped but only pre-enqueued
// when Gutenberg-adjacent deps pull them in, so we list them
// explicitly to guarantee load order.
wp_register_script(
'openstation',
OPENSTATION_URL . 'assets/js/desktop' . $suffix . '.js',
// `heartbeat` + `jquery` — the recycle-bin badge module
// (loaded as part of this bundle) opts into the WordPress
// Heartbeat API so the count tile / desktop-icon badge
// stays in sync even when the bin window is closed.
array( 'wp-hooks', 'wp-i18n', 'heartbeat', 'jquery' ),
$built_version( 'assets/js/desktop' . $suffix . '.js' ),
// Footer + defer: the shell boots on DOMContentLoaded anyway,
// so deferring frees the parser instead of blocking at the
// footer print point. Both inline payloads attached to this
// handle (`__openStationMenuCommands`, the jazz-quote version
// stamp) are `'before'`-position, which WP keeps as blocking
// inline ahead of a deferred tag — order is preserved. On WP
// < 6.3 the array collapses to a truthy `$in_footer`, same as
// before.
array(
'in_footer' => true,
'strategy' => 'defer',
)
);
// `os-iframe-bridge` — opt-in iframe-side bridge that
// provides `wp.os.iframe.publish/subscribe/onConnection/
// requestConnection` to any same-origin iframe that enqueues it.
// Same code is also injected inline by the chromeless bridge
// (so chromeless wp-admin pages don't need a separate enqueue)
// and auto-injected when a native window opts in via
// `iframeContent: { bridge: true }`. Plugins targeting their
// own iframe pages just enqueue this handle.
wp_register_script(
'os-iframe-bridge',
OPENSTATION_URL . 'assets/js/iframe-bridge' . $suffix . '.js',
array(),
$built_version( 'assets/js/iframe-bridge' . $suffix . '.js' ),
true
);
// `os-chromeless-bridge` — the iframe side of every window,
// enqueued on `admin_footer` by
// `openstation_chromeless_bridge_script()` with its per-request
// data attached as a `before` inline block. Registered here (not
// there) so the handle exists before the enqueue and so the asset
// guard's OPENSTATION_URL snapshot protects it like every other
// bundle of ours.
wp_register_script(
'os-chromeless-bridge',
OPENSTATION_URL . 'assets/js/chromeless-bridge' . $suffix . '.js',
array(),
$built_version( 'assets/js/chromeless-bridge' . $suffix . '.js' ),
true
);
// `os-gutenberg-drop-receiver` — iframe-side bundle
// enqueued only on the Block Editor screens (`post.php` /
// `post-new.php`) for openstation users. Listens for
// `os-drop` postMessages from the parent shell (see
// `src/drag/iframe-drop-targets.ts`) and inserts the matching
// Gutenberg block via `wp.data.dispatch('core/block-editor')`.
// Depends on `wp-blocks` + `wp-data` so the editor stores are
// guaranteed enqueued before the receiver runs its first message
// handler.
wp_register_script(
'os-gutenberg-drop-receiver',
OPENSTATION_URL . 'assets/js/gutenberg-drop-receiver' . $suffix . '.js',
array( 'wp-blocks', 'wp-data' ),
$built_version( 'assets/js/gutenberg-drop-receiver' . $suffix . '.js' ),
true
);
// `desktop-mode-recycle-bin` — small bundle for the Recycle Bin
// native window. Lazy-loaded by the native-window sync the first
// time the bin opens; registers a render callback on
// `window.openStationNativeWindows['desktop-mode-recycle-bin']`.
$recycle_bin_js = OPENSTATION_DIR . 'assets/js/recycle-bin' . $suffix . '.js';
wp_register_script(
'desktop-mode-recycle-bin',
OPENSTATION_URL . 'assets/js/recycle-bin' . $suffix . '.js',
// `heartbeat` + `jquery` — the bin opts in to the WordPress
// Heartbeat API while its window is open as the catch-all
// real-time channel for deletes that don't render an admin
// footer (REST/AJAX/other tabs/WP-CLI). See
// `src/recycle-bin/realtime.ts` for the subscriber.
array( 'wp-i18n', 'heartbeat', 'jquery' ),
file_exists( $recycle_bin_js ) ? (string) filemtime( $recycle_bin_js ) : $version,
true
);
wp_set_script_translations(
'desktop-mode-recycle-bin',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `desktop-mode-games` — bundle for the Games hub native window
// (launcher grid, scoreboard, challenges client). Lazy-loaded by
// the native-window sync the first time the hub opens; registers a
// render callback on
// `window.openStationNativeWindows['desktop-mode-games']`.
// `heartbeat` + `jquery` — the challenges client rides the
// WordPress Heartbeat bus for live delivery.
$games_js = OPENSTATION_DIR . 'assets/js/games' . $suffix . '.js';
wp_register_script(
'desktop-mode-games',
OPENSTATION_URL . 'assets/js/games' . $suffix . '.js',
array( 'wp-i18n', 'heartbeat', 'jquery' ),
file_exists( $games_js ) ? (string) filemtime( $games_js ) : $version,
true
);
wp_set_script_translations(
'desktop-mode-games',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `os-game-inkfall` — the Inkfall game bundle. Loaded
// lazily by the games framework on first launch; publishes the
// game def on `window.openStationGames.inkfall`.
$game_inkfall_js = OPENSTATION_DIR . 'assets/js/game-inkfall' . $suffix . '.js';
wp_register_script(
'os-game-inkfall',
OPENSTATION_URL . 'assets/js/game-inkfall' . $suffix . '.js',
array( 'wp-i18n' ),
file_exists( $game_inkfall_js ) ? (string) filemtime( $game_inkfall_js ) : $version,
true
);
wp_set_script_translations(
'os-game-inkfall',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `os-game-alphabet-soup` — the Alphabet Soup game
// bundle. Loaded lazily by the games framework on first launch;
// publishes the game def on
// `window.openStationGames['alphabet-soup']`.
$game_alphabet_soup_js = OPENSTATION_DIR . 'assets/js/game-alphabet-soup' . $suffix . '.js';
wp_register_script(
'os-game-alphabet-soup',
OPENSTATION_URL . 'assets/js/game-alphabet-soup' . $suffix . '.js',
array( 'wp-i18n' ),
file_exists( $game_alphabet_soup_js ) ? (string) filemtime( $game_alphabet_soup_js ) : $version,
true
);
wp_set_script_translations(
'os-game-alphabet-soup',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `os-posts-window` — small bundle for the native Posts
// window. Lazy-loaded by the native-window sync the first time the
// window opens (via the dock-click swap when the user opts in);
// registers a render callback on
// `window.openStationNativeWindows['desktop-mode-posts']`.
$posts_window_js = OPENSTATION_DIR . 'assets/js/posts-window' . $suffix . '.js';
wp_register_script(
'os-posts-window',
OPENSTATION_URL . 'assets/js/posts-window' . $suffix . '.js',
array( 'wp-i18n' ),
file_exists( $posts_window_js ) ? (string) filemtime( $posts_window_js ) : $version,
true
);
wp_set_script_translations(
'os-posts-window',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `os-plugins-window` — small bundle for the native
// Plugins window. Lazy-loaded by the native-window sync the first
// time the window opens (via the dock-click swap when the user
// opts in); registers a render callback on
// `window.openStationNativeWindows['desktop-mode-plugins']`.
$plugins_window_js = OPENSTATION_DIR . 'assets/js/plugins-window' . $suffix . '.js';
wp_register_script(
'os-plugins-window',
OPENSTATION_URL . 'assets/js/plugins-window' . $suffix . '.js',
array( 'wp-i18n' ),
file_exists( $plugins_window_js ) ? (string) filemtime( $plugins_window_js ) : $version,
true
);
wp_set_script_translations(
'os-plugins-window',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `os-comments-window` — small bundle for the native
// Comments window. Lazy-loaded by the native-window sync the first
// time the window opens (via the dock-click swap when the user
// opts in); registers a render callback on
// `window.openStationNativeWindows['desktop-mode-comments']`.
$comments_window_js = OPENSTATION_DIR . 'assets/js/comments-window' . $suffix . '.js';
wp_register_script(
'os-comments-window',
OPENSTATION_URL . 'assets/js/comments-window' . $suffix . '.js',
array( 'wp-i18n' ),
file_exists( $comments_window_js ) ? (string) filemtime( $comments_window_js ) : $version,
true
);
wp_set_script_translations(
'os-comments-window',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `os-animated-logo-wallpaper` — built-in PixiJS canvas
// wallpaper, moved out of `desktop.min.js`. The wallpaper
// `server-sync` loads this handle when the user selects the
// `wp-animated-logo` wallpaper (or opens OS Settings → Wallpaper
// and the picker pulls every registered canvas def in). The
// bundle's only side effect is publishing the `WallpaperDef` on
// `window.openStationWallpapers['wp-animated-logo']`.
$animated_logo_js = OPENSTATION_DIR . 'assets/js/animated-logo-wallpaper' . $suffix . '.js';
wp_register_script(
'os-animated-logo-wallpaper',
OPENSTATION_URL . 'assets/js/animated-logo-wallpaper' . $suffix . '.js',
array( 'wp-hooks' ),
file_exists( $animated_logo_js ) ? (string) filemtime( $animated_logo_js ) : $version,
true
);
// `os-snow-wallpaper` — built-in PixiJS canvas
// wallpaper: snowfall that accumulates on window tops and melts
// away. Same lazy-load path as the animated logo: the wallpaper
// `server-sync` injects this handle when the user selects the
// `wp-snow` wallpaper (or opens OS Settings → Wallpaper and the
// picker pulls the def in). The bundle's only side effect is
// publishing the `WallpaperDef` on
// `window.openStationWallpapers['wp-snow']`.
$snow_js = OPENSTATION_DIR . 'assets/js/snow-wallpaper' . $suffix . '.js';
wp_register_script(
'os-snow-wallpaper',
OPENSTATION_URL . 'assets/js/snow-wallpaper' . $suffix . '.js',
array( 'wp-hooks', 'wp-i18n' ),
file_exists( $snow_js ) ? (string) filemtime( $snow_js ) : $version,
true
);
wp_set_script_translations(
'os-snow-wallpaper',
'desktop-mode',
OPENSTATION_DIR . 'languages'
);
// `desktop-mode-ai-assistant` — AI Copilot spotlight overlay,
// moved out of `desktop.min.js`. The main bundle ships a
// stub matching the public `wp.os.ai` contract; the stub
// `