Add Sidecar to official OpenStation 1.1.0 - #4
Open
nickhamze wants to merge 94 commits into
Open
Conversation
* 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>
* 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
force-pushed
the
connected-inspector
branch
from
August 18, 2026 11:57
4f509da to
92fa83c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
64cbb980)Verification
npm run buildnpm run lintnpm run typechecknpm run test:js— 348 files, 4,345 testsOpen the corrected WordPress Playground demo