From 9a17ee077d0eb8cb2afecf1c3a841bcb25e01043 Mon Sep 17 00:00:00 2001 From: Miguel Torres <1233880+mmtr@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:59:40 +0200 Subject: [PATCH 01/94] Rebrand: Polish the leftovers, and announce the rename (#488) * Rebrand: Polish the leftovers, and announce the rename Tab strip edge fades were masked unconditionally. On the pre-brand light strip that was invisible, since the fade landed on empty area past the last tab; on the dark default it reads as two grey smudges bracketing every window's submenu. They now paint only on an edge that is actually hiding a tab. Links inside painted WordPress Blue at rest and a darker blue on hover, close to illegible on the notice wash. Two causes: the colour resolved through --wp-admin-theme-color, which the accent picker writes inline, so the link took whatever hue the user chose for focus rings; and ::slotted( a ) cannot beat wp-admin's own bare anchor rule, because a slotted link belongs to the document tree and CSS Scoping hands normal declarations to the outer tree. The palette now declares the tokens and a document-tree rule consumes them. OS Settings is renamed to OpenStation Settings and wears the logomark. The mark is a currentColor silhouette rather than the brand's app chip: the dock masks every image icon so plugin colours cannot break the monochrome rail, and a chip's alpha is its tile, so a chip renders as a plain white rounded square. Adds a one-off dialog explaining the rename, shown once per user and only on installs that were already running under the old name. Fresh installs never see it. Dismissal goes to the seen-intros registry, so "Reset what's-new dialogs" brings it back and one admin dismissing it does not silence it for their editors. Fixes the plugin slug in bin/setup-wp-env.sh. The rebrand sweep renamed it to a plugin that does not exist, and because the script runs under set -euo pipefail the failed activate aborted it: the Gutenberg Guidelines experiment never got enabled and the plugin was left inactive on every fresh wp-env start. Co-Authored-By: Claude Opus 5 * Rebrand: Aim the announcement at the users it is actually for Review follow-up. The gate was per-install, so an editor who joined an old site last week and enabled the shell this morning would be told about a rename they never saw. Migration 5 now flags individual users who carry proof of prior use: the `desktop_mode_mode` opt-in, or a saved OS-settings blob for someone who used it and has since switched back to classic. That also fixes a worse miss in the other direction. The install gate read `$from === 0` as "fresh install, nothing to explain", but the migration runner only shipped in 0.9.1, so a site still on 0.9.0 that updates straight to the rebrand release arrives with no stored version and looks brand new. Those installs update rarely, which makes them the most likely to be blindsided, and they were exactly the ones being silenced. The install gate is now just `$from < 4`; per-user evidence tells a dormant install apart from a new one, because a genuinely fresh site runs its first admin_init on the activation redirect, before anyone can have opened the shell. announce.css is no longer enqueued for users who will never see the dialog. The gate is computed once and feeds both the enqueue and the `rebrandNotice` config key, so the two cannot diverge; the dialog cannot paint without the stylesheet. The stylesheet's header now says out loud that it opts out of the palette, that this is a departure from "one declaration, one owner" rather than the house pattern, and that brand-palette.test.ts will not catch it drifting. Also caches the tab strip's text direction across scroll frames instead of calling getComputedStyle on every one, adds wiring tests for observeTabOverflow (listeners, coalescing, teardown, no-observer environments), and fixes a boot comment describing a bundle wait that no longer exists. Co-Authored-By: Claude Opus 5 * Rebrand: Flag anyone who ever opted in, not just current users Verifying the announcement against the upgrade paths turned up a user it was dropping: someone who tried the shell, changed nothing, and switched back to classic. They used Desktop Mode, so they are owed the explanation the next time they come in, and they were not getting it. Switching back writes an empty string to `desktop_mode_mode` rather than deleting the row, and the only two writers of that key are the toggle itself and the portal's auto-enable. So the row existing means "has been through the switch at least once", which is the question the migration is actually asking. Matching on the value being '1' asked a narrower one. Someone who never switched still has no row at all, so the newcomer case is unaffected. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- AGENTS.md | 4 +- README.md | 10 +- assets/css/announce.css | 412 ++++++++++++++++++ assets/css/desktop.css | 35 ++ assets/css/variables.css | 19 + assets/css/window-chrome.css | 55 ++- bin/setup-wp-env.sh | 10 +- docs/DEVELOPMENT.md | 4 +- docs/architecture.md | 28 +- docs/components-reference.md | 6 +- docs/desktop-themes.md | 22 +- docs/dock-customization.md | 10 +- docs/examples/agents.md | 2 +- docs/examples/custom-unfocus-effect.md | 4 +- docs/examples/dock-decoration-hooks.md | 2 +- docs/examples/dock-rail-renderer.md | 10 +- docs/examples/mio-customization.md | 2 +- docs/examples/native-posts.md | 2 +- docs/examples/register-command.md | 2 +- docs/examples/register-desktop-theme.md | 8 +- docs/examples/register-game.md | 2 +- docs/examples/register-wallpaper.md | 12 +- docs/examples/register-widget.md | 2 +- docs/examples/window-links.md | 6 +- docs/examples/window-reveal.md | 2 +- docs/files-on-desktop.md | 12 +- docs/hooks-reference.md | 58 ++- docs/javascript-reference.md | 92 ++-- docs/living-tree-algorithm.md | 2 +- docs/migration-ai-connectors.md | 2 +- docs/mio.md | 8 +- docs/native-windows-proposal.md | 10 +- docs/pwa.md | 2 +- includes/ai-copilot/search.php | 2 +- includes/assets.php | 14 + includes/migrations.php | 188 +++++++- includes/render/assets.php | 16 + includes/welcome-dialog.php | 4 +- readme.txt | 20 +- src/ai-assistant/impl.ts | 12 +- src/desktop.ts | 34 +- src/posts-window/intro-dialog.ts | 6 +- src/rebrand-notice.test.ts | 253 +++++++++++ src/rebrand-notice.ts | 290 ++++++++++++ src/settings/index.ts | 14 +- src/settings/sections/features.ts | 6 +- src/settings/sections/help.ts | 4 +- src/types.ts | 17 + src/ui/brand-mark.test.ts | 70 +++ src/ui/brand-mark.ts | 82 ++++ .../components/os-notice/os-notice.styles.ts | 19 +- src/ui/components/os-section/os-section.ts | 4 +- src/ui/components/os-steps/os-steps.ts | 2 +- src/window/index.ts | 22 +- src/window/tabs.test.ts | 299 ++++++++++++- src/window/tabs.ts | 140 ++++++ tests/phpunit/tests/rebrandNotice.php | 290 ++++++++++++ 57 files changed, 2447 insertions(+), 218 deletions(-) create mode 100644 assets/css/announce.css create mode 100644 src/rebrand-notice.test.ts create mode 100644 src/rebrand-notice.ts create mode 100644 src/ui/brand-mark.test.ts create mode 100644 src/ui/brand-mark.ts create mode 100644 tests/phpunit/tests/rebrandNotice.php diff --git a/AGENTS.md b/AGENTS.md index d7a954de..466bc8ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ The brand ships five mesh gradients with one instruction attached — *"meshes r Three rules, all with tests: -1. **A control paints the mesh when it is on, selected, primary or filled — and wears Obsidian the rest of the time.** A panel where every surface is iridescent has no identity moments left to spend. `` exists precisely so the loud version is hard to reach for by accident; `primary` deliberately did *not* become the mesh, because it is three-to-a-row in OS Settings and a mesh three-to-a-row is wallpaper. +1. **A control paints the mesh when it is on, selected, primary or filled — and wears Obsidian the rest of the time.** A panel where every surface is iridescent has no identity moments left to spend. `` exists precisely so the loud version is hard to reach for by accident; `primary` deliberately did *not* become the mesh, because it is three-to-a-row in OpenStation Settings and a mesh three-to-a-row is wallpaper. 2. **`holoTokens` is a prerequisite for every other fragment** — it declares the private `--_holo-*` aliases they read. Include it once per component. Never declare a `--os-ui-*` name on the bare `:host` (see the next rule). 3. **Reduced motion stops the tilt, never the fill.** A control that lost its mesh under `prefers-reduced-motion` would lose its *state*, not just its animation. @@ -281,7 +281,7 @@ Payload shape (`openstation_build_menu_payload()` in `includes/core/payload.php` - **PHP-declared** things are in the payload: dock, native windows, widgets, wallpapers. The shell diffs them and fires `registry.subscribe` listeners → UI repaints. No F5. - For widgets and wallpapers, the pattern is: PHP payload carries metadata + `scriptUrl`; the `server-sync` module (`src/{widgets,wallpapers}/server-sync.ts`) dynamically loads the plugin's JS, which then publishes a full def on a global (`window.openStationWallpapers[id]` / `window.openStationWidgets[id]`). The sync reads the def and registers it. - **Commands** use the same pattern via `openstation_register_command_script( $handle )` (primary, minimum-ceremony) or `openstation_register_command( $args )` (optional, declares metadata server-side). Sync module: `src/commands/server-sync.ts`. Live unregistration on deactivation works for commands that either (a) declare `script` in PHP metadata, or (b) set `owner` on their JS `registerCommand` call. Plugins that do neither still require F5 on deactivate, graceful backwards-compat. -- **OS Settings tabs** use the same pattern via `openstation_register_settings_tab_script( $handle )` (primary) or `openstation_register_settings_tab( $args )` (optional, id/label/capability/order/script). Sync module: `src/settings/server-sync.ts`; registry: `src/settings/registry.ts`; built-in tabs (appearance=10, themes=12, apps-icons=22, features=25, effects=27, help=40; help is admin-only and labelled "Components" in the UI, About is pinned last via `order: Number.MAX_SAFE_INTEGER`, and Features hosts the admin-only Extended options section) are interleaved with the registry in `src/settings/panel.ts` `renderOsSettingsPanel()` (lazy-loaded by the `renderPanel()` stub in `src/settings/index.ts`) and re-painted live via `subscribeSettingsTabs`. Same (a)/(b) live-unregister rules as commands. +- **OpenStation Settings tabs** use the same pattern via `openstation_register_settings_tab_script( $handle )` (primary) or `openstation_register_settings_tab( $args )` (optional, id/label/capability/order/script). Sync module: `src/settings/server-sync.ts`; registry: `src/settings/registry.ts`; built-in tabs (appearance=10, themes=12, apps-icons=22, features=25, effects=27, help=40; help is admin-only and labelled "Components" in the UI, About is pinned last via `order: Number.MAX_SAFE_INTEGER`, and Features hosts the admin-only Extended options section) are interleaved with the registry in `src/settings/panel.ts` `renderOsSettingsPanel()` (lazy-loaded by the `renderPanel()` stub in `src/settings/index.ts`) and re-painted live via `subscribeSettingsTabs`. Same (a)/(b) live-unregister rules as commands. - **AI Copilot extensibility** lives on a different axis from the live-refresh payloads, it's all per-request wiring inside `/ai/search` (`includes/ai-copilot/search.php`) plus the WordPress Abilities API. Two distinct registration surfaces. **Server-dispatched tools are abilities** (`includes/ai-copilot/abilities.php`): register with `wp_register_ability()` on `wp_abilities_api_init`, under the `openstation` category registered on `wp_abilities_api_categories_init`. The loop offers the model every registered ability whose `meta.annotations.readonly` is set and runs the chosen one through `wp_get_ability()->execute()`, which is where `permission_callback` and input-schema validation happen. There's no Desktop-Mode-specific opt-in: register a read-only ability (yours, Core's, another plugin's) and the assistant can call it. Read-only is a **security boundary, not an oversight**: a search turn can be driven by attacker-controlled content (comment or post text landing in a tool result), so the model is never handed an ability that could change the site. Model-facing tool names are the ability name minus its namespace with dashes as underscores (`desktop-mode/search-posts` → `search_posts`, see `openstation_ai_ability_tool_name()`), which keeps the system prompt, answer schema, and progress labels stable across the migration. The second surface is client-side `registerCommand({ aiCallable: true })` for JS-dispatched slash-commands the AI can pick via `/ai/search`'s `command_tools` param. The full filter/action surface is `openstation_ai_{system_prompt,system_prompt_appendix,system_prompt_replace_capability,request,tools,command_tools,command_allowed,tool_result,answer}` + observability actions `openstation_ai_{search_started,tool_called,search_completed,search_error}`, every call carries a shared `request_id` UUID for trace correlation. `openstation_register_ai_tool()` and the `openstation_ai_tool_registered` action were removed in 0.9.4, see `docs/migration-ai-connectors.md`. `wp.os.ai.ask()` (`src/ai/ask.ts`) is the client-side programmatic entry point; it harvests `aiCallable: true` commands into `command_tools` and handles the server's `answer_type: 'tool_call'` short-circuit by running `run()` locally. The command's `run` function always lives JS-side, the server only emits a slug+args intent; the client invokes. - **Games** (`openstation_register_game( $id, $args )`) use the metadata + `scriptUrl` pattern with one deliberate deviation: `src/games/server-sync.ts` registers metadata-only **stubs** on sync (no script load — the metadata is enough for the Games launcher grid + scoreboard tabs) and `launchGame()` in `src/games/launch.ts` fetches the script on first play, reading the full def off `window.openStationGames[id]`. Games are heavyweight (game bundle + PixiJS + a dictionary asset); eager loading would tax every boot for nothing. - **Desktop themes** (`openstation_register_desktop_theme()`, plus admin-uploaded ZIPs) ship metadata + a compiled stylesheet reference and carry **no script at all**, which makes `src/desktop-themes/server-sync.ts` the one synchronous reconciler in the family. Losing the ACTIVE theme from the payload deactivates it locally without saving — the server already treats an orphaned selection as the system default on every request. diff --git a/README.md b/README.md index 5e080e9b..77afe832 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ Zero Core patches. Every feature is wired through public WordPress hooks. Admin-bar toggle sets the `desktop_mode_mode` user meta. A dedicated `/openstation/` portal URL auto-enables OpenStation for first-time visitors (gated by `openstation_portal_auto_enable`) and the `admin_init` redirect sends opted-in users from `/wp-admin/` to the portal (`openstation_admin_redirect_to_portal`). - **Desktop shell** - Fixed-viewport desktop that overlays `/wp-admin`: wallpaper area, unified dock (placement picked in OS Settings — left / right / bottom, default bottom), right-column widget layer, and full windowing system. `openstation_mode_init`, `openstation_shell_before` / `_after`, and the `openstation_shell_config` filter are the main extension points. + Fixed-viewport desktop that overlays `/wp-admin`: wallpaper area, unified dock (placement picked in OpenStation Settings — left / right / bottom, default bottom), right-column widget layer, and full windowing system. `openstation_mode_init`, `openstation_shell_before` / `_after`, and the `openstation_shell_config` filter are the main extension points. - **Window system — iframe + native** Iframe windows load admin pages with `?openstation_chromeless=1` (chromeless mode). Native windows render directly in the parent DOM via `openstation_register_window()` / `wp.os.registerWindow()` — multi-tab native windows are supported through `openstation_register_window_tab()`. Both types share drag, resize, minimize, maximize, close, fullscreen, and detach-to-new-tab. - **Dock** - One unified rail hosting every admin menu — core and plugin alike — plus shell-level system tiles. Placement (left / right / bottom) is the user's OS Settings preference. Core menus are ordered before plugin menus; per-item hiding via `openstation_dock_placement` (`'hidden'`). Per-item multi-window support via `openstation_dock_item_multi`. Letter-badge icon fallback for plugins without icon art. + One unified rail hosting every admin menu — core and plugin alike — plus shell-level system tiles. Placement (left / right / bottom) is the user's OpenStation Settings preference. Core menus are ordered before plugin menus; per-item hiding via `openstation_dock_placement` (`'hidden'`). Per-item multi-window support via `openstation_dock_item_multi`. Letter-badge icon fallback for plugins without icon art. - **Virtual desktops (“Spaces”)** Multiple desktops per user, each with its own window set. Overview grid (zoom-out view) surfaces the Spaces switcher, thumbnails, and create/close controls. @@ -69,7 +69,7 @@ Zero Core patches. Every feature is wired through public WordPress hooks. - **Toast notifications** Shell-level toasts rendered via the `` component. Plugins register their own tone/icon via the `openstation_toast_types` filter. Iframe pages raise a toast through the `os-notification` bridge message — it survives the iframe's own lifecycle. -- **OS Settings** +- **OpenStation Settings** Native-window settings panel: wallpaper picker (with HD-only media filter), accent color swatches + custom gradient editor, dock size slider, AI platform config, and per-user default-on-startup window. Persisted via `/desktop-mode/v1/os-settings`. - **Session persistence** @@ -138,7 +138,7 @@ See [`docs/architecture.md`](./docs/architecture.md) for how the pieces fit toge │ ├── window-manager/ # stack, desktops, arrange, snap, overview │ ├── wallpapers/ # registry, layer, surfaces, server sync, vendor loader │ ├── widgets/ # registry, layer, frame, picker, storage -│ ├── settings/ # OS Settings panel sections +│ ├── settings/ # OpenStation Settings panel sections │ ├── ui/ # web components │ ├── modules/ # vendor-script lazy-loader │ └── plugins/ # built-in demos (animated-logo-wallpaper) @@ -202,7 +202,7 @@ Writes one `assets/js/.js` / `.min.js` pair per target, including: - `assets/js/desktop.js` / `.min.js` — main shell bundle (loaded based on `SCRIPT_DEBUG`). - `assets/js/iframe-bridge.js` / `.min.js` — opt-in bridge that gives any same-origin iframe access to `wp.os.iframe.*`. - `assets/js/recycle-bin.js` / `.min.js` — Recycle Bin native window. -- `assets/js/posts-window.js` / `.min.js` — Native Posts window (the `` replacement for the `edit.php` iframe; opt-in per user via OS Settings → Features). +- `assets/js/posts-window.js` / `.min.js` — Native Posts window (the `` replacement for the `edit.php` iframe; opt-in per user via OpenStation Settings → Features). **Development watch** — auto-recompiles the unminified bundle on save: diff --git a/assets/css/announce.css b/assets/css/announce.css new file mode 100644 index 00000000..47211ab3 --- /dev/null +++ b/assets/css/announce.css @@ -0,0 +1,412 @@ +/* + * Announcement dialog — the shell's full-stop moment. + * + * The look is the first-run welcome dialog's: a blurred Void scrim with + * Nebula and mint glows, a near-white card with a holographic hairline, + * and a dark shimmering hero at the top of it. That dialog is the one + * piece of UI in the product that gets to be loud, and an announcement + * that has to explain a rename is the only other thing that earns it. + * + * Built as plain classed DOM rather than out of `` components, on + * purpose and for the same reason the welcome dialog is: this is a + * poster, not a control panel. `` gives a dialog chrome and a + * focus trap, and would then have to be fought for every one of the + * gradients below. + * + * --------------------------------------------------------------- + * KNOWN DUPLICATION. `includes/welcome-dialog.php` inlines its own copy + * of this design. It has to be self-contained — it renders in *classic* + * wp-admin, on `admin_footer`, where none of the shell's stylesheets + * are enqueued — so the two cannot share a file without that dialog + * taking on a dependency it was deliberately built without. Restyle one + * and the other will drift. If they should converge, the move is to + * enqueue THIS file in classic admin too and delete the inline block, + * not to copy edits between them. + * --------------------------------------------------------------- + * + * --------------------------------------------------------------- + * THIS FILE OPTS OUT OF THE PALETTE, AND THAT IS A DEPARTURE. + * + * Every colour below is a literal. Not one reads a `--os-*` token, so + * nothing here answers to `variables.css` or to a desktop theme, which + * is exactly what AGENTS.md's "one declaration, one owner" rule tells + * you not to do. Read this as a one-off, NOT as the house pattern: a + * feature stylesheet that hardcodes its colours is a bug. + * + * The trade, deliberately made: the card is the product introducing + * itself by name, and a theme recolouring that would be the theme + * speaking in the product's voice. `` makes the same call for + * its own surface. The difference is that `` still routes + * through `--os-ui-modal-*` names, so a theme that really wants the + * surface can reach it; this file gives a theme nothing, and it is only + * defensible because the dialog exists for one release cycle and then + * stops firing forever. + * + * If it outlives that, tokenize it. Note that nothing will tell you to: + * `brand-palette.test.ts` only scans `variables.css`, so no test fails + * while this drifts from the palette beside it. + * --------------------------------------------------------------- + */ + +.os-announce, +.os-announce * { + box-sizing: border-box; +} + +.os-announce { + position: fixed; + inset: 0; + /* + * Above every window (z 100+), the dock, and the Mio layer (190). + * Matches the welcome dialog's own z-index so the two can never + * argue if a site somehow shows both. + */ + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: radial-gradient( + ellipse at 30% 20%, + rgba(236, 155, 255, 0.32), + transparent 60% + ), + radial-gradient( + ellipse at 70% 80%, + rgba(147, 240, 198, 0.26), + transparent 55% + ), + rgba(12, 11, 15, 0.62); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + animation: os-announce-fade 360ms cubic-bezier(0.22, 1, 0.36, 1); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@keyframes os-announce-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes os-announce-pop { + 0% { + opacity: 0; + transform: translateY(14px) scale(0.96); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes os-announce-shimmer { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 200% 50%; + } +} + +.os-announce__card { + position: relative; + width: 100%; + max-width: 620px; + border-radius: 22px; + overflow: hidden; + background: linear-gradient( + 145deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(255, 251, 255, 0.98) 100% + ); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset, + 0 30px 60px -20px rgba(26, 23, 33, 0.55), + 0 18px 36px -18px rgba(242, 82, 252, 0.45); + animation: os-announce-pop 460ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: 60ms; +} + +/* + * Holographic hairline. A padded pseudo-element masked with `xor` so + * only the 1px ring paints — a plain border cannot carry a gradient. + */ +.os-announce__card::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: linear-gradient( + 135deg, + rgba(242, 82, 252, 0.65), + rgba(236, 155, 255, 0.55), + rgba(147, 240, 198, 0.55) + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.os-announce__hero { + position: relative; + padding: 32px 36px 26px; + background: linear-gradient(135deg, #0c0b0f 0%, #2a2533 45%, #ec9bff 100%); + background-size: 200% 200%; + animation: os-announce-shimmer 14s ease infinite alternate; + color: #fff; + overflow: hidden; +} + +/* Station grid + glows, faded out at the edges so it reads as depth. */ +.os-announce__hero::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient( + circle at 85% 15%, + rgba(236, 155, 255, 0.35), + transparent 45% + ), + radial-gradient( + circle at 15% 90%, + rgba(147, 240, 198, 0.22), + transparent 50% + ), + linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px) 0 0 / + 28px 28px, + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px) 0 + 0 / 28px 28px; + pointer-events: none; + mask-image: radial-gradient(ellipse at 50% 40%, #000 35%, transparent 80%); + -webkit-mask-image: radial-gradient( + ellipse at 50% 40%, + #000 35%, + transparent 80% + ); +} + +/* The logomark, sitting above the hero's own overlay. */ +.os-announce__mark { + position: relative; + z-index: 1; + width: 44px; + height: 44px; + margin: 0 0 16px; + filter: drop-shadow(0 4px 14px rgba(242, 82, 252, 0.55)); +} + +.os-announce__mark svg { + display: block; + width: 100%; + height: 100%; +} + +.os-announce__title { + position: relative; + z-index: 1; + margin: 14px 0 6px; + font-size: 26px; + line-height: 1.2; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; +} + +.os-announce__subtitle { + position: relative; + z-index: 1; + margin: 0; + font-size: 14px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.85); +} + +.os-announce__body { + padding: 24px 36px 4px; + font-size: 14.5px; + line-height: 1.6; + color: #33303a; +} + +.os-announce__body p { + margin: 0 0 12px; +} + +.os-announce__body p:last-child { + margin-bottom: 0; +} + +/* + * The sign-off. Carries a little more weight than the explanation it + * follows, which is the whole reason it is its own paragraph. + */ +.os-announce__welcome { + font-weight: 600; + color: #0c0b0f; +} + +/* + * The reassurance line — quieter than the copy above it. It answers + * "did my install change?" for the people who think to ask, without + * competing with the explanation. + */ +.os-announce__fine { + padding: 14px 16px; + background: linear-gradient( + 135deg, + rgba(242, 82, 252, 0.08), + rgba(147, 240, 198, 0.08) + ); + border: 1px solid rgba(242, 82, 252, 0.22); + border-radius: 12px; + font-size: 13px; + color: #2a2533; +} + +.os-announce__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 22px 36px 28px; +} + +.os-announce__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 38px; + padding: 8px 18px; + font-size: 14px; + font-weight: 600; + font-family: inherit; + line-height: 1; + border-radius: 10px; + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: transform 120ms ease, box-shadow 120ms ease, + background 120ms ease, color 120ms ease; +} + +.os-announce__btn:focus-visible { + outline: 2px solid #f252fc; + outline-offset: 2px; +} + +.os-announce__btn--primary, +.os-announce__btn--primary:hover, +.os-announce__btn--primary:focus { + color: #fffbff; +} + +.os-announce__btn--primary { + /* + * Ends on Pulse, not Nebula. `background-position` slides this on + * hover, so every stop carries the label at some point, and Nebula + * is light enough that Starlight text on it falls to ~1.6:1. + */ + background: linear-gradient(135deg, #2a2533, #a12bb0 45%, #f252fc); + background-size: 180% 180%; + box-shadow: 0 10px 24px -10px rgba(242, 82, 252, 0.75), + 0 4px 10px -4px rgba(236, 155, 255, 0.55); +} + +.os-announce__btn--primary:hover { + transform: translateY(-1px); + background-position: 100% 50%; + box-shadow: 0 14px 30px -12px rgba(242, 82, 252, 0.85), + 0 6px 14px -4px rgba(236, 155, 255, 0.65); +} + +.os-announce__btn--primary:active { + transform: translateY(0); +} + +.os-announce__close { + position: absolute; + top: 14px; + right: 14px; + z-index: 2; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: rgba(255, 255, 255, 0.18); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 50%; + cursor: pointer; + font-size: 18px; + line-height: 1; + transition: background 120ms ease, transform 120ms ease; +} + +.os-announce__close:hover { + background: rgba(255, 255, 255, 0.32); + transform: rotate(90deg); +} + +.os-announce__close:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +@media (max-width: 600px) { + .os-announce__hero { + padding: 26px 24px 20px; + } + + .os-announce__body, + .os-announce__actions { + padding-left: 24px; + padding-right: 24px; + } + + .os-announce__title { + font-size: 22px; + } + + .os-announce__actions { + flex-direction: column-reverse; + align-items: stretch; + } + + .os-announce__btn { + width: 100%; + } +} + +/* + * Reduced motion stops the movement, never the paint. The shimmer, the + * pop and the scrim fade are decoration; the gradients they animate are + * the dialog's identity and stay put at their first frame. + */ +@media (prefers-reduced-motion: reduce) { + .os-announce, + .os-announce__card, + .os-announce__hero { + animation: none; + } + + .os-announce__btn, + .os-announce__close { + transition: none; + } + + .os-announce__close:hover { + transform: none; + } +} diff --git a/assets/css/desktop.css b/assets/css/desktop.css index db9ef301..d24faa9b 100644 --- a/assets/css/desktop.css +++ b/assets/css/desktop.css @@ -1583,3 +1583,38 @@ body.os-has-fullscreen-window .os-shell { .commands-command-menu__overlay { display: none !important; } + +/* + * ---- Links slotted into --------------------------- + * + * `` styles its slotted links from inside its shadow root, + * through `::slotted( a )` and the `--os-ui-notice-link` tokens. That + * rule cannot win here, and no amount of specificity on it would help. + * + * A slotted element lives in the DOCUMENT tree; the shadow root only + * borrows it for rendering. When declarations from two trees collide, + * CSS Scoping resolves normal (non-`!important`) ones in favour of the + * OUTER tree regardless of specificity — so `wp-admin/css/common.css`, + * which sets `a { color: #2271b1 }` and `a:hover { color: #135e96 }` on + * every anchor in the admin, beats `::slotted( a )` every time. The + * shell renders inside wp-admin, so it always loses: the link painted + * WordPress Blue at rest and a darker blue on hover, the second of + * which is close to illegible on a notice's dark wash. + * + * Hence a document-tree rule. It reads the same tokens the component + * documents, so a theme still restyles the link by setting them; this + * only moves the declaration into a tree that can win. `a:hover` is + * (0,1,1), so the hover selector has to carry its own pseudo-class to + * outrank it — matching `os-notice a` alone would lose the hover state + * while winning the base one, which is exactly the inert-looking link + * the component's own styles already went out of their way to avoid. + */ +os-notice a { + color: var(--os-ui-notice-link, #ec9bff); +} + +os-notice a:hover, +os-notice a:focus, +os-notice a:active { + color: var(--os-ui-notice-link-hover, #fffbff); +} diff --git a/assets/css/variables.css b/assets/css/variables.css index 09b7412e..29563886 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -699,6 +699,25 @@ body.os-active { --os-ui-notice-info-border: rgba(194, 241, 241, 0.28); --os-ui-notice-neutral-bg: rgba(255, 251, 255, 0.06); --os-ui-notice-neutral-border: rgba(255, 251, 255, 0.14); + /* + * A link inside a notice reads Nebula, the same value `--os-link` + * gives every other shell link — NOT the accent. + * + * Undeclared, the base fell through to `--wp-admin-theme-color`, + * which OpenStation Settings → Appearance writes inline on `` + * from the accent picker. That made the one piece of text a notice + * most needs the user to read take whichever hue they chose for + * focus rings. An accent is an identity colour; legibility on a dark + * surface is not something it can promise, so the link stops asking + * it to. Hover lifts to Starlight, and both ends stay in the palette. + * + * These are consumed from a DOCUMENT-tree rule in `desktop.css`, not + * only from the component's own `::slotted( a )`. See the comment + * there: a slotted link is a document-tree element, so wp-admin's + * bare `a` rule outranks anything the shadow root says about it. + */ + --os-ui-notice-link: #ec9bff; + --os-ui-notice-link-hover: #fffbff; --os-ui-badge-danger-bg: rgba(255, 90, 90, 0.16); --os-ui-badge-success-bg: rgba(147, 240, 198, 0.16); --os-ui-badge-warning-bg: rgba(248, 242, 182, 0.16); diff --git a/assets/css/window-chrome.css b/assets/css/window-chrome.css index fa62c89e..0d6841c6 100644 --- a/assets/css/window-chrome.css +++ b/assets/css/window-chrome.css @@ -647,14 +647,51 @@ overflow-y: hidden; scrollbar-width: thin; /* - * Soft edge fades so overflowing tabs tell the user "scroll for - * more." The mask is always applied — when the strip doesn't - * overflow, the faded ends are empty area beyond the last tab so - * the cosmetic is invisible. When it DOES overflow, the last- - * visible tab fades under the mask, creating the affordance. * `overscroll-behavior-x: contain` keeps horizontal trackpad * swipes inside the strip instead of triggering browser back. */ + overscroll-behavior-x: contain; +} + +/* + * Soft edge fades so overflowing tabs tell the user "scroll for more." + * + * Each fade is painted ONLY on an edge that is actually hiding a tab. + * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow` + * with the physical edges currently covered — `left`, `right`, `both`, + * or the attribute dropped when the strip fits — and re-measures on + * scroll, on resize, and when tabs are added or removed. + * + * This used to be one unconditional mask on both ends. The reasoning + * was that on a strip that fits, the faded ends land past the last tab + * and so fade nothing. That held on the pre-brand light strip, where + * the tab bar and the title bar above it were near enough the same + * near-white that a mask over empty area was invisible. It stopped + * holding on the station: the strip is Obsidian over a darker window + * edge, so masking its ends to transparent punches two grey smudges + * into every window's submenu, whether or not there is anything to + * scroll to. A permanent affordance for a state that is usually false + * is not an affordance — it is decoration that lies. + */ +.os-window__tabs[ data-overflow='left' ] { + mask-image: linear-gradient( to right, transparent 0, #000 16px ); + -webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px ); +} + +.os-window__tabs[ data-overflow='right' ] { + mask-image: linear-gradient( + to right, + #000 calc(100% - 16px), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to right, + #000 calc(100% - 16px), + transparent 100% + ); +} + +.os-window__tabs[ data-overflow='both' ] { mask-image: linear-gradient( to right, transparent 0, @@ -669,16 +706,8 @@ #000 calc(100% - 16px), transparent 100% ); - overscroll-behavior-x: contain; } -/* - * When JS assigns the --has-overflow marker (future enhancement), the - * mask is stronger. For now the CSS-only mask is subtle enough to be - * invisible on non-overflowing strips — mask cut-offs land outside the - * flex content. - */ - .os-window__tab { flex-shrink: 0; display: inline-flex; diff --git a/bin/setup-wp-env.sh b/bin/setup-wp-env.sh index c5aebd0d..6386ab56 100755 --- a/bin/setup-wp-env.sh +++ b/bin/setup-wp-env.sh @@ -9,7 +9,15 @@ enable_guidelines_experiment() { if ! "${WP_ENV_BIN}" run "${target}" wp plugin is-installed gutenberg; then "${WP_ENV_BIN}" run "${target}" wp plugin install gutenberg fi - "${WP_ENV_BIN}" run "${target}" wp plugin activate openstation gutenberg + # `desktop-mode` is the plugin SLUG, not the product name, and it is + # frozen: it is the directory both .wp-env*.json files mount this + # checkout into (`wp-content/plugins/desktop-mode`) and the wp.org + # slug. A rename sweep caught it here once and `wp plugin activate` + # started failing with "the 'openstation' plugin could not be found", + # which aborted this whole script — so the Guidelines experiment below + # never got enabled and the plugin was left INACTIVE on every fresh + # `wp-env start`. Leave it alone. + "${WP_ENV_BIN}" run "${target}" wp plugin activate desktop-mode gutenberg "${WP_ENV_BIN}" run "${target}" wp eval ' $experiments = get_option( "gutenberg-experiments", array() ); if ( ! is_array( $experiments ) ) { diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5b499db1..b84df7a7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -117,7 +117,7 @@ src/ │ # script loader. ├── widgets/ # Registry, layer, picker, frame │ # (movable/resizable chrome), state. -├── settings/ # OS Settings panel: state, sections, +├── settings/ # OpenStation Settings panel: state, sections, │ # media REST client. ├── ui/ │ ├── core/ # The tagged-template renderer + base @@ -156,7 +156,7 @@ the file itself is tracked. In particular: - `src/window-manager/desktops.ts`, `arrange.ts`, `overview.ts`, `snap.ts`, `geometry.ts` — package-private helpers of the `WindowManager` class. -- `src/settings/sections/*` — OS Settings internals. +- `src/settings/sections/*` — OpenStation Settings internals. - `src/widgets/frame.ts`, `state.ts` — widget-layer internals. Class fields prefixed with `_` (e.g. `_externalTabs`, `_activeDesktopId`) diff --git a/docs/architecture.md b/docs/architecture.md index 77b2bd84..bbaeacbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,7 +98,7 @@ Key server-side entry points: ## Desktop layout modes -OS Settings → Appearance lets the user pick one of three top-level layouts. The shell root reflects the choice in `data-os-layout`; the layout dispatcher (`src/desktop-layout.ts`) owns every dock instance and the synthesized desktop-icon list, tearing down and rebuilding when the user switches. +OpenStation Settings → Appearance lets the user pick one of three top-level layouts. The shell root reflects the choice in `data-os-layout`; the layout dispatcher (`src/desktop-layout.ts`) owns every dock instance and the synthesized desktop-icon list, tearing down and rebuilding when the user switches. | Mode | Default? | Bottom dock | Left side dock (`wp.os.sideDock`) | Wallpaper icons | |---|---|---|---|---| @@ -106,7 +106,7 @@ OS Settings → Appearance lets the user pick one of three top-level layouts. Th | **Unified** | — | Every menu sharing one rail | — *(no side dock)* | Plugin-registered icons only | | **Spatial** | — | Plugin menus only | — *(no side dock)* | Plugin-registered icons + **synthesized core icons** (one per core menu, prefixed `dock-core:`) | -A user-meta value (`desktopLayout` inside the OS Settings JSON blob, REST-synced via the existing `/wp-json/desktop-mode/v1/os-settings` endpoint) is the persistence layer. The dispatcher partitions the live dock-items list by the `isCore` flag the menu builder already stamps on every entry; no PHP API additions were needed for the layout modes. +A user-meta value (`desktopLayout` inside the OpenStation Settings JSON blob, REST-synced via the existing `/wp-json/desktop-mode/v1/os-settings` endpoint) is the persistence layer. The dispatcher partitions the live dock-items list by the `isCore` flag the menu builder already stamps on every entry; no PHP API additions were needed for the layout modes. **Where Spatial's core icons actually render (0.9.0+):** the layout dispatcher's `dock-core:*` synthesis (`src/desktop-layout.ts`) targets the legacy `.os-icons` grid via `deps.renderIcons()`. On shells where the files layer is mounted (`config.filesUrl` set — the modern default), that grid is hidden by CSS (`assets/css/desktop-files.css`'s `#os-area:has( > .os-files-layer )` rule), because the files layer is the actual visible wallpaper surface. The user-visible equivalent lives in `syncShortcutsWithVisibility()` (`src/settings/desktop-shortcuts-sync.ts`): while `desktopLayout === 'spatial'`, every core dock item without an explicit `'hidden'` override gets a synthetic `dock-promoted:` shortcut placement pushed into the files store — the same mechanism used for user-promoted dock items, so drag persistence, positions (`dockPromotedPositions`), and grid collision handling come for free. Leaving Spatial removes these placements but does *not* prune their saved positions, so a rearranged layout survives switching away and back. The legacy `dock-core:*` grid synthesis remains for shells without a files layer. @@ -137,9 +137,9 @@ Robustness guarantees: Persistence: - `dockRailRenderer` lives on `OsSettingsState` (REST-synced to user meta via `/wp-json/desktop-mode/v1/os-settings`). The field takes any `sanitize_key()`-clean string; the JS-side registry resolves at use time and falls back to `'default'` when the named renderer is missing (plugin deactivated, typo). No server-side allow-list — renderers register from JS at runtime. -- `unfocusEffect` lives on `OsSettingsState` the same way (default `'darken'`, `'none'` disables). The value is an unfocus-effect registry id or `'none'`; it is lower-cased and stripped to `[a-z0-9_/-]` server-side (slashes preserved so `vendor/sub-id` round-trips — unlike `sanitize_key()`). The engine resolves it at use time and treats an unknown id as "no effect". Plugin effects register from JS at runtime; PHP opt-in via `openstation_register_unfocus_effect_script()` adds the `serverUnfocusEffectScripts` payload entry so a plugin's effect surfaces in OS Settings → Effects without an F5. -- `windowReveal` follows the same pattern (default `'none'` — reveals are opt-in), and drives the transition that uncovers a window's content once it finishes loading. The shell paints an opaque surface (`.os-window__reveal`) into the window body at construction and on every subsequent loading edge, then animates its `clip-path` away on `WINDOW_CONTENT_LOADED`. The surface is a sibling of the `