Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions assets/css/openstation-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,32 @@ body.os-constellation-open .os-dock__tooltip {
opacity: 0.6;
}

.os-constellation__row-offsite {
flex: 0 0 auto;
font-size: 14px;
width: 14px;
height: 14px;
opacity: 0.55;
}

/*
* Reaches screen readers, never the eye. It rides beside the row's own
* label so the accessible name reads "Docs (opens in a new tab)"; an
* `aria-label` on the row would have replaced that name rather than
* extended it, taking the label and any plugin-appended meta with it.
*/
.os-constellation__row-note {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
white-space: nowrap;
clip-path: inset( 50% );
}

/* ---- Head -------------------------------------------------------- */

.os-constellation__head {
Expand Down
31 changes: 31 additions & 0 deletions docs/hooks-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,12 @@ Items built from the admin menu also carry `selfLabel`, `multi`, `placement`, `i

**`submenu` excludes the menu's own page.** WordPress auto-prepends a self-link to every parent menu (`All Posts` → `edit.php`, the same URL as the parent), and the builder strips it so `count( $submenu )` reliably means "how many distinct child pages" — the in-window tab strip would otherwise grow a duplicate first tab, and the right-click popover keys its suppression off an empty list. The stripped entry's label survives on **`selfLabel`** (`''` when the menu had none), so a surface that *lists* a menu's pages can put the main page back where wp-admin has it. The constellation flyout does exactly that, pointing the row at the item's `url`.

**Off-site menu entries never reach the dock.** Nothing on another host can load in a window, so a menu whose URL points off-site is dropped rather than turned into a tile that can only escape to a browser tab. The one exception is a child of a menu a regular plugin registered (`pluginFile` is non-null) whose own URL stays on-site: those keep their row and carry `'offSite' => true`, which the constellation marks as leaving the site and the in-window tab strip skips. The classifier is [`openstation_menu_item_is_external`](#openstation_menu_item_is_external--stable).

**A rescued menu takes on the identity of the slug it adopted.** When a menu's own slug points off-site and a surviving child stands in for it, `multi`, `placement`, `isCore`, `pluginFile` and the `$menu_slug` passed to `openstation_dock_item` are all derived from that child's slug, not from the off-site one. Otherwise a rescued Plugins tile reports as a plugin menu owned by whoever registered the replacement.

**Rows a host hid stay hidden, unless dropping them would lose the page.** A `$menu` / `$submenu` row carrying the `hide-if-js` class is out of the classic sidebar and out of the dock too. WordPress.com is why the rule has an exception: rather than repoint a Core entry at wordpress.com, Jetpack marks the wp-admin original `hide-if-js` and appends a Calypso duplicate beside it. When the duplicate is dropped as off-site, the original takes its place in the list — so Appearance → Themes, Plugins → Add Plugin and Users → All Users open the wp-admin screens Core registered.

**Example — add a virtual dock item:**

```php
Expand Down Expand Up @@ -1324,6 +1330,31 @@ The live menu-refresh path (chromeless `plugins.php` iframe postMessage, plus th

---

### `openstation_menu_item_is_external` — Stable

Whether a resolved admin-menu URL counts as off-site. Off-site entries are dropped from the dock payload (see [`openstation_dock_items`](#openstation_dock_items--stable) for the exception plugin menus get), because nothing on another host can load in a window.

```php
apply_filters( 'openstation_menu_item_is_external', bool $external, string $url );
```

By default a URL is off-site when its host matches neither `admin_url()`'s nor `home_url()`'s. Both count, so a site running its admin on a separate domain from its front end isn't misread.

**Example — keep a trusted sibling domain in the dock:**

```php
add_filter( 'openstation_menu_item_is_external', function ( $external, $url ) {
if ( str_contains( $url, 'admin.internal.example.com' ) ) {
return false;
}
return $external;
}, 10, 2 );
```

An entry you allow back in still has to survive the browser: a host that sends `X-Frame-Options` or a `frame-ancestors` policy refuses the iframe whatever this filter says.

---

### `openstation_arrange_menu_items` — Stable

The list of plugin-contributed items appended to the admin bar's **Arrange** submenu — the dropdown that sits next to the "Switch to…" toggle when OpenStation is active. Built-ins (Cascade, Overview, Snap to grid, Tile all windows) are always present; this filter adds to them. Only invoked when the user is viewing the desktop shell.
Expand Down
10 changes: 7 additions & 3 deletions docs/javascript-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ manager.closeDesktop( id: string ): void;
width?: number;
height?: number;
initialState?: 'normal' | 'minimized' | 'maximized' | 'fullscreen';
submenu?: { title: string; url: string }[];
submenu?: { title: string; url: string; offSite?: boolean }[];
}
```

Expand Down Expand Up @@ -911,7 +911,7 @@ Ownership is about z-order and focus. For a *visual* relationship between peer w

To react to a blocked focus attempt, subscribe to [`os-window-child-blocked`](#os-window-child-blocked) or the `os.window.child-blocked` action.

**`config.submenu`** — when present, the shell renders the array as an in-window tab strip below the title bar so the user can navigate child pages without leaving the window. Pass `item.submenu` whenever you open a window from a dock context — `openItem` and `openSubmenuPick` (in custom rail renderers) propagate it for you. Skip it for native windows that don't have admin sub-pages. The shell strips WordPress's auto-prepended self-link entry server-side, so `submenu.length > 0` reliably means "has real children" (no defensive filtering needed in your code). The shell prepends a synthetic "back to parent" tab (label = `config.title`, URL = `config.url`) as the first tab so the user can return to the parent listing without closing the window. If a caller-supplied submenu entry already points at `config.url` the synthetic tab is suppressed to avoid two tabs claiming the same URL.
**`config.submenu`** — when present, the shell renders the array as an in-window tab strip below the title bar so the user can navigate child pages without leaving the window. Entries flagged `offSite` are skipped: a tab loads its URL into this window's iframe, which an off-site origin refuses. Pass `item.submenu` whenever you open a window from a dock context — `openItem` and `openSubmenuPick` (in custom rail renderers) propagate it for you. Skip it for native windows that don't have admin sub-pages. The shell strips WordPress's auto-prepended self-link entry server-side, so `submenu.length > 0` reliably means "has real children" (no defensive filtering needed in your code). The shell prepends a synthetic "back to parent" tab (label = `config.title`, URL = `config.url`) as the first tab so the user can return to the parent listing without closing the window. If a caller-supplied submenu entry already points at `config.url` the synthetic tab is suppressed to avoid two tabs claiming the same URL.

Every iframe window gets the strip element, whether or not it has a submenu, because external sub-tabs can be added to it later. Its navigation semantics follow its contents: `role="tablist"` plus an `aria-label` of `"<title> sub-pages"` while it holds tabs, `role="presentation"` while it is empty — so a window with no sub-pages never advertises an empty tab list to assistive tech.

Expand Down Expand Up @@ -4798,7 +4798,7 @@ interface DockItem {
icon: string; // dashicon class | `data:` URI | `http(s):` URL
url: string; // admin URL the tile opens
badge: number; // numeric badge; 0 = no badge
submenu: { title: string; url: string }[];
submenu: { title: string; url: string; offSite?: boolean }[];
multi: boolean; // hover-peek + Ghost Card eligibility
isCore: boolean; // true for WP-shipped menus, false for plugin-contributed
pluginFile: string | null; // owning plugin file (e.g. `woocommerce/woocommerce.php`)
Expand Down Expand Up @@ -4827,6 +4827,10 @@ interface DockItem {

A custom rail renderer that decides whether to show a submenu indicator (a chevron, a hover treatment) can read `item.submenu.length > 0` without defensive `submenu.length > 1` or self-URL filtering. The framework owns the contract.

**`submenu[].offSite`** — the row leaves the site. Off-site admin-menu entries are dropped server-side; the survivors are children of a plugin's own menu (a docs or account link), and they carry this flag. Nothing off-site can load in an iframe, so a surface that routes a URL into a window must skip them: the in-window tab strip does, and the constellation flyout marks them with an outbound glyph and hands them to the browser instead. `tryOpenExternalUrl()` is the shared escape; a renderer calling `openSubmenuPick` gets it for free.

The name is `offSite` rather than `external` because the tab strip already spends that word on a different thing: a tab with `data-kind="external"` is a plugin-opened sub-iframe, which is on-site.

**Lifecycle pairing — `replaceItems` ↔ `appendSystemItem`** — these are independent update paths. `replaceItems( items )` swaps the menu-derived tiles wholesale (the live menu refresh fires it on every plugin activation / deactivation). `appendSystemItem` / `removeSystemItem` track the JS-owned cohort (OpenStation Preferences, plugin native-window launchers).

A custom rail renderer's controller MUST persist its system-tile DOM across `replaceItems` calls — the shell does NOT re-emit `appendSystemItem` for previously-added tiles after a menu refresh. Practical pattern: track system tiles in a closure-scoped `Map`, re-paint them in `replaceItems()` after rebuilding the menu cohort.
Expand Down
17 changes: 16 additions & 1 deletion docs/plugin-compat-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,25 @@ Dock URLs flow into the shell config as JSON, then end up assigned to `iframe.sr

Some plugins register a top-level menu with a stub callback whose actual landing page is the first submenu (`add_menu_page( …, 'woocommerce', null, … )` then `add_submenu_page( 'woocommerce', …, 'wc-admin', … )`). Classic admin's `wp-admin/menu-header.php` rewrites the parent's clickable link to the first submenu's URL. Hitting `?page=woocommerce` directly invokes the stub and 500s.

**Fix**: `openstation_build_dock_items()` mirrors this — if a parent menu has any visible submenu, the parent's effective URL is the first capability-passing submenu's URL.
**Fix**: `openstation_build_dock_items()` mirrors this — if a parent menu has any visible submenu, the parent's effective URL is the first capability-passing submenu's URL. A menu that registered a self-link keeps its own URL instead, wherever in the list that link sits: it has a working page of its own, and the fallthrough is only for menus that don't.

**Plugins this addresses**: WooCommerce, historically Yoast SEO, several membership / LMS plugins.

### Admin menus that point at another host (WordPress.com)

A site can be hosted somewhere that extends the admin menu with links to its own control panel. WordPress.com is the case that surfaced this: My Home, Theme Showcase, Hosting and friends are `wordpress.com` URLs, and Jetpack also repoints Core entries — Appearance → Themes, Plugins → Add Plugin, Users → All Users — at their Calypso counterparts. None of them can load in a window; the remote origin refuses the frame. Routing them to a browser tab instead, which is what the shell used to do, takes the user out of the desktop on a click that looked like every other dock click.

**Fix**, all in `openstation_build_dock_items()`:

1. Any menu whose resolved URL is off-site is dropped. `openstation_menu_item_is_external()` is the classifier, and it is filterable.
2. A child of a menu a regular plugin registered (`pluginFile` non-null) is the exception — a docs or account link under a plugin's own menu is a normal thing to ship. It keeps its row, flagged `offSite`, which the constellation marks with an outbound glyph and the in-window tab strip skips.
3. Rows carrying `hide-if-js` are dropped, which is also what removes the duplicate submenus WordPress.com produced: Jetpack keeps the wp-admin original and marks it hidden rather than replacing it, so the dock was rendering both copies.
4. Except when that hidden row is the original of an off-site row we just dropped — then it takes the dropped row's place in the list, and the menu opens the wp-admin screen Core registered. This is what puts Themes, Add Plugin and All Users back.

**Icons**: `Base_Admin_Menu::override_svg_icons()` moves every SVG-data-URI menu icon into an inline stylesheet and sets `$menu[ $i ][6]` to `'none'`, which left Jetpack, MailPoet and every other plugin shipping vector art with a generic gear in the dock. `openstation_snapshot_menu_icons()` samples `$menu` at several points across `admin_menu` and records, write-once, the first real icon each slug wore; the builder falls back to it when the live value has been blanked. Sampling rather than parking one priority below the known rewriter is deliberate — registrations and rewrites both happen at arbitrary priorities, and the live value still wins whenever there is one, so a menu that genuinely changes its icon is unaffected.

Nothing here is WordPress.com-specific: the rules read the menu arrays, not the host. That cuts both ways, and rule 1 is the one to know about: a plugin that registers its **top-level** menu as a link to its own hosted service loses its dock tile on any site, where before it opened a browser tab. Children are the documented exception, top-level entries are not, because a tile that can never open a window is a tile that lies about what a dock click does.

### Empty submenu titles

Plugins (notably WooCommerce's `wc-addons` Extensions row) register `menu_title => null` to keep a page reachable while hiding the row from classic admin's left menu. Our dock would otherwise render an empty, label-less tab that visually duplicates a sibling entry.
Expand Down
Loading