Skip to content

Add Sidecar to official OpenStation 1.1.0 - #4

Open
nickhamze wants to merge 94 commits into
trunkfrom
connected-inspector
Open

Add Sidecar to official OpenStation 1.1.0#4
nickhamze wants to merge 94 commits into
trunkfrom
connected-inspector

Conversation

@nickhamze

@nickhamze nickhamze commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • rebuild Sidecar directly on the official OpenStation v1.1.0 tag (64cbb980)
  • present the companion as a connected inspector extension of the editor
  • keep the main Gutenberg sidebar and the Sidecar copy open independently
  • replace the dropdown with familiar Post, Block, and discovered plugin icons
  • seed a first-load feature page and Jetpack offline demo

Verification

  • npm run build
  • npm run lint
  • npm run typecheck
  • npm run test:js — 348 files, 4,345 tests
  • Blueprint JSON and embedded PHP syntax validation
  • live Playground smoke test with Jetpack open in both sidebar copies

Open the corrected WordPress Playground demo

mmtr and others added 30 commits August 5, 2026 12:59
* Rebrand: Polish the leftovers, and announce the rename

Tab strip edge fades were masked unconditionally. On the pre-brand light
strip that was invisible, since the fade landed on empty area past the
last tab; on the dark default it reads as two grey smudges bracketing
every window's submenu. They now paint only on an edge that is actually
hiding a tab.

Links inside <os-notice> painted WordPress Blue at rest and a darker
blue on hover, close to illegible on the notice wash. Two causes: the
colour resolved through --wp-admin-theme-color, which the accent picker
writes inline, so the link took whatever hue the user chose for focus
rings; and ::slotted( a ) cannot beat wp-admin's own bare anchor rule,
because a slotted link belongs to the document tree and CSS Scoping
hands normal declarations to the outer tree. The palette now declares
the tokens and a document-tree rule consumes them.

OS Settings is renamed to OpenStation Settings and wears the logomark.
The mark is a currentColor silhouette rather than the brand's app chip:
the dock masks every image icon so plugin colours cannot break the
monochrome rail, and a chip's alpha is its tile, so a chip renders as a
plain white rounded square.

Adds a one-off dialog explaining the rename, shown once per user and
only on installs that were already running under the old name. Fresh
installs never see it. Dismissal goes to the seen-intros registry, so
"Reset what's-new dialogs" brings it back and one admin dismissing it
does not silence it for their editors.

Fixes the plugin slug in bin/setup-wp-env.sh. The rebrand sweep renamed
it to a plugin that does not exist, and because the script runs under
set -euo pipefail the failed activate aborted it: the Gutenberg
Guidelines experiment never got enabled and the plugin was left inactive
on every fresh wp-env start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rebrand: Aim the announcement at the users it is actually for

Review follow-up.

The gate was per-install, so an editor who joined an old site last week
and enabled the shell this morning would be told about a rename they
never saw. Migration 5 now flags individual users who carry proof of
prior use: the `desktop_mode_mode` opt-in, or a saved OS-settings blob
for someone who used it and has since switched back to classic.

That also fixes a worse miss in the other direction. The install gate
read `$from === 0` as "fresh install, nothing to explain", but the
migration runner only shipped in 0.9.1, so a site still on 0.9.0 that
updates straight to the rebrand release arrives with no stored version
and looks brand new. Those installs update rarely, which makes them the
most likely to be blindsided, and they were exactly the ones being
silenced. The install gate is now just `$from < 4`; per-user evidence
tells a dormant install apart from a new one, because a genuinely fresh
site runs its first admin_init on the activation redirect, before
anyone can have opened the shell.

announce.css is no longer enqueued for users who will never see the
dialog. The gate is computed once and feeds both the enqueue and the
`rebrandNotice` config key, so the two cannot diverge; the dialog
cannot paint without the stylesheet.

The stylesheet's header now says out loud that it opts out of the
palette, that this is a departure from "one declaration, one owner"
rather than the house pattern, and that brand-palette.test.ts will not
catch it drifting.

Also caches the tab strip's text direction across scroll frames instead
of calling getComputedStyle on every one, adds wiring tests for
observeTabOverflow (listeners, coalescing, teardown, no-observer
environments), and fixes a boot comment describing a bundle wait that
no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rebrand: Flag anyone who ever opted in, not just current users

Verifying the announcement against the upgrade paths turned up a user
it was dropping: someone who tried the shell, changed nothing, and
switched back to classic. They used Desktop Mode, so they are owed the
explanation the next time they come in, and they were not getting it.

Switching back writes an empty string to `desktop_mode_mode` rather
than deleting the row, and the only two writers of that key are the
toggle itself and the portal's auto-enable. So the row existing means
"has been through the switch at least once", which is the question the
migration is actually asking. Matching on the value being '1' asked a
narrower one.

Someone who never switched still has no row at all, so the newcomer
case is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… (#494)

The eye preview refreshed once and then went quiet. Four things were
behind that, and the first is the one users hit.

The classic editor had no live watch at all. Its branch of the
iframe-side watcher had no reactive store to observe, so it only
announced after core's own heartbeat autosave — at most once every
~60 s. Typing is now detected on the raw surfaces instead: `input` on
the title / content / excerpt fields plus edit events on every TinyMCE
editor, late arrivals included (a visual↔text switch re-initializes
one). Each debounced settle forces the autosave core would otherwise
sit on. Core still skips the request when nothing changed, so an idle
settle stays silent, and a settle landing while a round-trip is on the
wire retries rather than being dropped — core drops a `triggerSave()`
during `_blockSave` without a word.

The watch also died on every editor page load and was never re-armed,
which the classic editor guarantees: it reloads on each manual save.
The pairing survived, the typing refresh didn't. Readiness now re-arms
it under a fresh id, unwatching the previous one first so a re-announce
without a real reload can't leave two watches autosaving in parallel.

Opening the preview waited on the autosave round-trip before the
companion window appeared, so a slow save read as a hang — nothing on
screen moved for seconds. The companion now opens immediately wearing
the normal loading overlay while the save runs alongside it; a save
that actually landed silently swaps the companion to the fresher
content. That reordering exposed a real hazard in `swapReload()`: a
swap completing before the window's FIRST load removes the original
frame along with its pending load event, stranding the boot overlay
forever. A completed buffer load is proof of ready content, so it now
marks the window ready.

Unrelated but adjacent, and dead since the rebrand: the soft-reload
topic matcher in the chromeless bridge still expected an
`openstation.` prefix while the emitter three functions away moved to
`os.`. Every list-page soft reload had been silently failing to match.
The test pins both sides so they can only move together.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The detail panel derived a directory slug from the plugin's folder
name, which every installed plugin has. Private, premium and
self-hosted plugins got a "View on WordPress.org" button pointing at
a 404, plus Changelog / FAQ / Reviews tabs with nothing behind them.

Adds an `openstation_wporg_slug` REST field that reads the
`update_plugins` transient's `response` / `no_update` buckets, the
same source classic `plugins.php` uses to decide between "View
details" and "Visit plugin site". No entry means not listed, and the
panel falls back to the plugin's own header links.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…(#499)

The repaint on `fullscreenchange` wrote the new label to `title`, but
`wireTooltipsFor()` had already moved that label to
`data-desktop-tooltip` and `aria-label` and removed `title`. So the
tooltip and the accessible name kept describing the opposite action,
and the re-added `title` brought the native tooltip back on top of ours.

Route both branches through a `repaintLabel()` helper that writes all
three, touching `title` only while the node still has one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Revisions: Fix the classic revisions screen in a desktop window

Five fixes behind one report.

The block editor's revisions sidebar links out with `target="_blank"`,
which the chromeless bridge yielded on, so a same-origin wp-admin URL
opened a raw browser tab. A `_blank` on an admin URL now opens a
desktop window instead. Every other target still yields.

The window took its name from the link's `textContent`, and Core's
revisions link is a visible label plus a screen-reader one, so it came
out "Browse Browse revisions". The label harvest skips
`.screen-reader-text` and collapses whitespace, and a label the shell
had to guess is replaced by the destination page's own screen name
once the iframe loads.

`setTitle()` wrote to a detached node. Layer 3's slot painter repaints
each slot from `cloneNode` copies, so the span the constructor captured
was already orphaned. Every window was in this state, which broke
plugin `setTitle` and `os-title-change` too.

"Go to editor" is hidden and the revision tooltip gets its clearance
back, both page-scoped in chromeless.css. The tooltip is positioned
upward from the control frame and only cleared the top of the viewport
because of the screen H1 that chromeless hides.

Restoring a revision is a `document.location` assignment, so WP's
redirect turned the Revisions window into a second editor beside the
stale one. A window opened on `revision.php` now closes when it leaves
that screen and routes where it landed through the normal
window-owns-a-slug rules.

Fixes #491

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Chromeless: Never let a _blank link move the window it was clicked in

Claiming a `_blank` admin link handed it to the parent's normal slug
routing, which navigates the source iframe in place when the
destination shares the window's slug. That destroys the one thing the
`_blank` asked to keep. `deriveWindowId` treats `page` as identity but
not `tab`, so any plugin screen linking `admin.php?page=x&tab=b` from
`admin.php?page=x` hit it, and a `_blank` on a trash URL ran the
destructive branch in place for the same reason.

The bridge now claims a `_blank` only when the destination is a
different wp-admin file, the one call it can make without the shell's
slug rules, and flags the message `newContext` so the parent skips
both in-place branches. Everything else opens a browser tab as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Windows: Keep the title through a window-slot repaint

The slot painter restores each slot from a snapshot cloned at
construction. Every other slot's default is fixed markup, but the
title's is derived state, so restoring it verbatim put the window's
original name back and left `config.title` reporting the new one. A
repaint fires whenever the slot registry mutates, so activating a
plugin that registers a window slot renamed every open window back to
whatever it started as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Trim the comments on the revisions fixes

Cuts the walkthroughs down to what the next reader needs, drops the
revision-specific examples from code that isn't revision-specific, and
removes the compat-layer doc entry now that the CSS says it in three
lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Selection: hold more than one thing at a time

Every tile surface in the shell was single-select, and each one had
re-implemented it: six selection states and four near-identical
context-menu builders between the wallpaper, folder windows and the
five canvases in the site folder. You could not act on two things at
once anywhere.

This is one selection framework (`src/selection/`) that all of them
use, and one rule for the hard part — deciding what a set of unlike
things is allowed to do.

The rule. The item's own adapter answers "what can be done to this
one thing?", which is the tile-menu builder each surface already had,
filters and all. The framework answers "what do these N have in
common?": intersect by action id, and keep an id only when EVERY
contributing action declares `multi: true`. So a post and an image
keep Move to Trash and drop Navigate into and Download. `multi` is
opt-in because an action written against one item — Rename…, Share… —
would misbehave run twelve times, and its author never agreed to
that; existing menu entries keep working untouched and simply stay
single-item until they say otherwise. `multiId` merges the same deed
under different labels, which is how a folder and a file end up
sharing one Trash entry.

What the user gets: click / Ctrl-click / Shift-click / marquee /
Cmd+A / Escape on every canvas; WordPress's own bulk actions on the
site folder's lists (bulk Edit, Publish, Switch to Draft, Trash,
media Detach and Delete, user Change role and Delete); and multi-item
drag — three icons to the Trash is one toast with one Undo, three to
a folder is one move.

Bulk edit matches core where it counts: only the fields you changed
are written (every control starts on "No change"), and categories and
tags are ADDED to what each entry already has rather than replacing
it. Deleting users asks core's question about their content first,
because the REST endpoint refuses without an answer.

Drag payloads grew one optional field per type (`placements`,
`items`), absent for a single-item drag, so every drop target written
before this — ours and any plugin's — is untouched and correct. Two
helpers (`dragPlacements`, `dragShortcutItems`) give one code path
for "one" and "many". Targets that support sets apply their accept
gates to every member and refuse the whole drop when any one fails:
accepting a set and handling part of it reports success for an
operation that half-happened.

Also standardises the icon grid, which had drifted into two: the
desktop ran a 96x110 pitch and the site folder 108x112, and the
desktop's 8px of air was fiction — the tile had no `box-sizing`, so
its padding fell outside its declared width and filled the cell,
leaving icons touching. There is now one grid, declared as tokens in
variables.css (so a desktop theme can retune it) and mirrored in
grid.ts for the layout maths, with a test that parses the stylesheet
to prove the mirror stays faithful. The cell is derived, never
declared: cell = tile + gap. Tile height is fixed rather than
minimum, because the tile box IS the selection ring and a box that
grows with its label gives a row of selected icons a ragged edge.

Four fixes found while building it, each with the test that would
have caught it:

- `<os-tile>` rebuilt its children on any attribute change, so a
  selection change mid-gesture destroyed the node a mousedown landed
  on and the browser synthesized no click — no dblclick, and tiles
  stopped opening. Selection attributes now take a
  children-preserving path.
- The marquee's exclusion of `.os-window` (windows are children of
  the desktop area) also matched every canvas INSIDE a window, where
  the window is an ancestor. Scoped with `background.contains()`.
- The marquee anchor was stored in viewport coordinates, so scrolling
  mid-drag froze the band. It is content-space now, redraws on
  scroll, and auto-scrolls at the edges.
- A marquee released over an iframe stranded the gesture, because the
  pointerup fired in that document. The band takes pointer capture,
  with `lostpointercapture` and window blur as nets. It also
  suppresses native text selection while it runs — the bare canvas is
  selectable where a tile is not, so the browser was quietly running
  its own selection into whatever the pointer crossed.

Docs updated in the same change: javascript-reference, files-on-
desktop, components-reference, api-index, desktop-themes, plus a new
example for adding a multi-safe action with a batched runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Selection: review fixes

The wallpaper scrolled under a marquee dragged to the bottom of the
screen. Auto-scroll asked only whether the content overflowed its
box, and `.os-area` is `overflow: hidden` with icons and 80px of
bottom padding overflowing it constantly — so it scrolled a surface
with no scrollbar that nothing else can scroll. It now asks whether
the box is a scroll container at all.

From review:

1. Bulk Undo announced 'untrashed' for the whole batch without
   looking at the settle results, so a restore that failed still told
   the Recycle Bin badge the item was back. Now only what actually
   came back is announced, and the user is told when part of an Undo
   didn't take — the single-item paths already got this right by
   broadcasting inside their success branch.

2. `destroy()` cleared the shared `lastActive` snapshot whenever the
   surface matched, so closing folder window A blanked the selection
   window B had just written. Scope doesn't fix it either — two
   windows open on the SAME folder share both fields. Each controller
   now holds an identity object, and only clears the snapshot it
   owns.

3. A `multiId` merge called `contributors[0].bulk` with the entire
   heterogeneous set, discarding every other contributor's runner.
   Safe in this PR's own usage only by accident, and which
   contributor won depended on the order the user selected in.
   Rather than documenting a constraint people would violate, the
   resolver now groups the selection by runner IDENTITY and calls
   each once with the items that declared it; contributors without a
   `bulk` still fan out. The built-in Trash entries share one
   function reference so a mixed folder + file selection stays one
   batch — one toast, one Undo — which is now the documented way to
   opt into that.

4. The additive category/tag merge read each post's current terms
   BEFORE a modal that stays open as long as the user takes to fill
   it in, so a concurrent edit was silently overwritten. Re-read
   after the modal closes, and only when the patch actually merges
   terms.

5. A partial-failure toast dropped `firstError`, so the user could
   see that some rows failed and never learn why. It carries the
   reason now.

6. Dropped unused `os-role-picker` / `os-spinner` imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… (#500)

The site folder is now WP Explorer, on a folder icon carrying the
single-ringed WordPress mark. Its window used to be titled after the
site, which is the name of the root folder it opens on, not the name of
the app. The breadcrumb still says the site name.

OpenStation Settings is now OpenStation Preferences, on a gear. The name
collided with the admin menu's own Settings, and the logomark read as a
brand stamp in a rail of dashicons rather than as somewhere to go.

Display names only. The window ids, module directories, script and style
handles, and every openstation_my_wordpress_* filter keep their names.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`initFromManager()` waits for the shell to publish
`wp.os.windowManager` so it can mirror the persisted snap-to-grid
preference onto the layout menu's checkbox. It re-armed every 60ms
with no exit condition, so on any page where the manager never
arrives — the shell failed to boot, or a plugin surfaced this admin
bar somewhere the shell does not load — it woke the event loop
sixteen times a second for the life of the tab, to paint a checkbox
that was never going to change.

It now gives up after ten seconds. The manager lands within a frame
or two of the shell bundle executing, so nothing real waits that
long, and giving up leaves the server-rendered box exactly as it was
— which is what polling forever achieved anyway.

This also surfaced as a CI failure. The fullscreen-label suite
evaluates the shipped IIFE to test it, and its fixture published no
manager, so each of the four tests left a poll running past the end
of the file. The first tick after vitest tore down jsdom dereferenced
a `window` that no longer existed:

    ReferenceError: window is not defined
     ❯ getManager … ❯ Timeout.initFromManager [as _onTimeout]

Vitest reports that against the whole run ("this might cause false
positive tests"), and it only reproduces when teardown happens to
land before the next tick — so it passed locally and failed in CI.

The fixture now publishes a manager the way the shell does, which
terminates the poll on its first call and is the more faithful
fixture besides. It also renders the snap menu item the script paints
on startup, which the fixture had been omitting.

Two new tests cover the poll itself: that it paints the persisted
preference once the manager appears and leaves no timer behind, and
that it stops re-arming past the bound. The second fails against the
unpatched source.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rename dialog's name field was a raw `<input type="text">` in the
parent shell, which is a real wp-admin document — so core's forms.css
reached it:

    input[type="text"], … { background-color: #fff; color: #1e1e1e }

That weighs (0,1,1) and outranks a single class of ours, so the field
rendered as a white core-chrome box on the dialog's dark surface no
matter what we declared. Worse, the dialog pre-selects the name on
open (the Finder affordance), so the text the user is meant to read
first was painted by the shell's `::selection` — near-white ink on a
pale lavender wash, tuned for a dark surface and now sitting on
white. Barely visible, at exactly the wrong moment.

Both controls move to the component kit: `<os-text-field>` for the
name, `<os-button>` for Cancel / Rename. Shadow DOM ends the problem
structurally rather than by out-specifying core — that sheet cannot
follow, and the field resolves the palette instead. The sibling
url-dialog already used `<os-text-field>`; its buttons come along, and
the value/disabled/focus plumbing the two were about to duplicate is
factored into `dialog-fields.ts`.

The shared surface is token-aligned with `<os-modal>` while we're
here. It painted from `--os-bg` — the WALLPAPER token, whose desk
default is a gradient and which the wallpaper layer overwrites with
whatever artwork is active — and it left the control tokens at their
light-admin defaults, so `--os-window-bg` resolved `#fff` under a
light `--os-ui-fg`: the invisible-value trap os-modal.styles.ts calls
out. It now re-points the five `--os-ui-modal-*` names the way that
component does on its host, reading a palette-owned name first in each
so a desktop theme can still reach every one of them.

Tests: the create-folder suite moves to the component surface and
gains a guard that no raw input or button comes back; the url-dialog
had no suite and now has one. setup.ts registers os-button and
os-text-field, closing existing drift with shell-overlays/entry.ts.

Docs: components-reference.md gains "A raw `<input>` in the shell is
not a styling choice" — the specificity arithmetic, the ::selection
consequence, and the token block to copy when slotting a component
into a dark light-DOM surface.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mio in one `<script>` tag, on any page, with no WordPress and no build
step on the page's side:

    <script src="mio.min.js" data-mio-colliders="h1, h2"></script>

Everything under `extensions/mio-js/` — 420 kB of it is a trimmed
PixiJS, and a little over three hundred lines are ours.

**Not a copy.** The simulation, the renderer, the soft body, the
silhouettes and the palette are imported straight from `src/mio/`, so
there is one Mio and one place to change it; a retune of the springs
or a correction to the brand hues lands in the library on the next
build. Nothing is configurable, deliberately: this ships
`MIO_DEFAULTS`, the reference design, and no way to alter it. Someone
who wants a teal Mio wants the plugin.

Three things the shell provides that a blog does not:

- **PixiJS.** Bundled, published on `window.PIXI` for the duration of
  the mount only and then restored, so a page with its own PixiJS
  doesn't find its global swapped underneath it.
- **A layer.** Fixed and full-viewport, appended to `<body>`, with the
  two rules that matter inlined. `pointer-events: none` on it is
  load-bearing: only the small round handle riding the body takes
  clicks, so a click one pixel off Mio reaches the link underneath.
- **A desk.** `src/colliders.ts` answers
  `wp.os.getWallpaperSurfaces()` from a CSS selector, so any element
  can be something Mio bumps into and settles on. Rects are content
  boxes — margin and padding both off, so the boundary is the text and
  not the whitespace a stylesheet parked around it — and are re-read
  as the page scrolls. Only ever installed when `wp.os` is genuinely
  free; on a page already running the shell, Mio uses the real desk.

Two shell dependencies are replaced at build time by aliases, each
pointing at a stand-in whose header says what it stands in for:
`../hooks` (no `wp.hooks` on a blog, and the real module throws
without it — actions come out as DOM CustomEvents instead) and
`./style-panel` (the "Make it yours" panel, which would drag the
`<os-*>` kit, the overlay loader and i18n into a bundle whose whole
point is being one file). A third shell dependency appearing in the
graph fails the build rather than shipping broken.

**On trimming PixiJS**, because the numbers are not intuitive — 880 kB
minified to start with, 420 kB now, 124 kB gzipped:

- `import * as PIXI` was worth ~400 kB on its own. A namespace import
  must produce a complete object, so every export of the barrel is
  retained and tree-shaking never starts. The entry names the four
  symbols Mio uses and builds the global itself.
- Tree-shaking cannot touch the rest by design: Pixi registers each
  feature by side effect, and every `init.mjs` is in its own
  `sideEffects` allowlist. So the build redirects the unreachable ones
  to an empty module — the event system (nothing has an `eventMode`),
  the WebGPU renderer (`autoDetectRenderer` tries WebGL first, and no
  browser has WebGPU without WebGL), `@xmldom/xmldom` (166 kB, and
  reachable only from Pixi's Web Worker adapter). `PIXI_UNUSED` in
  `vite.config.js` carries the argument for each. The canvas fallback
  is deliberately kept: unlike WebGPU, that one is reachable.
- The failure mode of a trim is silence, so the list is pinned to a
  PixiJS version and the build fails if an entry stops matching.
  `BUNDLE_REPORT=1 npm run build` writes the treemap this came from.

A mount failure logs and cleans up rather than throwing — a mascot
must never take a stranger's page down with it.

`dist/` is committed on purpose: the deliverable is a file you can
hand someone, and a build output nobody can find is not one.
`demo/index.html` is a single static page with headings as collision
markers, served by `npm run demo`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(#502)

* Chromeless: re-init list tables after a soft reload

The soft reload replaced #wpbody-content wholesale, and Core's inline
editors bind inside it rather than delegating on document. The swap
threw away the elements those handlers were bound to, so Quick Edit,
Bulk Edit and the comment inline editors kept rendering, kept taking
focus, and did nothing from the first content-change broadcast onward.

Keep the #wpbody-content node and swap only its children, then re-run
Core's own init entry points before dispatching os-soft-reloaded.

Fixes #419

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Chromeless: drop setCommentsList from the soft-reload re-init

setCommentsList() re-runs wpList, whose process() binds on document
rather than on the list element. document survives the swap, so every
call stacked another set of comment row-action handlers: after three
soft reloads one Approve click fired four dim-comment requests, and
because the handler toggles current status they cancelled out or
raced.

Those handlers were never broken by the swap. Only the closure state
behind them goes stale, which costs a stale total count until the next
reload.

Also splits the comments branch into one try per re-init, so a throw
in one cannot leave Quick Edit and Reply dead, and trims the comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix: paint dock tile status indicators with the accent token.

- The active/focused dot and the all-minimized ring now read --os-dock-item-outline, which resolves to Pulse on the station and to a theme's ring colour/ink elsewhere, instead of the dark --os-ui-surface that made the dot indistinguishable from the glass.
- Fallbacks stay the pre-brand literals (#fff dot, rgba(255,255,255,.85) ring), so the floor and the frozen Legacy manifest are untouched and Legacy keeps its white markings.
- docs/desktop-themes.md: document the glyph row and pale-dock guidance for the status indicators.

* chore: update desktop-themes docs to reflect recent changes.

* test: guard dock status indicators against color regressions

- Guard the active/focused dot across all three placements reading  --os-dock-item-outline with the #fff fallback.
- Guard the all-minimized hollow ring across all three placements reading the same token with its rgba(255,255,255,.85) fallback.
- Catches a regression to surface-token or hardcoded-white paints, the failure mode that made the dot near-invisible.
* Dialogs: rework the rebrand announcement and fix the hero shimmer

The announcement now opens on a "New name" pill instead of the logomark,
matching the welcome dialog's "New here" chip, and the copy explains what
OpenStation means rather than what Desktop Mode did not. It also mentions
the new default theme, and drops the close chip in the corner so "Got it"
is the single visible way out.

The hero shimmer swept background-position to 200% while the gradient is
laid out at background-size: 200%. Past 100% the header ran onto a
repeated tile, so a hard edge between Nebula and Void swept across it.
Both dialogs now stop the sweep at 100%. The welcome dialog keeps its own
copy of the design, so it needed the same one-line fix.

Deletes src/ui/brand-mark.ts, whose only consumer was the logomark the
announcement no longer paints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Dialogs: keep Tab inside the announcement, drop the dead welcome chip

The focus trap only checked containment on the Shift branch, so a
forward Tab from anywhere that was neither end handed focus to the first
control on the desk behind the scrim. Selecting text inside the card
puts activeElement on <body>, which is exactly that state, and the
backdrop handler allows it on purpose. Both branches now check.

The welcome dialog's close button was unreachable. It is the card's
first child and the hero comes after it, both positioned with no
z-index, so the hero painted over it and swallowed its clicks. It was
still in the tab order, so it was a control nobody could see and
keyboard users could still fire. Removes the button, its styles and its
click branch rather than raising it, matching the announcement, which no
longer carries a chip either. Escape, the backdrop and "Got it" were
already the working ways out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Renames the eleven channels the shell publishes on the activity bus
from `desktop-mode/<event>` to `os/<event>`. The prefix predates the
OpenStation rebranding, which moved the hook prefix, the broadcast
topics and the global but left the channel names behind.

No alias ships, so a plugin left on an old name stops firing silently.
Adds docs/migration-activity-channels.md.

Also fixes two things the activity docs had wrong: the documented hook
name (`desktop-mode.activity.*`, when the real prefix has been
`os.activity.` since the rebrand), and the ActivityChannelMap
augmentation snippet, which shadowed the real module instead of
augmenting it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The thumb is placed from getBoundingClientRect, which reports boxes
through every ancestor transform. A window plays os-window--opening
(scale 0.92 to 1) while the panel inside it renders, so the pill was
measured at 0.92 and stayed there: a transform does not resize the
border box, so the ResizeObserver never fired when the animation
landed. Picking another option re-measured it at scale 1, which is
why it only looked wrong until you touched it.

Divide the scale back out, using offsetWidth as the untransformed
border box. Ratios within 0.02 of 1 are treated as 1, since
offsetWidth is integer-rounded and the group is content-sized, so
the ratio is never quite 1 even with no transform in play.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Migrations: don't flag a fresh install as a pre-rebrand one

Activation now records the shipped migration version when the site has no
history, so a fresh install never runs a migration.

Migration 5 reads user meta as proof that someone used Desktop Mode before
the rename, and on a fresh site that proof can be written after activation:
a programmatic activation (WP-CLI, a Playground Blueprint) runs no
admin_init, visiting /openstation/ auto-enables the shell and writes
desktop_mode_mode, and the redirect into wp-admin is the first admin_init.
The migration reads meta written seconds earlier as history and flags the
user, so every Playground preview opens on the rebrand announcement.

The stamp is skipped when any user already carries prior-use meta, so a site
coming from 0.9.0 (which has no stored migration version either) still runs
its migrations and still gets the announcement.

migrations.php now loads unconditionally, because activate_plugin() fires
the activation hook in whatever context it was called from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Migrations: drop the install_predates_rebrand filter

The rebrand announcement is a one-off for a single release, so there is
nothing worth letting a plugin extend. Migration 5 now checks `$from < 4`
directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Migrations: run the migrations at activation instead of stamping

Recording the version outright retired the runner, and two migrations do
site-level work that no user meta predicts: migration 3 deletes the stored
AI provider credential, migration 2 unschedules leftover analysis cron
events. A site with no prior-use users can still carry both, for instance
when the only shell user has since been deleted, so the credential
survived in the database forever.

Activation now runs the whole runner instead. It is the same conclusion for
migration 5 (nobody to flag on a site with no history) and it keeps the
timing that fixes the bug: the runner sees the site before the portal can
write the meta it reads.

Drops the "must be a no-op on a fresh install" note on the version
constant, which stated the wrong invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Removes an @SInCE tag, three "Since: X.Y.Z" status stamps, and a set of
feature-landing version parentheticals. AGENTS.md forbids all of them:
docs and docblocks describe the current release, git history is the
changelog.

Migration docs, the migrations.php version-gate reasoning, and Divi's
own version string are left alone.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ies (#519)

* Docs: Fix the two broken links in the docs index

The widget example entry pointed at ./register-widget.md, but the file
lives under ./examples/. The architecture-0.8.1 entry pointed at
./migration-0.7-to-0.8.1.md, a file that has never existed in this
repo. Repoint it at the section of docs/architecture.md that already
documents that refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Docs: Fix the version claims, and index the two unlisted migrations

Three wrong version claims: the AI comment-only migration was labelled
0.11.0 when its own doc says 0.9.1, the activity-channel count read ten
when eleven channels moved, and the channel rename was attributed to
0.9.9 when it ships in 1.0.0.

migration-0.8.4-async-windowmanager.md and migration-ai-connectors.md
existed but were unreachable from the index, so the async windowManager
change and the AI connectors removal were undiscoverable for anyone
starting at docs/README.md.

Also renumbers the list. The source had a duplicate 4. and four 13.,
which is how a dead entry sat unnoticed. Markdown auto-numbers, so the
rendered output does not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#521)

* Docs: fill the gaps in the WooCommerce and payload filter reference

Three filters fired since the last tag never reached the hook contract:
openstation_brand_migration_map, openstation_track_type_registrants and
openstation_my_wordpress_woo_coupon_bands. Document all three.

The rest of the WooCommerce filters were documented, but only as bare
signatures inside one long grouped section, so a plugin author scanning
for a hook name found nothing. Add an index table at the head of that
section naming every filter and what it shapes, and index the whole
WooCommerce surface (filters, REST routes, the openstation_woo_customer
REST field, the Customer window) in the API index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Migrations: Drop the brand-map filter

openstation_brand_migration_map exposed the rebrand migration's
pre-brand to brand value map for filtering. It should not be a public
surface. The migration runs once, against one release's stored
defaults, and a third party rewriting which values get migrated would
leave desks in a state no later migration accounts for.

The map is now a plain array. The empty() guard went with it: it existed
only to honour a filter returning nothing, and is dead code against a
literal.

Nothing shipped depended on it. No test referenced the filter, and it
was never in the hooks reference before this branch added it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…83 (#470)

Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.165 to 1.0.183.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](anthropics/claude-code-action@558b1d6...be7b93b)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.183
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* i18n: Fix the POT bug-report slug and refresh the catalogues

The POT header pointed translators at
https://wordpress.org/support/plugin/alcazaba-plugin, which is not this
plugin. bin/extract-i18n.sh hardcoded that slug in its --headers
override, so every regeneration reproduced it. The override now builds
the URL from $DOMAIN, giving the real support forum,
https://wordpress.org/support/plugin/desktop-mode.

Regenerates the POT, the es_ES PO and the per-handle JSON bundles ahead
of the 1.0.0 translation pass. The catalogue was last extracted before
the rebrand, so the header now reads "OpenStation 0.9.8" instead of
"Desktop Mode 0.9.7" and the string count goes from 1962 to 2239.
make-pot derives Project-Id-Version from the plugin header in
desktop-mode.php, so no version string was touched here.

msgmerge rewrites Report-Msgid-Bugs-To in the PO from the POT but keeps
Project-Id-Version and Last-Translator, so those two still named Desktop
Mode and are updated by hand.

Also fixes the .po and .json filename patterns in the extraction script
comment and in docs/DEVELOPMENT.md, which both said os-{locale} when the
files have always been desktop-mode-{locale}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Release: Bump the version before refreshing translations

wp i18n make-pot reads Project-Id-Version from the plugin header, so
extracting before bump-version.sh stamped every catalogue with the
previous release. That is why the shipped POT said "Desktop Mode 0.9.7"
while the plugin was at 0.9.8, on every release rather than once.

Moves the bump above the extraction. Both original reasons for the old
order still hold: nothing is committed until after the changelog gate,
so the Ctrl-C escape still covers both steps, and the single git commit
-am still sweeps the bump and the language churn into one commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Release: Keep an aborted run re-runnable after the bump moved earlier

Moving bump-version.sh above the changelog gate meant answering n there
left four version files written but uncommitted. The dirty-tree
preflight only tolerated languages/ and readme.txt, so the re-run the
gate promises aborted instead of returning to the prompt.

Adds the bump-owned files to the tolerated set. bump-version.sh rewrites
them deterministically every run, so a stale value cannot survive.

That exemption alone would let a re-run reach the resume check with the
version strings matching but nothing committed, and resume assumes the
bump was already pushed. It would then skip the commit and tag the
pre-bump commit. So the resume check now also requires the bump to be
committed; when it is not, the normal path re-runs bump-version.sh as a
no-op and commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Release: Actually ship the resume-heuristic fix

The preceding commit updated docs/RELEASE.md to describe this behaviour
but did not carry the code: the edits were discarded by a git checkout
during verification, leaving the doc describing a script that did not
match.

Adds the two bin/release.sh changes the doc already promises.

The dirty-tree preflight now tolerates the four files bump-version.sh
rewrites. Moving the bump above the changelog gate meant answering n
there left them written but uncommitted, so the re-run that gate
promises aborted instead of returning to the prompt. bump-version.sh
rewrites them deterministically every run, so a stale value cannot
survive.

That exemption alone would let a re-run reach the resume check with the
version strings matching but nothing committed, and resume assumes the
bump was already pushed. It would then skip the commit and tag the
pre-bump commit. So the resume check now also requires the bump to be
committed; when it is not, the normal path re-runs bump-version.sh as a
no-op and commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](actions/setup-node@v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Miguel Torres <1233880+mmtr@users.noreply.github.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](actions/checkout@v4.3.1...v7.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Miguel Torres <1233880+mmtr@users.noreply.github.com>
* Context menus: Clamp to the viewport after the menu has rendered

Desktop and wallpaper context menus opened near the bottom of the screen
ran off the edge, leaving most of their options unreachable. A 276px menu
opened at y=920 in a 1000px viewport overflowed the bottom by 196px, so
roughly the bottom quarter of the desktop was a dead zone, and worse on
short laptop screens.

The clamp measured with getBoundingClientRect on the line right after
appendChild, but Component renders its shadow DOM inside a queueMicrotask.
The measurement saw an empty box, so rect.bottom > window.innerHeight was
never true, no clamp was applied, and the menu then painted at full height
off the bottom.

Adds src/ui/util/menu-position.ts, which defers the measurement to the next
animation frame and hides the menu for that frame so the unplaced position
never paints. The wallpaper menu, the icon-canvas menu, both of their
flyouts, and the heartbeat widget menu now route through it. The item
visibility menu already carried a local copy of the fix and now shares the
helper instead.

The horizontal axis looked fine only because os-context-menu carries a
min-width, so the empty box still measured about 180px wide. That is luck,
not correctness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Context menus: Route the shared selection menu through the clamp too

src/selection/menu.ts kept its own append-then-measure-immediately
block, so it had the same dead zone the rest of this change fixes. It is
the widest-reaching one: it backs the desktop file tiles, My WordPress
entities, users, media list and media detail, which makes right-clicking
a file tile near the bottom of the desktop one of the most common ways
to hit the bug.

Also:

- Drops the now-redundant visibility toggle in item-visibility-menu.ts.
  placeAfterRender owns that invariant.
- Explains why positionFlyout's horizontal flip has no MARGIN. The flip
  is anchor-relative, so a margin would gap a submenu from the option
  that owns it, unlike the viewport-relative vertical branch.
- Points os-context-menu's docblock at menu-position.ts, so an author
  building their own menu does not walk into the microtask trap.
- Notes that dock-peek's same-named clampToViewport is a different
  placement model and is deliberately not folded in.

Sixth test case covers the selection menu. Verified it fails against the
old synchronous clamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Tests: Use onClick, not run, in the selection-menu case

ActionMenuEntry requires onClick; there is no run member. The test
passed anyway because it only asserts placement and never picks an item,
and because tsconfig.json scopes the project to src/**/*.ts, so nothing
typechecks tests/.

It matters because this is the only in-tree example of calling
openActionMenu from a test, so it is what the next person copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* PR previews: Resolve fork pull requests when publishing

Look the pull request up by head repository and branch instead of by
commit. listPullRequestsAssociatedWithCommit does not resolve commits
that live in a fork, so every fork PR failed the resolve step and never
got a Playground preview.

The head SHA still has to match before anything is published, and
head_repository and head_branch come from the same trusted workflow_run
payload as head_sha, so the trust model is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* PR previews: Enforce head ownership locally, not via the API filter

The head filter is server-side and fails open: a value GitHub does not
recognise is ignored rather than rejected, and the call returns every PR.
Re-check owner, branch and SHA against the workflow_run payload so the
ownership guarantee holds locally.

Also drops the comment's reference to a pr-meta artifact, which
pr-preview-build.yml does not produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…525)

* Windows: stop the loading spinner from fading over painted content

The loading overlay is appended into a body that already carries the
--loading modifier, so its first computed style is the visible one.
A CSS transition needs a before-change value, so the transition-delay
that was meant to hold the spinner back for 120ms never ran, and the
spinner reached full strength on every open. Dropping --loading then
cross-faded it out over content that had already painted.

The show delay now lives in JS: the overlay mounts without the
--visible modifier and gains it 120ms in, keyed off a clock stamped on
the body so a mid-load repaint resumes rather than restarts it. On the
ready edge, an overlay that never became visible is removed in the
same tick; one that did paint gets its fade-out to itself, with the
new --loading-out modifier holding the content back until it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Windows: Tighten the loading-overlay comments

The comments explained the bug's history at paragraph length where a
line or two does the job. Same facts, fewer words: why the show delay
lives in JS rather than in a transition-delay, and why the two fades
run back to back.

No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Windows: Scope the hand-off timers, and settle on one spinner signal

Two fixes from review.

Stale timers. Both hand-off timers guarded only on "is the body loading
now", which does not tell one load cycle from the next. Two loads inside
~500ms, reachable via title-bar Reload or an iframe redirect chain, let
cycle A's timer strip cycle B's hold mid fade-out and put the content
back on screen under a spinner that was still fading, which is the flash
this change exists to remove. The body now carries a cycle token, bumped
on every loading edge, and both timers bail when it moves.

One answer to "did the spinner paint?". loading.ts read the overlay's
--visible class while surface.ts re-derived it from a clock, and the two
disagreed at the show-delay boundary and on an overlay inherited from a
previous cycle. surface.ts now reads the same class. The class names
move to constants.ts because dom.ts already imports surface.ts and the
reverse would cycle.

Tests: a two-cycle case that fails without the token, and one for the
detached-overlay bail-out. The reveal-surface timing test now paints a
real overlay instead of back-dating a clock.

Also documents both couplings the --loading-out transition encodes, and
corrects a doc line that said a fast load's content appears with no
wait: true for a native body, but an iframe still fades in via its own
base rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Reveals: Drop the arm-time stamp, now that nothing reads it

Moving the "did the spinner paint?" decision onto the overlay's own
class removed this attribute's only reader. It was still stamped on
every layer on every arm, and its docblock still argued for storing it
on the element rather than in a side map, for a value with no consumer.

Removing the write makes the armedAt parameter and its Date.now() source
dead too, so the whole chain goes. Not a public surface: internal, and
absent from docs/.

The test that pinned the stamp goes with it. It asserted a value with no
behaviour behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Flagged by a /doctor context audit: the TOC is a link list into the
same file, reconstructible from the headings, and its ~2,300 chars
(~600 est. tokens) load into context on every request. Removing it
also brings the file back under Claude Code's ~40k-char large
memory-file warning threshold (41,085 -> 38,785 chars).
…sitioning (#443)

* fix(window): refine page-title-action button redundancy and layout positioning

- Extract and pass absolute submenu tab URLs from PHP into the chromeless bridge configuration payload.
- Update the chromeless iframe bridge MutationObserver to dynamically intercept and remove redundant `.page-title-action` buttons that match window tabs before they paint.
- Enforce strict `action` query parameter matching for `admin.php` routing to prevent false-positive removals of action buttons (e.g., WooCommerce's 'Add order' button).
- Apply a universal block-clearing layout format to `.page-title-action` in chromeless.css to prevent inline wrapping next to `.subsubsub` filter links on non-tabbed pages.
- Remove the redundant page-specific `.themes-php` CSS override since it is now dynamically covered by the URL matcher.
- Add test coverage in desktopModeRender.php to verify submenu URLs are successfully extracted and injected into the chromeless config.
- Update technical documentation (docs/plugin-compat-layer.md) and inline code comments to align with the new MutationObserver-based button matching architecture.

* Chromeless: Hide in-page Add New buttons that duplicate a window tab

Most list screens render an "Add New" button next to the page heading.
Inside a window the same destination is usually already a tab in the
submenu strip above it.

A button is hidden only when its href exactly matches one of the current
parent menu's submenu URLs. Partial path matching would take away the
only route to a page: "Upload Plugin" is plugin-install.php?tab=upload,
not the plugin-install.php tab, and WooCommerce's "Add order" carries
action=new while the Orders tab does not. Screens with no submenu strip
contribute no URLs, so plugin pages where the button is the only add-new
affordance keep it.

In-page toggles are excluded by class, because their href is a no-JS
fallback rather than where the button goes.

The chromeless bridge now yields clicks on .upload-view-toggle to
plugin-install.js, except on ?tab=upload where core deliberately leaves
the link a link. Our capture-phase interceptor was winning the click and
navigating to the uploader-only page, which showed the drop zone with no
plugin cards and no way back.

Kept buttons sit on their own row instead of sharing a line with the
.subsubsub filter links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Chromeless: Fix tab parity and weak tests in the button de-duplication

A submenu row with no usable title never becomes a tab, but it still
produced a hide rule, so the button disappeared with nothing on screen
replacing it. That is the failure mode the module exists to prevent.
openstation_build_dock_items() already skipped those rows, and the title
cleaning now lives in openstation_menu_item_title() so both callers
share it.

Two tests asserted nothing. The WooCommerce one used edit_shop_orders,
which an administrator doesn't have, so the fixture produced no CSS at
all and the negative assertion ran against an empty string. The Upload
Plugin one looked for a substring that can never appear, since the CSS
is built from tab URLs and never from button hrefs, so the loose
pathname compare would have passed it. Both now assert on the parsed
selectors, and the operator check is what rules out the prefix and
substring matches that would swallow Upload Plugin.

Renames openstation_css_string_escape() to
openstation_chromeless_css_attr_value(), which says what it does: the
helper also strips angle brackets and newlines.

Documents the two cases where this URL set can still disagree with the
tab strip the shell renders, the openstation_dock_item filter and a
window whose URL matches no dock entry, and moves two paragraphs that
had landed under the wrong docs heading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: mmtr <1233880+mmtr@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* App icons: one family for the four built-ins

The four apps that ship a built-in icon were drawn to three different
rules. WP Explorer and Corkboard were silhouettes, art in currentColor
that renderIcon() paints as a CSS mask so it takes the surface's own
text colour. Games was fixed-colour, in four hues belonging to no
palette the plugin ships. Trash had no custom icon at all and fell
back to dashicons-trash, wearing WP core's language next to the
shell's.

Games could not invert on a light surface, a background-image having
no colour to inherit, and collapsed outright under a desktop theme's
icon tint: that path runs applyIconMask(), which keeps only the
artwork's alpha, and every pixel of the old gamepad was opaque. It is
redrawn so the detail comes from negative space instead.

Corkboard's thread arced over its two notes and closed the top of the
silhouette, and a closed top over two rectangles is a bag, which is
what it looked like from 40px down. The board and the paper are gone;
the mark is now the graph itself, one focused post with related ones
fanned around it, matching what the window draws now that nodes are
discs. Pin-led marks were drawn and rejected, since pushpin.svg
belongs to pinned notes and would point at that feature instead.

Trash gets its first custom icon, the same object redrawn to the house
rule. The row actions inside the bin and the "Move to trash" context
menu entries stay on dashicons-trash: those are menu glyphs among
other menu glyphs and should match their neighbours.

WP Explorer keeps its drawing and gets its spacing fixed. The mark was
scaled 1.15x to 23 units on a panel with 30 units of clear height,
leaving under one stroke width above and below. At scale 1 the
clearance goes to five units.

Vessel stroke comes down from 3.5 to 3 across the set. At 3.5 the
outlines carried more weight than the shapes inside them; 2.8 was
where the joins started to go fragile at 20px.

Two docs went stale with this and are updated in the same change:
javascript-reference.md cited the Games gamepad as the in-tree example
of fixed-colour art, of which none remains, and hooks-reference.md
told plugin authors to swap the bin's dashicon, which is now icon_svg.

* Update the icon tests for the new marks

test_corkboard_svg_keeps_its_pins_legible asserted two r="3" pin
heads. The pins are gone, so it failed on CI. The property it was
guarding is not gone: it protected the smallest painted element from
being shrunk below what survives a 20px dock tile. That is now
asserted against the discs, with a floor of r=5 and a strict
largest-disc check so a tie for hub fails rather than being filtered
away by an array_diff.

Adds a silhouette guard across all four built-in icons. Games was the
last fixed-colour holdout and there is now nothing in the shell that
should carry a literal colour, so the rule is worth pinning: no
fill="#" or stroke="#", a 64-unit viewBox, and a data URI the icon
sanitizer accepts unchanged.

Adds the bin's own both-surfaces check, matching the one the Corkboard
already had, since the window icon and the tile are built from the
same SVG through two different code paths.

* Re-trigger CI after a GitHub action-resolution outage

* Trash: deepen the bin body and soften its taper

The bin was 24 units tall narrowing to 59% of its top width, which drew
a shallower, more conical tub than a bin actually is, and left an
interior with no room to hold anything. It is now 27 tall narrowing to
71%, with the bottom corners rounded at 3.5 instead of 4.

The lid, the handle and the stroke weight are untouched, so the mark
still reads as the same object and still sits at the same optical size
next to WP Explorer, Corkboard and Games.

Taken much past 71% the walls go vertical and the silhouette starts
reading as a bucket rather than a bin, which is the thing the taper was
there to prevent. This sits at the edge of that.

* Docs: name the real fixed-colour icon examples

The silhouette note claimed the shell no longer ships any fixed-colour
art, so there was nothing left in-tree to copy from. Not true: both
built-in games draw in literal hex, and src/games/launch.ts hands their
art to renderIcon() through the window registration, so their title-bar
icons take the background-image path the paragraph is describing.

Point at those two instead.

* Trash: show a full bin when it holds something, and drop the badge

The bin had one drawing and a count badge. The badge is positioned onto
the artwork rather than beside it, so on a 20px dock tile the pill
covered about 30% of the icon it was annotating. Replacing it with a
second drawing says the same thing in the space the icon already owns.

`openstation_recycle_bin_icon_svg()` now takes `$full`, which knocks
the lid askew and drops three crumpled balls into the body. The balls
are one path with three subpaths, so the mark stays at four elements.
Their layout is staggered on purpose: two at the same height with a
third centred below reads as a face.

Both drawings ship in the shell config, so crossing zero is a local
swap rather than a round trip, and the icon is right on the first paint.

Adds `setArt( id, svg )` to the three rails, the counterpart to
`setBadge` for a tile whose icon means something different depending
on state. Same unified id space, same fan-to-all-rails contract, and
the same survives-a-rebuild guarantee. Two things worth knowing:

- The value is recorded before the tile is resolved. Setting art
  during boot is the normal case, since the rails append system tiles
  asynchronously, and the tile builders re-apply from the map.
- `icons.setArt` paints both desktop layouts. Classic renders the
  `.os-icons` grid, Spatial renders an `<os-tile>` placement, and on a
  stock install the second one is what the user is looking at.

`src/recycle-bin/badge.ts` becomes `icon-state.ts`, since it no longer
badges. It keeps the count arithmetic exactly as it was, because the
deltas still have to add up to know when it crosses back to zero, but
only the sign of it now reaches the screen. The window-active
suppression goes: that policy is right for a notification and wrong
for a description of an object.

* Dock: route setArt through the rail's own icon resolver, and test both rails

Three fixes, all found by writing the tests.

`setArt` masked the art by hand, which silently rejected a dashicon
class even though the docs say one is accepted: `applyIconMask` only
takes URLs and data URIs. It now goes through `resolveIcon`, the same
path a registered icon takes, so every icon shape works and a desktop
theme's slot override is still honoured.

`os/art-changed` fired even when the paint had bailed, so a subscriber
could be told a tile changed when it had not. Publishing now happens
only after a paint, and clearing art that was never set stays silent.

Clearing with `''` dropped the override but left the swapped art on
screen until the next rebuild. It now restores the registered icon
immediately, the way `setBadge( id, 0 )` removes the pill immediately.

The icon rail used `CSS.escape`, which is absent in jsdom and in older
engines, so the first test to touch it threw on load rather than
failing an assertion. Guarded, with the fallback escaping the two
characters that can terminate a quoted attribute selector.

Covers both rails: the record-before-resolve ordering, survival across
`replaceItems()` and a full grid rebuild, the silent no-op for an id
the rail does not own, and the icon rail painting Classic and Spatial
from one call.

* Docs: add the tile-icon-state example for setArt

AGENTS.md asks for an example under docs/examples/ whenever a change
adds a public surface that doesn't have one. `setBadge` has
dock-badge.md; `setArt` had nothing.

Covers registering a tile with its resting icon, shipping both drawings
in the shell config so a swap is local, the fan-to-all-rails call, and
the four properties that are easy to get wrong: setting art before the
tile renders, survival across a live menu refresh, the icon rail
covering both desktop layouts, and what `''` does.

Also says why there is no "show 0 while my window is active" recipe
here. That policy is right for a notification and wrong for a
description of an object.

---------

Co-authored-by: Miguel Torres <1233880+mmtr@users.noreply.github.com>
mmtr and others added 28 commits August 14, 2026 17:24
* Dock: Move the admin bar's jobs into the dock and a notch

The admin bar is hidden by default, but its jobs never moved anywhere.
This gives them homes.

New System dock tile with a hover menu: OpenStation Preferences, View
site, Fullscreen, Keyboard shortcuts, Report a bug, Install web app,
Log out. It absorbs the standalone Preferences, Bug Report and
PWA-install tiles, so nine glyphs become one. New Overview tile for the
surface ArrowUp already toggled.

The site assistant gets a notch instead of a tile: a small pill at the
top centre, floating above windows. It never reserves work area, which
is the whole design constraint. A bar that permanently stole height is
what OpenStation removed, and an element that reserved space would be
the same mistake in a nicer shape.

Keyboard shortcuts and fullscreen had no shell-side implementation at
all. Both lived in assets/js/admin-bar.js, anchored to buttons that are
no longer on the page, so both are ported.

Two supporting changes to the dock:

The constellation flyout now hosts menus that are not admin-menu
derived. A system tile with a `submenu` fans it out through the same
panel, with `onSelect` rows for actions and `windowId` so it can list
its live windows. The panel is one shape either way: head, open
windows, Open. The trailing "New X window" row is gone, since it
offered a second copy of a page every row already opens.

System tiles gain `order`, and native windows a `dock_order`, because
registration order is not something the shell controls: native-window
tiles land when their lazy script resolves. Trash takes 40 to sit at
the end.

Three fixes fell out along the way. A window opened from a menu's
submenu now lights that menu's tile and shows under its open windows,
which `parentUrl` always knew and nothing read. A running app whose
icon lives on the desktop takes a dock tile while its window is open.
And the flyout lists a menu's own page again ("All Posts"), carried on
a new `selfLabel` field because `submenu` has two other consumers that
need it to be child links only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Dock: Keep Split's side rail to core admin menus, and name the first tab

Two follow-ups.

System tiles no longer route to Split's side rail. That rail is the
WordPress half of the layout, which is the whole idea the split
expresses, so shell affordances (System, Exit OpenStation) belong on
the bottom dock with everything else OpenStation owns. `'core'`
affinity survives as the classification `listSystemTiles()` reports,
but it no longer picks a rail.

The in-window tab that leads back to a menu's landing page now wears
WordPress's name for that page: "Themes" under Appearance, "All Posts"
under Posts. It said "Appearance" and "Posts" because `config.title`
(the menu's name) was the only label available; `selfLabel` carries the
stripped self-link through now, and threads to the window config
alongside `submenu`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Dock: Describe behaviour in comments, not the change that produced it

Comments and docblocks across the branch narrated what the code used to
do. Git history is the changelog; a comment about a state the reader
cannot see costs attention and goes stale on the next change. The
reasoning is kept, phrased in the present.

Also refreshes three comments the branch had left describing surfaces
it changed: the constellation's ASCII diagram (no page count, no
new-window row), its note on system-tile menus (they wear the same
three sections now), and install.ts, which described itself as a dock
tile rather than a System menu row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Dock: Trim over-explained comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Notch: Anchor to the shell so it clears a visible admin bar

Fixed positioning put the notch at the viewport's top edge, under the
admin bar, which paints above the shell. `.os-shell` already starts
below the bar when the user keeps it and at the viewport top when they
don't, so positioning against the shell lands the notch correctly in
every admin-bar mode without knowing which one is on. It also moves
the notch into the shell's own stacking context, where it no longer
competes with the bar for z-index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Dock: Fixes from review of #608

`'hidden'` now outranks the running-app override in
`applyDockPlacement()`. It means suppressed from every shell surface,
so an app the user hid was gaining a tile whenever its window opened,
and `runningIconSignature()` does not track hidden icons, so the tile
also outlived the window.

`selfLabel` reaches every route that opens a window with a submenu:
`openSubmenuItem()`, session restore (both paths), the link
interceptor, desktop icons and the file openers. Without it the same
window's first tab was named "All Posts" from one door and "Posts"
from another.

Exit OpenStation takes an explicit order (35), between System and
Trash. At the default 0 it sorted ahead of the shell cluster and
interleaved with plugin launchers, whose order is also 0.

Corrects three docblocks that described the flyout as it was mid-branch
rather than as it ships: `SystemDockItem.submenu` (the panel keeps its
head, and that head runs `submenu[0]`), `CONSTELLATION_PANEL`
(`instances` is populated for an action menu, from each row's
`windowId`), and the comments naming a Create tile that no longer
exists. Drops `OS_SITE_LOGO_ICON`, which lost its consumer with that
tile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Notch: Sit below an auto-hiding admin bar; dismiss a stale flyout

Three fixes.

The flyout is built on hover and never repaints, so anything a click
changes leaves it describing a state that has passed: clicking the
System tile while its Preferences window is minimized restores that
window, and the panel goes on reporting it as minimized. A click on a
dock tile now dismisses it, the way the rows already dismiss
themselves before acting.

With the admin bar set to Auto-hide, the shell starts at the viewport
top, so the notch sat under the bar whenever it rolled down. It steps
below the bar instead, and hovering the notch holds the bar out rather
than letting it retract mid-reach. The step needs a hover bridge over
the space the notch vacates: without it the notch moves out from under
the pointer, which ends the hover, retracts the bar and brings the
notch back up to start again.

Also unmangles the `$dock_order` docblock, which had been inserted
between `$placement`'s first line and its continuation, so `'none'
skips the tile` read as belonging to `dock_order`. Spotted by the #607
session while rebasing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…l (#607)

* Trash: Drop the desktop icon, keep the tile controllable

The bin registered a dock tile and a wallpaper icon for the same
window, so it landed on two surfaces at once. #545 removed the
duplicated menu icons and left this one. A desktop icon should be
something the user put there.

Losing the icon would have left the tile with no control at all: the
Apps & Plugins tab keys its rows by icon id, and a dock-placed native
window renders as a system tile, which the tab does not list. So
openstation_register_window() takes a `placeable` arg, opt-in because
most system tiles are load-bearing (Preferences is how you reach the
screen that would hide it), and the bin sets it. Its row offers On the
dock or Hidden, the same shape Mio's already had, and the two are now
independent of each other.

An icon-less system tile is only taken off the dock by an explicit
`hidden`. Anyone who had picked "On the desktop" for the Trash has that
value stored, and reading it as "not on the dock" would leave the bin
with nowhere to render at all. Its picker no longer offers the value,
so the row reads it back as On the dock.

Nothing is left behind on upgrade: a shortcut placement whose icon is
no longer registered fails can_read() and stops being served, so the
wallpaper tile goes on the next hydrate.

Ref #605

* Trash, Mio: Let a placeable tile reach the wallpaper

The four-way picker was cut to dock-or-hidden for system tiles, on the
grounds that nothing could paint one on the wallpaper. That is a
limitation to fix, not a rule to encode: where an item may live is the
user's call, and the dock is only where these two start.

syncShortcutsWithVisibility now promotes a placeable system tile the
way it already promotes an admin-menu item. The placement names the
tile rather than carrying a url, and the shortcut opener runs the
tile's own onOpen, so the wallpaper copy is the dock copy in another
place: the Trash opens its window, Mio toggles the companion.

With that, `dockOnly` has no producer and no reason to exist. The row
resolves through resolvePlacement like every other, which makes the
dock the default because the dock is where these tiles are registered,
and desktop, both and hidden all mean what they say. The dispatcher's
icon-less special case goes with it: `desktop` can take a tile off the
dock again now that there is somewhere for it to land.

Ref #605

* Trash: Close the gap the desktop icon leaves behind

Every viewer's first hydrate auto-placed the bin's icon into the icon
column, so removing the registration leaves a real row in the
placements table. It stops being served on its own (a shortcut whose
icon is gone fails can_read), so the tile disappears without help. What
it leaves is an empty cell with the icons that were under it still
sitting where they were.

Migration 6 deletes the row and pulls the rest of that column up by
one. Same column only: the auto-placer fills column-major, so a column
is the run the bin was part of, and a neighbouring one has no gap to
close. Two set-based queries rather than a pass per user, since the
unique index on (owner_id, parent_id, file_type, file_ref) means one
bin row per owner and nothing can be shifted twice.

It does move tiles a user may have arranged, which is the point. The
shell put that icon there and the shell is taking it away, so it tidies
up after itself rather than leaving a hole nobody chose.

* Trash: Make the wallpaper copy of a tile actually be the tile

Review catch. The promoted placement minted its ref as
`dock-promoted:<id>`, which is right for an admin menu (a promoted
menu must not be mistakable for a registered shortcut of the same id)
and wrong for a system tile, whose whole job on the wallpaper is to be
recognisable as itself.

Three lookups find the bin by `file.ref === 'desktop-mode-recycle-bin'`
and all three missed it: the drag-to-trash drop target, the
drop-rejection exemption, and the empty/full art swap. So the promoted
bin took no drops, actively refused them with the red chip, and was
drawn empty however full it was. The self-drop guard missed too, so it
could be dragged into itself. A system tile keeps the bare id now, and
`shortcutSystemTile` is the discriminator, which it already was in the
opener.

The art needed a second half. `setArt` stores the override and paints
the nodes that exist at the time, so a tile built later has to come and
get it. The files layer now reads the live override when it builds a
tile, exactly as `Dock.appendSystemItem` re-applies its own.

Also: system tiles register late, after their window's bundle loads, so
the boot sync could run before the tile existed and promote nothing. A
user with the Trash on the desktop got it on neither surface until an
unrelated store write. The sync re-runs on `dock.item-appended`, which
covers mid-session plugin activation too.

The two synthetic-placement builders were one field apart and are one
function now.

* Trash: Keep a running app on the dock when it is a system tile

#608 gives a desktop-only app a dock tile while its window is open,
because sending an app to the desktop says where its launcher lives,
not that a running window should be unswitchable with nowhere to
minimize back to. It reaches that through the icon a window's launcher
is registered as.

The Trash no longer has one. So the two changes meeting left it as the
one running app whose window had no tile, with WP Explorer keeping its
tile right beside it. A system tile's id IS its window id, so the
question is the same one asked of a tile rather than an icon, and the
running signature that decides whether a rail rebuild is worth it now
counts both.

`hidden` outranks the override, matching what the icon path does: it
means suppressed from every shell surface, so a bin someone hid must
not come back the moment a window opens.

Where the tile lands differs from the icon case on purpose. A
synthesized icon tile joins the plugin cluster because it has no home
in the trailing zone; a system tile's home IS that zone, so the Trash
appears at its usual `dock_order` 40 rather than somewhere it has
never been.
@nickhamze
nickhamze force-pushed the connected-inspector branch from 4f509da to 92fa83c Compare August 18, 2026 11:57
@nickhamze nickhamze changed the title Present Sidecar as an attached inspector Add Sidecar to official OpenStation 1.1.0 Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.