diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d5e05a9..57a4c69e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: name: Lint · Types · Vitest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 with: # Don't leave the GITHUB_TOKEN in .git/config; later steps run # PR-authored code (npm scripts, build) and this job never needs @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: 'npm' @@ -58,6 +58,26 @@ jobs: npm --prefix extensions/desktop-mode-feed-buddy run build git diff --exit-code -- extensions/desktop-mode-feed-buddy/assets/js + - name: Electron adapter extension checks + env: + # Nothing in this job launches Electron — this is lint, types, + # unit tests and the bundles. The `electron` package still + # installs (its type definitions are what `app/tsconfig.json` + # compiles against); only the ~100 MB binary download is + # skipped. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + run: | + npm --prefix extensions/openstation-electron-adapter ci + # The package's own `verify`, rather than a sequence spelled + # out again here. It builds BEFORE it tests, and that order is + # load-bearing: `connect-bundle.test.ts` asserts against + # `app/dist/renderer/connect.js`, so a run that tests first + # passes only on a machine that happens to have built already. + # Deferring to `verify` also means a developer running it + # locally is running what CI runs. + npm --prefix extensions/openstation-electron-adapter run verify + git diff --exit-code -- extensions/openstation-electron-adapter/assets/js + php: name: PHPUnit · PHP ${{ matrix.php }} runs-on: ubuntu-latest @@ -67,7 +87,7 @@ jobs: php: [ '8.3', '8.4' ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 with: # Don't leave the GITHUB_TOKEN in .git/config; later steps run # PR-authored code (npm scripts, build) and this job never needs @@ -75,7 +95,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: 'npm' @@ -130,7 +150,7 @@ jobs: name: Plugin Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 with: # Don't leave the GITHUB_TOKEN in .git/config; later steps run # PR-authored code (npm scripts, build) and this job never needs @@ -138,7 +158,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: 'npm' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 9261bbb4..15a5e187 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -33,13 +33,13 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml index 505501ca..4cde9d7f 100644 --- a/.github/workflows/pr-preview-build.yml +++ b/.github/workflows/pr-preview-build.yml @@ -22,10 +22,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 - name: Set up Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24' cache: 'npm' diff --git a/.github/workflows/pr-preview-publish.yml b/.github/workflows/pr-preview-publish.yml index 544b9100..c9932b8b 100644 --- a/.github/workflows/pr-preview-publish.yml +++ b/.github/workflows/pr-preview-publish.yml @@ -34,33 +34,65 @@ jobs: if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 - name: Resolve PR from trusted workflow_run data id: meta uses: actions/github-script@v9 with: script: | - // The `pr-meta` artifact is written by the fork-controlled build - // run, so its contents are untrusted — a malicious PR could point - // the preview comment at any PR. Derive the identity from the - // `workflow_run` event instead: GitHub sets `head_sha` and it - // cannot be spoofed by PR code. `workflow_run.pull_requests` is - // empty for fork PRs, so resolve the PR number via the API and - // require its head SHA to match before publishing anything. - const headSha = context.payload.workflow_run.head_sha; + // PR identity is never taken from the build run's artifacts — that + // run executes fork-controlled code and can write whatever it + // likes, so a malicious PR could aim the preview comment at any + // PR. Derive it from the `workflow_run` event instead: GitHub sets + // `head_sha`, `head_repository` and `head_branch`, and PR code + // cannot spoof any of them. + // + // Resolve the PR by head repo + branch. `workflow_run.pull_requests` + // is empty for fork PRs, and so is + // `listPullRequestsAssociatedWithCommit` — that endpoint does not + // resolve commits living in a fork, which left every fork PR + // without a preview. + // + // The `head` filter is server-side and fails open: a value GitHub + // does not recognise is ignored rather than rejected, and the call + // then returns every PR. Re-check owner, branch and SHA against the + // payload below so ownership is an invariant enforced here, not a + // bet on that filter's behaviour. + const run = context.payload.workflow_run; + const headSha = run.head_sha; if (!/^[0-9a-f]{40}$/.test(headSha)) { core.setFailed(`Unexpected workflow_run head SHA: ${headSha}`); return; } - const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + const headOwner = run.head_repository?.owner?.login; + const headBranch = run.head_branch; + if (!headOwner || !headBranch) { + core.setFailed(`Incomplete workflow_run head data: owner=${headOwner} branch=${headBranch}`); + return; + } + // `state: 'all'` because a PR can merge between the build finishing + // and this run starting; the old lookup published in that window + // and there's no reason to start failing there. A branch reused + // across several PRs can match more than once, so prefer the open + // one. + const prs = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: context.repo.repo, - commit_sha: headSha, + state: 'all', + head: `${headOwner}:${headBranch}`, + per_page: 100, }); - const pr = prs.find((p) => p.head.sha === headSha); + // `head.repo` is null when the head fork has been deleted. + const matches = prs.filter( + (p) => + p.head.sha === headSha && + p.head.repo?.owner?.login === headOwner && + p.head.ref === headBranch + ); + const pr = matches.find((p) => p.state === 'open') ?? matches[0]; if (!pr) { - core.setFailed(`No pull request found with head SHA ${headSha}; refusing to publish a preview.`); + core.setFailed(`No pull request for ${headOwner}:${headBranch} at head SHA ${headSha}; refusing to publish a preview.`); return; } core.setOutput('pr-number', String(pr.number)); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08d2bef3..3d09cd8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,30 @@ name: Release -# Triggered by pushing a vX.Y.Z tag. Builds the plugin zip and creates a -# GitHub Release with it attached. Use `bin/release.sh` locally to cut a -# release end-to-end (bump + commit + tag + push). +# Triggered by pushing a vX.Y.Z tag. Builds the plugin zip, creates a GitHub +# Release with it attached, then deploys stable tags to WordPress.org. Use +# `bin/release.sh` locally to cut a release end-to-end (bump + commit + tag +# + push). +# +# Also runnable manually against an already-published tag, which re-attempts +# the WordPress.org deploy and leaves the GitHub Release untouched: +# +# gh workflow run release.yml --ref trunk -f tag=v1.0.0 +# +# That exists because a tag push runs the workflow definition frozen into +# that tag's commit, so a deploy that fails for a workflow-level reason can +# never be fixed by re-running it. Dispatching from trunk runs the current +# definition instead. on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Published tag to deploy to WordPress.org (e.g. v1.0.0)' + required: true + type: string permissions: contents: write @@ -16,11 +33,18 @@ jobs: release: name: Build & publish release runs-on: ubuntu-latest + env: + # The tag being released: the pushed ref, or the dispatch input. + # Consumed as a shell variable rather than interpolated into `run:` + # bodies, so the dispatch input can't inject into the script. + TAG: ${{ inputs.tag || github.ref_name }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag || github.ref_name }} - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' cache: 'npm' @@ -31,8 +55,7 @@ jobs: - name: Verify tag matches plugin version run: | - tag="${{ github.ref_name }}" - tag="${tag#v}" + tag="${TAG#v}" pkg=$(node -p "require('./package.json').version") header=$(grep -oP '^\s*\*\s*Version:\s*\K\S+' desktop-mode.php) constant=$(grep -oP "OPENSTATION_VERSION',\s*'\K[^']+" desktop-mode.php) @@ -41,6 +64,12 @@ jobs: echo "::error::Version mismatch — tag '$tag' vs package.json=$pkg header=$header OPENSTATION_VERSION=$constant readme.txt Stable tag=$stable" exit 1 fi + # The deploy action derives VERSION from GITHUB_REF, which is only a + # tag ref on a tag push — on a dispatch it resolves to the branch ref + # and the SVN tag copy becomes `tags/refs/heads/trunk`. Publish the + # version that was just checked against all four locations, so the + # deploy uses a verified value however the workflow was triggered. + echo "VERSION=$tag" >> "$GITHUB_ENV" - name: Build run: npm run build @@ -48,29 +77,42 @@ jobs: - name: Package plugin zip run: bin/package.sh + # Skipped on a manual dispatch: the Release already exists, and + # `gh release create` is not idempotent — it would fail the job + # before it ever reached the deploy. - name: Create GitHub Release + if: github.event_name == 'push' env: GH_TOKEN: ${{ github.token }} run: | - tag="${{ github.ref_name }}" flags=() - if [[ "$tag" == *-* ]]; then + if [[ "$TAG" == *-* ]]; then flags+=(--prerelease) fi - gh release create "$tag" \ - --title "$tag" \ + gh release create "$TAG" \ + --title "$TAG" \ --generate-notes \ "${flags[@]}" \ openstation.zip - name: Unpack zip for wp.org deploy - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ !contains(env.TAG, '-') }} run: unzip -q openstation.zip -d build/ - name: Deploy to WordPress.org - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ !contains(env.TAG, '-') }} uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # 2.3.0 env: SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + # The wp.org slug is frozen at `desktop-mode` (see AGENTS.md) — it is + # the published plugin's SVN path and install directory, and renaming + # it would orphan every existing install's update check. Set it + # explicitly: the action otherwise defaults SLUG to the GitHub repo + # name, which used to coincide with the wp.org slug and stopped + # doing so when this repo was renamed to `openstation`. + SLUG: desktop-mode BUILD_DIR: ./build/desktop-mode + # VERSION comes from the verify step via $GITHUB_ENV — see the note + # there. Both it and SLUG default off a GitHub context that is only + # correct for a tag push, so neither is left implicit. diff --git a/.wordpress-org/blueprints/blueprint.json b/.wordpress-org/blueprints/blueprint.json index 6c27f474..dfadee4a 100644 --- a/.wordpress-org/blueprints/blueprint.json +++ b/.wordpress-org/blueprints/blueprint.json @@ -2,7 +2,7 @@ "$schema": "https://playground.wordpress.net/blueprint-schema.json", "landingPage": "/openstation/", "preferredVersions": { - "php": "8.3", + "php": "8.4", "wp": "latest" }, "features": { @@ -17,15 +17,39 @@ "username": "admin", "password": "password" }, + { + "step": "defineWpConfigConsts", + "consts": { + "JETPACK_DEV_DEBUG": true + } + }, { "step": "installPlugin", "pluginData": { "resource": "url", - "url": "https://github.com/WordPress/openstation/releases/latest/download/openstation.zip" + "url": "https://github.com/RegionallyFamous/sidecar/releases/latest/download/openstation.zip" }, "options": { "activate": true } + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "wordpress.org/plugins", + "slug": "jetpack" + }, + "options": { + "activate": true + } + }, + { + "step": "runPHP", + "code": "\n
\n

OpenStation field guide · Gutenberg

\n\n\n\n

Write here.
Keep the sidebar there.

\n\n\n\n

Sidebar Window gives Gutenberg's Post, Block, and plugin panels an attached inspector pane—so the controls stay visible while your writing canvas stays editable.

\n\n\n\n
\n
\n

~320 px

\n\n\n

A focused companion, not another full-screen workspace.

\n
\n\n\n\n
\n

Same height

\n\n\n

Its icon row continues the editor title bar, and the whole inspector follows the editor.

\n
\n\n\n\n
\n

Ephemeral

\n\n\n

Close it freely; only the source editor belongs to your saved session.

\n
\n
\n
\n\n\n\n
\n
\n

How to use it

\n\n\n

This demo opens Sidebar Window automatically with both sidebar copies visible. Use Gutenberg's own Settings and available plugin-sidebar buttons for the in-editor panel, and use the attached sidebar icons for the independent external copy. Later, use the Sidebar Window button in the editor window's title bar to reopen it.

\n
\n\n\n\n
\n
    \n
  1. Use Gutenberg's own Settings button or an available plugin-sidebar button to choose the panel inside the source editor.
  2. \n\n\n
  3. Use the companion's familiar sidebar icons to choose a different panel for the external copy.
  4. \n\n\n
  5. Keep both panels open at once—each control surface can show a different Post, Block, or plugin panel.
  6. \n\n\n
  7. Close the companion without affecting the source sidebar; later, use Sidebar Window to reopen it.
  8. \n
\n
\n
\n\n\n\n
\n

A real window, with an honest tradeoff

\n\n\n

The companion is a shell-managed sibling that loads the same post in a second Gutenberg document. That boundary is what lets plugin-owned React panels behave normally outside the source iframe. OpenStation leaves the source sidebar untouched while the companion runs independently beside it.

\n
\n\n\n\n

Built for the panels you already use

\n\n\n\n

Document settings, block controls, publishing checks, SEO tools, custom fields, and other Gutenberg extensions can remain in view without repeatedly covering your content. The result feels simple: one writing surface, one narrow control surface, both visible at once.

\n\n\n\n
\n\n\n\n

Try changing a block, opening its settings, and moving the connected editor pair. This page is yours to experiment with.

\n\nHTML;\n\n$existing = get_page_by_path('meet-sidebar-window', OBJECT, 'page');\n$page_data = array(\n 'post_title' => 'Meet Sidebar Window',\n 'post_name' => 'meet-sidebar-window',\n 'post_status' => 'publish',\n 'post_type' => 'page',\n 'post_content' => $content,\n 'post_author' => (int) $admin->ID,\n 'comment_status' => 'closed',\n);\nif ($existing instanceof WP_Post) {\n $page_data['ID'] = (int) $existing->ID;\n $page_id = wp_update_post($page_data, true);\n} else {\n $page_id = wp_insert_post($page_data, true);\n}\nif (is_wp_error($page_id)) {\n throw new RuntimeException($page_id->get_error_message());\n}\n\nupdate_user_meta((int) $admin->ID, 'desktop_mode_mode', '1');\nif (function_exists('openstation_clear_session')) {\n openstation_clear_session((int) $admin->ID);\n} else {\n delete_user_meta((int) $admin->ID, 'desktop_mode_session');\n}\n\n$editor_url = add_query_arg(\n array(\n 'post' => (int) $page_id,\n 'action' => 'edit',\n 'openstation_sidebar_window_auto' => '1',\n ),\n admin_url('post.php')\n);\nif (!function_exists('openstation_set_default_window') || !openstation_set_default_window((int) $admin->ID, $editor_url)) {\n throw new RuntimeException('Could not set the OpenStation default window.');\n}\n" } ] } diff --git a/AGENTS.md b/AGENTS.md index d7a954de..ff3eeee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,37 +2,6 @@ The imperative rules for working in this repo, plus the contributor-only gotchas that aren't obvious from reading the code. Public APIs and their contracts live in `docs/`; this file is the rulebook and cheatsheet for working *inside* the codebase. -## Contents - -- [Hard rules](#hard-rules) - - [Never hand-edit JS in `assets/js/`](#never-hand-edit-js-in-assetsjs) - - [The palette lives in `variables.css`](#the-palette-lives-in-variablescss--one-declaration-one-owner) - - [The holographic layer lives in `src/ui/holo.ts`](#the-holographic-layer-lives-in-srcuiholots) - - [Never declare a themeable token on a component's `:host`](#never-declare-a-themeable-token-on-a-components-host) - - [The Legacy theme manifest is frozen data](#the-legacy-theme-manifest-is-frozen-data-not-build-output) - - [`desktop_mode_*` values are frozen](#desktop_mode_-values-are-frozen--the-namevalue-mismatch-is-deliberate) - - [Use `wp.os.fetch` (or `trackedFetch`), never raw `fetch()`](#use-wposfetch-or-trackedfetch-never-raw-fetch) - - [Use `wp.os.confirm` (or `osConfirm`), never `window.confirm`/`alert`/`prompt`](#use-wposconfirm-or-osconfirm-never-windowconfirmalertprompt) - - [Use `os-*` components, not raw HTML controls](#use-os--components-not-raw-html-controls) - - [No version-history annotations in docs or comments](#no-version-history-annotations-in-docs-or-comments) -- [Workflow](#workflow) - - [Always run `npm run build` after all the changes](#always-run-npm-run-build-after-all-the-changes) - - [Run `npm run lint:php` after touching PHP](#run-npm-run-lintphp-after-touching-php) - - [Always branch + PR, never commit to trunk](#always-branch--pr-never-commit-to-trunk) - - [Use `bin/sync-to-wp-develop.sh` for local sync, not raw rsync](#use-binsync-to-wp-developsh-for-local-sync-not-raw-rsync) - - [Don't regenerate POT/PO/JSON in feature PRs](#dont-regenerate-potpojson-in-feature-prs) -- [Process reminders](#process-reminders) -- [Contributor gotchas](#contributor-gotchas) - - [Live-refresh on plugin install/activate — how it actually works](#live-refresh-on-plugin-installactivate--how-it-actually-works) - - [Event-driven framework](#event-driven-framework) - - [Presence — framework-level](#presence--framework-level) - - [Cross-bundle state — `wp.os.createSharedStore`](#cross-bundle-state--wposcreatesharedstore) - - [Chromeless admin-bar suppression](#chromeless-admin-bar-suppression) - - [Running PHPUnit](#running-phpunit) -- [Developer docs — read before, update after](#developer-docs--read-before-update-after) - ---- - ## Hard rules ### Never hand-edit JS in `assets/js/` @@ -76,7 +45,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. **Form controls wear the flat accent when they are on; the mesh is reserved for hero moments.** Checkboxes, radios, switches, the segmented thumb and the slider's elapsed track all resolve through `--os-ui-accent`, which the accent picker writes so the whole family follows the colour the user chose. The mesh appears where a single surface speaks for the brand: ``, and nothing else by default. A panel where every surface is iridescent has no identity moments left to spend; `primary` deliberately did *not* become the mesh either, because it is three-to-a-row in OpenStation Preferences 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. @@ -173,7 +142,7 @@ ESLint enforces this — raw `fetch( … )` and `window.fetch( … )` calls fail - The `trackedFetch` wrapper itself (the boot-time fallback before `wp.os` exists). - The PWA service worker (`src/pwa/sw.ts` — different context, no `wp.os` global). -- Genuinely silent background pollers where attribution would mis-render as user activity (`src/devtools/index.ts`, `src/recycle-bin/badge.ts`). +- Genuinely silent background pollers where attribution would mis-render as user activity (`src/devtools/index.ts`, `src/recycle-bin/icon-state.ts`). ### Use `wp.os.confirm` (or `osConfirm`), never `window.confirm`/`alert`/`prompt` @@ -269,6 +238,7 @@ Payload shape (`openstation_build_menu_payload()` in `includes/core/payload.php` serverCommandScripts, serverCommands, serverSettingsTabScripts, serverSettingsTabs, serverDockRailRendererScripts, serverTitleBarButtonScripts, + serverWindowActionScripts, serverUnfocusEffectScripts, serverWindowLinkRendererScripts, serverWindowThemeScripts, serverWindowThemes, serverWindowControlScripts, serverWindowControls, @@ -281,7 +251,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 Preferences 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. @@ -301,7 +271,7 @@ Three layers: When you're tempted to add a heuristic inside the framework, "do X automatically when Y", stop and turn it into a hook the app can subscribe to. App owns the policy. -Canonical example in-tree: `src/recycle-bin/badge.ts`. Full doc: `docs/event-driven-framework.md`. +Canonical examples in-tree: `src/recycle-bin/icon-state.ts` (state-driven tile art) and `docs/examples/dock-badge.md` (badge counts). Full doc: `docs/event-driven-framework.md`. ### Presence — framework-level diff --git a/README.md b/README.md index 5e080e9b..40afb45a 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,16 @@ 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 Preferences — 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. +- **Gutenberg Sidebar Window** + Gutenberg editor windows gain a title-bar launch chooser for Post settings, Block settings, and plugin sidebars discovered from Gutenberg's own complementary-area toggles. Choosing an entry opens an attached inspector pane with an always-visible row of familiar Gutenberg sidebar icons, including plugin artwork such as Jetpack when its toggle is present. The source sidebar remains open and independent: Gutenberg's own Settings and plugin-sidebar buttons continue controlling the in-editor panel while the matching Sidecar icons control the external copy. The source stays floating; the inspector attaches flush at the measured Gutenberg sidebar width (320px fallback), follows the source, and matches its height. The companion's own title bar, controls, and shadow are suppressed; its icon row continues the source title-bar surface across the inspector, with no exposed gap or second-window outline, while the sidebar body stays a flat white panel behind one Gutenberg-like divider. The shell shifts or minimally narrows the source only when the connected editor would otherwise leave the desktop. Under the visual shell the inspector remains an ephemeral iframe-backed sibling loading the same post URL, because a genuine sibling requires a second Gutenberg document with a separate data registry, undo history, dirty state, and autosave lifecycle; OpenStation does not synchronize those two editor instances. + - **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 a user preference in OpenStation Preferences. 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 +72,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 Preferences** 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 +141,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 Preferences panel sections │ ├── ui/ # web components │ ├── modules/ # vendor-script lazy-loader │ └── plugins/ # built-in demos (animated-logo-wallpaper) @@ -202,7 +205,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 Preferences → 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..41057e34 --- /dev/null +++ b/assets/css/announce.css @@ -0,0 +1,383 @@ +/* + * 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: 100% 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 eyebrow pill, sitting above the hero's own overlay. Same chip the + * welcome dialog wears for "New here", so the two dialogs read as the + * same voice saying two different things. + */ +.os-announce__eyebrow { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #fff; + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 999px; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.os-announce__eyebrow-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #ec9bff; + box-shadow: 0 0 0 4px rgba(236, 155, 255, 0.28); +} + +.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 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); +} + +@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 { + transition: none; + } +} diff --git a/assets/css/chromeless.css b/assets/css/chromeless.css index d999a6db..74f8094b 100644 --- a/assets/css/chromeless.css +++ b/assets/css/chromeless.css @@ -158,20 +158,22 @@ html.wp-toolbar:has( body.os-chromeless ) { * `.page-title-action` (the "Add New" / "Add Order" button next to * the H1) stays VISIBLE by default — it's the only entry point to * the add-new flow on many plugin pages (WooCommerce Orders, custom - * CPTs, plugin settings pages, etc.). Earlier versions of this CSS - * hid it on the assumption that the submenu tab strip exposed the - * same action — which holds for WP Core pages with a registered - * "Add New" submenu (Posts, Pages, Users) but breaks every third- - * party plugin page that has no submenu equivalent. A small bit of - * redundancy with the submenu strip on Core pages is the better - * trade-off vs. losing the primary affordance on plugin pages - * entirely. - * - * Sites that prefer the cleaner Core-page look can hide the button - * on specific pages via the `openstation_chromeless_styles` - * action — e.g.: - * - * body.edit-php .wrap > .page-title-action { display: none; } + * CPTs, plugin settings pages, etc.). A blanket hide would break + * every third-party plugin page that has no submenu equivalent, so + * the button is only removed where the window's own tab strip + * demonstrably leads to the same place — see + * `includes/render/chromeless-title-actions.php`, which emits one + * `[href="…"]` rule per submenu tab URL of the current screen. + * + * Sites that want to hide it somewhere that rule deliberately + * doesn't reach can add their own via the + * `openstation_chromeless_styles` action — e.g. WooCommerce's "Add + * order", which we keep because it points at `&action=new` and the + * Orders tab doesn't: + * + * body.woocommerce_page_wc-orders .wrap > .page-title-action { + * display: none; + * } * * --------------------------------------------------------------- */ .os-chromeless .wrap > h1, @@ -184,19 +186,30 @@ html.wp-toolbar:has( body.os-chromeless ) { /* * The H1 above it is hidden, so the button would float at the very * top of the iframe area without breathing room — give it a small - * margin so it lands cleanly. inline-block matches WP Core's - * computed style on this element (matters for vertical-align with - * any adjacent inline content like the `.subsubsub` filter row). + * margin so it lands cleanly. + * + * `block` + `fit-content` instead of Core's `inline-block`: with the + * H1 hidden, an inline button ends up on the same line as the + * floated `.subsubsub` filter row and reads as one more filter link + * ("All (6) | Published (4) | Trash (2) Add Post"). Its own row is + * also what the screen already looks like when an admin notice is + * showing. `clear` keeps it below anything floated before it. * * Themes.php's native "Add Theme" button lives in the submenu tab * strip via `openstation_inject_appearance_tabs` * (includes/themes-tabs.php), so its in-page page-title-action is - * redundant on that one screen — keep the per-page hide rule below - * so we don't ship two "Add Theme" entry points. + * redundant on that one screen. The generic href-matching hide in + * `includes/render/chromeless-title-actions.php` only matches exact + * URLs, and the injected tab points at + * `theme-install.php?browse=popular` while the button points at + * plain `theme-install.php`. Hence the per-page rule below. */ .os-chromeless .wrap > .page-title-action { - display: inline-block; + display: block; + width: fit-content; margin-top: 12px; + margin-bottom: 12px; + clear: both; } .os-chromeless.themes-php .wrap > .page-title-action { @@ -253,6 +266,31 @@ html.wp-toolbar:has( body.os-chromeless ) { margin-top: 0 !important; } +/* --------------------------------------------------------------- + * Revisions screen — page-scoped. + * + * "← Go to editor" is a back button for a screen the user navigated + * into. Here they didn't: the editor is open in its own window + * behind this one, and closing this window is the way back. + * + * The revision tooltip is positioned upward from the bottom of + * `.revisions-control-frame` and clears the top of the viewport only + * thanks to the screen H1, which chromeless hides. An iframe can't + * overflow its box, so the space has to be given back: 48px covers + * both slider modes. + * + * The padding goes on `.revisions`, not on the frame — the frame is + * the positioned ancestor for the compare-mode checkbox and the + * tooltip, so padding it would leave both behind at the old top edge. + * --------------------------------------------------------------- */ +.os-chromeless.revision-php .wrap > h1.long-header + a { + display: none; +} + +.os-chromeless.revision-php .revisions { + padding-top: 48px; +} + /* --------------------------------------------------------------- * Dashboard welcome panel * The default 16px top margin pushes the panel down and creates a @@ -422,6 +460,135 @@ html.wp-toolbar:has( body.os-chromeless ) { bottom: 0 !important; } +/* --------------------------------------------------------------- + * Gutenberg Sidebar Window + * + * `os-editor-sidecar-detached` belongs only to the iframe inside the + * real, shell-managed Sidebar Window. Hide the duplicate editor canvas + * and header, then let Gutenberg's complementary area occupy the whole + * window body. The sidebar remains in its own normal Gutenberg/React + * document; OpenStation's outer window supplies the title bar, border, + * drag surface, resize handles, and close control. + * --------------------------------------------------------------- */ +.os-chromeless.os-editor-sidecar-active + .interface-interface-skeleton__body { + overflow: hidden; + background: var( --os-ui-surface, #fff ); +} + +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__header, +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__content, +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__footer, +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__secondary-sidebar, +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__actions { + display: none !important; +} + +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__body { + display: block !important; + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; +} + +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__sidebar { + position: absolute !important; + inset: 0 !important; + display: flex !important; + flex-direction: column; + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + height: 100% !important; + min-height: 0; + margin: 0 !important; + box-sizing: border-box; + overflow: hidden; + border: 0 !important; + border-radius: 0 !important; + background: var( --os-ui-surface, #fff ); + box-shadow: none !important; + z-index: 25; +} + +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__sidebar + .interface-complementary-area__fill, +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__sidebar + .interface-complementary-area__fill + > div, +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__sidebar + .interface-complementary-area { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + height: 100% !important; + min-height: 0; +} + +.os-chromeless.os-editor-sidecar-detached + .interface-interface-skeleton__sidebar + .interface-complementary-area__fill { + flex: 1 1 auto; + height: 100% !important; + overflow: hidden; +} + +/* Retained for keyboard width persistence and the legacy message + * contract. The outer OpenStation window is the visible resize surface + * in detached mode, so this inner separator stays hidden there. */ +.os-chromeless .os-editor-sidecar-resizer { + position: fixed; + width: 9px; + margin: 0; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; + z-index: 100002; +} + +.os-chromeless.os-editor-sidecar-detached .os-editor-sidecar-resizer { + display: none; +} + +.os-chromeless .os-editor-sidecar-resizer::before { + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 2px; + background: transparent; + content: ''; +} + +.os-chromeless .os-editor-sidecar-resizer:hover::before, +.os-chromeless .os-editor-sidecar-resizer:focus-visible::before { + background: var( --wp-admin-theme-color, #2271b1 ); +} + +.os-chromeless .os-editor-sidecar-resizer:focus-visible { + outline: 2px solid var( --wp-admin-theme-color, #2271b1 ); + outline-offset: -2px; +} + +.os-chromeless.os-editor-sidecar-resizing, +.os-chromeless.os-editor-sidecar-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + /* --------------------------------------------------------------- * Notices * Give notices a bit of top margin since there's no admin bar above. diff --git a/assets/css/comments-window.css b/assets/css/comments-window.css index f6ec6f86..86599096 100644 --- a/assets/css/comments-window.css +++ b/assets/css/comments-window.css @@ -9,7 +9,7 @@ * properties rather than re-implemented here. */ -.desktop-mode-comments { +.os-comments { display: flex; flex-direction: column; /* @@ -25,7 +25,7 @@ inline-size: 100%; overflow: hidden; position: relative; - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); } /* @@ -36,7 +36,7 @@ * the action row would stay on screen underneath the inline comment * editor that just replaced it. Authoritative single override. */ -.desktop-mode-comments [hidden] { +.os-comments [hidden] { display: none !important; } @@ -46,7 +46,7 @@ * status dots and link hints below are meaningless without it — worth * not depending on load order for. */ -.desktop-mode-comments .screen-reader-text { +.os-comments .screen-reader-text { position: absolute; width: 1px; height: 1px; @@ -68,7 +68,7 @@ .os-comments__tabrow { flex: 0 0 auto; padding-inline: 12px; - border-block-end: 1px solid var( --wp-desktop-border-color, var( --os-ui-border, #dcdcde ) ); + border-block-end: 1px solid var( --os-ui-border, #dcdcde ); } /* `` owns the pill — this is spacing only. */ @@ -90,7 +90,7 @@ /* ── Left rail ───────────────────────────────────────────────── */ .os-comments__rail { - border-inline-end: 1px solid var( --wp-desktop-border-color, var( --os-ui-border, #dcdcde ) ); + border-inline-end: 1px solid var( --os-ui-border, #dcdcde ); display: flex; flex-direction: column; min-block-size: 0; @@ -122,10 +122,10 @@ padding: 8px 14px; font-size: 12px; background: color-mix( in srgb, var( --os-ui-accent, #2271b1 ) 8%, transparent ); - border-block-end: 1px solid var( --wp-desktop-border-color, var( --os-ui-border, #dcdcde ) ); + border-block-end: 1px solid var( --os-ui-border, #dcdcde ); } .os-comments__rail-filter-label { - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); font-weight: 600; overflow: hidden; text-overflow: ellipsis; @@ -161,11 +161,11 @@ } .os-comments__thread:hover { - background: var( --wp-desktop-hover-bg, var( --os-ui-hover, rgba( 0, 0, 0, 0.045 ) ) ); + background: var( --os-ui-hover, rgba( 0, 0, 0, 0.045 ) ); } .os-comments__thread:focus-visible { - outline: 2px solid var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + outline: 2px solid var( --os-ui-accent, #2271b1 ); outline-offset: -2px; } @@ -179,7 +179,7 @@ inset-inline-start: 0; inset-block: 0; width: 3px; - background: var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + background: var( --os-ui-accent, #2271b1 ); } /* In-flight moderation on this conversation. */ @@ -205,7 +205,7 @@ .os-comments__thread-name { font-size: 13.5px; font-weight: 600; - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); display: flex; align-items: center; gap: 6px; @@ -213,7 +213,7 @@ .os-comments__thread-snip { font-size: 12.5px; - color: var( --wp-desktop-muted-text-color, var( --os-ui-fg-muted, #50575e ) ); + color: var( --os-ui-fg-muted, #50575e ); margin-block-start: 2px; white-space: nowrap; overflow: hidden; @@ -287,7 +287,7 @@ flex-direction: column; min-width: 0; min-block-size: 0; - background: var( --wp-desktop-window-bg, var( --os-ui-surface, #fff ) ); + background: var( --os-ui-surface, #fff ); } /* `` owns the icon + copy; this is placement only. */ @@ -303,7 +303,7 @@ justify-content: space-between; gap: 12px; padding: 14px 18px; - border-block-end: 1px solid var( --wp-desktop-border-color, var( --os-ui-border, #dcdcde ) ); + border-block-end: 1px solid var( --os-ui-border, #dcdcde ); flex: 0 0 auto; } .os-comments__convo-context { @@ -319,7 +319,7 @@ .os-comments__convo-post { font-size: 15px; font-weight: 600; - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); margin-block-start: 2px; display: block; overflow: hidden; @@ -335,17 +335,17 @@ */ a.os-comments__convo-post--editable { text-decoration: none; - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); } .os-comments__convo-post-pencil { margin-inline-start: 6px; vertical-align: -2px; opacity: 0.55; - color: var( --wp-desktop-muted-text-color, var( --os-ui-fg-muted, #50575e ) ); + color: var( --os-ui-fg-muted, #50575e ); } a.os-comments__convo-post--editable:hover, a.os-comments__convo-post--editable:focus-visible { - color: var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + color: var( --os-ui-accent, #2271b1 ); text-decoration: underline; } a.os-comments__convo-post--editable:hover .os-comments__convo-post-pencil, @@ -354,7 +354,7 @@ a.os-comments__convo-post--editable:focus-visible .os-comments__convo-post-penci color: inherit; } a.os-comments__convo-post--editable:focus-visible { - outline: 2px solid var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + outline: 2px solid var( --os-ui-accent, #2271b1 ); outline-offset: 2px; border-radius: 3px; } @@ -366,7 +366,7 @@ a.os-comments__convo-post--editable:focus-visible { flex: 0 0 auto; } .os-comments__convo-link { - color: var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + color: var( --os-ui-accent, #2271b1 ); font-size: 12.5px; text-decoration: none; white-space: nowrap; @@ -376,7 +376,7 @@ a.os-comments__convo-post--editable:focus-visible { } .os-comments__convo-link:hover { text-decoration: underline; } .os-comments__convo-link:focus-visible { - outline: 2px solid var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + outline: 2px solid var( --os-ui-accent, #2271b1 ); outline-offset: 2px; border-radius: 3px; } @@ -410,7 +410,7 @@ a.os-comments__convo-post--editable:focus-visible { .os-comments__msg-line { flex: 1 1 auto; width: 2px; - background: var( --wp-desktop-border-color, var( --os-ui-border, #dcdcde ) ); + background: var( --os-ui-border, #dcdcde ); margin-block-start: 6px; border-radius: 2px; } @@ -427,7 +427,7 @@ a.os-comments__convo-post--editable:focus-visible { .os-comments__msg-name { font-weight: 600; font-size: 13.5px; - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); } .os-comments__msg-you { --os-ui-badge-padding: 0 5px; @@ -458,7 +458,7 @@ a.os-comments__convo-post--editable:focus-visible { .os-comments__msg-text { font-size: 13.5px; line-height: 1.5; - color: var( --wp-desktop-text-color, var( --os-ui-fg, #2c3338 ) ); + color: var( --os-ui-fg, #2c3338 ); margin-block-start: 4px; overflow-wrap: anywhere; } @@ -483,7 +483,7 @@ a.os-comments__convo-post--editable:focus-visible { .os-comments__act { --os-ui-button-padding: 0; - --os-ui-button-fg: var( --wp-desktop-accent, var( --os-ui-accent, #2271b1 ) ); + --os-ui-button-fg: var( --os-ui-accent, #2271b1 ); font-size: 12.5px; line-height: 1.4; } @@ -509,8 +509,8 @@ a.os-comments__convo-post--editable:focus-visible { /* ── Composer ────────────────────────────────────────────────── */ .os-comments__composer { - border-block-start: 1px solid var( --wp-desktop-border-color, var( --os-ui-border, #dcdcde ) ); - background: var( --wp-desktop-elevated-bg, var( --os-ui-surface-elevated, #f6f7f7 ) ); + border-block-start: 1px solid var( --os-ui-border, #dcdcde ); + background: var( --os-ui-surface-elevated, #f6f7f7 ); padding: 12px 18px 16px; flex: 0 0 auto; } @@ -519,11 +519,11 @@ a.os-comments__convo-post--editable:focus-visible { } .os-comments__composer-to { font-size: 12px; - color: var( --wp-desktop-muted-text-color, var( --os-ui-fg-muted, #50575e ) ); + color: var( --os-ui-fg-muted, #50575e ); margin-block-end: 8px; } .os-comments__composer-to b { - color: var( --wp-desktop-text-color, var( --os-ui-fg, #1d2327 ) ); + color: var( --os-ui-fg, #1d2327 ); } .os-comments__composer-row { display: flex; diff --git a/assets/css/desktop-files.css b/assets/css/desktop-files.css index 24318335..93c49822 100644 --- a/assets/css/desktop-files.css +++ b/assets/css/desktop-files.css @@ -152,7 +152,25 @@ align-items: center; justify-content: flex-start; gap: 6px; - width: 88px; + /* + * `box-sizing` is load-bearing, not housekeeping. Without it the + * horizontal padding is ADDED to the declared width, so an 88px + * tile occupies 96px — which was exactly the desktop's old cell + * pitch, and why icons sat edge to edge with no gap at all while + * the maths insisted there were 8px between them. + * + * The tile is the size the grid says it is. Padding lives inside. + */ + box-sizing: border-box; + width: var( --os-tile-w, 88px ); + /* + * Fixed, not minimum. The tile box IS the selection ring, and a + * box that grows with its label gives a row of selected icons a + * ragged top edge — one height per label that happened to wrap + * to two lines. Every tile occupies its cell; short labels leave + * the slack inside the ring, where it reads as padding. + */ + height: var( --os-tile-h, 104px ); padding: 8px 4px; border: 0; background: transparent; @@ -589,7 +607,15 @@ * a light background. Plugins / themes can override these * same variables at any scope to retint further. */ --os-tile-fg: var( --os-fg-on-light, var( --os-ui-fg, #1d2327 ) ); - --os-tile-fg-muted: rgba( 0, 0, 0, 0.55 ); + /* Chains through the palette for the same reason as + * `--os-tile-fg` beside it: a bare literal here outranks the + * palette's own value and pins every tile's secondary line to + * near-black, which disappears the moment the surface is dark. + * The literal stays as the pre-brand floor. */ + --os-tile-fg-muted: var( + --os-folder-window-fg-muted, + var( --os-ui-fg-muted, rgba( 0, 0, 0, 0.55 ) ) + ); --os-tile-hover-bg: rgba( 0, 0, 0, 0.06 ); /* Light-context label rendering — share the same recipe with * every other light-bg tile surface (My WordPress, future @@ -754,6 +780,58 @@ --os-tile-selected-bg: rgba( 34, 113, 177, 0.12 ); } +/* Type breakdown under a multi-selection's count in a folder + * window's preview pane. The count is the headline; this is the + * detail that tells you which actions the menu will offer. */ +.os-files-preview__selection-breakdown { + font-size: 12px; + opacity: 0.75; +} + +/* While a rubber band is live, nothing in the shell is selectable. + * + * The band starts on bare canvas, which — unlike a tile — IS + * selectable, so the browser begins its own text selection on the + * same press and keeps extending it as the pointer travels. Inside + * the canvas that stays invisible; drag out over another window and + * it starts highlighting that window's text in blue, mid-gesture. + * + * The controller also refuses `selectstart` outright while the band + * is up. This rule is the second half: it covers anything already + * selectable that the event route misses. */ +body[ data-os-marquee ] { + -webkit-user-select: none; + user-select: none; +} + +/* Marquee (rubber-band) selection box. Painted by the shared + * selection controller on any canvas that opts into it — the + * wallpaper, folder windows, every My WordPress list — so this one + * declaration dresses all of them. + * + * `pointer-events: none` is load-bearing: the box tracks under the + * cursor for the whole gesture, and without it every hit-test + * (drop targets, the controller's own tile lookup) would land on + * the box instead of what's beneath it. */ +.os-selection-marquee { + position: absolute; + z-index: 5; + pointer-events: none; + border: 1px solid + var( --os-tile-focus-ring, var( --wp-admin-theme-color, #2271b1 ) ); + border-radius: 2px; + /* A WASH, not a fill — the whole point of a rubber band is that + * you can see what it is about to catch. `--os-ui-accent-dim` is a + * solid hex (Pulse, one step back), so reaching for it directly + * paints an opaque rectangle over the icons; `--os-ui-accent-soft` + * is that same colour already taken down to a 14% wash, which is + * what every other ambient accent surface in the shell uses. */ + background: var( + --os-selection-marquee-bg, + var( --os-ui-accent-soft, rgba( 34, 113, 177, 0.14 ) ) + ); +} + .os-folder-status-bar { display: flex; justify-content: space-between; @@ -845,15 +923,37 @@ button.os-folder-status-bar__segment:hover { } /* - * "New folder" inline dialog. Centered modal with a focused - * input — replaces the placeholder window.prompt. Plain HTML - * controls so it works as a building block before richer - * dialog primitives land. + * The shell's two built-in modals — "New folder" / "Rename", and the + * web-link dialog, which reuses this class set for its surface. Both + * are light-DOM overlays that slot `` + + * `` for their controls. + * + * The surface reads the same `--os-ui-modal-*` family that + * `` and `` do, so the three look like + * one dialog system and answer to a desktop theme together. Two + * things it must NOT do, both of which it used to: + * + * - Paint from `--os-bg`. That is the WALLPAPER token: the desk + * default is a gradient and the wallpaper layer overwrites it + * with whatever artwork is active. A dialog is a surface, not a + * desk. Same note as `os-modal.styles.ts`. + * - Style raw form controls. Core's `forms.css` reaches every + * `` in the parent shell through `input[type="text"]` — + * (0,1,1), which outranks a single class of ours — so a plain + * input rendered as a white core-chrome box on this dark + * surface no matter what we declared. The controls live in + * shadow DOM now, where that sheet cannot follow. */ .os-create-folder-dialog__overlay { position: fixed; inset: 0; background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.45 ) ); + /* Desktop-theme texture slot: unset resolves to none. Mirrors + the scrim treatment in os-modal. */ + background-image: var( --os-ui-scrim-image, none ); + background-repeat: var( --os-ui-scrim-image-repeat, repeat ); + background-size: var( --os-ui-scrim-image-size, auto ); + background-position: var( --os-ui-scrim-image-position, center ); backdrop-filter: blur( 2px ); display: flex; align-items: center; @@ -862,10 +962,39 @@ button.os-folder-status-bar__segment:hover { } .os-create-folder-dialog { + /* + * Re-point the shared control tokens for a dark dialog surface, + * exactly as `` does on its host. The slotted + * `` / `` inherit these across the + * shadow boundary, so the field resolves a dark input with light + * ink instead of the light-admin defaults (`--os-window-bg` is + * `#fff` in an unthemed shell, which under a light `--os-ui-fg` + * is the invisible-value trap the modal styles call out). + * + * Each reads a palette-owned `--os-ui-modal-*` name first, so a + * desktop theme can still reach every one of them; the literals + * are only the floor for a shell whose stylesheet never loaded. + */ + --os-ui-fg: var( --os-ui-modal-text, #f0f0f1 ); + --os-ui-fg-muted: var( --os-ui-modal-text-muted, #a7aaad ); + --os-ui-border: var( --os-ui-modal-border, rgba( 255, 255, 255, 0.25 ) ); + --os-window-bg: var( --os-ui-modal-field-bg, #2c3338 ); + --os-ui-button-bg-hover: var( + --os-ui-modal-button-bg-hover, + rgba( 255, 255, 255, 0.08 ) + ); + width: min( 420px, 92vw ); - background: var( --os-bg, #1d2327 ); - color: var( --os-fg, #fff ); - border: 1px solid rgba( 255, 255, 255, 0.08 ); + /* Longhand, and a literal fallback: the shorthand would reset the + texture slot below, and a gradient is invalid as a + background-color — it would leave the dialog transparent. */ + background-color: var( --os-ui-modal-bg, #1d2327 ); + background-image: var( --os-ui-dialog-bg-image, none ); + background-repeat: var( --os-ui-dialog-bg-image-repeat, repeat ); + background-size: var( --os-ui-dialog-bg-image-size, auto ); + background-position: var( --os-ui-dialog-bg-image-position, center ); + color: var( --os-ui-modal-fg, var( --os-fg, #fff ) ); + border: 1px solid var( --os-ui-border, rgba( 255, 255, 255, 0.08 ) ); border-radius: 10px; box-shadow: 0 20px 50px rgba( 0, 0, 0, 0.6 ); padding: 20px 22px 18px; @@ -878,33 +1007,19 @@ button.os-folder-status-bar__segment:hover { margin: 0 0 4px; font-size: 16px; font-weight: 600; + color: var( --os-ui-fg, #f0f0f1 ); } -.os-create-folder-dialog__label { +.os-url-dialog__description { + margin: 0 0 4px; font-size: 12px; - color: var( --os-fg-muted, rgba( 255, 255, 255, 0.7 ) ); -} - -.os-create-folder-dialog__input { - width: 100%; - padding: 8px 10px; - background: rgba( 255, 255, 255, 0.06 ); - color: inherit; - border: 1px solid rgba( 255, 255, 255, 0.16 ); - border-radius: 6px; - font-size: 14px; - outline: none; - box-sizing: border-box; -} - -.os-create-folder-dialog__input:focus { - border-color: var( --wp-admin-theme-color, #2271b1 ); - box-shadow: 0 0 0 2px rgba( 34, 113, 177, 0.4 ); + line-height: 1.5; + color: var( --os-ui-fg-muted, #a7aaad ); } .os-create-folder-dialog__error { margin: 0; - color: #ff8a8a; + color: var( --os-ui-danger-hover, #ff8a8a ); font-size: 12px; } @@ -915,39 +1030,10 @@ button.os-folder-status-bar__segment:hover { margin-top: 6px; } -.os-create-folder-dialog__btn { - border: 0; - border-radius: 6px; - padding: 8px 14px; - font-size: 13px; - cursor: pointer; - font-weight: 500; -} - -.os-create-folder-dialog__btn--secondary { - background: rgba( 255, 255, 255, 0.08 ); - color: inherit; -} - -.os-create-folder-dialog__btn--secondary:hover { - background: rgba( 255, 255, 255, 0.14 ); -} - -.os-create-folder-dialog__btn--primary { - background: var( --wp-admin-theme-color, #2271b1 ); - color: var( --os-ui-fg-on-accent, #fff ); -} - -.os-create-folder-dialog__btn--primary:hover { - filter: brightness( 1.08 ); -} - -.os-create-folder-dialog__btn:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.os-create-folder-dialog--busy .os-create-folder-dialog__input { +/* The busy state dims the whole form area rather than the input + alone — the buttons carry their own disabled treatment from + os-button, and dimming one control of three read as a glitch. */ +.os-create-folder-dialog--busy .os-create-folder-dialog__field { opacity: 0.7; } @@ -961,7 +1047,10 @@ button.os-folder-status-bar__segment:hover { .os-file-tile__label { font-size: 12px; line-height: 1.2; - max-width: 84px; + /* Follows the tile rather than restating its width, so a section + * that re-points `--os-tile-w` (image-led sections do) gets a + * label that grows with the tile instead of staying narrow. */ + max-width: calc( var( --os-tile-w, 88px ) - 4px ); text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -1003,6 +1092,65 @@ button.os-folder-status-bar__segment:hover { cursor: no-drop; } +/* Multi-item drag ghost: the grabbed tile, with the rest of the set + * implied by two offset cards behind it and stated by a count badge. + * The stack is drawn with pseudo-elements rather than real clones — + * three cloned tiles cost three subtree copies per lift, and only + * the top one is ever legible. */ +.os-drag-stack { + /* The drag manager overwrites this with `position: fixed` when it + * mounts the ghost. It stays declared so the stack still composes + * correctly if the helper is used outside a live drag — both + * values establish the containing block the cards and the badge + * are placed against. */ + position: relative; +} + +.os-drag-stack::before, +.os-drag-stack::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: 6px; + background: var( --os-ui-surface, rgba( 30, 30, 40, 0.9 ) ); + box-shadow: 0 4px 14px rgba( 0, 0, 0, 0.35 ); +} + +.os-drag-stack::before { + transform: translate( 6px, 6px ); + opacity: 0.7; +} + +.os-drag-stack::after { + transform: translate( 12px, 12px ); + opacity: 0.45; +} + +.os-drag-stack__count { + position: absolute; + inset-block-start: -8px; + inset-inline-end: -8px; + z-index: 1; + min-width: 20px; + height: 20px; + padding: 0 6px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: var( + --os-ui-accent, + var( --wp-admin-theme-color, #2271b1 ) + ); + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 1; + box-shadow: 0 2px 8px rgba( 0, 0, 0, 0.4 ); +} + /* ============================================================ * * Upload progress HUD (since 0.31.0) * diff --git a/assets/css/desktop.css b/assets/css/desktop.css index db9ef301..d924319d 100644 --- a/assets/css/desktop.css +++ b/assets/css/desktop.css @@ -189,7 +189,15 @@ body.os-active.os-admin-bar-dynamic #wpadminbar { body.os-active.os-admin-bar-dynamic #wpadminbar:hover, body.os-active.os-admin-bar-dynamic #wpadminbar:focus-within, -body.os-active.os-admin-bar-dynamic #wpadminbar:active { +body.os-active.os-admin-bar-dynamic #wpadminbar:active, +/* + * …and while the notch is being used. The notch hangs from the same + * edge and steps down to sit under the bar when it appears (see + * notch.css), so letting the bar retract out from over it mid-reach + * would drop the notch back up under the pointer. + */ +body.os-active.os-admin-bar-dynamic:has( .os-notch:hover ) #wpadminbar, +body.os-active.os-admin-bar-dynamic:has( .os-notch:focus-visible ) #wpadminbar { transform: translateY(0); } @@ -785,48 +793,6 @@ body.os-has-fullscreen-window .os-shell { pointer-events: none; } -.os-icon[ data-icon-id="desktop-mode-recycle-bin" ] > .os-icon__badge { - /* - * The bin's count is tucked ONTO the lid rather than floating off - * the corner, so it gets its own smaller geometry. Every value - * derives from the icon image size, which keeps the badge on the - * lid when a theme scales wallpaper icons — hardcoded offsets slid - * off the artwork entirely. - * - * Override chain: bin-specific token → the generic icon-badge - * token (so a theme that says "badges are 22px" gets the bin too) - * → the derivation. All three resolve to the historical 15px / 9px - * at the default 48px icon. - */ - --os-recycle-badge-computed-size: var( - --os-recycle-badge-size, - var( - --os-icon-badge-size, - calc( var( --os-icon-image-size, 48px ) * 0.3125 ) - ) - ); - top: calc( var( --os-icon-image-size, 48px ) * 0.8125 ); - inset-inline-end: calc( var( --os-icon-image-size, 48px ) * 0.4167 ); - min-width: var( --os-recycle-badge-computed-size ); - height: var( --os-recycle-badge-computed-size ); - padding: 0 calc( var( --os-recycle-badge-computed-size ) * 0.267 ); - border-radius: 999px; - background: var( --os-recycle-badge-bg, rgba( 29, 35, 39, 0.74 ) ); - color: var( --os-recycle-badge-fg, rgba( 255, 255, 255, 0.96 ) ); - font-size: var( - --os-recycle-badge-font-size, - var( - --os-icon-badge-font-size, - calc( var( --os-recycle-badge-computed-size ) * 0.6 ) - ) - ); - font-weight: 700; - letter-spacing: 0; - box-shadow: - inset 0 0 0 1px rgba( 255, 255, 255, 0.28 ), - 0 1px 2px rgba( 0, 0, 0, 0.28 ); -} - .os-icon:hover, .os-icon:focus-visible { /* background-COLOR, not the shorthand: the shorthand would reset @@ -866,146 +832,6 @@ body.os-has-fullscreen-window .os-shell { word-break: break-word; } -/* --------------------------------------------------------------- - * Sticky notes — Gutenberg `wp_guideline` artifacts tagged with the - * sticky guideline type. Notes sit above wallpaper icons/widgets and - * below windows, so they behave like desktop objects rather than app - * chrome. - * --------------------------------------------------------------- */ - -.os-sticky-notes { - position: absolute; - inset: 0; - z-index: 2; - pointer-events: none; - transition: - opacity 0.22s ease, - transform 0.28s cubic-bezier( 0.2, 0, 0.2, 1 ); -} - -.os-area--overview .os-sticky-notes { - opacity: 0; - transform: translateY( 12px ); - pointer-events: none; -} - -.os-sticky-note { - position: absolute; - display: flex; - flex-direction: column; - pointer-events: auto; - background: #fff09a; - color: #2a2513; - border: 1px solid rgba( 84, 67, 0, 0.24 ); - border-radius: 4px; - box-shadow: - 0 12px 28px rgba( 0, 0, 0, 0.22 ), - 0 2px 5px rgba( 0, 0, 0, 0.16 ), - inset 0 1px 0 rgba( 255, 255, 255, 0.55 ); - overflow: hidden; - resize: both; -} - -.os-sticky-note--dragging { - opacity: 0.92; - resize: none; - user-select: none; -} - -.os-sticky-note__header { - display: flex; - align-items: center; - gap: 5px; - min-height: 30px; - padding: 4px 6px 4px 8px; - background: rgba( 82, 64, 0, 0.06 ); - border-bottom: 1px solid rgba( 82, 64, 0, 0.14 ); - cursor: grab; - touch-action: none; - user-select: none; -} - -.os-sticky-note--dragging .os-sticky-note__header { - cursor: grabbing; -} - -.os-sticky-note__grip { - flex: 0 0 auto; - width: 8px; - height: 14px; - background-image: radial-gradient( - circle, - rgba( 60, 48, 0, 0.5 ) 1.2px, - transparent 1.5px - ); - background-size: 4px 4px; - background-position: 0 1px; - background-repeat: space; - opacity: 0.42; -} - -.os-sticky-note__title { - flex: 1; - min-width: 0; - font-size: 11px; - font-weight: 650; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.os-sticky-note__status { - flex: 0 0 auto; -} - -.os-sticky-note os-window-button { - --os-ui-btn-color: rgba( 42, 37, 19, 0.7 ); - --os-ui-btn-color-hover: #2a2513; - --os-ui-btn-bg-hover: rgba( 42, 37, 19, 0.1 ); - --os-ui-btn-bg-active: rgba( 42, 37, 19, 0.16 ); - --os-ui-btn-danger-hover: rgba( 214, 54, 56, 0.18 ); - --os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 ); -} - -.os-sticky-note__open.is-disabled { - opacity: 0.34; - pointer-events: none; -} - -.os-sticky-note__editor { - flex: 1; - min-height: 0; - --os-window-bg: transparent; - --os-ui-border: transparent; - --os-ui-fg: #2a2513; - --os-ui-fg-muted: rgba( 42, 37, 19, 0.62 ); - font-family: - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; -} - -.os-sticky-note__editor::part(textarea) { - height: 100%; - min-height: 100%; - padding: 10px 12px 12px; - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - color: #2a2513; - font-size: 13px; - line-height: 1.4; - resize: none; -} - -.os-sticky-note__editor::part(textarea):hover, -.os-sticky-note__editor::part(textarea):focus-visible { - border-color: transparent; - box-shadow: none; -} - /* --------------------------------------------------------------- * Widgets column — right-edge glass strip that paints above the * wallpaper but beneath windows. Hosts stacked widget cards and a @@ -1583,3 +1409,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/dock.css b/assets/css/dock.css index 87eda7d8..475e9443 100644 --- a/assets/css/dock.css +++ b/assets/css/dock.css @@ -633,7 +633,7 @@ width: 4px; height: 4px; border-radius: 50%; - background: var( --os-ui-surface, #fff ); + background: var( --os-dock-item-outline, #fff ); } .os-dock[ data-os-dock-placement="left" ] @@ -659,7 +659,7 @@ height: 6px; border-radius: 50%; background: transparent; - border: 1px solid rgba( 255, 255, 255, 0.85 ); + border: 1px solid var( --os-dock-item-outline, rgba( 255, 255, 255, 0.85 ) ); } /* Right dock: indicator on the opposite edge (inside-facing). */ @@ -673,7 +673,7 @@ width: 4px; height: 4px; border-radius: 50%; - background: var( --os-ui-surface, #fff ); + background: var( --os-dock-item-outline, #fff ); } .os-dock[ data-os-dock-placement="right" ] @@ -689,7 +689,7 @@ height: 6px; border-radius: 50%; background: transparent; - border: 1px solid rgba( 255, 255, 255, 0.85 ); + border: 1px solid var( --os-dock-item-outline, rgba( 255, 255, 255, 0.85 ) ); } /* "Open another" chip — left: right of tile; right: left of tile. */ @@ -717,32 +717,49 @@ transform: translateY( -50% ) scale( 1.1 ); } -/* Separator — horizontal hairline between menu tiles and system - * tiles. With the `__scroll` / `__pinned` split the system separator - * lives at the top of `__pinned`; flex-flow handles the "system - * tiles at the bottom" placement, no margin-top: auto needed. The - * `--group` variant is used inline between core and plugin menu - * tiles in `__scroll`; it keeps the hairline styling and sits - * exactly where it's inserted. */ +/* Separator — the line between two groups of tiles. With the + * `__scroll` / `__pinned` split the system separator lives at the top + * of `__pinned`; flex-flow handles the "system tiles at the bottom" + * placement, no margin-top: auto needed. The `--group` variant is used + * inline between core and plugin menu tiles in `__scroll` and sits + * exactly where it's inserted. + * + * ONE treatment for every division in the rail. The selector carries + * only the base class on purpose: `--group` elements carry it too, so + * both boundaries resolve here and cannot drift apart. Two weights in + * one short rail read as two unrelated ideas rather than one system, + * which is why the earlier loud-plus-hairline pairing is gone. + * + * A gradient, not a flat fill: the line is brightest where the eye + * lands and gone by the time it reaches the rail's padding, so it + * never terminates in a visible stub against the glass. The soft glow + * around it is what keeps a 2px line from disappearing into the dock + * tint; nothing is drawn on the line itself. */ .os-dock[ data-os-dock-placement="left" ] .os-dock__separator, .os-dock[ data-os-dock-placement="right" ] .os-dock__separator { + position: relative; width: 60%; - height: 1px; - margin: 8px auto 4px; - background: var( --os-dock-border ); + height: 2px; + margin: 12px auto; + border-radius: 2px; + background: linear-gradient( + to right, + transparent 0%, + var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 28%, + var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 72%, + transparent 100% + ); + box-shadow: 0 0 10px + color-mix( + in srgb, + var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 45%, + transparent + ); flex-shrink: 0; } -.os-dock[ data-os-dock-placement="left" ] - .os-dock__separator--group, -.os-dock[ data-os-dock-placement="right" ] - .os-dock__separator--group { - margin: 10px auto; - background: rgba( 255, 255, 255, 0.22 ); -} - /* * Hide the system separator when nothing precedes it — on a clean * install (or a low-cap user with zero menu items) the separator @@ -764,6 +781,7 @@ display: none; } + /* * Drop an empty `__scroll` from the flex flow entirely. When only * system tiles are present (e.g. just the Recycle Bin), the empty @@ -908,7 +926,7 @@ width: 4px; height: 4px; border-radius: 50%; - background: var( --os-ui-surface, #fff ); + background: var( --os-dock-item-outline, #fff ); } .os-dock[ data-os-dock-placement="bottom" ] @@ -929,33 +947,30 @@ height: 6px; border-radius: 50%; background: transparent; - border: 1px solid rgba( 255, 255, 255, 0.85 ); + border: 1px solid var( --os-dock-item-outline, rgba( 255, 255, 255, 0.85 ) ); } /* - * Global "Show Desktop" indicator — set on `` by the dock when - * every live window on the active desktop is in the minimized state. - * The bottom-dock pill picks up a soft theme-color ring so the user - * has a global cue that the windows haven't disappeared, they're just - * minimized. ArrowUp / a wallpaper click / clicking any dock tile - * brings them back; without this the empty wallpaper looks identical - * to a freshly-loaded desktop with no windows open. + * "Show Desktop" — every live window on the active desktop minimized. + * + * The dock does not change when this happens. It used to: the bottom + * pill and the vertical rails each picked up a 1px inset ring, on the + * reasoning that an empty wallpaper otherwise looks identical to a + * desktop that never had anything on it. + * + * The reasoning was sound and the ring was still the wrong answer. It + * outlined the whole dock — the one surface on screen that is always + * present and never the subject — to say something about windows that + * are not on the dock at all. Every tile that has minimized windows + * already says so, precisely and locally, by swapping its solid + * indicator dot for a hollow ring. That is the cue: it points at the + * tiles the windows are under, in the place the user will click to get + * them back. + * + * `body.os-show-desktop-active` is still set (see `src/dock.ts`) and + * still public, so a theme or plugin that wants a global cue can paint + * one. Core does not. */ -body.os-show-desktop-active - .os-dock[ data-os-dock-placement="bottom" ] { - box-shadow: - inset 0 0 0 1px var( --wp-admin-theme-color, rgba( 255, 255, 255, 0.45 ) ), - inset 0 1px 0 rgba( 255, 255, 255, 0.08 ), - 0 10px 32px rgba( 0, 0, 0, 0.45 ); -} - -/* Vertical docks get the same treatment along their inner edge. */ -body.os-show-desktop-active - .os-dock[ data-os-dock-placement="left" ], -body.os-show-desktop-active - .os-dock[ data-os-dock-placement="right" ] { - box-shadow: inset 0 0 0 1px var( --wp-admin-theme-color, rgba( 255, 255, 255, 0.45 ) ); -} /* "Open another" chip — floats above the tile's top-right corner so * it doesn't collide with neighbouring tiles in the horizontal row. */ @@ -974,9 +989,19 @@ body.os-show-desktop-active /* * Bottom-dock inner wrappers — `__scroll` carries the menu tiles and * absorbs horizontal overflow; `__pinned` carries the system tiles and - * stays anchored to the trailing edge. Padding-top on `__scroll` gives - * the badge (top: -3px on the tile) room so `overflow-y: hidden` - * doesn't crop it. `min-width: 0` lets the wrapper shrink below its + * stays anchored to the trailing edge. + * + * **The padding around `__scroll` is what keeps badges visible, and it + * has to be on both axes.** `overflow-x: auto` below cannot coexist + * with `overflow-y: visible`: per spec the visible axis computes to + * `auto`, so this wrapper is a scroll container in BOTH directions + * however it is authored, and anything a child hangs outside its + * padding box is clipped. A badge sits at `top: -3px; right: -3px` on + * its tile, and the LAST tile's edge is the wrapper's edge once the + * content is wide enough to scroll — which is why this showed up as + * "the badge on the last plugin looks cut off" rather than as a + * general problem. The inline padding is symmetric so the tile cluster + * stays centred in the pill. `min-width: 0` lets the wrapper shrink below its * content's natural width, so the dock pill's `max-width` is what * decides when scroll kicks in instead of the content forcing the * pill wider. `justify-content: center` keeps tiles balanced inside @@ -993,7 +1018,14 @@ body.os-show-desktop-active min-width: 0; padding-top: 4px; padding-bottom: 6px; + padding-inline: 4px; overflow-x: auto; + /* + * Authored `visible`, computed `auto` — see the note above. Kept as + * the honest statement of intent: nothing here wants a vertical + * scrollbar, and `scrollbar-width: none` plus the padding is what + * makes that true in practice. + */ overflow-y: visible; scrollbar-width: none; } @@ -1014,44 +1046,41 @@ body.os-show-desktop-active padding-bottom: 6px; } -/* Separator — vertical hairline between menu tiles and system tiles. - * Lives at the leading edge of `__pinned`; flex flow handles the +/* Separator — the line between two groups of tiles. The system one + * lives at the leading edge of `__pinned` (flex flow handles the * "pinned at the trailing edge" placement, no margin-inline-start: - * auto needed. The `--group` variant keeps the hairline styling for - * the inline core→plugin boundary inside `__scroll` and sits where - * inserted. */ + * auto needed); the `--group` one sits inline inside `__scroll` where + * it was inserted. Both resolve through this single rule — see the + * note on the vertical placements above for why there is only one. */ .os-dock[ data-os-dock-placement="bottom" ] .os-dock__separator { - width: 1px; + position: relative; + width: 2px; /* Fixed height — `60%` collapses against the floating pill's - intrinsic height, leaving the hairline effectively invisible. - 28px matches the dock tile inner glyph area. */ - height: 28px; + intrinsic height, leaving the line effectively invisible. */ + height: 34px; /* Symmetric horizontal margin so the divider sits centered in the * inter-tile gap: the 6px flex gap on each side (`__scroll`→`__pinned` - * on the left, `__pinned`'s own gap on the right) plus an equal 6px + * on the left, `__pinned`'s own gap on the right) plus an equal * margin per side balances out. An asymmetric margin here nudges the * divider — and the whole menu cluster with it — off the pill's - * center. */ - margin: auto 6px; - background: var( --os-dock-border ); - flex-shrink: 0; -} - -/* - * Group separator — sits inline between the core cluster and the - * plugin cluster. Brighter than the menu-vs-system separator because - * it carries user meaning ("this is where your installed apps - * begin"), and flanked by extra gap so the boundary reads at a - * glance rather than disappearing into the dock tint. - */ -.os-dock[ data-os-dock-placement="bottom" ] - .os-dock__separator--group { - width: 1px; - height: 60%; - margin: auto 10px; - margin-inline-start: 10px; - background: rgba( 255, 255, 255, 0.22 ); + * center. Wider than the tile gap on purpose: the clusters need to + * read as groups before the line between them means anything. */ + margin: auto 14px; + border-radius: 2px; + background: linear-gradient( + to bottom, + transparent 0%, + var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 28%, + var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 72%, + transparent 100% + ); + box-shadow: 0 0 10px + color-mix( + in srgb, + var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 45%, + transparent + ); flex-shrink: 0; } @@ -1097,3 +1126,123 @@ body.os-show-desktop-active .os-dock__item:not( .os-dock__item--system ):active { cursor: grabbing; } + +/* ------------------------------------------------------------------ + * The way out — `Exit OpenStation` + * + * Every other system tile opens something you can close again. This + * one leaves the desktop, and with the admin bar hidden by default it + * is the only route back to classic admin. Drawn like its neighbours + * it read as one more launcher, which is the same "some tiles do a + * different kind of thing" confusion the single dock set out to fix, + * just moved to the other end of the rail. + * + * Three signals, none of them colour. Danger red would overstate it + * (nothing is destroyed, the session is saved and the desktop is one + * click away) and Pulse is already spent on the seam: + * + * 1. **Last, always.** `order: 1` rather than registration order — + * native-window tiles from plugins sync in after boot and would + * otherwise land behind it. + * 2. **Its own gap**, wider than the rail's 6px, so it reads as + * sitting outside the set rather than at the end of it. A second + * divider would have said this too, and would have cost the rail + * the one-structural-line rule it just earned. + * 3. **A different silhouette** — a ring rather than a plate. Shape + * survives every theme, every dock texture, greyscale and a + * colour-blind reading of the rail; a tint survives none of them + * reliably. + * + * And it leans toward the edge it leads to on hover instead of lifting + * toward the pointer, because every other tile's lift means "I will + * come to you" and this one means the opposite. + * + * Keyed on the tile id, the same idiom the Recycle Bin badge uses + * above. `os-exit` is frozen in `src/exit-openstation.ts` + * (`EXIT_OPENSTATION_TILE_ID`) and `dock-exit-tile.test.ts` pins the + * attribute this selector depends on. + * ------------------------------------------------------------------ */ + +.os-dock__item[ data-system-id="os-exit" ] { + order: 1; +} + +.os-dock__item[ data-system-id="os-exit" ] .os-dock__item-primary { + border-radius: 50%; + background-color: transparent; + box-shadow: inset 0 0 0 1px var( + --os-dock-exit-ring, + var( --os-dock-border, rgba( 255, 255, 255, 0.18 ) ) + ); + color: var( --os-dock-exit-icon, rgba( 255, 255, 255, 0.55 ) ); +} + +/* + * The gap runs along the rail's own axis, so it follows the placement: + * inline for the horizontal pill, block for the vertical pillars. + */ +.os-dock[ data-os-dock-placement="bottom" ] + .os-dock__item[ data-system-id="os-exit" ] { + margin-inline-start: 10px; +} + +.os-dock[ data-os-dock-placement="left" ] + .os-dock__item[ data-system-id="os-exit" ], +.os-dock[ data-os-dock-placement="right" ] + .os-dock__item[ data-system-id="os-exit" ] { + margin-block-start: 8px; +} + +/* + * Hover: the ring closes up and the tile moves TOWARD its edge. The + * generic tile rule scales up by 1.1, so these have to out-specify it + * rather than sit alongside it. + */ +.os-dock__item[ data-system-id="os-exit" ] .os-dock__item-primary:hover, +.os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:focus-visible { + background-color: transparent; + box-shadow: inset 0 0 0 1px var( + --os-dock-exit-ring-hover, + var( --os-dock-item-outline, rgba( 255, 255, 255, 0.7 ) ) + ); + color: var( --os-dock-icon-color-hover, var( --os-ui-fg-on-accent, #fff ) ); +} + +.os-dock[ data-os-dock-placement="bottom" ] + .os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:hover { + transform: translateY( 2px ); +} + +.os-dock[ data-os-dock-placement="left" ] + .os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:hover { + transform: translateX( -2px ); +} + +.os-dock[ data-os-dock-placement="right" ] + .os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:hover { + transform: translateX( 2px ); +} + +/* The press keeps the shared cue: every tile in the rail dips on + * :active, and this one should not feel unresponsive by comparison. */ +.os-dock__item[ data-system-id="os-exit" ] .os-dock__item-primary:active { + transform: scale( 0.95 ); +} + +@media ( prefers-reduced-motion: reduce ) { + .os-dock[ data-os-dock-placement="bottom" ] + .os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:hover, + .os-dock[ data-os-dock-placement="left" ] + .os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:hover, + .os-dock[ data-os-dock-placement="right" ] + .os-dock__item[ data-system-id="os-exit" ] + .os-dock__item-primary:hover { + transform: none; + } +} diff --git a/assets/css/my-wordpress.css b/assets/css/my-wordpress.css index 63230a54..8d9146ba 100644 --- a/assets/css/my-wordpress.css +++ b/assets/css/my-wordpress.css @@ -28,10 +28,22 @@ --os-my-wordpress-bg, var( --os-window-bg, #fff ) ); - /* Light-context tile color tokens — same recipe as the folder - * window. Tiles inside My WordPress paint dark-on-light. */ + /* Tile color tokens — same recipe as the folder window. Both + * chain through the palette so the surface follows the active + * desktop theme, with the pre-brand WordPress-admin literal as + * the floor if no stylesheet declares one. + * + * `--os-tile-fg-muted` used to be the bare literal below, which + * outranked the palette's own value and painted every tile's + * secondary line near-black — invisible on a dark station, while + * the label right beside it followed the theme correctly. A + * colour declared next to the thing it paints is out of reach of + * the palette AND of every desktop theme. */ --os-tile-fg: var( --os-my-wordpress-fg, var( --os-ui-fg, #1d2327 ) ); - --os-tile-fg-muted: rgba( 0, 0, 0, 0.55 ); + --os-tile-fg-muted: var( + --os-my-wordpress-fg-muted, + var( --os-ui-fg-muted, rgba( 0, 0, 0, 0.55 ) ) + ); --os-tile-hover-bg: var( --os-hover, rgba( 0, 0, 0, 0.06 ) @@ -217,8 +229,17 @@ * The cell pitch lives in `TILE_METRICS_LARGE` (src/my-wordpress/ * index.ts) and the two MUST move together — a wider tile in a cell * that didn't grow overlaps its neighbour. */ -.os-my-wordpress__tiles--large .os-file-tile { - width: 132px; +/* + * Image-led sections re-point the tile tokens for their whole + * subtree rather than overriding the tile's width directly. The base + * `.os-file-tile` rule then sizes itself from them, and so does + * everything derived from them (the label's max-width, the loading + * skeletons) — one declaration instead of a chain of overrides that + * have to be kept in step. + */ +.os-my-wordpress__tiles--large { + --os-tile-w: var( --os-tile-w-large, 132px ); + --os-tile-h: var( --os-tile-h-large, 160px ); } .os-my-wordpress__tiles--large .os-file-tile__visual, @@ -504,7 +525,10 @@ flex-direction: column; align-items: center; gap: 6px; - width: 88px; + /* Same box as the real tile it stands in for — a skeleton that + * doesn't match the grid makes the list jump when rows land. */ + width: var( --os-tile-w, 88px ); + height: var( --os-tile-h, 104px ); padding: 8px 4px; box-sizing: border-box; pointer-events: none; @@ -561,6 +585,8 @@ .os-my-wordpress__preview-empty { display: flex; + flex-direction: column; + gap: 6px; align-items: center; justify-content: center; height: 100%; @@ -570,6 +596,29 @@ text-align: center; } +/* Type / status breakdown under a multi-selection's count. */ +.os-my-wordpress__preview-selection-breakdown { + font-size: 12px; + opacity: 0.75; +} + +/* One labelled control in the bulk-edit modal. Label above field, + * because the fields are wide (a category tree, a tag row) and a + * two-column grid would put a one-word label beside a control four + * times its height. */ +.os-my-wordpress__bulk-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-block-end: 16px; +} + +.os-my-wordpress__bulk-label { + font-size: 12px; + font-weight: 600; + color: var( --os-ui-fg-muted, #787c82 ); +} + .os-my-wordpress__preview-loading { display: flex; align-items: center; @@ -1820,8 +1869,12 @@ os-tile[ status='future' ] .os-file-tile__icon { .os-my-wordpress__media-grid { display: grid; grid-template-columns: repeat( auto-fill, minmax( 120px, 1fr ) ); - gap: 12px; - padding: 16px; + /* A flow grid rather than the absolute canvas, but the air + * between two icons should not depend on which window you are + * looking at — so the gaps and the gutter come from the same + * tokens the canvas pitch is built from. */ + gap: var( --os-grid-gap-y, 16px ) var( --os-grid-gap-x, 20px ); + padding: var( --os-grid-padding, 16px ); overflow-y: auto; align-content: start; } @@ -1840,6 +1893,10 @@ os-tile[ status='future' ] .os-file-tile__icon { * the tile. */ position: relative; width: auto; + /* …and out of the fixed tile height for the same reason: a media + * tile is a square thumbnail plus a caption, sized by the grid + * column it lands in, not by the icon-canvas cell. */ + height: auto; display: flex; flex-direction: column; align-items: stretch; @@ -2008,8 +2065,8 @@ os-tile[ status='future' ] .os-file-tile__icon { .os-my-wordpress__usage-grid { display: flex; flex-wrap: wrap; - gap: 12px 8px; - padding: 16px; + gap: var( --os-grid-gap-y, 16px ) var( --os-grid-gap-x, 20px ); + padding: var( --os-grid-padding, 16px ); align-content: flex-start; } @@ -2018,7 +2075,7 @@ os-tile[ status='future' ] .os-file-tile__icon { * canvas-positioned post tiles use — except for `position: static` * so they participate in the flex layout. */ .os-my-wordpress__tile--usage { - width: 88px; + width: var( --os-tile-w, 88px ); align-items: center; padding: 8px 4px; gap: 6px; @@ -2057,6 +2114,16 @@ os-tile[ status='future' ] .os-file-tile__icon { box-sizing: border-box; } +/* The framework is opt-in but the section is always listed, so with + the option off the surface paints inert rather than vanishing. + Only the sidebar dims: the detail pane holds the empty state + explaining why, and the one button that undoes it — dimming the way + out along with everything else is how a disabled screen becomes a + dead end. */ +.dm-agents.is-disabled .dm-agents__sidebar { + opacity: 0.6; +} + /* Section-level loading — same scale curve as the My WordPress preview loader and the window-loading overlay, instead of the bare 48px component default. */ @@ -2176,6 +2243,16 @@ os-tile[ status='future' ] .os-file-tile__icon { gap: 10px; } +/* `` owns the icon + copy and centres them within + itself; the host is still a flex item that shrinks to its content, + so without this it sits at the top of a pane that is mostly empty + space. Auto margins on both axes absorb the slack — placement only, + same as the comments window. Covers the framework-off state and + "No agents yet" alike. */ +.dm-agents__detail > os-empty-state { + margin: auto; +} + .dm-agents__detail-head { display: flex; align-items: center; diff --git a/assets/css/notch.css b/assets/css/notch.css new file mode 100644 index 00000000..9cfa92f8 --- /dev/null +++ b/assets/css/notch.css @@ -0,0 +1,194 @@ +/* + * The notch — top-centre, floating, never reserving. + * + * The load-bearing rule in this file is that nothing here touches + * `.os-area`. Reserving height would make the notch another hardcoded + * claimant on a work-area rectangle that already has several + * disagreeing answers. It floats, and it gets out of the way instead. + */ + +/* + * Anchored to the SHELL, not the viewport. `.os-shell` starts below + * the admin bar when the user keeps it, and at the viewport top when + * they don't, so an absolutely-positioned notch lands correctly in + * every admin-bar mode without knowing which one is on. Fixed + * positioning put it under the bar, which paints above the shell. + */ +.os-notch { + position: absolute; + top: 0; + left: 50%; + transform: translateX( -50% ); + z-index: var( --os-z-notch, 9000 ); + + display: flex; + align-items: center; + gap: 7px; + max-width: min( 420px, 60vw ); + height: 34px; + padding: 0 14px; + /* + * A hairline all the way round except the top, where the pill meets + * the screen edge and a line would draw a lid on it. Without the + * border the surface disappeared into the default wallpaper — Void + * on Void, with only the shadow separating them. + */ + border: 1px solid var( --os-ui-border-strong, rgba( 255, 251, 255, 0.22 ) ); + border-top: 0; + /* Square at the top, round below — it reads as hanging from the + screen edge rather than floating near it. */ + border-radius: 0 0 15px 15px; + background: var( --os-ui-surface-raised, rgba( 20, 18, 24, 0.92 ) ); + box-shadow: 0 2px 12px rgba( 0, 0, 0, 0.4 ); + color: var( --os-ui-fg, #fffbff ); + font-size: 13px; + line-height: 1; + cursor: pointer; + /* `width` is not animated: the pill is sized by its content, and + transitioning an auto width does nothing. The message's own + max-width is what opens and closes. */ + transition: background 160ms ease, opacity 160ms ease, + border-color 160ms ease, top 180ms ease, + visibility 0s linear 160ms; +} + +.os-notch:hover, +.os-notch:focus-visible { + background: var( --os-ui-surface-elevated, rgba( 32, 29, 38, 0.96 ) ); + border-color: var( --os-ui-accent-dim, rgba( 242, 82, 252, 0.5 ) ); +} + +.os-notch:focus-visible { + outline: 2px solid var( --os-ui-accent, #f252fc ); + outline-offset: -2px; +} + +.os-notch__glyph { + display: flex; + flex: 0 0 auto; + width: 18px; + height: 18px; +} + +.os-notch__glyph svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +/* + * The resting label. It yields to a message rather than sitting + * beside one — the notch says one thing at a time, and "Site + * assistant · Saving…" would be two. + */ +.os-notch__label { + overflow: hidden; + max-width: 160px; + white-space: nowrap; + text-overflow: ellipsis; + transition: max-width 200ms ease, opacity 120ms ease; +} + +.os-notch--speaking .os-notch__label { + max-width: 0; + opacity: 0; +} + +/* + * The message is always in the DOM (it is an `aria-live` region and + * has to be, to be announced reliably); it is its WIDTH that opens. + * `max-width` rather than `display` so there is something to animate + * and so the live region is never removed from the accessibility tree. + */ +.os-notch__message { + overflow: hidden; + max-width: 0; + white-space: nowrap; + text-overflow: ellipsis; + transition: max-width 200ms ease; +} + +.os-notch--speaking .os-notch__message { + max-width: 320px; +} + +/* + * Out of the way of a maximized window. The notch overlaps the top + * edge, and a maximized window's title bar is right under it — so it + * fades rather than pushing the window down, which is the one thing + * this surface must never do. + */ +.os-shell:has( .os-window--maximized ) .os-notch:not( :hover ):not( .os-notch--speaking ), +.os-shell:has( .os-window--fullscreen ) .os-notch:not( :hover ):not( .os-notch--speaking ) { + opacity: 0.35; +} + +/* + * Overview is the shell talking about itself — a zoomed-out view of + * every window and desktop. A pill hanging over that view is chrome + * on top of chrome, and it lands squarely on the desktop thumbnails. + * Mio steps aside for the same reason and in the same way. + * + * `visibility` alongside the fade so it stops taking clicks on the way + * out; a transparent button over a desktop thumbnail still swallows + * the click that was meant to switch desktops. + */ +.os-shell:has( .os-area--overview ) .os-notch { + opacity: 0; + visibility: hidden; + /* No delay on the way OUT — the pill must stop taking clicks the + moment it starts fading, not after. The delay in the base rule + is what holds it visible while it fades back IN. */ + transition-delay: 0s; +} + +/* Solo mode is one window freed into a native OS window — the shell's + own chrome has no place in it. */ +body.os-solo .os-notch { + display: none; +} + +@media ( prefers-reduced-motion: reduce ) { + .os-notch, + .os-notch__label, + .os-notch__message { + transition-duration: 1ms; + } +} + +/* + * Auto-hide admin bar. + * + * The shell starts at the viewport top in this mode (the bar is out of + * flow), so the notch would be underneath the bar whenever it rolls + * down. It steps down to sit below it instead, on the bar's own timing. + */ +body.os-active.os-admin-bar-dynamic:has( #wpadminbar:hover ) .os-notch, +body.os-active.os-admin-bar-dynamic:has( #wpadminbar:focus-within ) .os-notch, +body.os-active.os-admin-bar-dynamic:has( .os-notch:hover ) .os-notch, +body.os-active.os-admin-bar-dynamic:has( .os-notch:focus-visible ) .os-notch { + top: var( --wp-admin--admin-bar--height, 32px ); +} + +/* + * The hover bridge, and it is what makes the step stable. + * + * Hovering the notch is one of the things that rolls the bar down, and + * the notch answers by moving 32px away from the pointer — which ends + * the hover, retracts the bar, and brings the notch back up under the + * pointer to start again. This claims the space the notch vacates, so + * the pointer is still over the element after the step. Hovering a + * pseudo-element counts as hovering its owner, the same trick the + * bar's own reveal zone uses. + * + * Only in this mode: nothing moves in the other two, and a permanent + * 32px hit area hanging off the notch would swallow clicks meant for + * whatever is behind it. + */ +body.os-active.os-admin-bar-dynamic .os-notch::before { + content: ""; + position: absolute; + inset-inline: 0; + inset-block-end: 100%; + height: var( --wp-admin--admin-bar--height, 32px ); +} diff --git a/assets/css/notes.css b/assets/css/notes.css index f2cc3d9f..01a4c0e6 100644 --- a/assets/css/notes.css +++ b/assets/css/notes.css @@ -1,10 +1,8 @@ /** * OpenStation — Pinned notes. * - * Paper notes pinned to the wallpaper with a pushpin. Deliberately - * differentiated from the window-like Guidelines sticky cards - * (`.os-sticky-note`): no chrome header, no border, no - * resize handle — a pin, a paper, ink. + * Paper notes pinned to the wallpaper with a pushpin: no chrome + * header, no border, no resize handle — a pin, a paper, ink. * * The pastel tokens are shared with the Note Pad widget * (`widget-notes.css` consumes them) — keep the slug list in sync @@ -122,8 +120,8 @@ pointer-events: none; } -/* Mission-control style overview hides the wall, like the sticky - layer does. */ +/* Mission-control style overview hides the wall — the notes are + desktop objects, not part of the window survey. */ .os-area--overview .os-notes { display: none; } @@ -259,18 +257,17 @@ z-index: 1; } -/* Push the color dot to the left edge; the action buttons cluster on - the right. Keeps a stable layout whether or not the convert button - is present (it's gated on the `edit_posts` capability). */ -.os-pinned-note__meta .os-pinned-note__color-dot { - margin-inline-end: auto; -} - .os-pinned-note:hover .os-pinned-note__meta, .os-pinned-note:focus-within .os-pinned-note__meta { opacity: 1; } +/* The save chip sits hard left and the colour dot hard right, which + keeps both clear of the pushpin painted over the middle of this row. */ +.os-pinned-note__meta .os-pinned-note__color-dot { + margin-inline-start: auto; +} + /* Color dot: painted in the NEXT color — the affordance shows the outcome. */ .os-pinned-note__color-dot { @@ -392,14 +389,56 @@ .os-pinned-note__footer { display: flex; + align-items: center; + gap: 4px; justify-content: flex-start; min-height: 16px; } +/* Every action lives here rather than in the meta row. The pushpin is + painted over the paper's chrome and covers note-relative x 62–138 + across its ±10px jitter, which is the middle of that row — it only + ever had room for two controls clear of the pin, and a third landed + underneath where a pointer couldn't reach it. + + Fades as a group on hover/focus, the same way the meta row does, so + each button keeps its own 0.75 → 1 hover on top. No gap: each button + is a 30px box around a 20px glyph, so its own padding is the spacing. */ +.os-pinned-note__actions { + display: flex; + align-items: center; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.os-pinned-note:hover .os-pinned-note__actions, +.os-pinned-note:focus-within .os-pinned-note__actions { + opacity: 1; +} + .os-pinned-note__status { opacity: 0.6; } +/* "Move to Trash" — same treatment as the other two actions. It does + NOT redden on hover: the other two only brighten, and one action + changing hue made the row look inconsistent. The confirm dialog it + opens is the danger cue. */ +.os-pinned-note__trash { + --os-ui-btn-color: var(--dm-note-ink-soft); + opacity: 0.75; +} + +.os-pinned-note__trash:hover { + opacity: 1; +} + +.os-pinned-note__trash svg { + width: 20px; + height: 20px; + fill: var(--dm-note-ink, #4d4419); +} + /* Author sticker on read-only public notes — always visible. */ .os-pinned-note__attribution { display: inline-flex; diff --git a/assets/css/openstation-layout.css b/assets/css/openstation-layout.css new file mode 100644 index 00000000..868bd988 --- /dev/null +++ b/assets/css/openstation-layout.css @@ -0,0 +1,704 @@ +/** + * OpenStation — the constellation. + * + * The flyout that fans a menu's submenu out of its tile on hover, on + * every rail in every layout. Body-attached (it has to escape the + * dock's stacking context and its `overflow`), so its selectors are + * rooted on `.os-constellation` and reach it wherever the dock is + * parked. + * + * Colour discipline, per the palette rule: every declaration here + * reads `var( --token, )`, and every literal is a plain + * value that stands on its own if `variables.css` never loads. The + * mesh appears in exactly two places — the hovered/focused row and + * the head's icon halo — because those are the moments the panel is + * answering the user. Everywhere else is Obsidian. + */ + +/* + * The dock tooltip stands down while a flyout is open. The panel's + * head carries the same label, larger and attached to the thing it + * names; the tooltip on top of it is a second hover surface saying + * the same word twice. + */ +body.os-constellation-open .os-dock__tooltip { + opacity: 0; + pointer-events: none; +} + +.os-constellation { + position: fixed; + z-index: var( --os-cn-z, 2147483000 ); + /* + * `--os-cn-shift` is written by the clamp when the panel would + * have overflowed a viewport edge; the beam compensates with + * `--os-cn-beam-x` so it stays pointed at the tile. Both default + * to 0, which is the un-clamped case. + */ + transform: translate( + calc( -50% + var( --os-cn-shift, 0px ) ), + -100% + ) + scale( 0.94 ); + transform-origin: bottom center; + opacity: 0; + pointer-events: none; + transition: + transform var( --os-ui-motion-slow, 340ms ) + var( --os-ui-ease-spring, cubic-bezier( 0.32, 1.5, 0.55, 1 ) ), + opacity var( --os-ui-motion-fast, 140ms ) ease-out; +} + +.os-constellation.os-constellation--open { + transform: translate( calc( -50% + var( --os-cn-shift, 0px ) ), -100% ) + scale( 1 ); + opacity: 1; + pointer-events: auto; +} + +/* + * The exit, and it is NOT the entrance in reverse. + * + * Arriving is an event and gets a spring that overshoots. Leaving is + * the user having already moved on: it eases IN (accelerating away + * rather than settling), runs in under half the time, and — because + * `transform-origin` is `bottom center`, where the beam meets the + * tile — the shrink plus the few pixels of downward travel read as + * the panel dropping back into the rail it came out of. + * + * `pointer-events: none` from the first frame. A panel that is + * visibly leaving must not still be clickable, or a fast pointer + * lands a row the user has already dismissed. + */ +.os-constellation.os-constellation--closing { + transform: translate( + calc( -50% + var( --os-cn-shift, 0px ) ), + calc( -100% + 8px ) + ) + scale( 0.96 ); + opacity: 0; + pointer-events: none; + transition: + transform 160ms var( --os-ui-ease-in, cubic-bezier( 0.4, 0, 1, 1 ) ), + opacity 140ms var( --os-ui-ease-in, cubic-bezier( 0.4, 0, 1, 1 ) ); +} + +/* + * The panel leaves as ONE object. Without this the rows revert to + * their pre-entrance state and each plays its own staggered exit + * inside a panel that is itself shrinking — two motions at two + * speeds, which reads as the menu coming apart rather than closing. + */ +.os-constellation--closing .os-constellation__row { + opacity: 1; + transform: none; + transition: none; +} + +/* + * The beam goes first, and faster. It is the thread to the tile, so + * cutting it a beat before the panel lands sells the panel as + * falling rather than fading. + */ +.os-constellation--closing .os-constellation__beam { + opacity: 0; + transition: opacity 90ms ease-in; +} + +/* + * A retiring panel never paints over a live one. + * + * Moving along the rail leaves two panels on screen at once — the one + * you left finishing its dismissal above its own tile, the one you + * arrived at rising above its. Adjacent dock tiles are ~46px apart + * and a panel is 230px+ wide, so they overlap heavily, and without an + * explicit order the outgoing one wins on source order and fades out + * ON TOP of the menu the user is actually looking at. + */ +.os-constellation--closing { + z-index: calc( var( --os-cn-z, 2147483000 ) - 1 ); +} + +/* ---- Fanning sideways -------------------------------------------- */ + +/* + * Everything above assumes the rail is along the bottom: the panel + * hangs off the top of its tile, is centred on it horizontally, and + * drops back down into the rail as it leaves. A dock on the left or + * the right needs the same three statements rotated a quarter turn. + * + * `data-os-cn-side` is written by `position()` in + * `src/dock-constellation/index.ts` and names where the PANEL is + * relative to its tile, not where the rail is: a left-hand rail fans + * its panels out to the `right`. + * + * Two things change per side and nothing else does. The panel is + * pinned by its facing edge instead of its bottom one, so the + * translate loses its vertical half entirely: `top` is an absolute + * position the JS already clamped, not an offset from the tile. And + * `transform-origin` swaps which axis is pinned and which is centred. + * + * ## The cross axis is CENTRED, and that is the whole animation + * + * The entrance is a 0.94 → 1 scale and nothing else, so what the eye + * reads is entirely down to where the origin is. Above a bottom rail + * it is `bottom center`: the growth along the pinned axis all goes + * one way (the panel's top edge rises out of the dock) and the growth + * across it is symmetrical, ±7px that cancel. The panel does not + * appear to travel at all — it opens. + * + * Anchoring the cross axis to `--os-cn-beam-y` instead — where the + * beam meets the panel, which sounds like the right place for a menu + * to grow out of — breaks exactly that. On the first tile of a rail + * the beam sits 20px down a 400px panel, which is a corner in all + * but name, and growth off a corner all goes one way: measured, the + * top edge does not move at all while the bottom and right edges + * both travel. It expands down-and-right where the bottom rail + * opens evenly. + * + * So the cross axis stays `center` here too. The beam still lands on + * the tile — it is positioned from `--os-cn-beam-y` independently — + * but it does not get to drag the origin with it. + */ +/* + * The properties below are PHYSICAL (`left` / `right`, not + * `inset-inline-*`), because the thing they follow is physical: the + * screen edge the dock is parked on. `dockPlacement` is `left` or + * `right` in both directions of text. + */ +.os-constellation[ data-os-cn-side='right' ] { + transform: translate( 0, 0 ) scale( 0.94 ); + transform-origin: left center; +} +.os-constellation[ data-os-cn-side='right' ].os-constellation--open { + transform: translate( 0, 0 ) scale( 1 ); +} +.os-constellation[ data-os-cn-side='right' ].os-constellation--closing { + transform: translate( -8px, 0 ) scale( 0.96 ); +} + +.os-constellation[ data-os-cn-side='left' ] { + transform: translate( -100%, 0 ) scale( 0.94 ); + transform-origin: right center; +} +.os-constellation[ data-os-cn-side='left' ].os-constellation--open { + transform: translate( -100%, 0 ) scale( 1 ); +} +.os-constellation[ data-os-cn-side='left' ].os-constellation--closing { + transform: translate( calc( -100% + 8px ), 0 ) scale( 0.96 ); +} + +/* The panel body. */ +.os-constellation__surface { + position: relative; + display: flex; + flex-direction: column; + min-width: 232px; + max-width: min( 340px, calc( 100vw - 24px ) ); + /* + * The vertical clamp. `--os-cn-max-h` is written by the JS on + * every placement: the distance from this panel's bottom edge up + * to the top of the viewport. The panel cannot be nudged + * downwards to fit — that would push it over the dock — so it is + * capped instead, and the group below takes the scroll. + */ + max-height: var( --os-cn-max-h, min( 78vh, 620px ) ); + /* + * The surface itself never scrolls. A long menu (WooCommerce's + * fifteen children, a CPT with every taxonomy under it) puts the + * scroll on its own group instead, which keeps the head and the + * new-window row pinned AND keeps this element a stable + * containing block for the spotlight + edge overlays — an + * absolutely-positioned child of a scroll container scrolls with + * the content, so the hairline would slide off the top. + */ + padding: 6px; + border-radius: var( --os-cn-radius, 14px ); + background-color: var( --os-cn-surface, rgba( 26, 23, 33, 0.82 ) ); + backdrop-filter: blur( 28px ) saturate( 170% ); + -webkit-backdrop-filter: blur( 28px ) saturate( 170% ); + box-shadow: var( + --os-cn-shadow, + 0 24px 64px rgba( 0, 0, 0, 0.62 ), + 0 2px 8px rgba( 0, 0, 0, 0.4 ) + ); + color: var( --os-cn-fg, #fffbff ); +} + +/* + * Cursor spotlight. The JS writes `--os-cn-x` / `--os-cn-y` on + * pointermove; this paints a soft bloom there so the panel lights up + * under the pointer instead of sitting inert. Additive (`screen`) so + * it brightens the surface without washing the text. + * + * `::before`, not `::after`, so it sits BELOW the rows in paint + * order — the rows are positioned, and a spotlight painted over the + * mesh of a hovered row would flatten exactly the moment it is + * meant to reward. + */ +.os-constellation__surface::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + 220px circle at var( --os-cn-x, 50% ) var( --os-cn-y, 0% ), + hsl( var( --os-cn-hue, 300 ) 90% 70% / 0.14 ) 0%, + transparent 70% + ); + mix-blend-mode: screen; + pointer-events: none; +} + +/* + * The iridescent hairline. Painted as a gradient on a padding-box + * mask rather than as a `border-color`, because a border cannot hold + * a gradient and `border-image` loses the corner radius — the same + * trick `holoEdge` uses inside the component kit. + */ +.os-constellation__surface::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: var( + --os-ui-holo-edge-quiet, + linear-gradient( + 124deg, + rgba( 154, 242, 255, 0.22 ) 0%, + rgba( 236, 155, 255, 0.28 ) 38%, + rgba( 242, 82, 252, 0.22 ) 62%, + rgba( 159, 152, 255, 0.2 ) 100% + ) + ); + -webkit-mask: + linear-gradient( #000 0 0 ) content-box, + linear-gradient( #000 0 0 ); + mask: + linear-gradient( #000 0 0 ) content-box, + linear-gradient( #000 0 0 ); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +/* + * The sheen. One diagonal band of light crossing the panel, once, as + * it opens. + * + * An element rather than a pseudo, and a child of the ROOT rather + * than of the surface: the surface has already spent `::before` on + * the spotlight and `::after` on the edge mask (the pseudo budget is + * exactly two), and hanging it off the root is what lets it sweep + * over the rows instead of under them. + */ +.os-constellation__sheen { + position: absolute; + inset: 0; + border-radius: var( --os-cn-radius, 14px ); + overflow: hidden; + pointer-events: none; + opacity: 0; +} + +.os-constellation--open .os-constellation__sheen { + animation: os-cn-sheen 900ms var( --os-ui-ease-out, ease-out ) 60ms 1; +} + +.os-constellation__sheen::before { + content: ""; + position: absolute; + inset: -40% -120%; + background: linear-gradient( + 104deg, + transparent 42%, + rgba( 255, 253, 255, 0.42 ) 50%, + transparent 58% + ); + transform: translateX( -60% ); +} + +@keyframes os-cn-sheen { + 0% { + opacity: 0; + } + 18% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX( 120% ); + } +} + +/* ---- The beam ---------------------------------------------------- */ + +/* + * The thread from the panel's underside down to the tile it belongs + * to. Its only real job is the clamped case: once the panel has been + * nudged sideways to stay on screen, the beam is the only thing left + * saying which tile it came out of. + */ +.os-constellation__beam { + position: absolute; + inset-inline-start: 50%; + top: 100%; + width: 2px; + height: 14px; + transform: translateX( + calc( -50% + var( --os-cn-beam-x, 0px ) ) + ); + background: linear-gradient( + to bottom, + var( --os-cn-beam, rgba( 217, 46, 227, 0.8 ) ) 0%, + transparent 100% + ); + pointer-events: none; + opacity: 0; + transition: opacity var( --os-ui-motion-fast, 140ms ) ease-out 80ms; +} + +.os-constellation--open .os-constellation__beam { + opacity: 1; +} + +/* + * The same thread, turned. It leaves the panel by its facing edge and + * runs back to the tile, so beside a rail it is 14px wide and 2px + * tall rather than the other way round, and the fade runs toward the + * tile in both cases. + * + * `--os-cn-beam-y` is where the tile is, measured down from the top + * of the panel — not a correction applied to a centred beam, which is + * what the bottom rail's `--os-cn-beam-x` is. Beside a rail the panel + * is top-aligned with its tile rather than centred on it, so the + * beam's position IS the anchoring, and a panel that had to be + * clamped away from a viewport edge still has a thread landing on the + * tile it belongs to. + */ +.os-constellation[ data-os-cn-side='right' ] .os-constellation__beam, +.os-constellation[ data-os-cn-side='left' ] .os-constellation__beam { + top: var( --os-cn-beam-y, 50% ); + width: 14px; + height: 2px; + transform: translateY( -50% ); +} +.os-constellation[ data-os-cn-side='right' ] .os-constellation__beam { + inset-inline-start: auto; + left: auto; + right: 100%; + background: linear-gradient( + to left, + var( --os-cn-beam, rgba( 217, 46, 227, 0.8 ) ) 0%, + transparent 100% + ); +} +.os-constellation[ data-os-cn-side='left' ] .os-constellation__beam { + inset-inline-start: auto; + right: auto; + left: 100%; + background: linear-gradient( + to right, + var( --os-cn-beam, rgba( 217, 46, 227, 0.8 ) ) 0%, + transparent 100% + ); +} + +/* ---- Rows -------------------------------------------------------- */ + +.os-constellation__row { + position: relative; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + /* The head and the new-window row are direct flex children of a + * height-capped surface; without this they would be squashed + * before the scrollable group in the middle gave way. */ + flex: 0 0 auto; + padding: 8px 10px; + border: none; + border-radius: 9px; + background: transparent; + color: inherit; + font: inherit; + font-size: 13px; + line-height: 1.3; + text-align: start; + cursor: pointer; + /* + * Staggered entrance. `--os-cn-row` is the row's index, written by + * the JS, so the list unfurls top-down instead of appearing all at + * once. Capped at 8 legs of delay: a 30-item submenu that took + * 30 × 26ms to finish would read as sluggish, not as choreography. + */ + opacity: 0; + transform: translateY( 6px ); + transition: + background-color var( --os-ui-motion-fast, 140ms ) ease-out, + color var( --os-ui-motion-fast, 140ms ) ease-out, + opacity 220ms ease-out, + transform 220ms + var( --os-ui-ease-spring, cubic-bezier( 0.32, 1.5, 0.55, 1 ) ); + transition-delay: 0s, 0s, + calc( min( var( --os-cn-row, 0 ), 8 ) * 26ms ), + calc( min( var( --os-cn-row, 0 ), 8 ) * 26ms ); +} + +.os-constellation--open .os-constellation__row { + opacity: 1; + transform: translateY( 0 ); +} + +.os-constellation__row:hover, +.os-constellation__row:focus-visible { + outline: none; + /* + * The identity moment. The row under the pointer is "selected", + * which is exactly the state the brand reserves the mesh for — so + * this is where the panel spends it, and the ink flips to Void + * because every mesh in the brand is a LIGHT surface. + */ + background-image: var( + --os-cn-row-fill, + var( --os-ui-holo-fill, linear-gradient( 124deg, #afa2e8, #c3b8ef ) ) + ); + color: var( --os-cn-row-ink, var( --os-ui-holo-ink, #0c0b0f ) ); +} + +.os-constellation__row:focus-visible { + box-shadow: var( + --os-ui-focus-ring, + 0 0 0 2px rgba( 12, 11, 15, 0.9 ), + 0 0 0 4px #f252fc + ); +} + +.os-constellation__row:active { + transform: scale( 0.985 ); +} + +.os-constellation__row-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.os-constellation__row-meta { + flex: 0 0 auto; + font-size: 11px; + opacity: 0.6; +} + +/* ---- Head -------------------------------------------------------- */ + +.os-constellation__head { + gap: 12px; + padding: 10px; + margin-bottom: 4px; +} + +.os-constellation__head-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: 10px; + color: var( --os-ui-holo-ink, #0c0b0f ); + /* + * The head's icon is the panel's second identity moment — the + * menu, stated. It wears the mesh permanently (unlike the rows, + * which earn it under the pointer) because there is exactly one + * head per panel and it is the thing the panel is about. + */ + background-image: var( + --os-cn-row-fill, + var( --os-ui-holo-fill, linear-gradient( 124deg, #afa2e8, #c3b8ef ) ) + ); + box-shadow: var( + --os-ui-holo-glow, + 0 0 0 1px rgba( 217, 46, 227, 0.2 ), + 0 2px 10px rgba( 217, 46, 227, 0.15 ) + ); +} + +.os-constellation__head-icon .dashicons { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; +} + +/* + * Drawn art — a data URI or a URL rather than a named dashicon, which + * is what every shell-owned tile wears. Painted as a mask filled with + * `currentColor` (the head's ink over the mesh), so only the artwork's + * alpha is used and a black-stroked glyph stays legible. + */ +.os-constellation__head-art { + width: 20px; + height: 20px; +} + +/* + * The head's own hover state deliberately does NOT take the row mesh + * — the icon beside it is already wearing it, and two meshes touching + * is where iridescence stops reading as emphasis. It gets a plain + * raised wash instead. + */ +.os-constellation__head:hover, +.os-constellation__head:focus-visible { + background-image: none; + background-color: var( + --os-ui-surface-raised, + rgba( 255, 251, 255, 0.06 ) + ); + color: var( --os-cn-fg, #fffbff ); +} + +.os-constellation__head-text { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + gap: 1px; +} + +.os-constellation__head-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 600; +} + + +/* ---- Groups ------------------------------------------------------ */ + +.os-constellation__group { + display: flex; + flex-direction: column; + padding-top: 4px; + border-top: 1px solid var( --os-cn-divider, rgba( 255, 251, 255, 0.1 ) ); + /* + * The scroll lives here rather than on the surface, so the head + * and the new-window row stay pinned while a long submenu rolls + * under them. + * + * `min-height: 0` is what lets the surface's `max-height` reach + * this: a flex item's default `min-height: auto` refuses to + * shrink below its content, so without it a tall submenu would + * simply overflow the capped surface instead of scrolling inside + * it. `flex: 0 1 auto` says this is the part that gives — the + * head and the new-window row do not. + */ + flex: 0 1 auto; + min-height: 0; + max-height: min( 46vh, 360px ); + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; +} + +.os-constellation__legend { + padding: 4px 10px 3px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var( --os-cn-legend, rgba( 255, 251, 255, 0.4 ) ); +} + +/* ---- Submenu rows ------------------------------------------------ */ + +/* + * The orbit dot. Each row's hue comes from its own title, so a long + * submenu reads as a spectrum rather than as fifteen identical grey + * bullets — and "Menus" is the same colour on every site, every + * session, because the hue is hashed from the string. + */ +.os-constellation__orbit { + position: relative; + flex: 0 0 auto; + width: 8px; + height: 8px; + margin-inline-start: 3px; + border-radius: 50%; + background: hsl( var( --os-cn-row-hue, 300 ) 80% 70% ); + box-shadow: 0 0 0 3px hsl( var( --os-cn-row-hue, 300 ) 80% 70% / 0.16 ); + transition: box-shadow var( --os-ui-motion-fast, 140ms ) ease-out; +} + +.os-constellation__row--sub:hover .os-constellation__orbit, +.os-constellation__row--sub:focus-visible .os-constellation__orbit { + /* On the mesh the halo would disappear; a Void ring reads instead. */ + box-shadow: 0 0 0 3px rgba( 12, 11, 15, 0.25 ); +} + +/* ---- Live-window rows -------------------------------------------- */ + +.os-constellation__pip { + flex: 0 0 auto; + width: 8px; + height: 8px; + margin-inline-start: 3px; + border-radius: 50%; + background: var( --os-ui-accent, #f252fc ); + box-shadow: 0 0 8px var( --os-ui-accent-dim, #d92ee3 ); +} + +/* A minimized window's pip is hollow — same cue the dock's own + * indicator uses for "open, but put away". */ +.os-constellation__row--live[ data-state="minimized" ] .os-constellation__pip { + background: transparent; + border: 1px solid var( --os-ui-accent, #f252fc ); + box-shadow: none; +} + +/* ================================================================== + * 3. Motion budget + * ================================================================== */ + +/* + * Reduced motion stops the travel, never the surface. The panel still + * appears, the seam node still glows, the hovered row still wears the + * mesh — losing those would lose STATE, not animation. What goes is + * the flight: the spring, the stagger, the sheen sweep, the breathing. + */ +@media ( prefers-reduced-motion: reduce ) { + .os-constellation, + .os-constellation__row { + transition-duration: 1ms; + transition-delay: 0s; + } + + /* + * No travel, in either direction. The exit is also removed from + * the document immediately under this preference (the JS reads + * the same media query), so `--closing` is listed here only to + * cover the frame between the class landing and the node going. + */ + .os-constellation, + .os-constellation--open, + .os-constellation--closing { + transform: translate( + calc( -50% + var( --os-cn-shift, 0px ) ), + -100% + ); + } + + .os-constellation__row { + transform: none; + } + + .os-constellation--open .os-constellation__sheen { + animation: none; + } + +} diff --git a/assets/css/os-settings.css b/assets/css/os-settings.css index bd6f8529..05200f69 100644 --- a/assets/css/os-settings.css +++ b/assets/css/os-settings.css @@ -10,8 +10,16 @@ * * @since 0.5.0 */ +/* + * Body Small, straight off the brand guide: Geist 14 / Regular / 1.5. + * The panel used to sit at 13, which is the guide's Caption tier and + * therefore the size everything SECONDARY should be, not the size the + * body is. Every rule below that names a smaller size was lifted one + * step with it, so the panel now bottoms out at Caption instead of + * running two tiers under the smallest size the guide defines. + */ .os-settings { - font-size: 13px; + font-size: 14px; line-height: 1.5; } @@ -21,19 +29,76 @@ } /* - * Card styling for each OS-Settings section. ``'s - * shadow DOM handles the heading + description; the outer card - * (padding, background, border) lives here because it's a - * per-surface visual choice, not a property of the component - * itself. Targets the tag so shadow-DOM internals stay private. + * The page header: the title again, at the size of a title, plus the + * one line the sidebar has no room for. + * + * Sits outside the section cards rather than inside the first one, so + * a section stays reusable on any page and has no opinion about being + * first on one. Heading 24 / Medium 500 with the guide's optical + * tightening; the sentence under it is Body Small on Ash, the same as + * every other piece of secondary text in the panel. + */ +.os-settings__page-header { + margin: 0 0 34px; +} + +.os-settings__page-title { + margin: 0 0 6px; + font-size: 24px; + font-weight: 500; + line-height: 1.25; + letter-spacing: -0.012em; + color: var( --os-ui-fg, #1d2327 ); +} + +.os-settings__page-description { + margin: 0; + max-width: 74ch; + line-height: 1.55; + color: var( --os-ui-fg-muted, #50575e ); +} + +/* + * Section chrome. The heading and its sentence sit on the page, and + * the BOX is the section's body alone: a bounded surface under a + * heading, not a card with a title locked inside it. That is the + * separator system of the whole panel: a box is the only divider, + * so there is no horizontal rule anywhere. + * + * ``'s shadow DOM handles the heading + description and + * exposes the body as `part="body"`; the box (background, border, + * radius, padding) lives here because it's a per-surface visual + * choice, not a property of the component itself. + * + * The box is the SUBTLE wash (3%) with the plain border on top of it, + * at the card radius. Its default padding is 18px, the mockup's + * inset for grids, which is what most section bodies hold. */ .os-settings os-section { display: block; - margin: 0 0 20px; - padding: 16px 16px 18px; - background: var( --os-ui-surface-elevated, #f6f7f7 ); + margin: 0 0 36px; +} + +.os-settings os-section::part( body ) { + padding: 18px; + background: var( --os-ui-surface-subtle, rgba( 0, 0, 0, 0.03 ) ); border: 1px solid var( --os-ui-border, #dcdcde ); - border-radius: 8px; + border-radius: 10px; + overflow: hidden; +} + +/* + * Rhythm between stacked pickers inside one section box. The + * components carry no outer margin of their own (a component cannot + * know its context), so the surface says how far apart they sit: + * 16px, two steps on the space scale, enough that a control's label + * reads as a label and not as a caption of the control above it. + */ +.os-settings os-section > os-select + os-select, +.os-settings os-section > os-select + os-segmented, +.os-settings os-section > os-segmented + os-select, +.os-settings os-section > os-segmented + os-segmented { + margin-block-start: 16px; } /* @@ -44,7 +109,7 @@ .os-settings__grid { display: grid; grid-template-columns: repeat( auto-fill, minmax( 140px, 1fr ) ); - gap: 10px; + gap: 12px; } /* @@ -55,17 +120,17 @@ * aspect ratio override. Everything else lives in the component. */ .os-settings__grid--wallpapers { - grid-template-columns: repeat( auto-fill, minmax( 160px, 1fr ) ); + grid-template-columns: repeat( auto-fill, minmax( 150px, 1fr ) ); } /* * Wallpaper preset label — sits above the gradient/image preview. * Presets span the full tonal range (graphite dark, mono near- * black, aurora blue, sunset hot pink) so neither pure white nor - * pure black text is universally readable. A frosted glass chip - * behind the text gives the label its own local contrast that's - * legible on every preset and respects the tile's visual language - * (rounded pill shape matches the selected-state corners). + * pure black text is universally readable. A Void chip at 75% behind + * the text gives the label its own local contrast on every preset. + * Flat rather than frosted, and at the small radius: a caption on + * the artwork, not a pill floating over it. */ .os-settings__swatch-label { display: inline-block; @@ -73,22 +138,98 @@ * must stay readable over a moving canvas. */ position: relative; z-index: 1; - padding: 3px 10px; - font-size: 11px; - font-weight: 600; + padding: 3px 8px; + font-size: 13px; + font-weight: 500; color: var( --os-ui-fg-on-accent, #fff ); - background: var( --os-ui-scrim, rgba(0, 0, 0, 0.45) ); - backdrop-filter: blur(8px) saturate(160%); - -webkit-backdrop-filter: blur(8px) saturate(160%); - border-radius: 999px; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); - letter-spacing: 0.01em; - /* Stop the chip from hugging the swatch edge — the 6 8 padding - * on the parent already gives a gap, but this keeps things - * consistent if the parent padding is ever tuned. */ + background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.45 ) ); + border-radius: 6px; pointer-events: none; } +/* + * "Use your own image", the last tile in the wallpaper grid. + * + * Dashed rather than solid because it is the only tile in the row that + * cannot show you what you are choosing: every other one paints its + * own preview, and this one is a question. The same dashed border is + * what the upload dropzone below it wears, so the tile and the drawer + * it opens read as one control. + * + * Sized off the same 16:9 the wallpaper swatches use so it sits in the + * grid rather than beside it. + */ +.os-settings__wallpaper-add { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + aspect-ratio: 16 / 10; + padding: 8px; + background: transparent; + /* + * `-strong`, not `--os-ui-border`. The plain border token is + * #33303a, the same Astro the section box draws its own border in: + * a dashed edge in it against that frame reads as part of the box + * rather than as a tile. Every dashed dropzone in this file has + * the same problem and the same fix. One pixel like every other + * edge in the panel; dashes get their emphasis from the gaps. + */ + border: 1px dashed var( --os-ui-border-strong, #c3c4c7 ); + border-radius: 10px; + color: var( --os-ui-fg-muted, #50575e ); + font: inherit; + font-size: 13px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; +} + +.os-settings__wallpaper-add:hover { + border-color: var( --os-ui-accent, #2271b1 ); + color: var( --os-ui-fg, #1d2327 ); +} + +.os-settings__wallpaper-add:focus-visible { + outline: none; + box-shadow: var( --os-ui-focus-ring, 0 0 0 2px #2271b1 ); +} + +/* A bare plus, not a button-in-a-tile: the dashed edge already says + "action", and a filled disc inside it is a control inside a + control. */ +.os-settings__wallpaper-add-plus { + font-size: 22px; + line-height: 1; +} + +/* + * The drawer the tile opens. Same 0fr to 1fr collapse as the editor + * and description slots above it. + */ +.os-settings__image-picker-slot { + display: grid; + grid-template-rows: 0fr; + margin-top: 0; + opacity: 0; + transition: + grid-template-rows 0.25s cubic-bezier(0.2, 0, 0.2, 1), + margin-top 0.25s cubic-bezier(0.2, 0, 0.2, 1), + opacity 0.2s ease; +} + +.os-settings__image-picker-slot[data-expanded="true"] { + grid-template-rows: 1fr; + margin-top: 12px; + opacity: 1; +} + +.os-settings__image-picker-slot-inner { + overflow: hidden; + min-height: 0; +} + /* * Live wallpaper preview — an overlay div slotted into a wallpaper * `` by the preview manager (see @@ -105,7 +246,7 @@ pointer-events: none; /* Match the swatch button's rounding so canvas corners don't * poke out of the tile. */ - border-radius: 8px; + border-radius: 10px; } .os-settings__wallpaper-live-preview canvas { @@ -125,15 +266,11 @@ margin-top: 12px; } -/* Top-level tab strip separating Appearance from AI Settings. - * Don't add a `display` rule for `os-tabpanel` here — the component's +/* Don't add a `display` rule for `os-tabpanel` here — the component's * shadow-DOM `:host([hidden]) { display: none }` has lower specificity * than any outer descendant selector, and overriding it from outside * breaks the auto-swap (hidden panels stay visible). The component's * own `:host { display: block }` already handles the shown state. */ -.os-settings os-tabs { - display: block; -} /* `__reset` now renders as ``. */ @@ -183,67 +320,6 @@ border-radius: 6px; } -/* - * Wallpaper description slot — a card that slides in below the swatch - * grid describing the ACTIVE wallpaper (its story, where its data comes - * from). Same 0fr → 1fr collapse as the editor slot; TS drives - * `[data-expanded]` from `syncWallpaperDescription()`. - */ -.os-settings__wallpaper-description-slot { - display: grid; - grid-template-rows: 0fr; - margin-top: 0; - opacity: 0; - transition: - grid-template-rows 0.25s cubic-bezier(0.2, 0, 0.2, 1), - margin-top 0.25s cubic-bezier(0.2, 0, 0.2, 1), - opacity 0.2s ease; -} - -.os-settings__wallpaper-description-slot[data-expanded="true"] { - grid-template-rows: 1fr; - margin-top: 12px; - opacity: 1; -} - -.os-settings__wallpaper-description-slot-inner { - overflow: hidden; - min-height: 0; -} - -.os-settings__wallpaper-description { - padding: 12px 14px; - background: color-mix(in srgb, var(--os-ui-accent, #2271b1) 6%, var( --os-ui-surface, #fff )); - border: 1px solid var( --os-ui-border, #dcdcde ); - border-inline-start: 3px solid var(--os-ui-accent, #2271b1); - border-radius: 6px; -} - -/* Glyph sits inline, right before the wallpaper's name. */ -.os-settings__wallpaper-description-header { - display: flex; - align-items: center; - gap: 7px; - margin-bottom: 4px; -} - -.os-settings__wallpaper-description-icon { - color: var(--os-ui-accent, #2271b1); - flex-shrink: 0; -} - -.os-settings__wallpaper-description-header strong { - font-size: 13px; - color: var( --os-ui-fg, #1d2327 ); -} - -.os-settings__wallpaper-description p { - margin: 0; - font-size: 12px; - line-height: 1.55; - color: var( --os-ui-fg-muted, #50575e ); -} - /* * Wallpaper config slot — hosts the "Wallpaper settings" button when * the ACTIVE wallpaper ships a `renderConfig` dialog. Same 0fr → 1fr @@ -336,13 +412,14 @@ margin-top: 16px; } +/* Sentence case, like every other label in the panel. The brand's + type scale has no uppercase tier, and a shouted 12px heading over a + single upload tile was the loudest thing on the page. */ .os-settings__uploader-heading { margin: 0 0 6px; - font-size: 12px; + font-size: 13px; font-weight: 600; color: var( --os-ui-fg-muted, #50575e ); - text-transform: uppercase; - letter-spacing: 0.04em; } .os-settings__file-input { @@ -359,7 +436,9 @@ background: var( --os-ui-surface, #fff ); background-size: cover; background-position: center; - border: 2px dashed var( --os-ui-border, #c3c4c7 ); + /* See the note on `__wallpaper-add`: the plain border token is the + card's own colour and vanishes on it. */ + border: 2px dashed var( --os-ui-border-strong, #c3c4c7 ); border-radius: 8px; color: var( --os-ui-fg-muted, #50575e ); cursor: pointer; @@ -441,7 +520,7 @@ } .os-settings__upload-hint { - font-size: 11px; + font-size: 12px; color: var( --os-ui-fg-muted, #8c8f94 ); } @@ -459,7 +538,7 @@ background: var( --os-ui-danger, #d63638 ); color: var( --os-ui-fg-on-accent, #fff ); border-radius: 4px; - font-size: 11px; + font-size: 12px; line-height: 1.3; text-align: start; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); @@ -484,6 +563,234 @@ min-height: 140px; } +/* + * The invisible anchor for the Custom accent's colour wheel. + * + * There is no visible colour field: picking the Custom swatch opens + * the native picker directly, and the picker anchors to THIS input, + * which `accent.ts` parks just under the swatch (absolute against + * the section wrapper, measured at click time) before calling + * showPicker(). Absolute rather than fixed because a dragged window + * carries a transform, and a transform turns fixed positioning into + * a lie while getBoundingClientRect keeps telling viewport truth. + * + * It stays out of the tab order and off the accessibility tree: the + * Custom swatch is the control, and activating it (pointer or + * keyboard) is what opens the wheel. + */ +.os-settings__accent-picker { + position: absolute; + width: 28px; + height: 1px; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + +/* --------------------------------------------------------------- + * Desktop layout cards. + * + * A layout is a spatial choice, so each option draws the arrangement + * it is offering. The four names on their own ("One dock", + * "OpenStation", "Side bar", "Spatial") asked the user to guess what + * each would do to their screen and then find out by trying it. + * + * The previews are schematic: two or three rectangles and a rail, on + * Void, with the dock (or the current Space) in the accent so the eye + * lands on the thing that differs between the four. Geometry comes + * from `desktop-layout.ts` as percentages, so the cards stay legible + * as the grid reflows. + * --------------------------------------------------------------- */ +/* + * Two layouts, half the pane each. Fixed tracks rather than + * auto-fill: there are exactly two of them and they are a pair to be + * compared, so a wide pane that fitted a third column would only + * shrink both cards away from each other for no reason. + * + * The cards stretch to the taller of the two (the grid's default + * `align-items`, left unset). They are a matched pair presented side + * by side, and two boxes of different heights read as two different + * KINDS of thing rather than as two answers to one question — which + * is the comparison the whole picker is for. What varies is the + * description: one line for Unified, two for Split, and neither is + * worth a ragged bottom edge. + * + * This used to be `align-items: start`, back when the Unified card + * also held Placement and Dock size and stretching Split to match + * left it mostly empty. Dock size has since moved out below both + * cards, so the gap it was guarding against is now one control deep. + */ +.os-settings__layout-grid { + display: grid; + grid-template-columns: repeat( 2, minmax( 0, 1fr ) ); + gap: 12px; +} + +/* + * The dock options, inside the One dock card. Stacked, because half + * a pane is not enough for two segmented controls side by side, and + * because reading them down the card follows the order the decision + * is made in: this layout, then where its dock sits, then how big. + */ +.os-settings__dock-options { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 14px; +} + +/* + * Dock size, under both cards rather than inside one: both layouts + * have a dock. Off the grid entirely, so it clears the taller card + * rather than sitting beside it, and on the section's own rhythm + * rather than the card's tighter one. + */ +.os-settings__dock-options--page { + margin-top: 20px; +} + +.os-settings__dock-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.os-settings__dock-option-label { + font-size: 14px; + font-weight: 500; + color: var( --os-ui-fg, #1d2327 ); +} + +/* + * The card is the BOX, not the control. It holds the radio that + * picks the layout and, for One dock, that layout's own options — + * and a radio cannot contain a segmented control, so the two are + * siblings inside it. See the docblock in + * `src/settings/sections/desktop-layout.ts`. + * + * Which means the box wears the state of a child it does not own. + * Both `:has()` rules name the CHOICE rather than the card, so a + * pointer resting on a segmented control does not lift the card, and + * a segment taking focus does not light the selection ring. + */ +.os-settings__layout-card { + display: flex; + flex-direction: column; + padding: 10px; + background: var( --os-ui-surface-raised, rgba( 0, 0, 0, 0.04 ) ); + border-radius: 10px; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.os-settings__layout-card:has( .os-settings__layout-choice:hover ) { + transform: translateY( -2px ); +} + +.os-settings__layout-card:has( [ aria-checked='true' ] ) { + box-shadow: 0 0 0 2px var( --os-ui-accent, #2271b1 ); +} + +/* The radio: everything in the card that means "this layout". */ +.os-settings__layout-choice { + display: flex; + flex-direction: column; + width: 100%; + padding: 0; + background: none; + border: 0; + border-radius: 8px; + font: inherit; + color: inherit; + text-align: start; + cursor: pointer; +} + +.os-settings__layout-choice:focus-visible { + outline: none; + box-shadow: var( --os-ui-focus-ring, 0 0 0 2px #2271b1 ); +} + +/* + * A screen, so it is shaped like one: 16/9, not whatever shape is left + * over once the card has been sized. A full-width box at a fixed 78px + * ran about 6.7:1, and every shape inside is a percentage of it — so + * the two windows came out five times wider than they were tall and + * read as stacked bars rather than as windows, which is the one thing + * these previews exist to show. + * + * Capped in width rather than stretched, because 16/9 across a whole + * half-pane card is ~290px of preview for a schematic with four + * rectangles in it. The cap sets the height (288 → 162px); the card + * keeps its own width and the preview sits centred in it, which is why + * `inline-size` is a percentage with a ceiling instead of a fixed + * width: below the cap — the stacked single-column pane — it goes back + * to filling the card, still at 16/9. + */ +.os-settings__layout-preview { + position: relative; + display: block; + inline-size: 100%; + max-inline-size: 288px; + aspect-ratio: 16 / 9; + margin-block-end: 9px; + margin-inline: auto; + overflow: hidden; + background: var( --os-ui-surface-sunken, #f0f0f1 ); + border-radius: 8px; +} + +/* A window: a filled rectangle with a boundary, because at this size + a borderless one dissolves into the Void behind it. */ +.os-settings__layout-win { + position: absolute; + background: var( --os-ui-surface-elevated, #f6f7f7 ); + border: 1px solid var( --os-ui-border-strong, #c3c4c7 ); + border-radius: 4px; +} + +/* A rail: the wp-admin menu, or the side rail. No border, because a + rail reads as a solid mass and a window does not. */ +.os-settings__layout-bar { + position: absolute; + background: var( --os-ui-border-strong, #c3c4c7 ); + border-radius: 3px; +} + +/* A rail the user gets to place: the dock, and in Side bar the admin + menu beside it. The accent is what separates the rails from the + windows they sit next to, so a card can carry more than one. */ +.os-settings__layout-win.is-accent, +.os-settings__layout-bar.is-accent { + background: var( --os-ui-accent, #2271b1 ); + border-color: transparent; +} + +.os-settings__layout-name { + font-size: 14px; + font-weight: 500; + color: var( --os-ui-fg, #1d2327 ); +} + +.os-settings__layout-desc { + margin-top: 3px; + font-size: 13px; + line-height: 1.45; + color: var( --os-ui-fg-muted, #50575e ); +} + +@media ( prefers-reduced-motion: reduce ) { + .os-settings__layout-card { + transition-duration: 0.01ms; + } + + .os-settings__layout-card:has( .os-settings__layout-choice:hover ) { + transform: none; + } +} + /* --------------------------------------------------------------- * Media Library picker. * @@ -506,7 +813,7 @@ border-radius: 4px; background: var( --os-ui-surface, #fff ); font: inherit; - font-size: 12px; + font-size: 13px; color: var( --os-ui-fg, #1d2327 ); } @@ -608,7 +915,7 @@ padding: 24px 16px; text-align: center; color: var( --os-ui-fg-muted, #646970 ); - font-size: 12px; + font-size: 13px; } .os-settings__library-error { @@ -626,7 +933,7 @@ .os-settings__library-meta { color: var( --os-ui-fg-muted, #8c8f94 ); - font-size: 11px; + font-size: 12px; } /* `__library-load-more` now renders as ``. */ @@ -640,6 +947,8 @@ * --------------------------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .os-settings__editor-slot, + .os-settings__image-picker-slot, + .os-settings__wallpaper-add, .os-settings__wallpaper-config-slot, .os-settings__upload-tile, .os-settings__library-tile, @@ -655,34 +964,27 @@ /* AI settings section — error and saving feedback */ .os-ai-settings__error { margin: 6px 0 0; - font-size: 12px; + font-size: 13px; color: var( --os-ui-danger, #d63638 ); } .os-ai-settings__saving { margin: 6px 0 0; - font-size: 12px; + font-size: 13px; color: var( --os-ui-fg-muted, #646970 ); font-style: italic; } /* Extended options section — hint paragraph + error/saving states */ -.os-ext__hint { - margin: 6px 0 0; - font-size: 12px; - color: var( --os-ui-fg-muted, #646970 ); - line-height: 1.5; -} - .os-ext__error { margin: 6px 0 0; - font-size: 12px; + font-size: 13px; color: var( --os-ui-danger, #d63638 ); } .os-ext__saving { margin: 6px 0 0; - font-size: 12px; + font-size: 13px; color: var( --os-ui-fg-muted, #646970 ); font-style: italic; } @@ -692,11 +994,6 @@ * (nav + detail) on desktop, stacks naturally on narrow windows via * the same container the OS Settings panel lives in. */ -.os-settings__help-count { - margin: 4px 0 0; - font-size: 12px; - color: var( --os-ui-fg-muted, #646970 ); -} .os-settings__help-layout { display: grid; @@ -737,7 +1034,7 @@ .os-settings__help-nav-empty { margin: 0; padding: 8px 10px; - font-size: 12px; + font-size: 13px; color: var( --os-ui-fg-muted, #646970 ); } @@ -774,7 +1071,7 @@ .os-settings__help-nav-tag { font-family: var( --os-ui-font-mono, Menlo, Consolas, monospace ); - font-size: 11px; + font-size: 12px; color: var( --os-ui-fg-muted, #646970 ); } @@ -802,17 +1099,15 @@ .os-settings__help-code { font-family: var( --os-ui-font-mono, Menlo, Consolas, monospace ); - font-size: 12px; + font-size: 13px; color: var( --os-ui-fg-muted, #646970 ); } .os-settings__help-badge { padding: 1px 8px; border-radius: 999px; - font-size: 11px; + font-size: 13px; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; background: var( --os-ui-surface-sunken, #e4e7eb ); color: var( --os-ui-fg, #1d2327 ); } @@ -838,10 +1133,8 @@ .os-settings__help-group h4 { margin: 0 0 8px; - font-size: 12px; + font-size: 13px; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; color: var( --os-ui-fg-muted, #646970 ); } @@ -855,17 +1148,27 @@ .os-settings__help-table { width: 100%; border-collapse: collapse; - font-size: 12px; + font-size: 13px; } +/* + * Rows are told apart by a wash rather than by a rule, which is the + * one place in the panel that needed a replacement rather than just a + * deletion: a props table with neither lines nor banding is a wall of + * words. Zebra striping carries the row across without drawing + * anything horizontal. + */ .os-settings__help-table th, .os-settings__help-table td { - padding: 6px 8px; - border-bottom: 1px solid var( --os-ui-border, #dcdcde ); + padding: 7px 8px; text-align: start; vertical-align: top; } +.os-settings__help-table tbody tr:nth-child( odd ) { + background: var( --os-ui-hover, rgba( 0, 0, 0, 0.05 ) ); +} + .os-settings__help-table th { font-weight: 600; color: var( --os-ui-fg-muted, #646970 ); @@ -875,7 +1178,7 @@ .os-settings__help-table code, .os-settings__help-list code { font-family: var( --os-ui-font-mono, Menlo, Consolas, monospace ); - font-size: 11px; + font-size: 12px; padding: 1px 4px; background: var( --os-ui-surface, #fff ); border: 1px solid var( --os-ui-border, #dcdcde ); @@ -883,19 +1186,19 @@ } .os-settings__help-list { + display: flex; + flex-direction: column; + gap: 4px; margin: 0; padding: 0; list-style: none; } .os-settings__help-list li { - padding: 4px 0; - border-bottom: 1px solid var( --os-ui-border, #ececee ); - font-size: 12px; -} - -.os-settings__help-list li:last-child { - border-bottom: 0; + padding: 6px 10px; + background: var( --os-ui-hover, rgba( 0, 0, 0, 0.05 ) ); + border-radius: 6px; + font-size: 13px; } .os-settings__help-note { @@ -904,7 +1207,7 @@ background: var( --os-ui-warning-bg, #fcf9e8 ); border: 1px solid var( --os-ui-warning-border, #f5e6a5 ); border-radius: 6px; - font-size: 12px; + font-size: 13px; color: var( --os-ui-warning-fg, #996800 ); } @@ -933,31 +1236,370 @@ * `:has()` is supported in every evergreen engine (Chrome 105+, * Safari 15.4+, Firefox 121+); fine for an admin-context shell. * --------------------------------------------------------------- */ -.os-window__body--native:has( .os-settings ) { - overflow: hidden; - display: flex; - flex-direction: column; +/* + * NOTE: the rule that used to sit here was + * `.os-window__body--native:has( .os-settings )`, and it never once + * matched. `renderOsSettingsPanel()` adds the `os-settings` class TO + * the native window body rather than to a child of it, so the two + * selectors name the same element, and `:has()` only ever looks at + * descendants. The `overflow: hidden` it wanted now sits on + * `.os-settings` itself, where it reaches the element it was written + * for; its `display: flex` is dropped, because the grid this file + * declares below superseded that column layout. + * + * The query context for every narrow-layout rule in this file goes on + * the WINDOW, one level up. A container query styles a container's + * descendants and never the container itself, so it cannot live on + * the panel that has to respond to it. `:has()` does the right thing + * from here: the panel really is a descendant of the window, so only + * the OS Settings window becomes a container and every other native + * window is left alone. + * + * It has to be a container and not the viewport. OS Settings is a + * window inside the shell, so dragging that window narrow never + * changes the viewport width; the `@media ( max-width: 640px )` rule + * this replaced could only fire when the whole BROWSER went under the + * breakpoint, which is both the wrong moment and, on a desktop, + * never. The Components tab has wanted this since it was written: its + * own `@container` rule further up this file had no container to + * resolve against and so had never matched either. + */ +.os-window--native:has( .os-settings ) { + container-type: inline-size; } +/* + * Two columns, two rows: the sidebar down the left, every pane sharing + * the cell on the right (only the unhidden one takes up space), and + * the footer spanning both. + */ +/* + * Two columns, four rows. The sidebar column stacks the search field, + * the scrolling strip and the empty-state line; the pane spans all + * three of those rows in column 2, and the reset bar spans both + * columns underneath. Four rows rather than a wrapper element because + * the strip and the panes have to stay siblings: see the note in + * `panel.ts` where they are rendered. + */ .os-settings { flex: 1; min-height: 0; - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: var( --os-settings-nav-width, 224px ) minmax( 0, 1fr ); + grid-template-rows: auto minmax( 0, 1fr ) auto auto; + /* + * The native window body defaults to overflow: auto. Left alone, + * a tall page scrolls the whole panel and takes the sidebar and + * the reset bar with it; each tabpanel already scrolls itself, so + * this pins the furniture and lets the content move inside it. + * See the note above about where this declaration used to live. + */ + overflow: hidden; } -/* The tab strip + footer must NOT shrink when the active panel - wants more height. Marking them flex-shrink:0 also keeps them - pinned visually as the panel below scrolls. */ +/* + * The sidebar column. Obsidian against the pane's Void, so the + * column reads as part of the window furniture (it continues the + * title bar) while the content sits on the deepest surface. No + * dividing rule between them: two surfaces meeting IS the boundary, + * and a line drawn on top of a tonal step only says the same thing + * twice. + * + * The search field, the strip and the empty-state line are three + * SIBLINGS assembled into one column by the grid rather than one + * wrapped element, because `` drives the panes by finding + * `os-tabpanel` children of its own parent. A sidebar div around the + * strip puts every pane out of its reach and the panel renders all + * nine at once. The shared background is what makes the three read as + * one surface. + */ +.os-settings__search, .os-settings > os-tabs, -.os-settings > .os-settings__footer { - flex: 0 0 auto; +.os-settings__search-empty { + grid-column: 1; + background: var( --os-ui-surface, #fff ); } -.os-settings > os-tabpanel { +/* + * No inline padding on the strip, which is load-bearing rather than + * tidy. The selected row's accent is a 2px edge at + * `inset-inline-start: 0`, so any padding here would float it in the + * middle of the gutter instead of sitting it on the column edge. The + * rows carry their own inline padding to hold the labels off it. + */ +.os-settings > os-tabs { + grid-row: 2; + min-height: 0; + overflow-y: auto; +} + +/* + * The only vertical space in the column, and the entire grouping + * mechanism. `panel.ts` stamps this on the first row of each band; + * see `navGroup()` there for why the bands come from tab order. + */ +.os-settings > os-tabs > os-tab[ data-group-start='true' ] { + margin-top: 20px; +} + +/* + * Filtered out by the search field. A plain `display: none` rather + * than anything cleverer, so a hidden row takes its group gap with it + * and a filtered list closes up instead of keeping the shape of the + * one it came from. + */ +.os-settings > os-tabs > os-tab[ data-search-hidden ] { + display: none; +} + +/* + * Search. Sits above the nav rather than over the content, because + * what it filters is the nav. + * + * The field wears the row's own geometry (same 14px, same Ash) so the + * column reads as one list with a way in at the top, rather than as a + * form control that happens to have been parked above some links. + */ +/* + * The cell holds the column's surface and the field's inset; the + * field inside it holds the pill. Two elements because the surface has + * to run edge to edge (it is the sidebar) while the field must not. + */ +.os-settings__search { + grid-row: 1; + padding: 12px 12px 14px; +} + +/* + * A sunken well in the Obsidian column: the field is a window onto + * the pane's Void, with the same Astro border the section boxes + * wear, at the inner radius (8px). The rows below it are + * square-edged and full-bleed, so the inset alone already says "not + * one of them". + * + * The field wears the row's own type: 14px Regular on Ash, a 15px + * glyph at full strength. 8px top and bottom around a 21px line puts + * the content box at 37px. + */ +.os-settings__search-field { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var( --os-ui-surface-sunken, #f0f0f1 ); + border: 1px solid var( --os-ui-border, #dcdcde ); + border-radius: 8px; + color: var( --os-ui-fg-muted, #50575e ); +} + +.os-settings__search svg { + flex: 0 0 15px; + width: 15px; + height: 15px; +} + +/* + * The class stack is the whole fix, not tidiness. The shell renders + * inside a real wp-admin document, and TWO outrankers dress this + * input as a second field inside the well: Core's forms.css styles + * `input[type="search"]` at (0,1,1), and our own window-chrome rule + * for raw controls in native window bodies weighs (0,2,1). Three + * classes are (0,3,0) and beat both, and the input goes back to + * being invisible: the FIELD is the control, the input is just + * where the letters go. + */ +.os-settings .os-settings__search .os-settings__search-input { flex: 1; + min-width: 0; + min-height: 0; + padding: 0; + border: 0; + border-radius: 0; + background: none; + box-shadow: none; + font: inherit; + font-size: 14px; + line-height: 21px; + color: var( --os-ui-fg, #1d2327 ); +} + +/* Chrome draws its own clear button on type=search; it lands on the + pill's right edge and fights the rounding. */ +.os-settings__search-input::-webkit-search-cancel-button { + appearance: none; +} + +.os-settings__search-input::placeholder { + color: var( --os-ui-fg-muted, #50575e ); + opacity: 1; +} + +/* The field is the visible control, so the ring goes on it, not on + the bare input inside. */ +.os-settings__search-field:focus-within { + box-shadow: var( --os-ui-focus-ring-field, 0 0 0 1px #2271b1 ); +} + +/* Same specificity story as above: Core's `input[type=search]:focus` + is (0,2,1) and would draw its blue ring on the inner input. */ +.os-settings .os-settings__search .os-settings__search-input:focus, +.os-settings .os-settings__search .os-settings__search-input:focus-visible { + outline: none; + border: 0; + box-shadow: none; + background: none; +} + +.os-settings__search-empty { + grid-row: 3; + margin: 0; + padding: 4px 20px 12px; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 13px; +} + +/* + * The column's surface has to survive the empty-state line being + * hidden, which is most of the time: without this the sunken + * background would stop at the bottom of the nav and leave the rest + * of the column showing the pane's Obsidian. + */ +.os-settings__search-empty[ hidden ] { + display: block; + padding: 0; + font-size: 0; +} + +/* + * Stand-in for a glyph a third-party tab cannot supply. Matches the + * 17px the real icons are sized to in the component's shadow styles, + * so the labels in a mixed column all start at the same x. + */ +.os-settings__nav-glyph-blank { + flex: 0 0 17px; + width: 17px; +} + +/* Spans the three sidebar rows: the pane is one surface whatever the + column beside it is doing. */ +.os-settings > os-tabpanel { + grid-column: 2; + grid-row: 1 / -1; min-height: 0; overflow: auto; + padding: 28px 32px 44px; + background: var( --os-ui-surface-sunken, #f0f0f1 ); +} + +/* + * Reset sits at the foot of the nav, and it resets everything. + * + * In the column rather than across the page because that is what it + * addresses: the list names the pages, and the thing under the list + * acts on all of them. Across the bottom of the pane it read as + * belonging to whatever page happened to be open, which is the whole + * reason it once had to grow a name and a sentence to disown that + * reading. + * + * The column's own surface, so it continues the sidebar rather than + * starting a third one. No rule above it, for the same reason the + * sidebar has no divider against the pane: it is already the last + * thing in a column that ends. + */ +.os-settings > .os-settings__footer { + grid-column: 1; + grid-row: 4; + display: flex; + /* + * Stated, not inherited. `` is a stack and sets + * `flex-direction: column` on its own host, which would stretch + * the button to the column's full width. + */ + flex-direction: row; + align-items: center; + justify-content: flex-start; + margin-top: 0; + padding: 12px; + background: var( --os-ui-surface, #fff ); +} + +/* + * The reset button at the mockup's measurements: a quiet 6% fill, + * no border, inner radius, Caption type. All reachable through the + * component's own custom-property knobs, so nothing here touches the + * kit's defaults. + */ +.os-settings > .os-settings__footer os-button { + --os-ui-button-padding: 8px 15px; + --os-ui-button-border-radius: 8px; + --os-ui-button-border: 0; + --os-ui-button-bg: var( --os-ui-hover, rgba( 0, 0, 0, 0.05 ) ); + font-size: 13px; +} + +/* Narrow windows: the sidebar would eat the pane, so fall back to the + stacked layout the panel had before. Container-scoped, so it tracks + the WINDOW being dragged narrow rather than the browser viewport. */ +@container ( max-width: 640px ) { + .os-settings { + grid-template-columns: minmax( 0, 1fr ); + grid-template-rows: auto minmax( 0, 1fr ) auto; + } + .os-settings > os-tabs { + grid-column: 1; + grid-row: 1; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + } + /* + * Back to a strip, so the group gap becomes horizontal. + * + * The rows KEEP their vertical treatment, which this rule cannot + * reach: it is styled inside the component's shadow root off + * `data-orientation`, and a container query has no way in there. + * They are full-bleed 40px rows laid in a horizontal line, which + * reads as a strip of wide chips rather than as the compact tab + * strip this width wants. Fixing it properly means the component + * choosing its own layout at a width, not the panel overriding it + * from outside; only the gap is restated here. + */ + .os-settings > os-tabs > os-tab[ data-group-start='true' ] { + margin-top: 0; + margin-inline-start: 20px; + } + /* + * The search field goes with the sidebar's width, which it no + * longer has: a full-bleed input above a horizontal strip reads as + * the window's search rather than the nav's. The strip is short + * enough at this width to scan without it. + */ + .os-settings__search, + .os-settings__search-empty, + .os-settings__search-empty[ hidden ] { + display: none; + } + .os-settings > os-tabpanel { + grid-column: 1; + grid-row: 2; + } + /* + * Row 3, not row 4. The wide layout has four rows and puts the + * reset bar last; this one has three, so the wide declaration would + * place the bar in a row that does not exist and the grid would + * grow an implicit one below the fold. + */ + .os-settings > .os-settings__footer { + grid-row: 3; + } + /* + * Half a pane this narrow is not a card any more, and the One dock + * card has segmented controls inside it that would start clipping + * their labels. The two stack instead. + */ + .os-settings__layout-grid { + grid-template-columns: minmax( 0, 1fr ); + } } /* Components tab — split-pane layout. Don't scroll the whole panel; @@ -975,6 +1617,13 @@ overflow: hidden; display: flex; flex-direction: column; + /* + * Square off the bottom inset. Every other page carries 44px there + * so a scrolling column has somewhere to end; this one does not + * scroll, so those extra 16px are just the panes stopping short of + * the window. Matching the top puts the same frame on both ends. + */ + padding-bottom: 28px; } .os-settings > os-tabpanel[ for='help' ]:not( [ hidden ] ) @@ -1025,53 +1674,115 @@ min-height: 22px; } -/* One toggle + hint pair. Stacking these without separators reads as - * a wall of checkboxes; a hairline above each item (after the first) - * groups the label with its description and gives the eye a stopping - * point between unrelated settings. The first item sits flush so the - * section heading already serves as its top boundary. */ +/* + * One toggle + hint pair, in its own box. + * + * These used to be separated by a hairline above each item after the + * first. A box does the same job without drawing a line: it groups the + * label with its description and gives the eye a stopping point + * between unrelated settings, and it says which text belongs to which + * switch by enclosing it rather than by sitting between it and the + * next one. No horizontal rule survives anywhere in this panel. + */ .os-features__item { display: flex; flex-direction: column; - gap: 6px; + gap: 4px; + padding: 12px 16px; + background: var( --os-ui-surface, #fff ); + border-radius: 8px; } .os-features__item + .os-features__item { - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) ); -} - -/* "Reset what's-new dialogs" row — separates the action from the - * native-Posts toggle above and gives breathing room around the - * button. `align-items: flex-start` keeps the inline-flex - * `` from stretching full-width inside the column, - * and the explicit margin on the button below works around the - * fact that flex `gap` doesn't always render visibly when one - * neighbour is an inline-flex custom element with zero outer - * margin. */ + margin-top: 12px; +} + +/* Feature title line: Body 14 / Medium 500. The outer-tree rule wins + * over the component's own :host font-size, and the shadow label + * inherits both properties from the host. */ +.os-features__item > os-checkbox-label { + font-size: 14px; + font-weight: 500; +} + +/* A functional notice (provider gating) gets a touch more air than + * the 4px title-to-description rhythm: 4px gap + 4px margin = 8px. */ +.os-features__item > os-notice { + margin-top: 4px; +} + +/* Sub-toggles (the window-links children) sit inside the parent box: + * no box-in-box, indented 24px so their checkboxes align under the + * parent's label text (16px box + 8px gap). Declared after the + * sibling rule above so the 8px margin wins for nested siblings: + * 4px parent gap + 8px margin = the same 12px block rhythm. */ +.os-features__item .os-features__item { + margin-top: 8px; + margin-left: 24px; + padding: 0; + background: none; + border-radius: 0; +} + +/* "Reset what's-new dialogs" row: description first, button directly + * under it on the shared 12px gap. `align-items: flex-start` keeps + * the inline-flex `` from stretching full-width inside + * the column. */ .os-features__row { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; - margin-top: 20px; - padding-top: 20px; - border-top: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) ); + margin-top: 12px; + padding: 12px 16px; + background: var( --os-ui-surface, #fff ); + border-radius: 8px; } .os-features__row > os-button { display: inline-flex; - margin-block: 4px 8px; } .os-features__row > .os-features__hint { margin: 0; } +/* "Delete folder sharing data": the one-line description and its + * danger button read as a single row, button on the trailing edge. + * 4px parent gap + 8px margin = the 12px block rhythm above it. */ +.os-features__danger-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 8px; +} + +.os-features__danger-row > os-button { + flex: 0 0 auto; +} + +/* Heartbeat rate: 8px label-to-control, description 8px below the + * control (4px parent gap + 4px margin). */ +.os-features__select-label { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.os-features__select-title { + font-size: 14px; + font-weight: 500; +} + +.os-features__select-label + .os-features__hint { + margin-top: 4px; +} + .os-features__hint { margin: 0; - font-size: 12px; + font-size: 13px; color: var( --os-ui-fg-muted, #50575e ); line-height: 1.5; } @@ -1080,7 +1791,7 @@ display: inline-flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: 13px; font-weight: 500; padding: 2px 10px; border-radius: 999px; @@ -1214,22 +1925,25 @@ /* Apps & Icons section (since 0.25.0) — per-item placement chooser. */ +/* + * The boxed list Núria pointed at when she asked for boxes instead of + * rules. Each row encloses its own icon, title and placement select, + * so a long list reads as a stack of things rather than as a table + * that has lost its header. + */ .os-apps-icons__list { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; } .os-apps-icons__row { display: flex; align-items: center; gap: 12px; - padding: 8px 4px; - border-bottom: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) ); -} - -.os-apps-icons__row:last-child { - border-bottom: 0; + padding: 8px 12px; + background: var( --os-ui-surface, #fff ); + border-radius: 8px; } .os-apps-icons__identity { @@ -1283,14 +1997,22 @@ position: relative; } +/* + * Same geometry in every state: the border is 1px in both, and + * selection is a box-shadow ring OUTSIDE the box, so an active card + * occupies exactly the pixels an idle one does and the row stays + * aligned. The selected ring is the flat accent, the same language + * as the wallpaper tiles and layout cards beside it. + */ .os-settings__theme-card { display: flex; flex-direction: column; gap: 6px; width: 100%; - padding: 8px; + height: 100%; + padding: 10px; background: var( --os-ui-surface, #fff ); - border: 2px solid var( --os-ui-border, #dcdcde ); + border: 1px solid var( --os-ui-border, #dcdcde ); border-radius: 10px; cursor: pointer; text-align: start; @@ -1300,17 +2022,17 @@ } .os-settings__theme-card:hover { - border-color: var( --wp-admin-theme-color, #2271b1 ); + border-color: var( --os-ui-border-strong, #8c8f94 ); } .os-settings__theme-card:focus-visible { - outline: 2px solid var( --wp-admin-theme-color, #2271b1 ); + outline: 2px solid var( --os-ui-accent, #2271b1 ); outline-offset: 2px; } .os-settings__theme-card[ aria-pressed="true" ] { - border-color: var( --wp-admin-theme-color, #2271b1 ); - box-shadow: 0 0 0 3px rgba( 34, 113, 177, 0.18 ); + border-color: transparent; + box-shadow: 0 0 0 2px var( --os-ui-accent, #2271b1 ); } .os-settings__theme-preview { @@ -1350,11 +2072,15 @@ overflow-wrap: anywhere; } +/* Wraps rather than clipping. This string is a theme's own + description and a plugin can make it any length; a one-line box cut + "The look OpenStation ships with" off mid-sentence. */ .os-settings__theme-meta { - font-size: 11px; + font-size: 13px; color: var( --os-ui-fg-muted, #646970 ); - line-height: 1.3; - min-height: 1.3em; + line-height: 1.45; + min-height: 1.45em; + overflow-wrap: anywhere; } /* Delete overlay — outside the card button, because nesting a button @@ -1401,11 +2127,21 @@ margin-block: 16px; } +/* + * The last cell of the theme grid, not a dropzone underneath it. + * Uploading a theme is one of the ways to answer "which theme", so it + * belongs in the row of answers; dashed because it is the only one + * that cannot show you what you are picking until you have picked it. + * Same reasoning, and the same treatment, as "Use your own image" in + * the wallpaper grid. + */ .os-settings__theme-upload { display: block; - border: 2px dashed var( --os-ui-border, #c3c4c7 ); - border-radius: 8px; - background: var( --os-ui-surface, #fff ); + /* Same reason as the other two dropzones above: the plain border + token is the card's own colour and vanishes on it. */ + border: 2px dashed var( --os-ui-border-strong, #c3c4c7 ); + border-radius: 10px; + background: transparent; transition: border-color 0.15s ease, background-color 0.15s ease; } @@ -1415,7 +2151,8 @@ align-items: center; justify-content: center; gap: 6px; - min-height: 96px; + /* Matches a theme card: 16:10 preview plus its name and meta. */ + min-height: 100%; padding: 16px; color: var( --os-ui-fg-muted, #50575e ); cursor: pointer; diff --git a/assets/css/posts-window.css b/assets/css/posts-window.css index 0052c210..c950690e 100644 --- a/assets/css/posts-window.css +++ b/assets/css/posts-window.css @@ -1220,153 +1220,6 @@ padding: 0 24px; } -/* ---------------------------------------------------------------- */ -/* Posts-window intro dialog (first-open Pixi preview) */ -/* ---------------------------------------------------------------- */ - -.os-intro-backdrop { - position: fixed; - inset: 0; - background: rgba( 15, 23, 42, 0.55 ); - backdrop-filter: blur( 6px ); - display: flex; - align-items: center; - justify-content: center; - z-index: 100000; - animation: os-intro-fade 180ms ease-out; -} - -.os-intro { - width: min( 720px, calc( 100vw - 48px ) ); - max-height: calc( 100vh - 48px ); - background: var( --os-ui-surface, #fff ); - color: var( --os-ui-fg, #1d2327 ); - border-radius: 14px; - box-shadow: 0 20px 60px rgba( 0, 0, 0, 0.35 ); - padding: 28px 28px 22px; - display: flex; - flex-direction: column; - gap: 12px; - outline: none; - animation: os-intro-pop 220ms cubic-bezier( 0.2, 0.8, 0.2, 1.05 ); - overflow: hidden; -} - -.os-intro__title { - margin: 0; - font-size: 20px; - font-weight: 600; - letter-spacing: -0.01em; -} - -.os-intro__lede { - margin: 0; - font-size: 14px; - line-height: 1.5; - color: var( --os-ui-fg, #1d2327 ); -} - -.os-intro__stage { - position: relative; - height: 550px; - border-radius: 10px; - overflow: hidden; - background: - radial-gradient( - circle at 30% 30%, - rgba( 124, 58, 237, 0.10 ), - transparent 55% - ), - radial-gradient( - circle at 70% 70%, - rgba( 34, 113, 177, 0.10 ), - transparent 55% - ), - linear-gradient( 180deg, #f8fafc, #eef2f7 ); - border: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) ); -} - -.os-intro__canvas { - display: block; - width: 100% !important; - height: 100% !important; -} - -.os-intro__fallback { - margin: 0; - padding: 40px 24px; - font-size: 14px; - line-height: 1.5; - text-align: center; - color: var( --os-ui-fg-muted, #50575e ); -} - -.os-intro__tip { - margin: 0; - font-size: 12px; - color: var( --os-ui-fg-muted, #50575e ); - font-style: italic; -} - -.os-intro__escape { - margin: 0; - font-size: 13px; - line-height: 1.5; - padding: 10px 12px; - border-radius: 8px; - background: rgba( 34, 113, 177, 0.07 ); - color: var( --os-ui-fg, #1d2327 ); -} - -.os-intro__actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 4px; -} - -.os-intro__btn { - appearance: none; - font: inherit; - font-size: 13px; - font-weight: 500; - padding: 8px 14px; - border-radius: 8px; - border: 1px solid transparent; - cursor: pointer; - transition: background-color 0.12s ease, border-color 0.12s ease, - filter 0.12s ease; -} - -.os-intro__btn--secondary { - background: var( --os-ui-hover, rgba( 0, 0, 0, 0.04 ) ); - color: var( --os-ui-fg, #1d2327 ); - border-color: var( --os-ui-border, rgba( 0, 0, 0, 0.10 ) ); -} - -.os-intro__btn--secondary:hover { - background: var( --os-ui-hover, rgba( 0, 0, 0, 0.07 ) ); -} - -.os-intro__btn--primary { - background: var( --wp-admin-theme-color, #2271b1 ); - color: var( --os-ui-fg-on-accent, #fff ); -} - -.os-intro__btn--primary:hover { - filter: brightness( 1.05 ); -} - -@keyframes os-intro-fade { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes os-intro-pop { - from { opacity: 0; transform: translateY( 8px ) scale( 0.98 ); } - to { opacity: 1; transform: translateY( 0 ) scale( 1 ); } -} - /* ─── Native Users window — tabs + Add User panel ────────────────── */ /* * Lives in the shared posts-window stylesheet because the Users diff --git a/assets/css/shortcuts.css b/assets/css/shortcuts.css new file mode 100644 index 00000000..131f4249 --- /dev/null +++ b/assets/css/shortcuts.css @@ -0,0 +1,133 @@ +/* + * Keyboard-shortcuts window. + * + * Styles the window `src/shortcuts.ts` paints. Reads the palette's + * tokens rather than the admin bar's, which is what lets a desktop + * theme reach it. + * + * Every colour resolves through `var( --os-ui-*, )`, and + * each literal is the pre-brand WordPress-admin value. That is the + * floor if the stylesheet fails to load, and it is what the Legacy + * snapshot collected. + */ + +.os-shortcuts { + padding: 20px 22px; + color: var( --os-ui-fg, #1d2327 ); + font-size: 13px; + line-height: 1.5; +} + +.os-shortcuts__section + .os-shortcuts__section { + margin-top: 22px; +} + +.os-shortcuts__heading { + margin: 0 0 10px; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* + * The table is the one piece of content here that can outgrow a narrow + * window: four columns of prose. It scrolls inside its own box so the + * window body never scrolls sideways as a whole. + */ +.os-shortcuts__scroller { + overflow-x: auto; +} + +.os-shortcuts__table { + width: 100%; + border-collapse: collapse; + text-align: start; +} + +.os-shortcuts__table th, +.os-shortcuts__table td { + padding: 7px 10px; + border-bottom: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) ); + vertical-align: top; +} + +.os-shortcuts__table th { + color: var( --os-ui-fg-muted, #50575e ); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.os-shortcuts__table tbody tr:last-child td { + border-bottom: 0; +} + +.os-shortcuts__key-cell { + white-space: nowrap; +} + +.os-shortcuts__note { + display: block; + margin-top: 3px; + color: var( --os-ui-fg-muted, #50575e ); + font-size: 11px; +} + +.os-shortcuts__list { + margin: 0; + padding: 0; + list-style: none; +} + +.os-shortcuts__item { + display: flex; + gap: 12px; + align-items: baseline; + padding: 7px 0; + border-bottom: 1px solid var( --os-ui-border, rgba( 0, 0, 0, 0.08 ) ); +} + +.os-shortcuts__item:last-child { + border-bottom: 0; +} + +.os-shortcuts__chord { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.os-shortcuts__plus { + color: var( --os-ui-fg-muted, #50575e ); + font-size: 10px; +} + +.os-shortcuts__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 20px; + padding: 0 5px; + border: 1px solid var( --os-ui-border-strong, rgba( 0, 0, 0, 0.18 ) ); + /* Thicker bottom edge — the one cue that makes a box read as a key. */ + border-bottom-width: 2px; + border-radius: 4px; + background: var( --os-ui-surface-elevated, #fff ); + color: var( --os-ui-fg, #1d2327 ); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 11px; + font-weight: 600; +} + +.os-shortcuts__description { + color: var( --os-ui-fg, #1d2327 ); +} + +.os-shortcuts__empty { + margin: 0; + color: var( --os-ui-fg-muted, #50575e ); +} diff --git a/assets/css/solo.css b/assets/css/solo.css new file mode 100644 index 00000000..b8dca7f6 --- /dev/null +++ b/assets/css/solo.css @@ -0,0 +1,116 @@ +/** + * OpenStation — Solo window rendering mode. + * + * One window, no desk. Loaded only on a `?openstation_solo=` request + * (see `includes/solo-window.php`); a normal shell never downloads it. + * + * ## What this sheet does NOT do + * + * It does not build a second, simpler window. Every rule here hides a + * surface or removes a decoration — the window itself is the same + * `.os-window` the desktop paints, wearing the same theme, running the + * same render callback, carrying the same title-bar buttons its plugin + * registered. That is the whole point of solo mode: what the user gets + * is the window they already had, not a lookalike of it. + * + * ## What this sheet leaves alone + * + * The title bar. Solo mode is generic — an embed or a kiosk keeps its + * title bar, because nothing else on the page offers a way to move, + * close or identify the window. An embedder that supplies its own + * chrome (the native desktop host does; the OS frame is real chrome + * with real traffic lights) layers its own sheet on top and takes ours + * away there. Doing that here would leave the generic case chromeless + * and stranded. + */ + +/* + * Kill the desk. Wallpaper, dock, icon rail, widgets, Mio, sticky + * notes: all of them are the *desktop*, and in solo mode there isn't + * one. `display: none` rather than `visibility` so nothing keeps a + * layout box, an animation frame, or a pointer target. + */ +body.os-solo .os-wallpaper, +body.os-solo .os-dock, +body.os-solo .os-icons, +/* + * Both icon layouts, not just one. `.os-icons` is the Classic grid; + * the Spatial layout paints its `` placements into + * `.os-files-layer` instead, and Spatial is what a stock install + * actually shows. Missing it left the rail painted under every solo + * window — invisible while a window covered it, a flash on every boot + * before one landed, and fully on show when the requested window did + * not exist. It also kept exactly the three things the `display: none` + * above is chosen to deny: a layout box, animation frames, and live + * drop targets. + */ +body.os-solo .os-files-layer, +body.os-solo .os-widgets, +body.os-solo .os-mio, +body.os-solo .os-mio-panel, +body.os-solo .os-notes, +body.os-solo .os-dock-peek { + display: none !important; +} + +/* + * The admin bar goes too. It is the desk's chrome, not the window's: + * in a surface asked to hold exactly one thing, a strip of site-wide + * navigation across the top is the desk sneaking back in — and its + * "Switch to Classic Admin" link would navigate the embedder's window + * clean out of the window it was given. + * + * `html.wp-toolbar` carries a `padding-top` for a bar that is no + * longer painted, and plugins position UI against + * `--wp-admin--admin-bar--height`; both are rebound to zero so the + * window's own chrome math resolves against what is actually there. + * Same treatment `chromeless.css` gives an iframe, for the same reason. + */ +body.os-solo #wpadminbar { + display: none !important; +} + +html.wp-toolbar:has( body.os-solo ) { + padding-top: 0 !important; + --wp-admin--admin-bar--height: 0px; + --wp-admin--admin-bar--position-offset: 0px; +} + +/* + * The shell fills its viewport. With the bar gone the usual 32px inset + * would leave a dark band along the top. + */ +body.os-solo .os-shell { + inset-block-start: 0; + background: var( --os-ui-bg, #1d2327 ); +} + +/* No dock at the bottom means no room reserved for one. */ +body.os-solo .os-area { + padding-bottom: 0; +} + +/* + * One more rule lives next to these and is NOT in this file: the one + * that hides every window except the one this surface was booted to + * paint. Its selector needs that window's id, which is only known per + * request, so `includes/render/assets.php` emits it inline. Without it + * a second window — a game launched from a freed Games hub — lands on + * top of the first, because of the rule directly below. + */ + +/* + * The single window fills the desk. Positioned rather than laid out, + * so the window manager's own geometry writes — which still run, this + * is the real window class — are overridden rather than fought with. + */ +body.os-solo .os-window { + inset-block-start: 0 !important; + inset-inline-start: 0 !important; + width: 100% !important; + height: 100% !important; + border: 0; + border-radius: 0; + box-shadow: none; + transform: none !important; +} diff --git a/assets/css/variables.css b/assets/css/variables.css index 09b7412e..6962818c 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -143,10 +143,68 @@ body.os-active { --os-backstop: #0c0b0f; --os-area-inset: 0; + /* + * ── The icon grid ──────────────────────────────────────────── + * + * ONE grid, everywhere placements are laid out: the wallpaper, + * folder windows, and every canvas in the site folder. Before + * these tokens each surface carried its own pitch and its own + * tile width, and the two drifted apart — the desktop ended up + * with an 88px tile in a 96px cell whose 8px of air the tile's + * own padding ate, so icons touched edge to edge while the site + * folder had 20px between them. + * + * The cell is derived, never declared: `cell = tile + gap`. A + * gap you can see is the whole point, so it is the number that + * gets tuned; the pitch follows. + * + * The TypeScript side mirrors these in `src/desktop-files/grid.ts` + * (layout maths can't read CSS), and + * `tests/vitest/grid-metrics.test.ts` parses this file to prove + * the two agree. Change a number here and that test tells you + * exactly which constant to move with it. + */ + --os-tile-w: 88px; + /* + * A FIXED height, and it has to fit the tallest a tile can get: + * 8px padding + 48px icon well + 6px gap + two clamped label + * lines (2 × 12px × 1.2 = 28.8px) + 8px padding = 98.8px, so + * 104px with a little slack for font metrics. + * + * Fixed rather than minimum because the selection ring is drawn + * around the tile box: let the box follow its label and a row of + * selected icons is a ragged run of different-height rectangles, + * one per label that happened to wrap. + */ + --os-tile-h: 104px; + --os-grid-gap-x: 20px; + --os-grid-gap-y: 16px; + /* Gutter from the top / inline-start edge of any icon canvas. */ + --os-grid-padding: 16px; + /* + * Image-led sections opt into a bigger tile (`tileSize: 'large'`) + * — a shop's products read as a catalogue, not a file list. Same + * gaps, bigger tile. + */ + --os-tile-w-large: 132px; + --os-tile-h-large: 160px; + /* Window chrome — Obsidian body inside a Starlight hairline. */ --os-window-bg: #1a1721; --os-window-border: rgba(255, 251, 255, 0.12); - --os-window-radius: 8px; + /* + * Window corner. Softer than the 8px the shell carried before, so + * a window reads as an object on the desk rather than a panel + * pinned to it. + * + * The tab strip's own radius is deliberately NOT tied to this one. + * A tab is 30px tall, so a corner anywhere near this size eats the + * whole straight edge and turns the tab into a capsule — it stops + * reading as a folder tab attached to the page, which is the one + * thing the tab has to say. `--os-tabs-radius` stays smaller and + * independent on purpose. + */ + --os-window-radius: 16px; /* * Shadows stay black and go deeper than they were: on a Void desk * a soft grey shadow is invisible, and depth is the only thing @@ -189,14 +247,19 @@ body.os-active { /* * ---- Title bar --------------------------------------------- * - * Astro when focused, Obsidian when not: the focused window's - * chrome lifts one step up the Shade ramp while its unfocused - * neighbours sit flush with their own bodies. That is the entire - * focus signal, and it costs no colour — which matters, because - * the accent is spoken for. + * Obsidian when focused, Void when not: the focused window's + * chrome sits one step up the Shade ramp while its unfocused + * neighbours sink toward the desk they are lying on. That is the + * entire focus signal, and it costs no colour — which matters, + * because the accent is spoken for. + * + * The pair used to be Astro-over-Obsidian, a step higher. It came + * down so a focused window's title bar and its tab strip are the + * SAME Obsidian, and the only thing lifting out of that surface is + * the tab itself. The step is the same size; the whole ramp moved. */ - --os-titlebar-bg: #1a1721; - --os-titlebar-bg-focused: #33303a; + --os-titlebar-bg: #0c0b0f; + --os-titlebar-bg-focused: #1a1721; --os-titlebar-color: #99969c; --os-titlebar-color-focused: #fffbff; --os-titlebar-height: 40px; @@ -204,6 +267,33 @@ body.os-active { --os-titlebar-divider: rgba(255, 251, 255, 0.12); --os-titlebar-divider-unfocused: rgba(255, 251, 255, 0.06); + /* + * The status ring — leading mark of the title bar, where the app + * icon used to be. Four states: the resting ring is Starlight, + * work and success are both Pulse, failure is the danger colour. + * + * Pulse twice over is not an oversight. In flight and landed + * differ in FILL — an open ring breathing, then a solid disc with + * a check — and failure differs from both in fill AND glyph AND + * hue. Shape carries the distinction that colour alone cannot for + * a user who can't separate the two hues, and the accent stays the + * shell's own voice for "this is your station working". + * + * Failure resolves through `--os-ui-danger` and is the one value + * here that must not be quietened: a failure that whispers is a + * failure the user misses. + * + * The resting ring is one value in both title-bar states, not two: + * the ring reports a phase, and dimming it on an unfocused window + * would make `idle` say something different depending on which + * window you last clicked. + */ + --os-titlebar-activity-idle-color: #fffbff; + --os-titlebar-activity-color: var(--os-ui-accent, #2271b1); + --os-titlebar-activity-saved-color: var(--os-ui-accent, #2271b1); + --os-titlebar-activity-failed-color: var(--os-ui-danger, #d63638); + --os-titlebar-activity-size: 16px; + /* * Title-bar control glyphs — minimise / maximise / close, the ⋯ * menu trigger, and the screen-meta cluster — in each of the two @@ -576,7 +666,8 @@ body.os-active { * 45% to 30%, and onto the dim — which is the layer that was * making a focused control look like it was on fire. */ - --os-ui-focus-ring: 0 0 0 2px rgba(12, 11, 15, 0.9), 0 0 0 4px #f252fc, + --os-ui-focus-ring: 0 0 0 2px rgba(12, 11, 15, 0.9), + 0 0 0 4px var(--os-ui-accent, #f252fc), 0 0 12px 2px color-mix(in srgb, var(--os-ui-accent-dim) 30%, transparent); /* * …and one for FIELDS, which is a different problem. A text input @@ -587,9 +678,50 @@ body.os-active { * outside it: unmistakable, and quiet enough to live in a stack of * twelve. */ - --os-ui-focus-ring-field: 0 0 0 1px #f252fc, + --os-ui-focus-ring-field: 0 0 0 1px var(--os-ui-accent, #f252fc), 0 0 0 4px color-mix(in srgb, var(--os-ui-accent-dim) 16%, transparent); + /* + * The selected row of a VERTICAL ``: the sidebar in + * OpenStation Preferences, and any native window that lays its + * pages down the side rather than across the top. + * + * Three layers, and the split is the whole point. Colour repeated + * down nine rows is wallpaper, so the accent is spent as a + * two-pixel EDGE and the row itself gets only a wash that falls + * away: it appears where it is cheap to look at and expensive to + * ignore. + * + * `-edge` is the accent the user picked, flat. It used to be + * Miomesh, and a mesh does hold up at 2px where a radial one + * would crop to an arbitrary lilac — but the edge is the loudest + * thing on the page that says "this row", and a row that says it + * in Pulse while the controls beside it say it in the chosen + * accent reads as two systems. + * + * `-wash` and `-bloom` are AMBIENT accent, so both resolve through + * `--os-ui-accent-dim` like every other glow in the kit. Turning + * the station down is still one edit. The Starlight stop in the + * middle of the wash is what keeps the row from reading as a pink + * smear: it lifts the surface under the label rather than tinting + * it, and it is gone by the time the row ends. + */ + --os-ui-tab-edge: linear-gradient( + var(--os-ui-accent, #f252fc), + var(--os-ui-accent, #f252fc) + ); + --os-ui-tab-wash: linear-gradient( + 90deg, + color-mix(in srgb, var(--os-ui-accent-dim) 16%, transparent) 0%, + rgba(255, 251, 255, 0.04) 42%, + transparent 100% + ); + --os-ui-tab-bloom: linear-gradient( + 90deg, + color-mix(in srgb, var(--os-ui-accent-dim) 26%, transparent), + transparent + ); + /* * ---- Motion -------------------------------------------------- * @@ -625,6 +757,14 @@ body.os-active { --os-ui-ease-spring: cubic-bezier(0.32, 1.5, 0.55, 1); /* Decelerating. The default for anything arriving. */ --os-ui-ease-out: cubic-bezier(0.22, 0.9, 0.28, 1); + /* + * Accelerating — the mirror of `out`, and the default for anything + * LEAVING. A surface that eases out on the way in and eases in on + * the way out is the difference between "dismissed" and "undone": + * decelerating into nothing reads as the thing hesitating, which + * is the wrong note when the user has already moved on. + */ + --os-ui-ease-in: cubic-bezier(0.4, 0, 1, 1); /* Symmetric, for a loop that has to come back where it started. */ --os-ui-ease-loop: cubic-bezier(0.45, 0, 0.55, 1); @@ -699,6 +839,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 Preferences → 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); @@ -920,19 +1079,24 @@ body.os-active { * icon-badge token, so "badges are 22px" reaches * the bin too. * - * The count badges keep their red, and keep it as a LITERAL: a - * badge is a status, not an identity moment, and Pulse next to a - * Pulse-accented dock tile would read as decoration rather than as - * "three things need you". Naming the gradient here also keeps it - * a gradient — both stops used to resolve through `--os-ui-danger`, - * which the palette moved, flattening the badge to one flat red - * and turning its white numerals Void. The bin's neutral badge has - * no such constraint and follows the station. - */ - --os-dock-badge-bg: linear-gradient(180deg, #ff5a5a 0%, #d63638 100%); - --os-dock-badge-fg: #fffbff; - --os-icon-badge-bg: linear-gradient(180deg, #ff5a5a 0%, #d63638 100%); - --os-icon-badge-fg: #fffbff; + * The count badges wear Pulse, and wear it as a LITERAL gradient: + * both stops used to resolve through `--os-ui-danger`, which the + * palette moved, flattening the badge to one flat fill and turning + * its numerals Void by accident. Naming the stops here keeps it a + * gradient on purpose. The bin's neutral badge is deliberately not + * part of this: a count of things in the trash is ambient state, + * not something asking for you. + * + * **The numerals are Void, not Starlight, and that is a + * requirement rather than a preference.** Starlight on Pulse is + * 2.9:1, which fails AA for text this small; Void on Pulse is + * 6.8:1. Any future retint of these has to keep the pair legible, + * so check the ratio before swapping the ink back to white. + */ + --os-dock-badge-bg: linear-gradient(180deg, #f97dff 0%, #f252fc 100%); + --os-dock-badge-fg: #0c0b0f; + --os-icon-badge-bg: linear-gradient(180deg, #f97dff 0%, #f252fc 100%); + --os-icon-badge-fg: #0c0b0f; --os-recycle-badge-bg: rgba(26, 23, 33, 0.85); --os-recycle-badge-fg: rgba(255, 251, 255, 0.96); --os-dock-recycle-badge-bg: rgba(26, 23, 33, 0.85); @@ -982,7 +1146,13 @@ body.os-active { --os-dock-icon-color: rgba(255, 251, 255, 0.72); --os-dock-icon-color-hover: #fffbff; --os-dock-item-bg-hover: rgba(242, 82, 252, 0.18); - --os-dock-item-outline: #f252fc; + /* + * The status indicator and the focus ring, and both say "this + * one" about a tile the user chose — so they read the accent + * rather than a fixed Pulse. Pulse is still what an untouched + * install shows, because that is what the picker starts on. + */ + --os-dock-item-outline: var(--os-ui-accent, #f252fc); /* * Hairline between the dock and the desktop. Reaches the side * placements (as a single inline-edge border) AND the floating @@ -995,12 +1165,68 @@ body.os-active { * back to the shared value above it. */ --os-dock-border: rgba(255, 251, 255, 0.1); + + /* + * The dividers INSIDE the rail, which are a different job from the + * outline around it: they separate tiles that belong to different + * groups — core menus, plugin apps, and OpenStation's own controls. + * + * One treatment for all of them. An earlier pass gave the + * WordPress-to-OpenStation boundary Pulse and dropped the + * core-to-plugin one to a hairline, on the theory that only one + * boundary was worth reading. Two weights in one short rail read as + * two unrelated ideas instead of one system, so both are now the + * same line: Pulse at the waist, falling away to nothing at both + * ends, with a soft glow to hold it against the dock tint. + * + * Resolved one step back from the accent through + * `--os-ui-accent-dim`, where every ambient use of Pulse resolves, + * so "tone the station down" stays one edit. + */ + --os-dock-divider: color-mix(in srgb, var(--os-ui-accent-dim) 70%, transparent); --os-dock-floating-bg: rgba(12, 11, 15, 0.62); --os-dock-floating-border: rgba(255, 251, 255, 0.12); --os-dock-floating-border-top: rgba(255, 251, 255, 0.18); --os-dock-floating-highlight: rgba(255, 251, 255, 0.1); --os-dock-floating-shadow: rgba(0, 0, 0, 0.55); + /* + * ---- The OpenStation layout --------------------------------- + * + * The constellation: the flyout that fans a menu's submenu out of + * its tile on hover, on every rail in every layout. The rail's + * divider used to live here too; it is shared chrome now and reads + * through `--os-dock-divider`. + * + * The constellation's SURFACE is Obsidian, deliberately. The mesh + * is spent on the row under the pointer and on the head's icon + * halo — the two moments where the panel is answering the user — + * and nowhere else. A flyout that was iridescent edge to edge + * would have nothing left to say when you actually pointed at + * something in it. + */ + --os-cn-surface: rgba(26, 23, 33, 0.82); + --os-cn-border: rgba(255, 251, 255, 0.14); + --os-cn-shadow: 0 24px 64px rgba(0, 0, 0, 0.62), + 0 2px 8px rgba(0, 0, 0, 0.4); + --os-cn-fg: #fffbff; + --os-cn-fg-muted: rgba(255, 251, 255, 0.62); + --os-cn-legend: rgba(255, 251, 255, 0.4); + --os-cn-divider: rgba(255, 251, 255, 0.1); + /* The row under the pointer — the panel's identity moment. */ + --os-cn-row-fill: var(--os-ui-holo-fill); + --os-cn-row-ink: var(--os-ui-holo-ink); + /* Beam: the thread from the panel's underside down to the tile. */ + --os-cn-beam: color-mix(in srgb, var(--os-ui-accent-dim) 80%, transparent); + --os-cn-radius: 14px; + /* + * Above every window and above the dock. Two panels coexist while + * the pointer moves along the rail — one dismissing, one arriving + * — and the retiring one is painted one step below so it can + * never fade out on top of the menu being read. + */ + --os-cn-z: 2147483000; + /* * ---- Shell surfaces ---------------------------------------- * @@ -1040,8 +1266,114 @@ body.os-active { --os-my-wordpress-fg: #fffbff; --os-my-wordpress-surface: rgba(255, 251, 255, 0.06); --os-ai-panel-bg: rgba(26, 23, 33, 0.97); - --os-tabs-bg: #33303a; + /* + * ---- Window tab strip --------------------------------------- + * + * A window with sub-pages reads as three surfaces stacked in + * depth, and the values below are that ramp: Astro on the focused + * title bar, VOID for the track the tabs sit in, then the page + * itself. The track is the darkest of the three on purpose — it + * has to stay a distinct band under BOTH title-bar states, and + * Obsidian would collapse into the unfocused bar (also Obsidian) + * exactly when a window has the least going on to distinguish it. + * + * The active tab is not a colour, it is the page arriving early. + * `chromeless.css` paints every admin page inside a window `#fff` + * whatever the admin colour scheme says, so the tab can name that + * white and the joint between the two is seamless. Retinting the + * tab means retinting the page it belongs to, and these two move + * together or the seam comes back. + * + * `-color-muted` is the second tone anything nested in an active + * tab needs — the external tab's detach and close chips. It is + * the only reason a "muted on light" value exists this far down a + * dark palette. + */ + --os-tabs-bg: #1a1721; + /* + * The track on an UNFOCUSED window, which follows the title bar + * down to Void rather than holding Obsidian. + * + * A focused window already reads as two surfaces: title bar and + * track are the same Obsidian, and the tab lifts out of them. Let + * the track keep that Obsidian while the bar above it dims and + * the window reads as THREE, with the strip belonging to neither + * the chrome above nor the page below. Following the bar down + * keeps the count at two in both states. + * + * Both tracks read this: the shell's strip on an iframe window, + * and `` inside a native one. The track + * is chrome in both cases, so it dims with the chrome; what keeps + * the tab attached to the content while that happens is the + * plate's fill, which wears the body's own colour rather than the + * track's. + * + * Note the asymmetry with the title bar's pair, which is + * deliberate: there, `--os-titlebar-bg` is the unfocused base and + * `-focused` is the modifier. Here the base is the LIT value, + * so a theme naming one track colour lands on the state where the + * track is doing the most work. + * + * A theme that sets only `--os-tabs-bg` (Legacy, and anything + * written before this token existed) resolves through it in both + * states and keeps its single strip colour. + */ + --os-tabs-bg-unfocused: #0c0b0f; --os-tabs-color: #b3afb5; + --os-tabs-active-bg: #fff; + --os-tabs-active-color: #0c0b0f; + --os-tabs-active-color-muted: rgba(12, 11, 15, 0.6); + /* + * The plate's own body: a frosted crown resolving to flat page + * white well before the joint. + * + * Every stop is OPAQUE, and that is a requirement rather than a + * preference. The face used to be translucent over a + * `backdrop-filter`, which promotes it to its own compositor layer + * and leaves a faint grey hairline where that layer is clipped — + * down the plate's sides and across its bottom, which is precisely + * where this design has a bright rail and needs everything else to + * be invisible. + * + * The last stop must also land above the joint (the bottom + * `--os-tabs-radius` of the plate), because the joint is painted + * in flat `--os-tabs-active-bg` and a body still tinted where the + * two meet draws a seam of its own. If you retune these stops, + * check the bottom edge first. + */ + --os-tabs-active-frost: linear-gradient( + 180deg, + #f4eff9 0%, + #ffffff 58% + ); + /* + * The crown. Holomesh, masked away before the joint for the same + * reason. It is on exactly ONE tab at a time — that is what keeps + * the mesh an identity moment instead of wallpaper, and it is the + * rule the rest of the kit follows. Set it to `none` to get the + * plain frosted plate back. + */ + --os-tabs-active-crown: var(--os-mesh-holo); + --os-tabs-active-crown-opacity: 0.3; + /* + * The rail: one continuous line around the silhouette of + * page-plus-tab. + * + * This is the one place the accent is spent on the tab strip, and + * spending it here is what lets the tab itself stay uncoloured — + * so it is the accent the user picked, flat, and not Pulsemesh. + * A mesh here would be the accent everywhere else on the tab + * strip disagreeing with the one line that carries it. Written as + * a gradient rather than a colour because the rail is painted as + * a background-image through a mask. + * + * Set it to `none` for the plain frosted plate with no line. + */ + --os-tabs-rail: linear-gradient( + var(--os-ui-accent, #f252fc), + var(--os-ui-accent, #f252fc) + ); + --os-tabs-rail-width: 2px; --os-media-tile-bg: rgba(255, 251, 255, 0.05); --os-media-visual-bg: rgba(255, 251, 255, 0.04); --os-skeleton-low: rgba(255, 251, 255, 0.05); @@ -1124,6 +1456,13 @@ body.os-active { * the dock (200) so it never covers navigation. */ --os-z-mio: 190; + /* + * The notch — above every window and above the dock, because it + * overlaps the shell's top edge deliberately and must stay legible + * over a maximized window's title bar. Scoped inside the shell's + * own stacking context, so it never competes with the admin bar. + */ + --os-z-notch: 9000; /* * Window-link ties (relation splines between windows). The accent diff --git a/assets/css/window-chrome.css b/assets/css/window-chrome.css index fa62c89e..79650a94 100644 --- a/assets/css/window-chrome.css +++ b/assets/css/window-chrome.css @@ -72,6 +72,76 @@ filter var( --os-fx-transition-duration, 0.5s ) ease-in-out; } +/* + * Gutenberg Sidebar Window is a real sibling iframe, but visually it behaves + * like an inspector extension. The source keeps its normal OpenStation + * window; the companion replaces its own chrome with a continuation of the + * source title bar, then contributes a flat panel separated by Gutenberg's + * quiet sidebar divider. + */ +.os-window.os-window--editor-sidecar-source { + isolation: isolate; + overflow: visible; + border-inline-end-color: transparent; + border-end-end-radius: 0; +} + +.os-window--editor-sidecar-source > .os-window__titlebar { + border-start-end-radius: 0; +} + +.os-window--editor-sidecar-source > .os-window__body { + border-end-end-radius: 0; +} + +/* Focus belongs to the complete editor surface, even when the inspector's + * iframe received the last pointer event. */ +.os-window.os-window--editor-sidecar-pair-focused { + filter: none !important; +} + +.os-window.os-window--editor-sidecar-source.os-window--editor-sidecar-pair-focused { + box-shadow: var( --os-window-shadow-focused ); +} + +.os-window.os-window--editor-sidecar-companion { + min-width: 280px; + background: var( --os-ui-surface, #fff ); + background-clip: padding-box; + border: 0; + border-block-start: 1px solid var( --os-window-border ); + border-block-end: 1px solid var( --os-window-border ); + border-inline-end: 1px solid var( --os-window-border ); + border-start-start-radius: 0; + border-start-end-radius: var( --os-window-radius ); + border-end-start-radius: 0; + border-end-end-radius: var( --os-window-radius ); + box-shadow: none; + color: var( --os-ui-fg, #1d2327 ); +} + +/* The editor owns the only title bar. */ +.os-window--editor-sidecar-companion > .os-window__titlebar { + display: none; +} + +.os-window--editor-sidecar-companion > .os-window__body { + border-end-end-radius: max( + 0px, + calc( var( --os-window-radius ) - 1px ) + ); +} + +/* Its width and height follow Gutenberg/the source, not free resizing. */ +.os-window--editor-sidecar-companion .os-window__resize-handle { + display: none; +} + +.os-window--editor-sidecar-companion .os-window__menu-btn, +.os-window--editor-sidecar-companion .os-window__menu-panel { + display: none; +} + /* * Suppress the left/top/width/height transition during drag or resize — * otherwise every pointer move lerps and the window lags the cursor. @@ -193,6 +263,16 @@ color: var(--os-titlebar-color-focused); } +.os-window--editor-sidecar-source.os-window--editor-sidecar-pair-focused + > .os-window__titlebar { + background-color: var(--os-titlebar-bg-focused); + background-image: var( + --os-titlebar-image-focused, + var(--os-titlebar-image, none) + ); + color: var(--os-titlebar-color-focused); +} + /* Window icon in title bar. */ .os-window__icon { font-size: 18px; @@ -207,14 +287,94 @@ font-size: 9px; } -/* Activity indicator slot — sits between the icon and the title. - * Reserves a fixed width so the indicator's blink animation can't - * shift the title text horizontally. The inner `` - * paints a 12px modem-style dot (always visible, accent-colored). +/* --------------------------------------------------------------- + * Window activity — the status ring. + * + * The leading mark of the title bar, in the position the app icon + * used to hold. That icon was a copy of the window's own dock tile a + * few hundred pixels below it, and a title bar has room for one mark + * of that size — better spent on something that changes. + * + * The ring is an ``, found + * by `[data-os-activity-indicator]` — the same public attribute a + * plugin uses to mount its own. The framework's ring is not a special + * case; it is the first subscriber. * - * `--wp-admin-theme-color` is forwarded as `color` on the host so - * the inner shadow-DOM stylesheet's `currentColor` references - * (used in the box-shadow glow) resolve to the live accent. */ + * Four states, and only one of them fills: + * + * idle white outline, no glyph + * saving accent outline, breathing + * saved accent fill, white check + * failed open red outline, red bang + * + * Colour alone is not a distinction every user can make, which is why + * the two outcomes differ in SHAPE — filled versus open, check versus + * bang — and not only in hue. + * + * `Window._paintActivityIndicator()` also mirrors the phase onto the + * title bar as `data-os-activity` (absent while idle) so a desktop + * theme can react to window state without reaching into the + * component's shadow root. + */ +.os-window__status { + --os-ui-save-status-size: var(--os-titlebar-activity-size, 16px); + /* + * At rest: a white ring. One value, in both title-bar states — + * the phase is what the ring reports, and dimming it on an + * unfocused window would make "idle" say two different things + * depending on which window you last clicked. + */ + --os-ui-save-status-idle-color: var(--os-titlebar-activity-idle-color, #fff); + /* + * The RING colour, not the dot's background. Setting + * `--os-ui-save-status-bg` here instead is what once painted a + * solid accent fill inside the resting outline: that token is the + * dot's `background` on the component's base rule, and the ring + * only borrows it as a border. The ring has its own name for + * exactly this reason. + */ + --os-ui-save-status-ring-color: var(--os-titlebar-activity-color, #2271b1); + --os-ui-save-status-saved-bg: var(--os-titlebar-activity-saved-color, #2271b1); + --os-ui-save-status-failed-bg: var(--os-titlebar-activity-failed-color, #d63638); + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +/* + * The announcement. A glow is invisible to a screen reader, and + * "did that save?" is precisely the question that can't be answered + * by looking. `Window._paintActivityIndicator()` writes the outcome + * here; absolute positioning keeps it out of the title bar's flex + * flow so it contributes neither a box nor a `gap`. + */ +.os-window__activity-status { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* + * Activity indicator slot — opt-in, and empty by default. + * + * The framework paints the glow above instead of putting an + * `` in the title bar of its own accord. The slot + * survives for anything that DOES want a literal dot as well: give an + * `` the `data-os-activity-indicator` attribute, drop + * it in a title-bar slot inside a `.os-window__activity` wrapper, and + * `Window._paintActivityIndicator()` drives its phase for you. + * + * The fixed width is what keeps the blink from shifting the title + * text sideways, and `--wp-admin-theme-color` is forwarded as `color` + * so the component's shadow-DOM `currentColor` references (the glow's + * box-shadow) resolve to the live accent. + */ .os-window__activity { display: inline-flex; align-items: center; @@ -298,6 +458,11 @@ * `flex-shrink: 0`, before/after spacers, etc.) keeps its * original layout role. * + * The `icon` slot is empty by default now — the app icon it used to + * carry duplicated the window's dock tile — so on most windows it + * contributes nothing at all. It keeps `display: contents` for the + * ones where a plugin or a desktop theme does render an icon into it. + * * The `title` slot is the exception: it's the flex-grow region of * the title bar (`flex: 1`). When a plugin replaces the default * title with custom HTML or a render callback, we want the new @@ -321,6 +486,7 @@ display: contents; } + .os-window__slot--title { flex: 1; min-width: 0; @@ -340,6 +506,110 @@ display: none; } +/* + * This row is the inline continuation of the source title bar. It mirrors + * Gutenberg's compact sidebar-toggle cluster instead of introducing a form + * field inside the window chrome. + */ +.os-window--editor-sidecar-companion + .os-window__slot--after-titlebar { + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + height: 40px; + padding: 4px 5px 4px 8px; + border-bottom: 0; + border-start-end-radius: max( + 0px, + calc( var( --os-window-radius ) - 1px ) + ); + background-color: var( --os-titlebar-bg ); + background-image: var( --os-titlebar-image, none ); + background-repeat: var( --os-titlebar-image-repeat, repeat ); + background-size: var( --os-titlebar-image-size, auto ); + background-position: var( --os-titlebar-image-position, center ); + color: var( --os-titlebar-color ); + --os-ui-fg: var( --os-titlebar-color ); + --os-ui-fg-muted: var( --os-titlebar-color ); + --os-ui-active: rgba( 255, 255, 255, 0.17 ); +} + +.os-window--editor-sidecar-companion.os-window--editor-sidecar-pair-focused + .os-window__slot--after-titlebar { + background-color: var( --os-titlebar-bg-focused ); + background-image: var( + --os-titlebar-image-focused, + var( --os-titlebar-image, none ) + ); + color: var( --os-titlebar-color-focused ); + --os-ui-fg: var( --os-titlebar-color-focused ); + --os-ui-fg-muted: var( --os-titlebar-color-focused ); +} + +.os-window--editor-sidecar-companion + .os-editor-sidecar-panel-buttons { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-inline: contain; + scrollbar-width: none; +} + +.os-window--editor-sidecar-companion + .os-editor-sidecar-panel-buttons::-webkit-scrollbar { + display: none; +} + +.os-window--editor-sidecar-companion + .os-editor-sidecar-panel-button { + flex: 0 0 auto; + --os-ui-btn-color: var( --os-titlebar-color ); + --os-ui-btn-color-hover: var( --os-titlebar-color ); + --os-ui-btn-bg-hover: var( + --os-titlebar-btn-bg-hover, + var( --os-ui-hover, rgba( 255, 255, 255, 0.11 ) ) + ); + --os-ui-btn-bg-active: var( + --os-titlebar-btn-bg-active, + var( --os-ui-active, rgba( 255, 255, 255, 0.2 ) ) + ); + --os-ui-btn-outline: var( --os-ui-focus, #72aee6 ); +} + +.os-window--editor-sidecar-companion.os-window--editor-sidecar-pair-focused + .os-editor-sidecar-panel-button { + --os-ui-btn-color: var( --os-titlebar-color-focused ); + --os-ui-btn-color-hover: var( --os-titlebar-color-focused ); +} + +.os-window--editor-sidecar-companion + .os-editor-sidecar-panel-icon { + display: block; + box-sizing: border-box; + width: 20px !important; + height: 20px !important; + margin: 0; + font-size: 20px; + line-height: 1; + object-fit: contain; + pointer-events: none; +} + +.os-window--editor-sidecar-companion + .os-editor-sidecar-panel-close { + flex: 0 0 auto; + --os-ui-btn-color: var( --os-ui-fg-muted, #646970 ); + --os-ui-btn-color-hover: var( --os-ui-fg, #1d2327 ); + --os-ui-btn-bg-hover: var( --os-ui-hover, rgba( 0, 0, 0, 0.08 ) ); + --os-ui-btn-bg-active: var( --os-ui-active, rgba( 0, 0, 0, 0.12 ) ); + --os-ui-btn-outline: var( --os-ui-focus, #2271b1 ); +} + /* --------------------------------------------------------------- * Window control buttons. * @@ -366,7 +636,7 @@ * theme could not reach them: these declarations land on the WINDOW * element, so a `--os-ui-btn-color` set at the shell root loses to * them, and the same names also drive buttons outside the title bar - * (sticky notes, the desk chrome) that a theme usually does not want + * (pinned notes, the desk chrome) that a theme usually does not want * to move in the same stroke. * * `--os-titlebar-btn-focused-*` mirrors the unfocused set @@ -387,7 +657,17 @@ --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 ); } -.os-window:not( .os-window--focused ) { +.os-window.os-window--editor-sidecar-source.os-window--editor-sidecar-pair-focused { + --os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) ); + --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff ); + --os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) ); + --os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) ); + --os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) ); +} + +.os-window:not( .os-window--focused ):not( + .os-window--editor-sidecar-pair-focused +) { --os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) ); --os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) ); --os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) ); @@ -417,7 +697,9 @@ * unthemed shell. No theme, no change. */ .os-shell[ data-os-desktop-theme ] - .os-window:not( .os-window--focused ) { + .os-window:not( .os-window--focused ):not( + .os-window--editor-sidecar-pair-focused + ) { --os-ui-btn-color: var( --os-titlebar-btn-color, color-mix( in srgb, var( --os-titlebar-color, #50575e ) 72%, transparent ) @@ -472,7 +754,7 @@ var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) ); } -.os-window:not(.os-window--focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls { +.os-window:not(.os-window--focused):not(.os-window--editor-sidecar-pair-focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls { border-inline-start-color: var( --os-titlebar-divider-unfocused, rgba(0, 0, 0, 0.1) @@ -599,8 +881,26 @@ background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) ); } +.os-window--editor-sidecar-pair-focused .os-window__meta-btn { + color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) ); +} +.os-window--editor-sidecar-pair-focused .os-window__meta-btn:hover { + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); + background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) ); +} +.os-window--editor-sidecar-pair-focused .os-window__meta-btn:focus-visible { + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); + background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) ); + outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) ); + outline-offset: 1px; +} +.os-window--editor-sidecar-pair-focused .os-window__meta-btn--active { + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); + background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) ); +} + /* Unfocused window: divider and buttons adapt to light title bar. */ -.os-window:not(.os-window--focused) .os-window__screen-meta { +.os-window:not(.os-window--focused):not(.os-window--editor-sidecar-pair-focused) .os-window__screen-meta { border-inline-end-color: var( --os-titlebar-divider-unfocused, rgba(0, 0, 0, 0.1) @@ -613,14 +913,14 @@ * buttons sit in the same strip as controls that were already themable. * The literals are unchanged, so an unthemed bar looks as it always did. */ -.os-window:not(.os-window--focused) .os-window__meta-btn { +.os-window:not(.os-window--focused):not(.os-window--editor-sidecar-pair-focused) .os-window__meta-btn { color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) ); } -.os-window:not(.os-window--focused) .os-window__meta-btn:hover { +.os-window:not(.os-window--focused):not(.os-window--editor-sidecar-pair-focused) .os-window__meta-btn:hover { color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) ); background: var( --os-ui-hover, rgba(0, 0, 0, 0.08) ); } -.os-window:not(.os-window--focused) .os-window__meta-btn--active { +.os-window:not(.os-window--focused):not(.os-window--editor-sidecar-pair-focused) .os-window__meta-btn--active { color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) ); background: rgba(0, 0, 0, 0.12); } @@ -630,31 +930,559 @@ * below the title bar. Each tab swaps the iframe URL in place; no * new window opens. Horizontally scrollable on narrow windows so the * same component works on tablet and mobile shells unchanged. + * + * These are TABS in the physical sense: the strip is a recessed + * track, and the active tab is a plate that rises out of it wearing + * the page's own fill, filleted into the floor at both bottom + * corners so tab and page read as one continuous surface. That joint + * is the whole design. The previous treatment — flat text with an + * accent underline, on a strip painted the same colour as the + * focused title bar — gave the sub-pages no container to belong to + * and no relationship to the page they navigate, which is why they + * read as loose text floating in the chrome. + * + * The page under an iframe window is always `#fff`: `chromeless.css` + * paints `body.os-chromeless` white whatever the admin colour scheme + * says. So "the page's own fill" is a colour the shell can name, and + * `--os-tabs-active-bg` names it. * --------------------------------------------------------------- */ .os-window__tabs { + /* + * Corner radius and fillet size are the same measurement — the + * fillet is the *inverse* of the corner, so a mismatch reads as a + * kink where the tab meets the floor. One alias, read by both. + */ + --_tab-radius: var( --os-tabs-radius, 8px ); + /* Tab height, shared with the plate that has to sit exactly on it. */ + --_tab-h: 30px; + /* Weight of the rail that traces the silhouette. */ + --_tab-stroke: var( --os-tabs-rail-width, 2px ); + /* + * How far each straight run of the rail overlaps the arc it hands + * over to. Purely an anti-seam allowance — see the mask sizes on + * the ring. It has to stay well under the radius or the overlap + * would reach past the arc it is covering for. + */ + --_tab-seam: 1px; + position: relative; display: flex; + /* Tabs sit ON the floor of the track, not centred in it. */ + align-items: flex-end; flex-shrink: 0; gap: 2px; - padding: 0 8px; + /* + * Horizontal padding is a floor as well as a breathing space. The + * ring reaches one radius plus one stroke (10px at the shipped + * values) past the plate on each side, so a first or last tab + * under a smaller padding would have its outer corner clipped by + * the `overflow` rule below. + */ + padding: 8px 12px 0; background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) ); /* TABBAR texture slot — layered over the strip's own colour. */ background-image: var( --os-tabs-image, none ); background-repeat: var( --os-tabs-image-repeat, repeat ); background-size: var( --os-tabs-image-size, auto ); background-position: var( --os-tabs-image-position, center ); - border-bottom: 1px solid var(--os-window-border); + /* + * No bottom border. A hairline here would run straight through + * the joint between the active tab and its page, which is the one + * edge this design exists to erase. + */ overflow-x: auto; 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; +} + +/* + * Unfocused, the track follows the title bar down instead of holding + * its lit colour. + * + * Focused, the bar and the track are the same Obsidian and the tab is + * the only thing lifting out of them: two surfaces plus the tab. Dim + * the bar alone and the strip becomes a third colour belonging to + * neither the chrome above it nor the page below, which is the seam + * this rule removes. + * + * The chain ends at `--os-tabs-bg`, so a theme that names only the one + * strip colour keeps it in both states. + */ +.os-window:not( .os-window--focused ) .os-window__tabs { + background-color: var( + --os-tabs-bg-unfocused, + var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) ) + ); +} + +/* + * The same strip on a NATIVE window, which is the same strip: one + * stylesheet, whatever is behind the window. What changes is only + * what the active tab is wearing, and it changes in tokens. + * + * The rule the whole design rests on is that the active tab wears the + * page's own fill and is filleted into it, so tab and content read as + * one surface. For an iframe window that fill is `#fff`, because + * `chromeless.css` paints every admin page inside a window white + * whatever the colour scheme says. A native window's page is its + * body, and `--os-window-bg` is what paints it — so that is the fill + * here, and the two move together by construction. + * + * The frost and the crown go with the white. They are what makes a + * bright plate read as a surface lifting out of dark chrome; on a + * native window the tab and the track are the same colour when the + * window is focused, and a crown on a tab you cannot see the edges of + * is decoration with nothing to decorate. The rail carries the + * silhouette on its own, which is the point: a native window has no + * value step to wear, so the line IS the tab. + * + * The label follows the fill. `--os-tabs-active-color` names the text + * on a white plate and resolves to near-black, which would be + * invisible here. + */ +.os-window--native { + --os-tabs-active-bg: var( --os-window-bg, #fff ); + --os-tabs-active-color: var( --os-ui-fg, #1d2327 ); + --os-tabs-active-color-muted: var( --os-ui-fg-muted, #50575e ); + --os-tabs-active-frost: none; + --os-tabs-active-crown: none; +} + +/* + * Windows with no submenu still get a strip — `createWindowElement()` + * appends it unconditionally so `addExternalTab()` has somewhere to + * put a tab later. Empty, it must take no room at all: with vertical + * padding on the track, an empty strip would otherwise paint a bare + * band of track colour under every submenu-less window's title bar. + * + * `:has()` rather than `:empty`, because the strip is never empty any + * more — it always carries the plate. What makes a strip vacant is + * having no TABS in it. + */ +.os-window__tabs:not( :has( .os-window__tab ) ) { + display: none; +} + +/* --------------------------------------------------------------- + * The plate — the active tab's surface. + * + * It is one element that TRAVELS between tabs rather than a fill + * that switches off on one tab and on at the next. That distinction + * is the whole reason it exists: a fill that switches has to cross- + * fade a dark tab into a light one, and every frame in between is a + * muddy grey that belongs to neither. Nothing crossfades here. The + * surface simply moves, and the labels change colour underneath it. + * + * `tabs.ts` publishes the target geometry as `--_tab-plate-x` and + * `--_tab-plate-w` on this element; everything else is CSS. + * --------------------------------------------------------------- */ +.os-window__tab-plate { + position: absolute; + bottom: 0; + left: 0; + height: var( --_tab-h ); + width: var( --_tab-plate-w, 0 ); + transform: translateX( var( --_tab-plate-x, 0 ) ); + pointer-events: none; + transition: + transform var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + width var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + opacity 0.15s ease; +} + +/* + * Until the first measurement lands, the plate has no business + * animating — it would slide in from the strip's left edge every + * time a window opens. + */ +.os-window__tab-plate:not( [ data-placed ] ) { + transition: none; +} + +/* + * The frosted face. + * + * The tint is TOP-WEIGHTED and reaches zero before the bottom of the + * plate, which is the load-bearing part: the joint below is the + * page's own colour, so anything still tinted down there draws a + * seam across the one edge this design exists to erase. Keep the + * gradient's last stop fully transparent and keep it above ~75%. + * + * The fallback is a flat `--os-tabs-active-bg`, so a stylesheet that + * loses the palette gets the plain white tab rather than nothing. + */ +.os-window__tab-plate-fill { + position: absolute; + /* + * Overshoots the track's floor by one radius, and is then clipped + * by the strip's own `overflow`. Ending the face exactly ON the + * floor puts a paint boundary on the one edge that has to be + * invisible; pushing it past and cutting it means there is no edge + * there to resolve at all. + */ + inset: 0 0 calc( var( --_tab-radius ) * -1 ) 0; + overflow: hidden; + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0; + /* + * OPAQUE, and deliberately so — no `backdrop-filter`, no alpha. + * + * A translucent element with a backdrop filter is promoted to its + * own compositor layer, and the edge of that layer is a clip the + * compositor resolves against whatever is behind it. That leaves a + * faint grey hairline down the plate's sides and across its + * bottom. Everywhere else that would be a nuisance; here it draws + * a second, dimmer line a few pixels inside the real one and flatly + * contradicts what this design is claiming. The gradient below + * reproduces the frosted crown by eye with no alpha at all. + * + * `background-size` pins the gradient to the tab's true height so + * the overshoot underneath stays flat page-white. + */ + background-color: var( --os-tabs-active-bg, #fff ); + background-image: var( --os-tabs-active-frost, none ); + background-size: 100% var( --_tab-h ); + background-repeat: no-repeat; +} + +/* + * The crown — Holomesh over the top of the plate, masked away before + * the joint. Holomesh is nine stacked gradients, so it cannot be + * faded by adding a colour stop; it needs its own layer and a mask. + * + * It is on exactly one tab at a time. That is what keeps the mesh an + * identity moment rather than wallpaper, and it is the same rule the + * rest of the kit follows (see `src/ui/holo.ts`). + */ +.os-window__tab-plate-fill::before { + content: ''; + position: absolute; + inset: 0; + background-image: var( --os-tabs-active-crown, none ); + background-size: 210% 210%; + background-position: 20% 26%; + opacity: var( --os-tabs-active-crown-opacity, 0.5 ); + -webkit-mask-image: linear-gradient( + 180deg, + #000 0%, + rgba( 0, 0, 0, 0.55 ) 34%, + transparent 66% + ); + mask-image: linear-gradient( + 180deg, + #000 0%, + rgba( 0, 0, 0, 0.55 ) 34%, + transparent 66% + ); + animation: os-tab-crown-drift 16s ease-in-out infinite alternate; +} + +@keyframes os-tab-crown-drift { + from { background-position: 20% 26%; } + to { background-position: 72% 62%; } +} + +/* + * The joint: two concave quarter-circles carrying the plate's fill + * out to the floor of the track. Without them the plate is a rounded + * rectangle NEAR the page; with them it is attached to it. + * + * Its own element rather than a pseudo on the face, so the face can + * clip its crown (`overflow: hidden`) without clipping the curve + * that does the attaching. Pure `--os-tabs-active-bg` — see the note + * on the tint above. + */ +.os-window__tab-plate-joint { + position: absolute; + bottom: 0; + left: calc( var( --_tab-radius ) * -1 ); + right: calc( var( --_tab-radius ) * -1 ); + height: var( --_tab-radius ); + background: + radial-gradient( + circle at 0 0, + transparent var( --_tab-radius ), + var( --os-tabs-active-bg, #fff ) var( --_tab-radius ) + ) left bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat, + radial-gradient( + circle at 100% 0, + transparent var( --_tab-radius ), + var( --os-tabs-active-bg, #fff ) var( --_tab-radius ) + ) right bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat; +} + +/* --------------------------------------------------------------- + * The rail — one continuous line around the whole silhouette. + * + * It runs the top edge of the page, curves up through the fillet, + * traces the tab and comes back down. What it outlines is therefore + * page-plus-tab as ONE shape, which is the same claim the joint makes + * by omission, said out loud. + * + * Two rules govern every number below. + * + * 1. **The line lives on the DARK side of the boundary, everywhere.** + * It hugs the white shape from outside and never paints on it. Get + * this wrong on any one segment and the line steps sideways by its + * own width at the tangent point where that segment meets the next + * — which is exactly what a stroke that does not follow the shape + * looks like. The convex tab corners are therefore annulus R→R+s + * (outside the plate) while the concave fillets are R−s→R (inside + * the fillet circle, whose white lies OUTSIDE it). Those look like + * opposite conventions and are the same one. + * + * 2. **Every piece samples one mesh laid across the whole strip.** + * `--_tab-strip-w` is why. Give the rail and the ring their own + * backgrounds and you get two unrelated gradients meeting at a + * visible join in the middle of the fillet. + * + * CSS cannot stroke a path, so the mesh is painted as a background and + * a mask cuts it to the outline. Seven layers, one per segment, all + * positioned off the box edges so the whole thing survives the plate + * changing width mid-slide with no JS. + * --------------------------------------------------------------- */ +.os-window__tabs::before { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: var( --_tab-stroke ); + background-image: var( --os-tabs-rail, none ); + background-size: var( --_tab-strip-w, 100% ) 100%; + background-repeat: no-repeat; + pointer-events: none; + /* + * Two segments, stopping where each fillet begins. Run the rail + * straight through and it draws a chord across the concave curve. + */ + -webkit-mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 ); + mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 ); + /* + * Each segment runs one `--_tab-seam` PAST where its fillet begins, + * so the rail overlaps the arc rather than meeting it exactly — + * the same anti-seam allowance the ring uses, for the same reason. + */ + -webkit-mask-size: + calc( + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam ) + ) 100%, + calc( + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) - + var( --_tab-radius ) + var( --_tab-seam ) + ) 100%; + mask-size: + calc( + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam ) + ) 100%, + calc( + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) - + var( --_tab-radius ) + var( --_tab-seam ) + ) 100%; + -webkit-mask-position: left top, right top; + mask-position: left top, right top; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + transition: + -webkit-mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ); +} + +/* With no active tab there is no gap to leave, so the rail is whole. */ +.os-window__tabs[ data-tab-plate-empty ]::before { + -webkit-mask-image: none; + mask-image: none; +} + +/* + * The tab's half of the line. The box is the plate grown by one + * stroke upward and by radius-plus-stroke on each side, so the top + * edge, both corners and both fillet arcs all have room to sit + * OUTSIDE the white. + */ +.os-window__tab-plate::after { + --_ring-arc-out: radial-gradient( + circle at 0 0, + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ), + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ), + #000 var( --_tab-radius ), + transparent var( --_tab-radius ) + ); + --_ring-arc-out-r: radial-gradient( + circle at 100% 0, + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ), + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ), + #000 var( --_tab-radius ), + transparent var( --_tab-radius ) + ); + --_ring-corner-l: radial-gradient( + circle at 100% 100%, + transparent var( --_tab-radius ), + #000 var( --_tab-radius ), + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ), + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) ) + ); + --_ring-corner-r: radial-gradient( + circle at 0 100%, + transparent var( --_tab-radius ), + #000 var( --_tab-radius ), + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ), + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) ) + ); + --_ring-bar: linear-gradient( #000 0 0 ); + + content: ''; + position: absolute; + inset: + calc( var( --_tab-stroke ) * -1 ) + calc( ( var( --_tab-radius ) + var( --_tab-stroke ) ) * -1 ) + 0; + background-image: var( --os-tabs-rail, none ); + background-size: var( --_tab-strip-w, 100% ) 100%; + background-position: + calc( + var( --_tab-radius ) + var( --_tab-stroke ) - + var( --_tab-plate-x, 0px ) + ) + 0; + background-repeat: no-repeat; + pointer-events: none; + + -webkit-mask-image: + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ), + var( --_ring-bar ), var( --_ring-bar ), + var( --_ring-arc-out ), var( --_ring-arc-out-r ); + mask-image: + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ), + var( --_ring-bar ), var( --_ring-bar ), + var( --_ring-arc-out ), var( --_ring-arc-out-r ); + /* + * The three straight runs are each grown by `--_tab-seam` at BOTH + * ends, and shifted back by the same amount, so every one of them + * overlaps the arc it hands over to instead of meeting it exactly. + * + * Two layers that abut on a shared boundary each contribute a + * partial, antialiased alpha there, and the sum can fall short of + * 1 — which prints as a faint hairline across the stroke at all + * six tangent points. Overlapping cannot go wrong in the other + * direction: mask alpha is clamped, so doubling it is still opaque + * and the seam simply stops existing. + * + * The overlap stays on the dark side of the curve at every one of + * those points, so it never bleeds onto the white. + */ + -webkit-mask-size: + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ), + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), + var( --_tab-radius ) var( --_tab-radius ), + var( --_tab-radius ) var( --_tab-radius ); + mask-size: + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ), + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), + var( --_tab-radius ) var( --_tab-radius ), + var( --_tab-radius ) var( --_tab-radius ); + /* + * Four-value syntax throughout, and it is load-bearing. A + * percentage in `mask-position` resolves against the container + * MINUS the layer's own size, so `calc(100% - 8px)` does not mean + * "8px from the right edge" — it means "right-aligned, then pushed + * left by 8px PLUS the layer's width". Every right-hand segment + * lands a radius too far left that way. `right ` is + * measured from the edge and does not care how wide the layer is. + */ + -webkit-mask-position: + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0, + left var( --_tab-radius ) top 0, + right var( --_tab-radius ) top 0, + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), + left var( --_tab-stroke ) bottom 0, + right var( --_tab-stroke ) bottom 0; + mask-position: + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0, + left var( --_tab-radius ) top 0, + right var( --_tab-radius ) top 0, + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), + left var( --_tab-stroke ) bottom 0, + right var( --_tab-stroke ) bottom 0; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + /* Travels with the plate, so the mesh stays locked to the strip + * for the whole slide instead of snapping on the first frame. */ + transition: background-position var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ); +} + +/* + * No active tab to sit under — `syncActiveTab` can land on a URL that + * matches nothing, and an external sub-tab deactivates every submenu + * tab. The plate keeps its last geometry and fades, so re-activating + * a tab does not read as the plate flying in from nowhere. + */ +.os-window__tab-plate[ data-empty ] { + opacity: 0; +} + +@media ( prefers-reduced-motion: reduce ) { + .os-window__tab-plate, + .os-window__tab-plate::after, + .os-window__tabs::before { + transition-duration: 1ms; + } + .os-window__tab-plate-fill::before { + animation: none; + } +} + +/* + * 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,33 +1497,25 @@ #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 { + position: relative; flex-shrink: 0; display: inline-flex; align-items: center; - height: 32px; - padding: 0 12px; + height: 30px; + padding: 0 14px; border: none; + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0; background: transparent; color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) ); font: inherit; font-size: 12px; line-height: 1; cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; white-space: nowrap; - transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; + transition: color 0.15s ease, background-color 0.15s ease; } .os-window__tab:hover { @@ -708,10 +1528,39 @@ outline-offset: -2px; } +/* + * The active tab. It paints NO surface of its own — the plate does + * that, and the plate is a sibling that slides. All this rule owns is + * the label. + * + * No accent anywhere on it either. The shape already says "this one", + * and the accent is spent: one Pulse divider in the dock, one focus + * ring. A tab that is both a distinct surface AND coloured is saying + * the same thing twice. + */ .os-window__tab--active { - color: var(--wp-admin-theme-color, #2271b1); - border-bottom-color: var(--wp-admin-theme-color, #2271b1); + color: var( --os-tabs-active-color, #1d2327 ); font-weight: 600; + /* + * Anything nested in an active tab (the external tab's detach and + * close chips) is now sitting on a light fill inside dark chrome, + * so the two tones it reads have to flip with the tab. Both are + * re-pointed through names the palette owns rather than hardcoded + * here, so a desktop theme retints the chips with the tab. + */ + --os-ui-fg-muted: var( --os-tabs-active-color-muted, rgba(29, 35, 39, 0.6) ); + --os-ui-hover: color-mix( + in srgb, + var( --os-tabs-active-color, #1d2327 ) 8%, + transparent + ); +} + +/* An active tab has the plate under it; a hover wash on top of that + * would double-paint the surface. */ +.os-window__tab--active:hover { + background: transparent; + color: var( --os-tabs-active-color, #1d2327 ); } /* @@ -897,12 +1746,12 @@ os-tab-chip + os-tab-chip { * output) and kept aria-hidden so the spinner's own SR-label is * the only loading announcement. * - * `transition-delay` on the overlay's fade-in means a render - * that lands within ~120ms never paints the spinner — the - * overlay only becomes visible when there's actually a wait - * worth surfacing. This keeps the affordance from flashing on - * fast local-dev loads while still covering the slow production - * iframe boots that motivated it. + * The `--visible` modifier turns the overlay on. JS adds it ~120ms + * into the load (`LOADING_OVERLAY_SHOW_DELAY_MS`), so a fast render + * never paints a spinner. The delay cannot be a `transition-delay` + * here: the overlay is appended into a body that already carries + * `--loading`, so its first computed style is the visible one and the + * transition never runs. */ .os-window__loading { position: absolute; @@ -935,13 +1784,31 @@ os-tab-chip + os-tab-chip { transition: opacity 0.25s ease; } -.os-window__body--loading > .os-window__loading { +.os-window__body--loading > .os-window__loading--visible { + opacity: 1; +} + +/* + * Content hand-off. `--loading` drops as soon as the content is ready, + * but the overlay above it still needs 250ms to fade out. Without this + * both layers are on screen at once, which reads as a flash. + * + * This modifier holds the content transparent for the length of that + * fade, then fades it in. The delay in the shorthand does the holding. + * + * Only added when the spinner actually painted. A load that finishes + * inside the show delay never reached `--visible`, so the shell drops + * the overlay in the same tick instead. + * + * `--revealing` declares the same selector at the same specificity and + * comes later in the file, so a window playing a reveal keeps its + * content opaque under the reveal surface. + */ +.os-window__body--loading-out > .os-window__iframe, +.os-window__body--loading-out + > :not(.os-window__loading):not(.os-window__reveal) { opacity: 1; - /* Entry transition with a small delay — loads that finish in - * under ~120ms never paint the spinner, so fast local-dev / - * hot-cache fires don't flash. Slow production iframe boots - * still see the spinner. */ - transition-delay: 0.12s; + transition: opacity 0.25s ease 0.25s; } @media ( prefers-reduced-motion: reduce ) { @@ -949,6 +1816,9 @@ os-tab-chip + os-tab-chip { .os-window__loading, .os-window__body--loading > .os-window__iframe, .os-window__body--loading + > :not(.os-window__loading):not(.os-window__reveal), + .os-window__body--loading-out > .os-window__iframe, + .os-window__body--loading-out > :not(.os-window__loading):not(.os-window__reveal) { transition: none; } diff --git a/assets/css/window-overview.css b/assets/css/window-overview.css index 0e814f8d..0f327e32 100644 --- a/assets/css/window-overview.css +++ b/assets/css/window-overview.css @@ -117,6 +117,25 @@ opacity 0.22s ease; } +/* + * The escape hatch for the rule above, and the reason it lives here + * rather than in `dock.css`: this is the only place `.os-dock` is + * given a transition, so it is the only place worth looking when one + * fires where it should not. + * + * The properties above are geometry, and geometry also changes for a + * reason that is not a state change: moving the dock to another edge + * tears the rail down and builds a new one on the SAME element, which + * still carries the previous placement's padding and width. Animated, + * the new dock slides out of the old one's shape — it arrives a size + * too big and settles. `Dock`'s constructor wears this class while it + * commits the placement, so that change lands with nothing to tween + * from. + */ +.os-dock--no-transition { + transition: none !important; +} + /* * Collapse EVERY dock (bottom + side rails, Classic / Unified / * Spatial layouts alike) when overview is active. @@ -165,7 +184,7 @@ * hidden during overview so they don't compete with window * thumbnails or overlap them (especially noticeable with few * windows or Spatial layout). Matches the fade-out pattern - * used by widgets and sticky notes. Scoped to DIRECT children + * used by widgets and pinned notes. Scoped to DIRECT children * of the desktop area only: folder windows mount their own * files layer inside the window body, and that must stay * visible in its thumbnail. diff --git a/assets/js/admin-bar.js b/assets/js/admin-bar.js index 1e2e653e..b42f35b5 100644 --- a/assets/js/admin-bar.js +++ b/assets/js/admin-bar.js @@ -84,10 +84,26 @@ // then mirror the persisted snap preference. Polled rather than // hooked because the inline script ships with the admin bar // (loads early) and the shell's WindowManager arrives later. + // + // Bounded, because a poll with no exit is a timer that runs for + // the life of the page. The manager lands within a frame or two of + // the shell bundle executing; if it has not arrived in ten seconds + // it is not coming — the shell failed to boot, or a plugin + // surfaced this admin bar somewhere the shell never loads — and + // repainting one checkbox does not justify waking the event loop + // sixteen times a second until the tab closes. Giving up leaves + // the server-rendered box exactly as it is, which is what polling + // forever achieves anyway. + var SNAP_POLL_INTERVAL_MS = 60; + var SNAP_POLL_TIMEOUT_MS = 10000; + var snapPollWaited = 0; function initFromManager() { var wm = getManager(); if ( ! wm || typeof wm.isSnapEnabled !== 'function' ) { - window.setTimeout( initFromManager, 60 ); + snapPollWaited += SNAP_POLL_INTERVAL_MS; + if ( snapPollWaited < SNAP_POLL_TIMEOUT_MS ) { + window.setTimeout( initFromManager, SNAP_POLL_INTERVAL_MS ); + } return; } paintSnapCheckbox( wm.isSnapEnabled() ); @@ -184,11 +200,11 @@ if ( on ) { fsBtn.classList.add( 'is-fullscreen' ); if ( fsLabel ) fsLabel.textContent = fsI18n.exitFullscreen || 'Exit fullscreen'; - if ( fsLink ) fsLink.setAttribute( 'title', fsI18n.exitTitle || 'Exit fullscreen' ); + repaintLabel( fsLink, fsI18n.exitTitle || 'Exit fullscreen' ); } else { fsBtn.classList.remove( 'is-fullscreen' ); if ( fsLabel ) fsLabel.textContent = fsI18n.enterFullscreen || 'Fullscreen'; - if ( fsLink ) fsLink.setAttribute( 'title', fsI18n.enterTitle || 'Enter fullscreen' ); + repaintLabel( fsLink, fsI18n.enterTitle || 'Enter fullscreen' ); } } fsBtn.addEventListener( 'click', function( e ) { @@ -258,8 +274,8 @@ * Re-anchors a native `title` attribute to a `data-desktop-tooltip` * data attribute on the same node, plus mirrors it to `aria-label` * so assistive tech keeps the description. Pure-CSS tooltip then - * renders from the data attribute via the `[data-desktop-tooltip] - * .ab-item::after` rule in admin-bar.php. + * renders from the data attribute via the + * `.ab-item[ data-desktop-tooltip ]::after` rule in admin-bar.php. */ function wireTooltipsFor( ids ) { for ( var i = 0; i < ids.length; i++ ) { @@ -277,6 +293,30 @@ } } + /** + * Relabels a button whose action changed under it (today: Fullscreen, + * which flips between enter and exit). Writing only `title` is wrong + * once `wireTooltipsFor()` has run: the visible tooltip renders from + * `data-desktop-tooltip` and the accessible name comes from + * `aria-label`, so a stale pair describes the opposite action while + * the freshly re-added `title` brings the native OS tooltip back on + * top of ours (GH#493). + * + * `title` is only touched when the node is still wearing one, which + * makes this safe to call before wiring as well — the label lands on + * `title` and `wireTooltipsFor()` re-anchors it from there. + */ + function repaintLabel( link, label ) { + if ( ! link ) return; + if ( link.hasAttribute( 'title' ) ) { + link.setAttribute( 'title', label ); + } + if ( link.hasAttribute( 'data-desktop-tooltip' ) ) { + link.setAttribute( 'data-desktop-tooltip', label ); + } + link.setAttribute( 'aria-label', label ); + } + function wireShortcutsPopover( btn, data ) { if ( ! btn || ! data ) { return; diff --git a/bin/extract-i18n.sh b/bin/extract-i18n.sh index 75a97647..86ae6cf5 100755 --- a/bin/extract-i18n.sh +++ b/bin/extract-i18n.sh @@ -7,7 +7,7 @@ # This script is the "step 1" of the i18n pipeline. The full chain is: # # bin/extract-i18n.sh -> regenerates languages/desktop-mode.pot -# and updates languages/os-.po +# and updates languages/desktop-mode-.po # (translate the .po files in your editor / GlotPress / etc.) # bin/build-i18n.sh -> compiles each .po into per-handle JSON files # that wp_set_script_translations() can load @@ -69,6 +69,19 @@ js_segments="$tmp_dir/js-segments" # --skip-js prevents wp-cli from also scanning .js/.jsx (we already # have those from babel). The exclude list keeps build output, # third-party code, sibling plugins, and tests out of the result. +# +# Two header fields are worth knowing about: +# +# Project-Id-Version is derived by make-pot from the plugin header in +# desktop-mode.php (Plugin Name + Version), so it tracks the product +# name and the released version with no extra wiring here. Don't pin +# it, or it goes stale the moment either one moves. +# +# Report-Msgid-Bugs-To points translators at the plugin's wp.org +# support forum. The slug is `desktop-mode` and stays that way, it is +# the published wp.org slug and is frozen even though the plugin is +# now called OpenStation. See AGENTS.md, "desktop_mode_* values are +# frozen". EXCLUDE_PATHS=( "assets/js" "dist" @@ -88,7 +101,7 @@ wp i18n make-pot "$PLUGIN_DIR" "$POT_FILE" \ --skip-js \ --exclude="$EXCLUDE_CSV" \ --merge="$js_pot" \ - --headers='{"Report-Msgid-Bugs-To":"https://wordpress.org/support/plugin/alcazaba-plugin"}' \ + --headers="{\"Report-Msgid-Bugs-To\":\"https://wordpress.org/support/plugin/${DOMAIN}\"}" \ >/dev/null echo "extract-i18n.sh: wrote $POT_FILE" diff --git a/bin/package.sh b/bin/package.sh index 39b1c385..611fabb1 100755 --- a/bin/package.sh +++ b/bin/package.sh @@ -62,7 +62,10 @@ done # probes for `assets/js/desktop.js` and serves `.min` when the dev # bundles are absent, so even a SCRIPT_DEBUG site degrades gracefully # instead of 404ing. -mapfile -t bases < <(sed -n "s/^[[:space:]]*fileBase:[[:space:]]*'\([^']*\)',\{0,1\}[[:space:]]*$/\1/p" vite.config.js) +bases=() +while IFS= read -r base; do + bases+=("$base") +done < <(sed -n "s/^[[:space:]]*fileBase:[[:space:]]*'\([^']*\)',\{0,1\}[[:space:]]*$/\1/p" vite.config.js) if (( ${#bases[@]} == 0 )); then echo "error: no 'fileBase' entries found in vite.config.js." >&2 @@ -86,15 +89,15 @@ done # ship via `git archive` and are not listed here. Dev bundles # (`.js`) are legitimate on-disk build output — expected but # not shipped. -declare -A expected=() -for file in "${built[@]}"; do - expected["$file"]=1 -done -for base in "${bases[@]}"; do - expected["assets/js/$base.js"]=1 -done while IFS= read -r file; do - if [[ -z "${expected[$file]:-}" ]]; then + expected=false + for base in "${bases[@]}"; do + if [[ "$file" == "assets/js/$base.js" || "$file" == "assets/js/$base.min.js" ]]; then + expected=true + break + fi + done + if [[ "$expected" != true ]]; then echo "error: '$file' is not produced by any vite.config.js target." >&2 echo " Stale build output? Remove it ('git clean -fX assets/js/') and re-run." >&2 exit 1 diff --git a/bin/release.sh b/bin/release.sh index 27050e8b..0d5e3c8d 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -67,6 +67,18 @@ if ! gh auth status >/dev/null 2>&1; then exit 1 fi +# gh guesses its "base repo" from the remotes, and that guess is unreliable in +# a clone that carries contributor forks alongside origin — some subcommands +# resolve it, others bail with "No default remote repository has been set". +# Resolve origin once through the API instead (which also follows repository +# renames, so a stale remote URL still lands on the right slug) and pass +# --repo explicitly to every gh call below. +repo=$(gh repo view "$(git remote get-url origin)" --json nameWithOwner -q .nameWithOwner 2>/dev/null || true) +if [[ -z "$repo" ]]; then + echo "error: could not resolve the GitHub repository behind 'origin'." >&2 + exit 1 +fi + # Fetches GitHub's auto-generated release notes for the next tag, keeps # only bullet lines, strips the trailing "by @user in " suffix, # and drops "first contribution" boilerplate. Echoes the transformed @@ -80,8 +92,7 @@ generate_changelog_draft() { echo "warning: no previous tag found — skipping changelog draft." >&2 return 1 fi - local repo raw - repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) + local raw raw=$(gh api -X POST "repos/$repo/releases/generate-notes" \ -f tag_name="$target_tag" \ -f previous_tag_name="$prev" \ @@ -233,14 +244,22 @@ if [[ "$branch" != "trunk" ]]; then exit 1 fi -# Leftovers from an aborted release attempt (the i18n refresh under -# languages/, a drafted-but-unconfirmed changelog in readme.txt) are -# regenerated or re-reviewed on every run and belong in the bump commit, -# so they must not block a re-run. Anything else dirty still aborts: -# the bump uses `git commit -am` and would silently sweep it up. -dirty=$(git status --porcelain --untracked-files=no | grep -vE '^.{3}(languages/|readme\.txt$)' || true) +# Leftovers from an aborted release attempt are regenerated or +# re-reviewed on every run and belong in the bump commit, so they must +# not block a re-run: the i18n refresh under languages/, a +# drafted-but-unconfirmed changelog in readme.txt, and the version +# strings themselves. The version files are on this list because +# `bump-version.sh` runs before the changelog gate, so answering `n` +# there leaves them written but uncommitted; without the exemption the +# re-run that gate promises would abort instead of returning to the +# prompt. `bump-version.sh` rewrites them deterministically on every +# run, so a stale value cannot survive. +# +# Anything else dirty still aborts: the bump uses `git commit -am` and +# would silently sweep it up. +dirty=$(git status --porcelain --untracked-files=no | grep -vE '^.{3}(languages/|readme\.txt$|package\.json$|package-lock\.json$|desktop-mode\.php$|packages/openstation-types/)' || true) if [[ -n "$dirty" ]]; then - echo "error: working tree has changes beyond languages/ and readme.txt. Commit or stash first:" >&2 + echo "error: working tree has changes beyond the release-owned files. Commit or stash first:" >&2 printf '%s\n' "$dirty" >&2 exit 1 fi @@ -276,7 +295,21 @@ header=$(awk '/^[[:space:]]*\*[[:space:]]*Version:/ { print $3; exit }' desktop- constant=$(awk -F"'" '/OPENSTATION_VERSION/ { print $4; exit }' desktop-mode.php) stable=$(awk '/^Stable tag:/ { print $3; exit }' readme.txt) -if [[ "$pkg" == "$new" && "$header" == "$new" && "$constant" == "$new" && "$stable" == "$new" ]]; then +# Matching strings are not proof the bump was committed. `bump-version.sh` +# writes the files and never commits, and the changelog gate below can +# exit between the write and the commit, so an aborted run leaves the +# tree bumped and uncommitted. Resuming on the strings alone would skip +# the commit and tag whatever HEAD already is, which is the pre-bump +# commit. Require the bump to be committed as well; when it is not, the +# else branch below re-runs `bump-version.sh` as a no-op and commits +# normally. +if git diff HEAD --quiet -- package.json package-lock.json packages/openstation-types/package.json desktop-mode.php; then + bump_committed=1 +else + bump_committed=0 +fi + +if [[ "$pkg" == "$new" && "$header" == "$new" && "$constant" == "$new" && "$stable" == "$new" && "$bump_committed" == "1" ]]; then echo "All version locations already at $new — skipping bump, resuming at CI wait." confirm_changelog # In resume mode the bump commit is already pushed; readme.txt edits @@ -289,12 +322,21 @@ if [[ "$pkg" == "$new" && "$header" == "$new" && "$constant" == "$new" && "$stab exit 1 fi else - # Refresh translation files BEFORE the version bump so any churn - # (renumbered #: source refs, fresh POT-Creation-Date, fuzzy - # flags, new JSON bundles) ends up in the same commit as the - # bump. Running this here also gives a cheap Ctrl-C escape: if - # the language-file diff looks wrong, abort now — nothing has - # been committed or pushed yet. + # Bump the version BEFORE refreshing translations, because + # `wp i18n make-pot` reads Project-Id-Version straight from the + # plugin header in desktop-mode.php. Extracting first stamps the + # catalogues with the PREVIOUS version, which is how the shipped + # POT came to say "Desktop Mode 0.9.7" while the plugin was at + # 0.9.8. Nothing is committed here, so the Ctrl-C escape below + # still covers the bump too. + ./bin/bump-version.sh "$new" + + # Refresh translation files after the bump but BEFORE any commit, + # so the catalogues carry $new and any churn (renumbered #: source + # refs, fresh POT-Creation-Date, fuzzy flags, new JSON bundles) + # still ends up in the same commit as the bump. If the + # language-file diff looks wrong, abort now — nothing has been + # committed or pushed yet. if [[ "$skip_i18n" == "0" ]]; then echo "Refreshing translation files (npm run i18n)..." npm run --silent i18n @@ -381,9 +423,8 @@ else # and require an explicit yes before the bump commit. confirm_changelog - ./bin/bump-version.sh "$new" if git diff --quiet; then - echo "bump-version.sh produced no changes — versions already in sync." + echo "Nothing to commit — versions already in sync and no language churn." else git commit -am "chore: bump to $new" # Skip the interactive pre-push trunk prompt — the preflight checks above @@ -398,7 +439,7 @@ echo "Waiting for CI to register a run on ${sha} (polling for up to 5 min)..." # CI may take a few minutes to register the run after the push. run_id="" for i in $(seq 1 100); do - run_id=$(gh run list --branch trunk --workflow ci.yml --commit "$sha" --limit 1 --json databaseId -q '.[0].databaseId' 2>/dev/null || true) + run_id=$(gh run list --repo "$repo" --branch trunk --workflow ci.yml --commit "$sha" --limit 1 --json databaseId -q '.[0].databaseId' 2>/dev/null || true) [[ -n "$run_id" ]] && break # Heartbeat every 30 s so the script doesn't look frozen while CI registers. if (( i % 10 == 0 )); then @@ -412,12 +453,12 @@ if [[ -z "$run_id" ]]; then exit 1 fi -run_url=$(gh run view "$run_id" --json url -q '.url' 2>/dev/null || true) +run_url=$(gh run view "$run_id" --repo "$repo" --json url -q '.url' 2>/dev/null || true) echo "CI run ${run_id} registered — watching until it finishes (typically 3-5 min)." [[ -n "$run_url" ]] && echo " ${run_url}" echo " (gh run watch is silent until each job completes; this is normal.)" -gh run watch "$run_id" --exit-status +gh run watch "$run_id" --repo "$repo" --exit-status echo "CI passed — tagging ${tag} and pushing..." @@ -425,4 +466,4 @@ git tag "$tag" git push origin "$tag" echo "Tagged $tag. Release workflow now building — watch with:" -echo " gh run watch \$(gh run list --workflow release.yml --limit 1 --json databaseId -q '.[0].databaseId')" +echo " gh run watch --repo $repo \$(gh run list --repo $repo --workflow release.yml --limit 1 --json databaseId -q '.[0].databaseId')" 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/desktop-mode.php b/desktop-mode.php index d7e14777..581b1774 100644 --- a/desktop-mode.php +++ b/desktop-mode.php @@ -3,7 +3,7 @@ * Plugin Name: OpenStation * Plugin URI: https://github.com/WordPress/openstation * Description: Renders the WordPress admin as a desktop OS. Admin screens become draggable, resizable, minimizable windows floating on a desktop with a dock. Purely opt-in per user. - * Version: 0.9.8 + * Version: 1.1.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: Daniel López Sánchez @@ -11,13 +11,14 @@ * License: GPLv2 or later * License URI: https://www.gnu.org/licenses/gpl-2.0.html * Text Domain: desktop-mode + * Domain Path: /languages * * @package OpenStation */ defined( 'ABSPATH' ) || exit; -define( 'OPENSTATION_VERSION', '0.9.8' ); +define( 'OPENSTATION_VERSION', '1.1.0' ); define( 'OPENSTATION_FILE', __FILE__ ); define( 'OPENSTATION_DIR', plugin_dir_path( __FILE__ ) ); define( 'OPENSTATION_URL', plugin_dir_url( __FILE__ ) ); @@ -98,11 +99,26 @@ function openstation_request_needs_admin_modules() { require_once OPENSTATION_DIR . 'includes/session.php'; require_once OPENSTATION_DIR . 'includes/presence.php'; require_once OPENSTATION_DIR . 'includes/nonce-refresh.php'; -require_once OPENSTATION_DIR . 'includes/sticky-notes/heartbeat.php'; require_once OPENSTATION_DIR . 'includes/os-settings.php'; require_once OPENSTATION_DIR . 'includes/seen-intros.php'; +// One-time data migrations. After os-settings.php and seen-intros.php, +// whose meta-key constants and helpers the migrations call. +// +// Unconditional, unlike the rest of the admin-only set below, because +// its activation hook has to be registered on ANY request that can +// dispatch activation. `activate_plugin()` includes the plugin file and +// fires `activate_` in whatever context it was called from, +// and a programmatic activation (a Playground Blueprint, a provisioning +// script) need not look like an admin request at all. Registering the +// runner's `admin_init` hook on a frontend request costs nothing: it +// never fires there. +require_once OPENSTATION_DIR . 'includes/migrations.php'; require_once OPENSTATION_DIR . 'includes/portal.php'; require_once OPENSTATION_DIR . 'includes/default-window.php'; +// Solo window rendering mode (`?openstation_solo=`). Unconditional +// because `includes/render/` reads its flag, and because extensions +// (the Electron adapter) call its helpers from their own hooks. +require_once OPENSTATION_DIR . 'includes/solo-window.php'; require_once OPENSTATION_DIR . 'includes/themes-tabs.php'; require_once OPENSTATION_DIR . 'includes/media-query.php'; require_once OPENSTATION_DIR . 'includes/accents.php'; @@ -117,6 +133,7 @@ function openstation_request_needs_admin_modules() { require_once OPENSTATION_DIR . 'includes/settings-tabs.php'; require_once OPENSTATION_DIR . 'includes/dock-rail-renderer.php'; require_once OPENSTATION_DIR . 'includes/title-bar-buttons.php'; +require_once OPENSTATION_DIR . 'includes/window-actions.php'; require_once OPENSTATION_DIR . 'includes/unfocus-effects.php'; require_once OPENSTATION_DIR . 'includes/window-links.php'; require_once OPENSTATION_DIR . 'includes/window-chrome.php'; @@ -165,9 +182,6 @@ function openstation_request_needs_admin_modules() { // order preserved from the historical unconditional list. if ( openstation_request_needs_admin_modules() ) { require_once OPENSTATION_DIR . 'includes/ajax.php'; - // One-time data migrations. After os-settings.php so the meta-key - // constant and save/sanitize helpers the migrations call already exist. - require_once OPENSTATION_DIR . 'includes/migrations.php'; require_once OPENSTATION_DIR . 'includes/welcome-dialog.php'; require_once OPENSTATION_DIR . 'includes/update-notice.php'; require_once OPENSTATION_DIR . 'includes/core-notices.php'; diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5b499db1..1d8cf47f 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 Preferences panel: state, sections, │ # media REST client. ├── ui/ │ ├── core/ # The tagged-template renderer + base @@ -138,7 +138,7 @@ src/ The tree above is curated, not exhaustive — `src/` holds many more single-purpose modules and feature directories (drag bridge, devtools, -sticky notes, …). Run `ls src/` for the full picture; the shipped +pinned notes, …). Run `ls src/` for the full picture; the shipped bundles (and the TS entry behind each) are the `build:*` scripts in `package.json`, resolved via `OPENSTATION_TARGET` in `vite.config.js`. @@ -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 Preferences internals. - `src/widgets/frame.ts`, `state.ts` — widget-layer internals. Class fields prefixed with `_` (e.g. `_externalTabs`, `_activeDesktopId`) @@ -218,10 +218,10 @@ Strings flow through three files per locale in `languages/`: - `desktop-mode.pot` — extracted from PHP and TS sources. Regenerate with `npm run extract:i18n` (wraps `wp i18n make-pot` and then `msgmerge`-es the refreshed POT into every existing - `os-{locale}.po`). -- `os-{locale}.po` / `.mo` — translator output, one pair per + `desktop-mode-{locale}.po`). +- `desktop-mode-{locale}.po` / `.mo` — translator output, one pair per shipped locale. -- `os-{locale}-{handle}.json` — JS translation bundles. +- `desktop-mode-{locale}-{handle}.json` — JS translation bundles. WordPress's `wp_set_script_translations()` looks up these files by the script handle, NOT by source-file hash, because we pass a path argument from `includes/assets.php`. Today three handles have @@ -229,6 +229,19 @@ Strings flow through three files per locale in `languages/`: `os-posts-window`, and `desktop-mode-recycle-bin`; see `bin/build-i18n.sh` for the handle to source-prefix map. +### POT header fields + +`Project-Id-Version` is derived by `make-pot` from the plugin header +in `desktop-mode.php` (Plugin Name plus Version). Nothing pins it in +the extraction script, and nothing should: pinning is how it goes +stale. + +`Report-Msgid-Bugs-To` points translators at +`https://wordpress.org/support/plugin/desktop-mode`. That slug is the +published wp.org slug and is frozen, so it keeps reading +`desktop-mode` even though the plugin is now called OpenStation. See +AGENTS.md, "`desktop_mode_*` values are frozen". + The two-step pipeline is: ```bash diff --git a/docs/README.md b/docs/README.md index 15026ec8..3ca7ee37 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,23 +10,28 @@ If you are **building a plugin** that interacts with the desktop shell — opens 2. **[Event-Driven Framework](./event-driven-framework.md)** — *Stable.* The mental model: framework as transport, apps own UX policy. Read once before building anything non-trivial. 3. **[Agents Security Model](./agents-security.md)** — *Experimental.* The trust model for the one part of the framework that acts with capability: why agents can never authenticate, why a run is ceilinged at the invoker's capabilities, why tool output is untrusted input, and why granting an agent a role is granting capability. **Read before registering an ability agents can call or adding a trigger intake.** 4. **[Architecture](./architecture.md)** — what renders where, and why. -4. **[Hooks Reference](./hooks-reference.md)** — every PHP action and filter, with signatures, defaults, and minimal examples. -5. **[JavaScript Reference](./javascript-reference.md)** — CustomEvents on `document`, the `window.wp.os` API, and the iframe `postMessage` bridge. -6. **[API Index](./api-index.md)** — single-page table of every `wp.os.*` method, CustomEvent, and `postMessage` type with its current status. Use this when you need to grep the surface, then jump to the per-API reference for details. -7. **[Examples](./examples/README.md)** — recipes you can copy into a plugin. -8. **[Bridge Protocol Overview](./bridge-protocol.md)** — *internals doc.* End-to-end wiring of `wp.os.connect()` / `wp.os.iframe.*` / the synthesised iframe inside native windows. Read when debugging a stuck handshake or building unusual integrations. -9. **[Native Windows & Framework Interop](./native-windows-proposal.md)** — *Stable.* Public API for `openstation_register_window()` / `openstation_register_window_tab()`, Web Components as first-class, and how React / Vue / Svelte plug in without the shell taking a framework dependency. See also [examples/native-windows.md](./examples/native-windows.md) and [examples/native-window-with-tabs.md](./examples/native-window-with-tabs.md). -10. **[Dock Customization](./dock-customization.md)** — *Stable.* Three orthogonal registries — decoration hooks, submenu renderer, dock rail renderer — that let a plugin author go from "tweak a className" to "replace the entire rail with a circular ring." Start here if you want to customize the dock visual. -11. **[Plugin Compatibility Layer](./plugin-compat-layer.md)** — *internals doc.* How OpenStation adapts third-party plugins (WooCommerce, Yoast, etc.) whose CSS or menu-registration assumes classic admin chrome. The three-tier mental model — CSS variables → runtime offset scanner → targeted overrides — and the decision tree for adding a new fix. Read before touching `chromeless.css` or the dock builder for plugin-specific work. -12. **[Files on the Desktop](./files-on-desktop.md)** — *Experimental.* `OpenStation_File` base class, `openstation_register_file_type()`, and `wp.os.files.*`. Phase-0 registry only today; folders, opener associations, sharing, and drag-from-Recycle-Bin land in subsequent phases. -13. **[Desktop Themes](./desktop-themes.md)** — *Experimental.* Whole-OS reskins uploaded as a ZIP of `theme.json` plus images and fonts: every design token, the typeface, a texture on any of 22 surfaces (chrome, dock, desk, menus, dialogs, tables, buttons) plus a documented way to add your own, and a complete iconset down to the window control glyphs. No author CSS or JS ever executes — PHP validates the manifest and compiles the stylesheet, `@font-face` rules included. Read before authoring a theme, or before touching the texture and typography tokens in `variables.css`. See also [examples/register-desktop-theme.md](./examples/register-desktop-theme.md). -13. **[Folder Sharing](./folder-sharing.md)** — *Experimental.* Per-principal read / write grants on desktop folders with first-sight opt-in, polymorphic `target_type` schema, If-Match conflict detection, and a ``-based Share Settings UI. -13. **[Mio](./mio.md)** — *Experimental.* The desk companion: a PixiJS soft-body blob with a chroma neon outline that floats over the wallpaper, feels the gravity of nearby windows and settles onto them, watches your cursor (including across window iframes), and can be dragged anywhere. Covers the simulation, the two soft-body failure modes worth knowing before touching it, the `openstation_mio_config` filter, and `wp.os.mio`. -13. **[Progressive Web App (PWA)](./pwa.md)** — *Stable.* Web app manifest, service worker (root-scope, narrow fetch handler), install affordance, and `wp.os.notify()` for local notifications. Phase-4 Web Push wiring lands later without breaking the v1 call surface. -14. **[Migration 0.7 → 0.8.1](./migration-0.7-to-0.8.1.md)** — what landed in the architecture-0.8.1 refactor: the `@core` / `@api` / `@protocol` / `@layout` / `@ui` path aliases, the registry / server-sync / api-client primitives, the public-API facade home, and the PHP slicing of `helpers.php` / `components.php` / `render.php`. Read once before adopting any of the new modules in your plugin. -15. **[Migration — AI comment-only + native search (0.11.0)](./migration-ai-comment-only.md)** — the AI Copilot is scoped to comment spam scoring; post/term auto-analysis and its hooks are removed, the assistant now finds content with native keyword search, and the bulk `/ai/reindex` endpoint is gone. Read if you depended on any `openstation_ai_*post*` / `*term*` hook or the reindex route. -16. **[Register a widget — polling, storage, canvas charts](./register-widget.md)** -17. **[The Living Tree — algorithm definition](./living-tree-algorithm.md)** — *Experimental.* The full normative spec for the `wp-living-tree` canvas wallpaper: WordPress emits hormones, the biology (Space Colonization) decides geometry inside age-bounded morphological constraints. Read before touching any part of the wallpaper. +5. **[Hooks Reference](./hooks-reference.md)** — every PHP action and filter, with signatures, defaults, and minimal examples. +6. **[JavaScript Reference](./javascript-reference.md)** — CustomEvents on `document`, the `window.wp.os` API, and the iframe `postMessage` bridge. +7. **[API Index](./api-index.md)** — single-page table of every `wp.os.*` method, CustomEvent, and `postMessage` type with its current status. Use this when you need to grep the surface, then jump to the per-API reference for details. +8. **[Examples](./examples/README.md)** — recipes you can copy into a plugin. +9. **[Bridge Protocol Overview](./bridge-protocol.md)** — *internals doc.* End-to-end wiring of `wp.os.connect()` / `wp.os.iframe.*` / the synthesised iframe inside native windows. Read when debugging a stuck handshake or building unusual integrations. +10. **[Native Windows & Framework Interop](./native-windows-proposal.md)** — *Stable.* Public API for `openstation_register_window()` / `openstation_register_window_tab()`, Web Components as first-class, and how React / Vue / Svelte plug in without the shell taking a framework dependency. See also [examples/native-windows.md](./examples/native-windows.md) and [examples/native-window-with-tabs.md](./examples/native-window-with-tabs.md). +11. **[Dock Customization](./dock-customization.md)** — *Stable.* Three orthogonal registries — decoration hooks, submenu renderer, dock rail renderer — that let a plugin author go from "tweak a className" to "replace the entire rail with a circular ring." Start here if you want to customize the dock visual. +12. **[Plugin Compatibility Layer](./plugin-compat-layer.md)** — *internals doc.* How OpenStation adapts third-party plugins (WooCommerce, Yoast, etc.) whose CSS or menu-registration assumes classic admin chrome. The three-tier mental model — CSS variables → runtime offset scanner → targeted overrides — and the decision tree for adding a new fix. Read before touching `chromeless.css` or the dock builder for plugin-specific work. +13. **[Files on the Desktop](./files-on-desktop.md)** — *Experimental.* `OpenStation_File` base class, `openstation_register_file_type()`, and `wp.os.files.*`. Phase-0 registry only today; folders, opener associations, sharing, and drag-from-Recycle-Bin land in subsequent phases. +14. **[Desktop Themes](./desktop-themes.md)** — *Experimental.* Whole-OS reskins uploaded as a ZIP of `theme.json` plus images and fonts: every design token, the typeface, a texture on any of 22 surfaces (chrome, dock, desk, menus, dialogs, tables, buttons) plus a documented way to add your own, and a complete iconset down to the window control glyphs. No author CSS or JS ever executes — PHP validates the manifest and compiles the stylesheet, `@font-face` rules included. Read before authoring a theme, or before touching the texture and typography tokens in `variables.css`. See also [examples/register-desktop-theme.md](./examples/register-desktop-theme.md). +15. **[Folder Sharing](./folder-sharing.md)** — *Experimental.* Per-principal read / write grants on desktop folders with first-sight opt-in, polymorphic `target_type` schema, If-Match conflict detection, and a ``-based Share Settings UI. +16. **[Mio](./mio.md)** — *Experimental.* The desk companion: a PixiJS soft-body blob with a chroma neon outline that floats over the wallpaper, feels the gravity of nearby windows and settles onto them, watches your cursor (including across window iframes), and can be dragged anywhere. Covers the simulation, the two soft-body failure modes worth knowing before touching it, the `openstation_mio_config` filter, and `wp.os.mio`. +17. **[Native Desktop Host](./desktop-host.md)** — *Experimental.* The optional Electron layer, shipped as an **extension** so core never mentions Electron: any window can be **set free** into a real OS window ("Send to your Mac"). Covers the two generic core capabilities it stands on (`wp.os.registerWindowAction()` and `?openstation_solo=`), the capability-probe detection model, and the deliberately cheap liveness pulse. Read before touching the ⋯ menu or solo mode. +18. **[Progressive Web App (PWA)](./pwa.md)** — *Stable.* Web app manifest, service worker (root-scope, narrow fetch handler), install affordance, and `wp.os.notify()` for local notifications. Phase-4 Web Push wiring lands later without breaking the v1 call surface. +19. **[Architecture 0.8.1 layout](./architecture.md#architecture-081-layout-in-progress)** — what landed in the architecture-0.8.1 refactor: the `@core` / `@api` / `@protocol` / `@layout` / `@ui` path aliases, the registry / server-sync / api-client primitives, the public-API facade home, and the PHP slicing of `helpers.php` / `components.php` / `render.php`. Read once before adopting any of the new modules in your plugin. +20. **[Migration — AI comment-only + native search (0.9.1)](./migration-ai-comment-only.md)** — the AI Copilot is scoped to comment spam scoring; post/term auto-analysis and its hooks are removed, the assistant now finds content with native keyword search, and the bulk `/ai/reindex` endpoint is gone. Read if you depended on any `openstation_ai_*post*` / `*term*` hook or the reindex route. +21. **[Migration — activity channels move to `os/` (1.0.0)](./migration-activity-channels.md)** — the eleven framework-published activity channels drop the pre-rebrand `desktop-mode/` prefix. No alias ships: a subscriber left on an old slug stops firing silently. Read if you subscribe to or filter any built-in channel. +22. **[Migration — async `windowManager` (0.8.4)](./migration-0.8.4-async-windowmanager.md)** — `registerWindow()` and its siblings return a `Promise`. Read if you call the window manager from a plugin bundle. +23. **[Migration — AI connectors (0.9.4)](./migration-ai-connectors.md)** — `openstation_register_ai_tool()` and the `openstation_ai_tool_registered` action are gone; server-dispatched tools are WordPress abilities. Read if you integrated with the AI Copilot's provider or credential surface. +24. **[Migration — native window tabs move to the chrome](./migration-window-tabs.md)** — a multi-tab native window no longer renders an `` strip into its body; the shell builds one strip in the window chrome from the same metadata. `openstation_register_window_tab()` is unchanged. Read if you listened for `os-tab-change`, or styled or queried that strip. +25. **[Register a widget — polling, storage, canvas charts](./examples/register-widget.md)** +26. **[The Living Tree — algorithm definition](./living-tree-algorithm.md)** — *Experimental.* The full normative spec for the `wp-living-tree` canvas wallpaper: WordPress emits hormones, the biology (Space Colonization) decides geometry inside age-bounded morphological constraints. Read before touching any part of the wallpaper. ## Conventions used in this docs folder diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a4a5465b..57c29873 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -8,7 +8,7 @@ Maintainer guide. Users install by downloading `/releases/latest/download/openst ./bin/release.sh 0.5.0 ``` -Refreshes translation files (`npm run i18n`), drafts a `= X.Y.Z =` changelog block into `readme.txt` from GitHub's auto-generated release notes, then stops at **a single interactive gate**: it shows the block and requires an explicit `y` to continue — on every path, including `--skip-changelog` and resumed runs, and with a loud warning if the block is missing. Editing `readme.txt` while the prompt waits is supported: the bump commit picks up the file as saved, and the script re-prints the block if it changed. Answering `n` stops the release with nothing committed; fix the block and re-run — leftovers are tolerated, the draft merge is idempotent, and your edits survive, so the re-run lands straight back at the gate. If `readme.txt` already has a `= X.Y.Z =` block (hand-written, or committed by a feature PR), the draft still runs: existing entries are kept, only drafted bullets not already present verbatim are appended, and the appended ones are listed — watch for semantic duplicates. After confirmation it bumps all four version locations, commits, pushes to trunk, **waits for CI green**, tags, pushes the tag. Aborts cleanly if you're not on trunk, local trunk is out of sync with origin, CI fails, or the working tree has changes beyond the script-owned files (`languages/` and `readme.txt` leftovers from an aborted attempt are fine; they're re-reviewed and swept into the bump commit). Resumable — re-running after a mid-flow failure picks up where it left off. +Bumps all four version locations, refreshes translation files (`npm run i18n`) — in that order, because `wp i18n make-pot` reads `Project-Id-Version` from the plugin header, so extracting first would stamp the catalogues with the *previous* version — drafts a `= X.Y.Z =` changelog block into `readme.txt` from GitHub's auto-generated release notes, then stops at **a single interactive gate**: it shows the block and requires an explicit `y` to continue — on every path, including `--skip-changelog` and resumed runs, and with a loud warning if the block is missing. Editing `readme.txt` while the prompt waits is supported: the bump commit picks up the file as saved, and the script re-prints the block if it changed. Answering `n` stops the release with nothing committed; fix the block and re-run — leftovers are tolerated, the draft merge is idempotent, and your edits survive, so the re-run lands straight back at the gate. If `readme.txt` already has a `= X.Y.Z =` block (hand-written, or committed by a feature PR), the draft still runs: existing entries are kept, only drafted bullets not already present verbatim are appended, and the appended ones are listed — watch for semantic duplicates. After confirmation it commits the bump and the language churn together, pushes to trunk, **waits for CI green**, tags, pushes the tag. Nothing is committed before the gate, so answering `n` leaves the bumped version files and refreshed catalogues in the working tree only. Aborts cleanly if you're not on trunk, local trunk is out of sync with origin, CI fails, or the working tree has changes beyond the script-owned files (`languages/`, `readme.txt` and the version files from an aborted attempt are fine; `bump-version.sh` rewrites them deterministically and they're swept into the bump commit). Resumable — re-running after a mid-flow failure picks up where it left off. The resume path requires the bump to be **committed**, not merely written: matching version strings in a dirty tree mean an earlier run stopped at the gate, so the re-run redoes the bump and commits it rather than tagging the pre-bump commit. Flags: @@ -16,10 +16,30 @@ Flags: - `--skip-changelog` — skip drafting the `readme.txt` changelog block. Use when you've already hand-written it, or for hotfixes with nothing notable to log. The interactive changelog confirmation still runs; only the drafting step is skipped. - `--dry-run-changelog` — print the changelog draft that would be inserted into `readme.txt`, then exit without modifying any files or pushing. -The tag push fires [`.github/workflows/release.yml`](../.github/workflows/release.yml), which builds and publishes a GitHub Release with `openstation.zip` attached. +The tag push fires [`.github/workflows/release.yml`](../.github/workflows/release.yml), which builds and publishes a GitHub Release with `openstation.zip` attached, then — for stable tags only — deploys to WordPress.org. Requires the `gh` CLI authenticated (`gh auth status`). +## The WordPress.org deploy + +The last step of `release.yml` unpacks the zip and hands `build/desktop-mode/` to [`10up/action-wordpress-plugin-deploy`](https://github.com/10up/action-wordpress-plugin-deploy), which commits it to SVN trunk and tags it. Pre-releases are skipped — the step is gated on the tag having no hyphen. + +Two of the action's inputs default off a GitHub context that is only correct for a tag push, so the workflow sets both explicitly rather than leaving them implicit — `SLUG` (below) and `VERSION`, which the action derives from `GITHUB_REF` and which would resolve to the *branch* ref on a manual dispatch, producing an SVN tag called `refs/heads/trunk`. `VERSION` is exported from the version-gate step, so the deploy publishes the string that was just verified against all four version locations. + +**`SLUG` is set explicitly to `desktop-mode` and must stay that way.** It is the published plugin's SVN path (`plugins.svn.wordpress.org/desktop-mode/`) and its install directory, so it is frozen for the same reason as every other `desktop_mode_*` value in [AGENTS.md](../AGENTS.md): changing it doesn't migrate anything, it points the deploy at a repository that doesn't exist and orphans every installed copy's update check. The action defaults `SLUG` to the GitHub repository name when unset — that default silently matched while the repo was named `desktop-mode`, and broke the moment it was renamed to `openstation`. Never rely on it. + +Assets (banners, icons, screenshots) come from `.wordpress-org/`, the action's default `ASSETS_DIR`. + +### Re-deploying a published tag + +A tag push runs the workflow definition **frozen into that tag's commit**, so a deploy that failed for a workflow-level reason cannot be fixed by re-running it — the re-run replays the same broken definition. Dispatch the workflow from `trunk` instead, which runs the current definition against an existing tag: + +```bash +gh workflow run release.yml --repo WordPress/openstation --ref trunk -f tag=v1.0.0 +``` + +The GitHub Release is left untouched: `gh release create` is not idempotent, so that step is gated on `github.event_name == 'push'` and skipped on dispatch. Everything else — checkout of the tag, the version gate, build, package, deploy — runs identically. The action itself is idempotent against SVN: a version already published is detected and skipped rather than re-committed. + ## Pre-releases Hyphenated versions publish as GitHub pre-releases, so `/releases/latest` keeps pointing at the last stable. The workflow detects the hyphen and sets `--prerelease` automatically: @@ -35,7 +55,7 @@ Hyphenated versions publish as GitHub pre-releases, so `/releases/latest` keeps | `bin/bump-version.sh ` | Syncs `package.json`, `package-lock.json`, plugin header, `OPENSTATION_VERSION`, `readme.txt` `Stable tag:`. | | `bin/package.sh` | Packages `openstation.zip` from HEAD + current built JS. The ZIP keeps the internal `desktop-mode/` directory so WordPress.org upgrades and dependent plugins continue to resolve the established plugin slug. Derives the expected bundle list from `vite.config.js` TARGETS and ships each target's `.min.js` **only** — the unminified dev bundles (~4–5 MB) stay out of the zip; `openstation_asset_suffix()` falls back to `.min` on installs where they're absent, so a `SCRIPT_DEBUG` site degrades gracefully. Errors if any expected `.min.js` is missing under `assets/js/`, or if a stale gitignored `.js` not produced by any Vite target is left behind there. | | `bin/release.sh ` | Full end-to-end release. | -| `release.yml` — `push: tags: v*` | Build + publish the GitHub Release. | +| `release.yml` — `push: tags: v*` | Build + publish the GitHub Release, then deploy stable tags to WordPress.org. | ## Version locations diff --git a/docs/api-index.md b/docs/api-index.md index cf0a4e46..59acd2b0 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -25,6 +25,8 @@ The full surface is documented in [`javascript-reference.md`](./javascript-refer | `HOOKS` | `typeof HOOKS` *(typed hook-name constants)* | Stable | | `hooks` | `wp.hooks` bridge | Stable | | `saveSession` | `() => void` | Stable | +| `registerWindowAction` / `unregisterWindowAction` / `listWindowActions` | `( def: WindowActionDef ) => void` *(rows in every window's ⋯ menu; `label`/`icon`/`isVisible` may be per-window functions)* | Experimental | +| [`electron`](./desktop-host.md) | `ElectronAdapterApi` *(set a window free into a real OS window; published by the Electron Adapter extension, absent in a browser)* | Experimental | ### HTTP & UI primitives — must-know @@ -56,7 +58,8 @@ The full surface is documented in [`javascript-reference.md`](./javascript-refer |---|---|---| | `dock` | `Dock \| null` *(primary / bottom rail)* | Stable | | `sideDock` | `Dock \| null` *(left rail; classic only)* | Stable | -| `desktopLayout` | `'classic' \| 'unified' \| 'spatial'` | Stable | +| `desktopLayout` | `'classic' \| 'unified'` | Stable | +| `dockPlacement` | `'bottom' \| 'left' \| 'right'` *(Unified)* | Stable | | `Dock.setBadge` | `( id: string, count: number ) => void` | Stable | | `Dock.removeSystemItem` | `( id: string ) => void` | Stable | | `icons` | `IconsApi` *(see `icons.setBadge`)* | Stable | @@ -80,6 +83,7 @@ The full surface is documented in [`javascript-reference.md`](./javascript-refer | `subscribe` | `( topic: string, cb ) => () => void` *(cross-window)* | Stable | | — topic family | `os..changed` *(content-change realtime; `{ source, action, ids }`)* | Stable | | `presence` | `PresenceApi` | Stable | +| `selection` | `SelectionApi` *(`active()`, `resolveCommonActions()`, `createModel()`)* | Experimental | ### Commands, palettes, AI, settings @@ -190,7 +194,20 @@ shared-store + registries. Index: | `/desktop-mode/v1/agents[…]` REST routes | [`includes/rest/README.md`](../includes/rest/README.md) | Experimental | | `openstation_agent_*` PHP helpers, actions, filters | [`hooks-reference.md`](./hooks-reference.md#ai-agents) | Experimental | | `desktop-mode/agents-chat` shared-store key + `desktop-mode-agent-run` window | [`javascript-reference.md`](./javascript-reference.md#ai-agents--client-surface-experimental) | Experimental | -| `agent` site-folder entity kind | `registerEntityKind()` seam | Experimental | +| `agent` WP Explorer entity kind | `registerEntityKind()` seam | Experimental | + +### WooCommerce integration *(Experimental — inert unless WooCommerce is active)* + +No `wp.os.woo` namespace. The surface is PHP filters + REST + a +native window, all hanging off WP Explorer. Index: + +| Surface | Where | Status | +|---|---|---| +| What the integration renders, and why | [`plugin-compat-layer.md`](./plugin-compat-layer.md#the-site-window-side-woocommerce) | Experimental | +| `openstation_my_wordpress_woo_*` filters (orders, products, coupons, store, summaries, customers) | [`hooks-reference.md`](./hooks-reference.md#woocommerce-integration--experimental-filters) | Experimental | +| `desktop-mode/v1/woocommerce/{orders, store, summary//, customers, customers/}` | `includes/my-wordpress/integrations/` | Experimental | +| `openstation_woo_customer` REST field on the core `user` resource | [`hooks-reference.md`](./hooks-reference.md#customers) | Experimental | +| `desktop-mode-woo-customer` native window *(retargetable singleton, `customerId` param)* | [`hooks-reference.md`](./hooks-reference.md#the-customer-window) | Experimental | ## CustomEvents on `document` @@ -211,7 +228,9 @@ Every event bubbles from `document`. See [`javascript-reference.md`](./javascrip | `os-window-content-changed` | Experimental | | `os-window-link-groups-changed` | Experimental | | `os-presence-changed` | Stable | +| `os-selection-changed` | Experimental | | `os-layout-changed` | Stable | +| `os-item-menu-opening` | Stable | | `os-registry-changed` | Stable | | `os.drag.start` / `.move` / `.enter` / `.leave` / `.rejected` / `.commit` / `.cancel` / `.end` | Stable | | `os-cross-frame-drag-start` / `-end` *(cross-iframe drag bridge)* | Stable | @@ -224,6 +243,7 @@ Every event bubbles from `document`. See [`javascript-reference.md`](./javascrip | `os-auth-lost` / `os-auth-restored` *(session expiry / recovery)* | Stable | | `os-desktop-theme-changed` *(whole-OS reskin activated / cleared)* | Experimental | | `os-editor-preview-opened` / `-closed` *(editor↔preview pairing lifecycle)* | Experimental | +| `os-desktop-host-freed` / `-docked` / `-connection` *(window set free into a real OS window)* | Experimental | --- diff --git a/docs/architecture.md b/docs/architecture.md index 77b2bd84..e9c5e642 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,21 +96,42 @@ Key server-side entry points: 6. The iframe renders WordPress normally, but the chromeless stylesheet hides the admin bar, side menu, and wp-footer. 7. The iframe `postMessage`s its title, navigation, and screen-meta state up to the parent. +### When OpenStation stops being active underneath the shell + +The server cannot announce this, because the next request no longer loads OpenStation. `src/plugin-presence.ts` detects it client side instead, in two halves. + +Triggers are cheap and allowed to be wrong: an iframe loading on an admin path whose `` lacks `os-chromeless` (catches deactivate and delete from the classic `plugins.php`, row and bulk), or a Heartbeat tick without the `desktop_mode_nonces` field (catches another tab or WP-CLI). Neither is conclusive, since `wp_die()` screens carry no `admin_body_class` and core skips `heartbeat_received` on a tick with no client data. + +Confirmation is a `GET` of the `desktop-mode/v1` REST namespace index, needing neither nonce nor capability. Only a `404` carrying WordPress's own `rest_no_route` body evicts, because a bare 404 is also what a REST-hardening plugin or a firewall rule on `/wp-json` returns while OpenStation is perfectly healthy. A network error, a non-JSON body, or a shell config with no `restUrl` all leave the shell up. On a confirmed absence the shell toasts and navigates the top frame to `adminUrl` via `leaveForClassicAdmin()`, the same helper the native Plugins window's `reloadOutOfOpenStation()` uses. + +The watcher stops pinging after three consecutive "still here" answers, and any proof the plugin is alive (a chromeless page, a tick carrying the field) resets that. The Heartbeat field is gated on `openstation_is_enabled()`, not on the plugin being loaded, so a user who turns OpenStation off in another tab would otherwise make every later tick a trigger forever. + +State lives in a `createSharedStore` because this module compiles into both the main bundle and the lazy `window-system` one. + +The native Plugins window keeps its own faster path (`isOpenStationSelf()` / `reloadOutOfOpenStation()` in `src/plugins-window/rest.ts`), since it knows which plugin the user just acted on. + ## 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 Preferences → Appearance lets the user pick **Unified** or **Split**. The shell root reflects the choice in `data-os-layout`; the layout dispatcher (`src/desktop-layout.ts`) owns every dock instance, tearing down and rebuilding when the user switches. + +| Mode | Primary dock (`wp.os.dock`) | Left side dock (`wp.os.sideDock`) | Wallpaper icons | +|---|---|---|---| +| **Unified** *(default)* | Every menu sharing one rail, **re-sorted core-first** with one divider on the boundary | — *(no side dock)* | Plugin-registered icons only | +| **Classic** (shown as "Split") | Plugin-contributed top-level menus (`isCore: false`) | Core admin menus (Dashboard, Posts, Media, Settings, …) | Plugin-registered icons only | + +**The constellation** (`src/dock-constellation/`) is the flyout a menu tile fans out on hover, carrying the menu's own page, its live windows, one row per submenu entry, and a new-window row. It serves every rail in every layout and fans away from whichever edge the rail is parked on, reading the direction off that rail's `data-os-dock-placement`. `dock-peek` stands down for menu tiles wherever a constellation is mounted (the shared predicate lives in `src/dock-constellation/active.ts`) so the two hover surfaces never stack; system tiles keep the peek everywhere. Styling is in `assets/css/openstation-layout.css` behind `--os-cn-*` tokens; the JS hooks are `os.constellation.panel` / `.opened` / `.closed`. -| Mode | Default? | Bottom dock | Left side dock (`wp.os.sideDock`) | Wallpaper icons | -|---|---|---|---|---| -| **Classic** | ✅ | Plugin-contributed top-level menus (`isCore: false`) | Core admin menus (Dashboard, Posts, Media, Settings, …) | Plugin-registered icons only | -| **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:`) | +Inside a rail, tiles are grouped by what they do rather than by where they came from: the admin menus first, then a divider, then OpenStation's own controls (Preferences, the bin, the way out) — the cohort the dock calls *system tiles*. That divider is the rail's one structural line, because it is the only boundary where behaviour changes: before it a tile opens an admin screen, after it a tile acts on the desktop. A softer hairline also separates core menus from plugin apps, mirroring wp-admin's own menu, but that boundary is provenance rather than behaviour and is drawn quietly. The two read through `--os-dock-divider` and `--os-dock-divider-soft`. -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. +**Unified groups before it draws.** `Dock.replaceItems()` inserts the `--group` divider at the first tile whose `isCore` is `false`, so it only produces one clean boundary when the list is already grouped — and the admin menu is not: a plugin that registers high up (Yoast, Jetpack) would otherwise put the line two tiles in and strand the rest of WordPress on the plugin side of it. `coreFirstRailItems()` in the dispatcher sorts core ahead of plugins, preserving relative order inside each cluster so drag-to-reorder still holds within a group. One consequence to know about: a tile cannot be dragged from one cluster into the middle of the other. -**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. +**The way out is drawn differently.** The `os-exit` tile leaves the desktop rather than opening something closeable, and with the admin bar hidden by default it is the only route back to classic admin. `dock.css` gives it `order: 1` (always last, whatever order plugin-owned native-window tiles sync in), its own wider gap, a ring instead of a filled plate, and a hover that leans toward the edge it leads to instead of lifting toward the pointer. The rules key on `[data-system-id="os-exit"]`, the same idiom the Recycle Bin badge uses; `tests/vitest/dock-exit-tile.test.ts` pins that attribute. -Listen for `os-layout-changed` on `document` to react to a switch in plugin code — the event detail carries the new `layout` string plus current `primary`/`side` `Dock` references. +**Dock placement.** Unified sits on the edge named by the `dockPlacement` preference (`bottom` — the default — `left`, or `right`), reflected on each rail as `data-os-dock-placement`. Classic ignores it, and `primaryOrientation()` in the dispatcher is the one place that decides so: its side bar already owns the left edge, and honouring the pick would stack both rails on one side. OpenStation Preferences paints the Dock position control inside the Unified card, the one offered layout that reads it, and paints Dock size under both cards, since both have a dock to size. The pick is remembered while Classic is worn and applies again the moment the user returns to Unified. Moving the dock is a full rebuild (placement reaches a renderer through `mount()`), and fires `os-layout-changed` for the same reason a layout switch does. + +Both values are user meta (`desktopLayout` and `dockPlacement` inside the OpenStation Preferences JSON blob, REST-synced via the existing `/wp-json/desktop-mode/v1/os-settings` endpoint). 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. + +Listen for `os-layout-changed` on `document` to react to a switch in plugin code — the event detail carries the new `layout` and `placement` strings plus current `primary`/`side` `Dock` references. ## Dock customization — two registries @@ -137,9 +158,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 `