From 6f81659dc99cdf7ca57cd45be2c8fc2dc62a839d Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Wed, 12 Aug 2026 22:53:14 +0200 Subject: [PATCH 1/4] Dock: fold the bottom rail into switchable groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom dock is a pill, and a pill has a width. A site with a dozen plugins overran it and the rail's answer was a hidden horizontal scroll — tiles that existed only for people who knew to swipe for them. Turn on OpenStation Preferences → Appearance → Dock groups and the rail folds into decks instead: Favorites, WordPress, Plugins, OpenStation, one on screen at a time behind a tab strip at the leading edge. The active tab wears the brand mesh on a plate that travels between tabs rather than switching off one and on at the next. A hidden deck still speaks — its tab carries an indicator dot when it holds an open window and the sum of its tiles' badges. Favorites is the deck the user composes: every icon in the dock now has "Add to favorites" in its right-click menu, system tiles included. Load-bearing system tiles get the star and the settings shortcut without the hide/move entries, gated on the same `placeable` flag Apps & Icons already reads. Opt-in, and bottom-rail only. A vertical rail has the height to show everything, and a rail whose tiles all land in one deck paints exactly as it did before. Public surface: `os.dock.decks` (filter) and `os.dock.deck-changed` (action), plus `Dock.getActiveDeck()` / `setActiveDeck()` and three OS-settings keys. Co-Authored-By: Claude Opus 5 (1M context) --- assets/css/dock.css | 523 ++++++++++ assets/css/variables.css | 28 + docs/api-index.md | 2 + docs/architecture.md | 2 + docs/desktop-themes.md | 37 + docs/dock-customization.md | 59 ++ docs/examples/README.md | 1 + docs/examples/dock-decks.md | 206 ++++ docs/javascript-reference.md | 85 +- includes/os-settings.php | 103 +- src/api/facade.ts | 30 +- src/dock-decks/index.ts | 1085 +++++++++++++++++++++ src/dock.ts | 137 +++ src/hooks.ts | 34 + src/item-visibility-menu.ts | 142 ++- src/settings/constants.ts | 8 + src/settings/index.ts | 18 + src/settings/panel.ts | 2 + src/settings/registry.ts | 12 + src/settings/sections/dock-decks.ts | 102 ++ src/settings/state.ts | 13 + src/settings/types.ts | 34 + tests/phpunit/tests/osSettings.php | 107 ++ tests/vitest/dock-decks.test.ts | 746 ++++++++++++++ tests/vitest/dock-icon-resolve.test.ts | 18 +- tests/vitest/item-visibility-menu.test.ts | 30 + 26 files changed, 3505 insertions(+), 59 deletions(-) create mode 100644 docs/examples/dock-decks.md create mode 100644 src/dock-decks/index.ts create mode 100644 src/settings/sections/dock-decks.ts create mode 100644 tests/vitest/dock-decks.test.ts diff --git a/assets/css/dock.css b/assets/css/dock.css index 3873d7c45..44b48d3bf 100644 --- a/assets/css/dock.css +++ b/assets/css/dock.css @@ -1083,6 +1083,529 @@ flex-shrink: 0; } +/* ================================================================== + * Decks — one group of tiles on screen at a time. + * + * The bottom rail is a pill, and a pill has a width. A site with a + * dozen plugins overran it and the rail's answer was a hidden + * horizontal scroll: the tiles were there, if you knew to swipe. The + * three clusters it already drew dividers between — core menus, + * plugin apps, OpenStation's own controls — are now decks, and + * exactly one is on screen. A strip of tabs at the leading edge names + * the one you're on and offers the others. + * + * Bottom placement only. A vertical rail is a column with the shell's + * whole height to spend; folding it into decks would hide tiles that + * already fit. `Dock` builds the controller for `'bottom'` and tears + * it down on any flip away, so these selectors simply never match + * elsewhere. + * + * **Which tiles are hidden is decided in JS, not here.** CSS cannot + * compare two attributes — there is no way to write "hide the tiles + * whose `data-os-deck` differs from the dock's `data-os-deck-active`" + * without one rule per deck id, which would cover the three built-ins + * and silently fail for every deck a plugin registers through + * `os.dock.decks`. `DockDecks.applyVisibility()` stamps + * `--deck-off` and this stylesheet only paints it. + * ================================================================== */ + +/* + * A tile leaves by COLLAPSING, not by disappearing. + * + * This is the load-bearing rule of the whole switch, and it used to + * be `display: none`. The pill is `width: fit-content`, so its width + * is whatever its tiles add up to — and taking eight tiles out of the + * flow in one frame moved that by several hundred pixels instantly. + * Everything else was animating politely around a snap, which is the + * one thing the eye actually tracks. + * + * Collapsing to zero width instead means the pill's intrinsic width + * is recomputed every frame of the transition and `margin: 0 auto` + * re-centres it every frame with it. Nothing measures anything, + * nothing is pinned, and the outgoing tiles, the incoming tiles, the + * travelling plate and the label unfurling all move on one clock. + * + * `inert` (set from JS alongside the class) is what keeps a collapsed + * tile out of the tab order and the accessibility tree. A CSS + * `visibility: hidden` would have to be delayed until the collapse + * finished, which would couple this rule to the duration token. + */ +/* + * The flex `gap` goes with it, and this is why. + * + * `gap` is drawn BETWEEN items and cannot be animated per item, so a + * tile collapsed to zero width still leaves its 6px of gap behind — + * twenty of them is 120px of pill that never closes. Trading the gap + * for a symmetric 3px margin on every tile gives the same 6px between + * neighbours and the same 3px at the ends, and puts the spacing on + * the tile, where it can collapse with it. + * + * Scoped to decked rails: an undecked rail never collapses a tile, so + * it keeps the plain `gap` it has always had. + */ +.os-dock[ data-os-deck-active ] .os-dock__scroll, +.os-dock[ data-os-deck-active ] .os-dock__pinned { + gap: 0; +} + +.os-dock[ data-os-deck-active ] .os-dock__item { + margin-inline: 3px; + transition: + width var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + margin-inline-start + var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + margin-inline-end + var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + /* Faster than the collapse on purpose: a tile that is still + * fully opaque at 4px wide reads as a sliver of debris. It + * fades while there is still enough of it to fade. */ + opacity 260ms ease-out; +} + +/* + * The collapsed state, and it MUST out-specify the rule above. + * + * Both would otherwise sit at (0,3,0) and source order alone would + * decide whether `margin-inline: 0` or `margin-inline: 3px` won — and + * when the base rule won, every folded tile kept 6px it no longer had + * any width to justify. Twenty of those scattered through a deck is + * why the visible icons came out unevenly spaced: the phantom gap + * between any two of them was however many collapsed tiles happened + * to sit between them in DOM order. + * + * Carrying the placement attribute takes this to (0,4,0), so it wins + * wherever it is written rather than only where it happens to be. + * Decks are a bottom-rail affordance anyway, so the extra qualifier + * states something already true. + */ +.os-dock[ data-os-dock-placement="bottom" ][ data-os-deck-active ] + .os-dock__item--deck-off { + width: 0; + margin-inline: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +/* + * The first paint is not a switch. + * + * Without this the dock would open by showing every tile it has and + * then visibly folding two thirds of them away — a collapse the user + * did not ask for and has no way to read. The JS clears this + * attribute one frame after the first partition, once the starting + * state is already on screen. + */ +.os-dock[ data-os-deck-init ] .os-dock__item { + transition: none; +} + +/* + * The dividers stand down while the rail is decked. They exist to say + * "these tiles are a different group from those tiles", and that is + * now the tab strip's whole job — a line inside a deck would be + * dividing a group from nothing. + * + * The strip's own leading separator is exempt: that one divides the + * switcher from the tiles, which is a real boundary in either mode. + */ +.os-dock[ data-os-deck-active ] + .os-dock__separator:not( .os-dock__separator--decks ) { + display: none; +} + +/* + * Drop whichever wrapper the active deck left empty. + * + * `__scroll` carries `flex: 1 1 auto` and its own padding, so on the + * OpenStation deck — where every menu tile is `display: none` — it + * would otherwise sit in the flow as a phantom column, pushing the + * system tiles off the pill's centre. The existing "no tiles at all" + * rule can't cover this: the tiles ARE there, they're just hidden. + * + * Specificity has to clear the per-placement rules that set + * `display: flex` on these wrappers, which sit at (0,3,0). Carrying + * both dock attributes puts this at (0,5,0). + */ +.os-dock[ data-os-dock-placement="bottom" ][ data-os-deck-active ] + .os-dock__scroll:not( + :has( .os-dock__item:not( .os-dock__item--deck-off ) ) + ), +.os-dock[ data-os-dock-placement="bottom" ][ data-os-deck-active ] + .os-dock__pinned:not( + :has( .os-dock__item:not( .os-dock__item--deck-off ) ) + ) { + display: none; +} + +/* + * The strip's own divider runs a little taller and sits a little + * closer than the one between tile clusters. Taller because it is + * measuring itself against a 46px tab rather than a 40px tile; + * closer because the shared 14px is sized for a gap that has tiles on + * both sides, and here one side is a filled plate that already reads + * as an edge. + */ +.os-dock[ data-os-dock-placement="bottom" ] .os-dock__separator--decks { + height: 40px; + margin-inline: 10px; +} + +/* ---- The tab strip ----------------------------------------------- */ + +/* + * The strip claims the pill's full inner height, and that is the + * whole trick to it sitting centred. + * + * The pill's own padding is deliberately bottom-heavy — `4px` above, + * `8px` below — because a tile hangs its active dot at `bottom: -3px` + * and needs the room. `__scroll` adds `4px / 6px` on top of that for + * the same reason at the other end (the badge sits at `top: -3px`). + * A tab has neither: its dot and its badge are INSIDE its own box. So + * matching the wrapper's padding, which is what this used to do, just + * inherited a clearance the tabs don't spend and left them floating + * 8px from the top edge and 14px from the bottom. + * + * `padding-bottom: 0` cancels the half it doesn't need. The tab grows + * to fill what that frees, so the gap is 4 + 4 = 8px above and + * 0 + 8 = 8px below — even, and the button is bigger for it. + */ +.os-dock__decks { + /* Containing block for the travelling plate, and the offsetParent + * the JS measures each tab against. */ + position: relative; + display: flex; + flex-direction: row; + align-items: center; + /* The same 6px the tiles use. The strip is a row of buttons in a + * row of buttons; a tighter gap here made the tabs read as one + * segmented control welded together rather than as three places + * to go. */ + gap: 6px; + flex-shrink: 0; + padding-top: 4px; + padding-bottom: 0; +} + +/* + * The same 8px on the leading edge. The pill's 12px inline padding is + * sized for a tile, whose 40px plate is a circle-ish glyph with air + * already built into it; a tab's plate is a filled rectangle that + * starts where it says it does, so the same 12px reads as a wider + * gap on the left than on the top. Scoped to decked rails so an + * undecked pill keeps the inset it has always had. + */ +.os-dock[ data-os-dock-placement="bottom" ][ data-os-deck-active ] { + padding-inline: 8px; +} + +/* + * A tab is a tile-sized thing, because it sits in a row of tiles. + * + * The 40px square and the `--os-dock-icon-size` glyph are the same + * two numbers `.os-dock__item-primary` uses, and matching them is + * what makes the strip read as part of the rail rather than as a + * control bolted to the end of it — a collapsed tab is then exactly + * a tile with a different job. It also means the whole strip grows + * and shrinks with the Dock size preference for free; a hardcoded + * glyph would have stayed put at Compact and Large. + * + * `min-width` rather than `width`: the active tab has to grow past + * the square to fit its label. + */ +.os-dock__deck { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 46px; + height: 46px; + padding: 0 11px; + border: 0; + border-radius: 14px; + background-color: transparent; + color: var( --os-dock-icon-color, rgba( 255, 251, 255, 0.72 ) ); + font: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: + background-color var( --os-ui-motion-fast, 140ms ) ease-out, + /* The ink rides with the plate, not with the hover wash: it is + * flipping to Void because a light surface is arriving over + * it, so settling 200ms before that surface gets there would + * read as the label changing its mind. */ + color var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + box-shadow var( --os-ui-motion-fast, 140ms ) ease-out; +} + +/* + * A shade larger than a tile glyph, because the plate under it is a + * shade larger too — 46px against the tile's 40. Expressed against + * the same token so the Dock size preference still carries the whole + * strip; a hardcoded 22px would have stayed put at Compact and Large. + */ +.os-dock__deck .dashicons { + width: calc( var( --os-dock-icon-size, 20px ) * 1.1 ); + height: calc( var( --os-dock-icon-size, 20px ) * 1.1 ); + font-size: calc( var( --os-dock-icon-size, 20px ) * 1.1 ); + line-height: calc( var( --os-dock-icon-size, 20px ) * 1.1 ); + flex-shrink: 0; +} + +/* Non-dashicon glyphs are painted as a mask so they take the tab's + * ink — which is what lets one go from muted to Void-on-mesh when it + * becomes the active tab, exactly as a themed tile icon does. */ +.os-dock__deck-mask { + width: calc( var( --os-dock-icon-size, 20px ) * 1.1 ); + height: calc( var( --os-dock-icon-size, 20px ) * 1.1 ); + flex-shrink: 0; + background-color: currentColor; + -webkit-mask: var( --os-deck-mask ) center / contain no-repeat; + mask: var( --os-deck-mask ) center / contain no-repeat; +} + +/* + * Only the active tab is named. + * + * Three labels across the leading edge is a menu bar, and the rail + * already has thirty things to look at. Collapsed to zero width the + * inactive tabs read as what they are — two more places to go — and + * the one that matters is spelled out. `max-width` rather than + * `display` so the name has something to animate from; the cap is + * generous enough for a translated "OpenStation" and stops a plugin's + * essay from taking the rail with it. + */ +.os-dock__deck-label { + display: block; + max-width: 0; + margin-inline-start: 0; + overflow: hidden; + opacity: 0; + /* + * The SAME curve the plate travels on, deliberately. The plate is + * chasing this tab's growing width frame by frame; two different + * easings would have the surface and the label it is uncovering + * disagreeing about where they are for the whole 340ms. + */ + transition: + max-width var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + margin-inline-start + var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + opacity var( --os-ui-motion-fast, 140ms ) ease-out; +} + +.os-dock__deck--active .os-dock__deck-label { + max-width: 14ch; + margin-inline-start: 6px; + opacity: 1; +} + +/* + * The identity moment, and it TRAVELS. + * + * The mesh is not painted on the active tab — it is painted on one + * element that slides between them, the same construction the window + * tab strip's plate uses and for the same reason: a fill that + * switched off on one tab and on at the next would have to cross-fade + * a mesh into nothing, and every frame in between belongs to neither + * state. Nothing cross-fades here. The surface moves and the ink + * flips underneath it. + * + * `DockDecks.positionPlate()` publishes the geometry as + * `--_deck-plate-x` / `--_deck-plate-w` on the strip; everything else + * is CSS. It follows the active tab for ~500ms after a switch because + * the label reveal is still growing that tab underneath it — see the + * method's own note for why the alternative (snapping the labels) is + * worse. + */ +.os-dock__deck-plate { + position: absolute; + inset-block: 4px 0; + left: 0; + width: var( --_deck-plate-w, 0 ); + transform: translateX( var( --_deck-plate-x, 0 ) ); + border-radius: 14px; + pointer-events: none; + background-image: var( + --os-dock-deck-fill, + var( + --os-ui-holo-fill, + linear-gradient( 124deg, #afa2e8, #c3b8ef ) + ) + ); + box-shadow: var( + --os-ui-holo-glow, + 0 0 0 1px rgba( 217, 46, 227, 0.2 ), + 0 2px 10px rgba( 217, 46, 227, 0.15 ) + ); + transition: + transform var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + width var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ); +} + +/* + * Until the first measurement lands the plate has no business + * animating — a width of 0 at x 0 is the strip's leading edge, and + * travelling out of it is what "flying in from nowhere" looks like + * every time the dock paints. + */ +.os-dock__decks:not( [ data-plate-placed ] ) .os-dock__deck-plate { + transition: none; +} + +/* + * The tabs ride above it. Both are positioned, so without an explicit + * order the plate would win on source... it is the first child, so it + * loses — but only by accident of DOM order, and the JS inserts it + * first for exactly this reason. Stating it here means a future + * reorder cannot quietly paint the mesh over the labels. + */ +.os-dock__deck { + z-index: 1; +} + +.os-dock__deck-plate { + z-index: 0; +} + +/* + * Ink flips to Void because every mesh in the brand is a light + * surface. This is the only thing the active tab still owns — its + * fill left for the plate. + */ +.os-dock__deck--active { + color: var( + --os-dock-deck-ink, + var( --os-ui-holo-ink, #0c0b0f ) + ); +} + +/* + * The inactive tabs get the tile hover wash, not a second mesh — two + * meshes touching is where iridescence stops reading as emphasis. + * Scoped away from the active tab because a `background-color` under + * the mesh would show through its transparent stops and muddy it. + */ +.os-dock__deck:not( .os-dock__deck--active ):hover { + background-color: var( + --os-dock-item-bg-hover, + rgba( 242, 82, 252, 0.18 ) + ); + color: var( --os-dock-icon-color-hover, #fffbff ); +} + +.os-dock__deck:focus-visible { + outline: none; + box-shadow: var( + --os-ui-focus-ring, + 0 0 0 2px rgba( 12, 11, 15, 0.9 ), + 0 0 0 4px #f252fc + ); +} + +.os-dock__deck:active { + transform: scale( 0.96 ); +} + +/* + * A hidden deck still has to be able to say something. + * + * The indicator dot is the same shape and the same position the tiles + * use one row over — a dot for "something in here is open", a short + * bar when the focused window is in there. Painted on the INACTIVE + * tabs only: the active deck's tiles are on screen carrying their own + * dots, and repeating them an inch away says the same thing twice. + */ +.os-dock__deck--has-open:not( .os-dock__deck--active )::after { + content: ""; + position: absolute; + inset-inline-start: 50%; + bottom: 1px; + transform: translateX( -50% ); + width: 4px; + height: 4px; + border-radius: 50%; + background: var( --os-dock-item-outline, #f252fc ); +} + +.os-dock__deck--has-focused:not( .os-dock__deck--active )::after { + width: 14px; + height: 4px; + border-radius: 2px; +} + +/* + * The aggregate badge — the sum of the deck's tiles' own badges. + * + * Without it, folding the rail into decks would fold the update + * counts away with them: five pending plugin updates would be true, + * on screen a moment ago, and now behind a tab that looked idle. + * Inactive tabs only, for the same reason as the dot. + */ +.os-dock__deck-badge { + position: absolute; + top: -1px; + inset-inline-end: -3px; + min-width: 15px; + height: 15px; + padding: 0 4px; + border-radius: 999px; + background: var( + --os-dock-badge-bg, + linear-gradient( 180deg, #f97dff 0%, #f252fc 100% ) + ); + color: var( --os-dock-badge-fg, #0c0b0f ); + font-size: 10px; + font-weight: 700; + line-height: 15px; + text-align: center; + pointer-events: none; +} + +/* ---- The switch -------------------------------------------------- */ + +/* + * There is no separate entrance animation, and that is the point. + * + * An earlier pass dealt the incoming tiles in with a staggered + * keyframe — translate, scale, fade, 44ms a leg. It was pretty and it + * was fighting the wrong problem: it ran ON TOP of a pill whose width + * had already snapped, so the flourish played over the very jump it + * was distracting from. Now that a tile leaves and arrives by + * changing width, the arrival IS the animation, and a second motion + * layered over it only muddies whose clock the rail is on. + * + * If a staggered entrance comes back, it must not touch layout — + * `transform` and `opacity` only — or it will fight the width + * transition for the pill's intrinsic size. + */ + +/* + * Reduced motion stops the travel, never the switch. The deck still + * changes, the tab still wears the mesh, the label still appears, the + * tiles still swap — losing those would lose STATE, not animation. + */ +@media ( prefers-reduced-motion: reduce ) { + .os-dock[ data-os-deck-active ] .os-dock__item, + .os-dock__deck-label, + .os-dock__deck, + /* + * The plate still MOVES — it is where the selection is, and a + * selection that stopped appearing would be lost state, not lost + * animation. What goes is the travel: it cuts to the new tab. + */ + .os-dock__deck-plate { + transition: none; + } + + .os-dock__deck:active { + transform: none; + } +} + /* Tooltip — above the hovered tile. JS writes the horizontal center * + a top near the tile's top edge; CSS translates the tooltip up by * its own height so it clears the tile. */ diff --git a/assets/css/variables.css b/assets/css/variables.css index 9e4f58bb7..0f46f3ea7 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -1142,6 +1142,34 @@ body.os-active { --os-dock-floating-highlight: rgba(255, 251, 255, 0.1); --os-dock-floating-shadow: rgba(0, 0, 0, 0.55); + /* + * ---- Deck motion -------------------------------------------- + * + * ONE timing, deliberately slower than the shell's usual + * `--os-ui-motion-slow`, and everything a deck switch moves is on + * it: the mesh plate travelling between tabs, the incoming tab's + * name unfurling to widen it, the outgoing tiles collapsing to + * zero width, the incoming ones opening back out — and, because + * the pill is `width: fit-content`, the pill itself resizing and + * re-centring around all of it. + * + * One token rather than several is the whole design. An earlier + * pass gave the tiles their own duration and their own staggered + * keyframe; what that produced was two clocks in one gesture, and + * the faster one always read as the thing going wrong. Anything + * added here later should join this token, not bring its own. + * + * Slower than the rest of the rail because this is the one place + * in the dock where a state change is worth WATCHING rather than + * just registering; everything else stays quick, which is what + * keeps this from reading as sluggishness. + * + * A theme that wants the old snap sets a shorter duration; `0s` + * removes the choreography without losing any of the state it + * carries. + */ + --os-dock-deck-slide: 720ms cubic-bezier(0.22, 1, 0.28, 1); + /* * ---- The OpenStation layout --------------------------------- * diff --git a/docs/api-index.md b/docs/api-index.md index fc044ce65..aa88eeba0 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -62,6 +62,8 @@ The full surface is documented in [`javascript-reference.md`](./javascript-refer | `dockPlacement` | `'bottom' \| 'left' \| 'right'` *(Unified + Spatial)* | Stable | | `Dock.setBadge` | `( id: string, count: number ) => void` | Stable | | `Dock.removeSystemItem` | `( id: string ) => void` | Stable | +| `Dock.getActiveDeck` | `() => string \| null` *(bottom rails; `null` when undecked)* | Stable | +| `Dock.setActiveDeck` | `( deckId: string ) => void` | Stable | | `icons` | `IconsApi` *(see `icons.setBadge`)* | Stable | | `icons.setBadge` | `( iconId: string, count: number ) => void` | Stable | | `registerSystemTile` | `( item: SystemDockItem ) => void` | Stable | diff --git a/docs/architecture.md b/docs/architecture.md index bcf905788..2ce174ede 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,6 +125,8 @@ OpenStation Preferences → Appearance lets the user pick one of four top-level Inside a rail, tiles are grouped by what they do rather than by where they came from: the admin menus first, then a divider, then OpenStation's own controls (Preferences, the bin, the way out) — the cohort the dock calls *system tiles*. That divider is the rail's one structural line, because it is the only boundary where behaviour changes: before it a tile opens an admin screen, after it a tile acts on the desktop. A softer hairline also separates core menus from plugin apps, mirroring wp-admin's own menu, but that boundary is provenance rather than behaviour and is drawn quietly. The two read through `--os-dock-divider` and `--os-dock-divider-soft`; the OpenStation layout replaces the *first* with its own luminous seam. +**On a bottom rail those three groups can become decks** (`src/dock-decks/`). A pill has a width, and a site with a dozen plugins overran it; the rail's answer used to be a hidden horizontal scroll, which meant tiles that existed only for people who knew to swipe. Turn `dockDecksEnabled` on and exactly one group is on screen at a time — Favorites, WordPress, Plugins, OpenStation — with a tab strip at the leading edge naming it and offering the others. It is opt-in because decking trades "every tile is on screen" for "the rail fits": a good trade once there are enough tiles to feel the crowding, a bad one before then, and not the shell's call either way. **Favorites** is the one deck the user composes rather than inherits — starring a tile from its right-click menu appends its id to `dockFavorites`, and because that deck sorts first it takes the tile out of its provenance group entirely. The dividers stand down while it is on: they exist to say "these tiles are a different group from those", which is now the strip's whole job. The rail keeps its old shape wherever decking would be a loss: never on a vertical placement (a column has the shell's full height to spend and scrolls honestly), and not on a bottom rail whose tiles all land in one deck. Which tiles are hidden is decided in JS rather than in a stylesheet, because CSS cannot compare `data-os-deck` on a tile against `data-os-deck-active` on the rail without one rule per deck id — and the deck list is a filter (`os.dock.decks`), so plugin-registered decks would be the ones to silently break. A tab carries what its hidden tiles were saying: an indicator dot when the deck holds an open window, and the sum of their badges as a number. Whether focusing a window pulls the rail to its deck is a second call, next to the first in OpenStation Preferences → Appearance → Dock groups; the default is no, on the [event-driven framework](event-driven-framework.md)'s rule that the shell is a transport rather than a UX policy maker. + **Both one-rail layouts group before they draw.** `Dock.replaceItems()` inserts the `--group` divider at the first tile whose `isCore` is `false`, so it only produces one clean boundary when the list is already grouped — and the admin menu is not: a plugin that registers high up (Yoast, Jetpack) would otherwise put the line two tiles in and strand the rest of WordPress on the plugin side of it. `coreFirstRailItems()` in the dispatcher sorts core ahead of plugins for `unified` and `openstation` alike, preserving relative order inside each cluster so drag-to-reorder still holds within a group. One consequence to know about: a tile cannot be dragged from one cluster into the middle of the other. **The way out is drawn differently.** The `os-exit` tile leaves the desktop rather than opening something closeable, and with the admin bar hidden by default it is the only route back to classic admin. `dock.css` gives it `order: 1` (always last, whatever order plugin-owned native-window tiles sync in), its own wider gap, a ring instead of a filled plate, and a hover that leans toward the edge it leads to instead of lifting toward the pointer. The rules key on `[data-system-id="os-exit"]`, the same idiom the Recycle Bin badge uses; `tests/vitest/dock-exit-tile.test.ts` pins that attribute. diff --git a/docs/desktop-themes.md b/docs/desktop-themes.md index 60318ae31..5e134dfbd 100644 --- a/docs/desktop-themes.md +++ b/docs/desktop-themes.md @@ -652,6 +652,43 @@ disappears the moment you give the dock a light background. Recolouring the strip alone is the most common way a first theme ends up with an invisible dock. +Two more cover the **deck tabs** — the strip at the leading edge of a +bottom rail that names the group of tiles currently on screen (see +[Dock decks](javascript-reference.md#dock-decks)): + +| Token | Role | +|---|---| +| `--os-dock-deck-fill` | The fill behind the active tab. Falls back to `--os-ui-holo-fill`, the brand mesh | +| `--os-dock-deck-ink` | Its label and glyph. Falls back to `--os-ui-holo-ink`, near-Void | + +The inactive tabs deliberately have no tokens of their own: they read +through `--os-dock-icon-color` and `--os-dock-item-bg-hover`, the same +two the tiles beside them use, so a theme that recoloured its dock +glyphs gets the strip for free. **Set the pair together or not at +all** — an active tab with a repainted fill and default Void ink is +the one combination that can end up unreadable. + +One more tunes how a deck switch *moves*: + +| Token | Role | +|---|---| +| `--os-dock-deck-slide` | ` ` for the whole switch | + +Everything a switch moves is on that single token: the mesh fill +travelling between tabs, the incoming tab's name unfurling to widen +it, the outgoing tiles collapsing to zero width, the incoming ones +opening back out — and, since the pill is `width: fit-content`, the +pill resizing and re-centring around all of it. + +**One token rather than several is the design, not a shortcut.** An +earlier pass gave the tiles their own duration and their own staggered +entrance; the result was two clocks in one gesture, and the faster one +always read as the thing going wrong. It is deliberately slower than +the rest of the rail — this is the one dock state change worth +watching rather than just registering — so shorten it for a snappier +rail, and set `0s` to remove the choreography without losing any of +the state it carries. + `--os-dock-icon-color` is a **colour**, not a fill, which matters if your iconset uses [`"iconColor": "currentColor"`](#icon-slots): those icons are masked diff --git a/docs/dock-customization.md b/docs/dock-customization.md index ae6ccc6b0..3d1769ead 100644 --- a/docs/dock-customization.md +++ b/docs/dock-customization.md @@ -217,9 +217,68 @@ See the full reference for the [`DockItem` shape](./javascript-reference.md#dock --- +## Decks — the third thing you can customize + +The two registries above answer *what paints the rail* and *what +decorates a tile*. Decks answer a third question the default renderer +asks on a bottom rail: **which tiles are on screen right now.** + +With `dockDecksEnabled` on (opt-in, in Appearance → Dock groups) the +rail folds itself into named groups — `favorites`, `wordpress`, +`apps`, `station` — and shows one at a time, with a tab strip at its +leading edge. The list is a filter, so it is yours to reorder, rename, +extend, or switch off: + +```js +wp.hooks.addFilter( 'os.dock.decks', 'my-plugin', ( decks ) => [ + ...decks, + { + id: 'shop', + label: 'Shop', + icon: 'dashicons-cart', + order: 15, // between Apps (20) and WordPress (10) + matchItem: ( item ) => item.id.startsWith( 'woocommerce' ), + }, +] ); +``` + +Three things worth knowing before you reach for it: + +- **A tile joins the first deck that claims it**, in `order` order. A + narrow deck at a low `order` therefore wins its tiles without you + having to also rewrite the built-ins' predicates — which is exactly + how `favorites` (order 5) pulls a starred tile out of `apps`. +- **Decks only exist on a bottom rail**, and only when at least two of + them are non-empty. A custom *rail renderer* replaces the default + `Dock` entirely and so opts out of decks along with everything else + the class paints — if your renderer wants grouping, it owns it. +- **Returning `[]` turns decks off** for every rail, which is the + supported way for a theme or plugin to get the old single-row rail + back. + +Reading and driving the current deck from anywhere: + +```js +wp.os.dock?.getActiveDeck(); // 'wordpress' | 'apps' | … | null +wp.os.dock?.setActiveDeck( 'apps' ); + +wp.hooks.addAction( 'os.dock.deck-changed', 'my-plugin', ( ctx ) => { + // ctx.reason: 'click' | 'keyboard' | 'wheel' | 'swipe' + // | 'restore' | 'auto' + console.log( ctx.previousDeckId, '→', ctx.deckId ); +} ); +``` + +Both methods are no-ops on a rail that isn't decked, so neither needs +a layout check around it. Full shapes in the +[JavaScript Reference](./javascript-reference.md#dock-decks). + +--- + ## Where to go next - **[Decoration hooks recipes](./examples/dock-decoration-hooks.md)** — six examples from one-line classNames to grid-wide IntersectionObservers. - **[Rail renderer walk-through](./examples/dock-rail-renderer.md)** — full "ring" implementation with circular layout math. - **[JavaScript Reference](./javascript-reference.md#dock-decoration)** — every API entry, every hook, every type. +- **[Deck tokens](./desktop-themes.md#dock-glyphs)** — the two tokens a desktop theme sets to repaint the active deck tab. - **[Architecture](./architecture.md#dock-customization--two-registries)** — how the registries plug into the layout dispatcher and the live menu-refresh pipeline. diff --git a/docs/examples/README.md b/docs/examples/README.md index 141d4e8b2..391c6190b 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -15,6 +15,7 @@ defined( 'ABSPATH' ) || exit; - [Add a dock item with a badge](./dock-badge.md) - [Give a tile two icons, one per state](./tile-icon-state.md) - [Decorate the dock without forking the renderer](./dock-decoration-hooks.md) +- [Group the dock your way — custom decks](./dock-decks.md) - [Replace the dock rail entirely](./dock-rail-renderer.md) - [Gate OpenStation by role](./gate-by-role.md) - [React to window events](./react-to-window-events.md) diff --git a/docs/examples/dock-decks.md b/docs/examples/dock-decks.md new file mode 100644 index 000000000..21166f534 --- /dev/null +++ b/docs/examples/dock-decks.md @@ -0,0 +1,206 @@ +# Group the dock your way — custom decks + +> **Where this fits.** Dock customization has layers — see +> [the overview](../dock-customization.md). This page covers the one +> that decides *which tiles are on screen*. +> +> | If you want to… | Use… | +> |---|---| +> | Change which tiles are grouped together, and how they're named | **Decks** *(this page)* | +> | Add classNames, wrap tiles, animate them in | [Decoration hooks](./dock-decoration-hooks.md) | +> | Replace the entire rail (ring, stack, etc.) | [Rail renderer](./dock-rail-renderer.md) | + +A bottom dock is a pill, and a pill has a width. Rather than pushing +its overflow into a hidden horizontal scroll — tiles that exist only +for people who know to swipe — the rail can fold itself into **decks** +and show one at a time, with a tab strip at its leading edge naming +the one you're on. + +Four ship: `favorites` (whatever the user has starred), `wordpress` +(core admin menus), `apps` — which the shell labels *Plugins* — and +`station` (system tiles: OpenStation Preferences, the recycle bin, +plugin-owned native windows, Exit OpenStation). The list is a filter, +so all of it is yours. + +**Decks are opt-in** — `dockDecksEnabled`, off by default, in +OpenStation Preferences → Appearance → Dock groups. Your filter still +runs when they're off (the rail asks on every partition pass either +way), it just has nothing to paint. Don't turn the setting on for the +user from a plugin. + +**Status:** Stable. + +## The surface + +| Hook | Kind | Signature | +|---|---|---| +| `os.dock.decks` | Filter | `( decks: DockDeck[], ctx: DockHookContextBase ) => DockDeck[]` | +| `os.dock.deck-changed` | Action | `( ctx: DockHookContextBase & { deckId, previousDeckId, reason } ) => void` | + +```typescript +interface DockDeck { + id: string; // stable; persisted, and written to each tile's data-os-deck + label: string; // tab label and accessible name + icon: string; // dashicons class, or a URL / data: URI painted as a mask + order: number; // lower is closer to the leading edge + matchItem?: ( item: DockItem ) => boolean; + matchSystem?: ( item: SystemDockItem ) => boolean; +} +``` + +Four rules govern the result: + +1. **A tile joins the first deck that claims it**, in `order` order. A + narrow deck registered at a low `order` therefore wins its tiles + without you having to rewrite anyone else's predicate. This is how + the built-in `favorites` deck (order 5) takes a starred tile out of + `apps` (order 20) without either predicate knowing about the other. +2. **A deck that matches nothing is dropped.** No error, no empty tab. +3. **Fewer than two live decks means no decks at all** — the strip is + removed and every tile is on screen, exactly as an undecked rail. +4. **Bottom rails only.** A left or right rail is a column with the + shell's full height to spend and scrolls honestly, so it is never + decked and your filter simply never runs for it. + +The filter runs on every partition pass — boot, every live menu refresh, +and every system tile arriving or leaving — so keep it cheap and pure. + +## Give your plugin its own deck + +The common case: your plugin registers several admin menus and you'd +rather they lived together than scattered through Plugins. + +```php +add_action( 'admin_enqueue_scripts', function () { + if ( ! function_exists( 'openstation_is_enabled' ) || ! openstation_is_enabled() ) { + return; + } + wp_enqueue_script( + 'my-plugin-deck', + plugins_url( 'deck.js', __FILE__ ), + array( 'wp-hooks' ), + '1.0.0', + true + ); +} ); +``` + +```js +// deck.js +wp.hooks.addFilter( 'os.dock.decks', 'my-plugin/deck', ( decks ) => [ + ...decks, + { + id: 'my-shop', + label: 'Shop', + icon: 'dashicons-cart', + // Between WordPress (10) and Plugins (20) — the shop reads as a + // first-class area of this site, not as one more plugin. + order: 15, + matchItem: ( item ) => + item.id.startsWith( 'woocommerce' ) || + item.id === 'edit.php?post_type=product', + }, +] ); +``` + +Because the shop deck sorts ahead of `apps`, it takes those menus off +the Plugins deck automatically. Nothing else has to change. + +## Rename a built-in + +```js +wp.hooks.addFilter( 'os.dock.decks', 'my-plugin/rename', ( decks ) => + decks.map( ( deck ) => + deck.id === 'apps' ? { ...deck, label: 'Add-ons' } : deck + ) +); +``` + +**Keep the `id`.** It is what the user's remembered pick is stored +under, so changing it silently resets everyone to the leading deck — +which is exactly why the shell's own *Apps → Plugins* relabel moved +the `label` and left `apps` alone. + +## Turn decks off + +Return an empty list. The rail goes back to one undivided row with its +old horizontal scroll — which is the supported way for a theme to opt +out. + +```js +wp.hooks.addFilter( 'os.dock.decks', 'my-theme/no-decks', () => [] ); +``` + +## React to the deck changing + +```js +wp.hooks.addAction( 'os.dock.deck-changed', 'my-plugin/track', ( ctx ) => { + // ctx.reason tells you WHO changed it: + // 'click' | 'keyboard' | 'wheel' | 'swipe' — the user did + // 'restore' — the rail resolving a deck without being asked + // (boot, or a deactivation emptying the active one) + // 'auto' — the follow-focus preference, on the user's behalf + if ( ctx.reason === 'restore' ) { + return; + } + console.log( `${ ctx.rail }: ${ ctx.previousDeckId } → ${ ctx.deckId }` ); +} ); +``` + +And to drive it: + +```js +wp.os.dock?.setActiveDeck( 'my-shop' ); +wp.os.dock?.getActiveDeck(); // 'my-shop' | 'wordpress' | … | null +``` + +Both are no-ops on a rail that isn't decked, so neither needs a layout +check around it — `getActiveDeck()` returns `null` there. + +## Seed a starred set + +`dockFavorites` is a plain ordered list of canonical item ids, and it +is writable through the public settings writer — so a plugin that +knows what its users reach for can offer to set it up. The write +repaints the rail synchronously; no reload. + +```js +const snap = wp.os.getOsSettings(); +wp.os.updateOsSettings( { + dockFavorites: [ ...snap.dockFavorites, 'edit.php', 'my-plugin' ], +} ); +``` + +Append rather than replace unless the user explicitly asked for a +reset — the order is theirs, and the Favorites deck renders in it. +The same list is what the tile right-click menu's *Add to favorites* / +*Remove from favorites* entry writes — an entry every icon in the dock +carries, system tiles included, whether or not decks are switched on. + +Your own system tiles are starrable the moment you register them; ids +in `dockFavorites` are matched against `SystemDockItem.id` and +`DockItem.id` alike, so nothing extra is needed on your side. + +## Two things worth knowing + +**A hidden deck still speaks.** Its tab carries an indicator dot when +one of its tiles has an open window, and the sum of its tiles' badges +as a number. Folding a group away must not fold away what it was +telling the user, so if you add a deck you get this for free — and if +you set badges with `wp.os.dock.setBadge()`, they roll up without any +extra work on your side. + +**A custom rail renderer opts out.** Decks are painted by the shipped +`Dock` class; a renderer registered through +`wp.os.registerDockRailRenderer` replaces that class entirely, so it +owns its own grouping. Your `os.dock.decks` filter will not run for it. + +## See also + +- [Dock customization overview](../dock-customization.md) — how decks, + decoration hooks and rail renderers compose. +- [JavaScript Reference → Dock decks](../javascript-reference.md#dock-decks) + — every shape and every value. +- [Desktop themes → Dock glyphs](../desktop-themes.md#dock-glyphs) — + `--os-dock-deck-fill` / `--os-dock-deck-ink`, the two tokens that + repaint the active tab. diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index 02ef47f31..173925198 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -367,6 +367,8 @@ document.addEventListener( 'os-item-menu-opening', ( e ) => { Named for the intent rather than the input: a menu opened from the keyboard has the same collision, and so does one opened programmatically. +**Every icon in the dock has this menu**, admin-menu tiles and system tiles alike (OpenStation Preferences, the recycle bin, plugin-owned native windows, Exit OpenStation). What it *offers* differs: a system tile is only given the hide / move-between-rails entries when it declared `placeable: true` on registration — the same flag that decides whether it appears in Apps & Icons. Most don't, because most are load-bearing (Preferences is how you reach the screen that would put it back). The star and the settings shortcut are offered unconditionally: adding a tile to Favorites is non-destructive and belongs on every icon. + --- ### Drag-and-drop CustomEvents — Stable @@ -3295,7 +3297,7 @@ Register a tab in the OpenStation Preferences window. The tab is appended (or so | Field | Type | Notes | |---|---|---| | `isAdmin` | `boolean` | `true` when current user has `manage_options`. | -| `getOsSettings()` | `function` | Snapshot of the persisted OpenStation Preferences state — `{ wallpaper, accent, dockSize, windowRadius, unfocusEffect, ai: { enabled } }` plus `adminBarMode` (`'static'` \| `'dynamic'` \| `'hidden'` — how the WordPress admin bar presents above the shell; emitted as a `os-admin-bar-` body class), `desktopLayout`, `dockPlacement` (`'bottom'` \| `'left'` \| `'right'` — which edge the dock sits on; read by the one-rail layouts, ignored by `classic`), `dockRailRenderer`, `desktopTheme`, `appliedThemeRecommendations`, the native-window opt-ins (`nativePostsEnabled`, `nativePostsHiddenColumns`, `nativePagesEnabled`, `nativeUsersEnabled`, `nativePluginsEnabled`, `nativeCommentsEnabled`), `developerModeEnabled`, `foldersSharingEnabled`, `itemVisibility`, `dockOrder`, and `dockPromotedPositions` — see `OsSettingsSnapshot` in `src/settings/registry.ts` for the authoritative shape. `unfocusEffect` is the active unfocused-window effect id (`'darken'` default, `'none'` disables). `windowReveal` is the active window-reveal id — the clip-path transition that uncovers a window's content when it finishes loading (`'none'` by default; reveals are opt-in) — and `windowRevealDuration` is the global speed override in ms (`0`, the default, means each reveal keeps its own timing). `ai.enabled` is the per-user AI assistant toggle (opt-in, default off; enable-able only once a provider is configured in Settings → Connectors). `developerModeEnabled` (default `false`) gates developer-facing surfaces — the Starter Widget in the add-widget picker and the OpenStation Preferences → Components tab's missing-import-warner demo — set from OpenStation Preferences → Features. **Removed:** `ai.apiKey`, `ai.transport`, `ai.provider` and `ai.model` were removed — credentials live in WordPress Core's Settings → Connectors and provider + model selection is delegated to the Core AI Client. Read-only; returns a defensive copy. | +| `getOsSettings()` | `function` | Snapshot of the persisted OpenStation Preferences state — `{ wallpaper, accent, dockSize, windowRadius, unfocusEffect, ai: { enabled } }` plus `adminBarMode` (`'static'` \| `'dynamic'` \| `'hidden'` — how the WordPress admin bar presents above the shell; emitted as a `os-admin-bar-` body class), `desktopLayout`, `dockPlacement` (`'bottom'` \| `'left'` \| `'right'` — which edge the dock sits on; read by the one-rail layouts, ignored by `classic`), `dockRailRenderer`, `desktopTheme`, `appliedThemeRecommendations`, the native-window opt-ins (`nativePostsEnabled`, `nativePostsHiddenColumns`, `nativePagesEnabled`, `nativeUsersEnabled`, `nativePluginsEnabled`, `nativeCommentsEnabled`), `developerModeEnabled`, `foldersSharingEnabled`, `itemVisibility`, `dockOrder`, `dockDecksEnabled`, `dockFavorites`, and `dockPromotedPositions` — see `OsSettingsSnapshot` in `src/settings/registry.ts` for the authoritative shape. `unfocusEffect` is the active unfocused-window effect id (`'darken'` default, `'none'` disables). `windowReveal` is the active window-reveal id — the clip-path transition that uncovers a window's content when it finishes loading (`'none'` by default; reveals are opt-in) — and `windowRevealDuration` is the global speed override in ms (`0`, the default, means each reveal keeps its own timing). `ai.enabled` is the per-user AI assistant toggle (opt-in, default off; enable-able only once a provider is configured in Settings → Connectors). `developerModeEnabled` (default `false`) gates developer-facing surfaces — the Starter Widget in the add-widget picker and the OpenStation Preferences → Components tab's missing-import-warner demo — set from OpenStation Preferences → Features. **Removed:** `ai.apiKey`, `ai.transport`, `ai.provider` and `ai.model` were removed — credentials live in WordPress Core's Settings → Connectors and provider + model selection is delegated to the Core AI Client. Read-only; returns a defensive copy. | | `subscribeOsSettings( cb )` | `function` | Subscribe to in-panel OpenStation Preferences changes (user toggles a feature in the Features tab, etc.). Returns an unsubscribe function. Fires on local edits only — cross-device changes arrive on the next page load. | ```javascript @@ -3528,9 +3530,9 @@ wp.os.updateOsSettings( ): void; ``` -- **Whitelist semantics.** Only keys present on the public `OsSettingsSnapshot` shape are honored; unknown (or wrong-typed) keys are silently ignored, so a typo'd field can't bloat the persisted state. Collection fields are sanitized on the way in (`nativePostsHiddenColumns` / `dockOrder` entries must be non-empty strings, `itemVisibility` values must be one of `'both' | 'dock' | 'desktop' | 'hidden'`, `dockPromotedPositions` values must be finite `{ x, y }` coordinates). +- **Whitelist semantics.** Only keys present on the public `OsSettingsSnapshot` shape are honored; unknown (or wrong-typed) keys are silently ignored, so a typo'd field can't bloat the persisted state. Collection fields are sanitized on the way in (`nativePostsHiddenColumns` / `dockOrder` / `dockFavorites` entries must be non-empty strings, `itemVisibility` values must be one of `'both' | 'dock' | 'desktop' | 'hidden'`, `dockPromotedPositions` values must be finite `{ x, y }` coordinates). - **Persistence.** The write runs through the same pipeline as the panel: a `localStorage` cache write plus a debounced REST sync (250 ms window). -- **Presentation keys apply live.** A patch touching `wallpaper`, `accent`, `dockSize`, `windowRadius`, `adminBarMode`, `desktopLayout`, `dockPlacement`, `dockRailRenderer` or `desktopTheme` also runs the shell's apply pass, so the change is visible immediately rather than on the next page load. `unfocusEffect` repaints too, through the subscriber above rather than the apply pass. `windowReveal` and `windowRevealDuration` reach the shell the same way, and take effect on the next window load. Every other key is state-only. +- **Presentation keys apply live.** A patch touching `wallpaper`, `accent`, `dockSize`, `windowRadius`, `adminBarMode`, `desktopLayout`, `dockPlacement`, `dockRailRenderer`, `desktopTheme` or `dockDecksEnabled` also runs the shell's apply pass, so the change is visible immediately rather than on the next page load. `unfocusEffect` repaints too, through the subscriber above rather than the apply pass. `itemVisibility`, `dockOrder` and `dockFavorites` repaint the rail synchronously via the layout dispatcher. `windowReveal` and `windowRevealDuration` reach the shell the same way, and take effect on the next window load. Every other key is state-only. - **Subscribers fire.** Both the top-level `wp.os.subscribeOsSettings( cb )` and every settings tab's `ctx.subscribeOsSettings` see the new snapshot. - **Observable save lifecycle.** Each phase fires on `document` as [`os-settings-save-lifecycle`](#os-settings-save-lifecycle--stable) (`'pending'` → `'saving'` → `'saved'` / `'failed'`), same as a built-in tab's save. `` renders it for free. - **`opts.windowId`** attributes the in-flight REST sync to a specific window's activity phase (defaults to the OpenStation Preferences window). @@ -3576,7 +3578,7 @@ Each entry is a read-only descriptor — the underlying `SystemDockItem` (with i ] ``` -`placeable` is opt-in (`SystemDockItem.placeable`), because most system tiles are load-bearing — OpenStation Preferences is how you reach the very screen that would hide it. Set it on tiles that are genuinely optional decoration; Mio's toggle is the shipped example. Note the visibility override is honoured whether or not the flag is set: all it controls is whether the user is offered a row. +`placeable` is opt-in (`SystemDockItem.placeable`), because most system tiles are load-bearing — OpenStation Preferences is how you reach the very screen that would hide it. Set it on tiles that are genuinely optional decoration; Mio's toggle is the shipped example. Note the visibility override is honoured whether or not the flag is set: all it controls is whether the user is offered a row — in Apps & Icons, and in the tile's own right-click menu, which drops its hide / move entries for a tile that isn't placeable and keeps the Favorites star either way. ```js const tiles = wp.os.listSystemTiles(); @@ -4509,6 +4511,81 @@ Custom rail renderers (registered via `wp.os.registerDockRailRenderer`, see belo | `os.dock.item-appended` | action | Stable | `{ id }` — fires when `wp.os.registerSystemTile()` lands a tile | | `os.dock.item-removed` | action | Stable | `{ id, placement }` — symmetric counterpart to `item-appended` | | `os.dock.refresh-active` | action | Experimental | No payload. One you **fire**, not listen to: repaints every tile's active dot. The dock already repaints on window lifecycle events, so this is only for a system tile whose `isOpen()` asks something other than "is a window open?" — Mio's asks whether the companion is on screen, and no window event will ever fire for that | +| `os.dock.decks` | filter | Stable | `( decks: DockDeck[], ctx: DockHookContextBase ) → DockDeck[]` — the groups a bottom rail folds itself into. See [Dock decks](#dock-decks) | +| `os.dock.deck-changed` | action | Stable | `DockHookContextBase & { deckId, previousDeckId: string \| null, reason }` — fires after the visible deck changes | + +##### Dock decks + +A bottom rail is a pill, and a pill has a width. Rather than pushing the overflow into a hidden horizontal scroll, the rail can fold its tiles into **decks** and show one at a time, with a tab strip at its leading edge naming the one you're on. Four ship: + +| Deck id | `order` | Holds | +|---|---|---| +| `favorites` | 5 | Every tile whose id is in `dockFavorites` — menu tiles and system tiles alike. Empty until the user stars something from a tile's right-click menu | +| `wordpress` | 10 | Core admin menus — every `DockItem` whose `isCore` is not `false` | +| `apps` | 20 | Plugin-contributed menus — `isCore === false`. Labelled *Plugins*; the id stays `apps` because the user's remembered deck is stored under it | +| `station` | 30 | Every JS-registered system tile (OpenStation Preferences, the recycle bin, plugin-owned native windows, Exit OpenStation) | + +**Decks are opt-in** (`dockDecksEnabled`, default `false`) and a **bottom-placement affordance only**. A left or right rail is a column with the shell's full height to spend and scrolls honestly; folding it would hide tiles that already fit. A rail whose tiles all land in one deck loses the strip entirely and paints exactly as an undecked rail does — which is also what filtering every deck out does. + +Favorites leads on `order`, so a starred tile is starred *instead of* living with its provenance group — that is the point of starring it — and it is also the deck the rail opens on for a user who has any. + +```typescript +interface DockDeck { + id: string; // stable; persisted, and written to each tile's data-os-deck + label: string; // tab label and accessible name + icon: string; // dashicons class, or a URL / data: URI painted as a mask + order: number; // lower is closer to the leading edge + matchItem?: ( item: DockItem ) => boolean; + matchSystem?: ( item: SystemDockItem ) => boolean; +} +``` + +A tile joins the **first** deck in sorted order whose predicate claims it, so a narrow deck registered at a low `order` takes precedence without you having to rewrite the built-ins' predicates: + +```javascript +wp.hooks.addFilter( 'os.dock.decks', 'my-plugin', ( decks ) => [ + ...decks, + { + id: 'favourites', + label: 'Favourites', + icon: 'dashicons-star-filled', + order: 5, + matchItem: ( item ) => [ 'edit.php', 'upload.php' ].includes( item.id ), + }, +] ); +``` + +The filter runs on every partition pass — boot, every live menu refresh, and every system tile arriving or leaving — so keep it cheap and pure. + +`reason` on `os.dock.deck-changed` is `'click' | 'keyboard' | 'wheel' | 'swipe' | 'restore' | 'auto'`. `'restore'` is the rail resolving which deck to show without the user asking (boot, or a deactivation emptying the deck they were on); `'auto'` is the follow-focus preference moving the rail on their behalf. + +**Reading and driving the rail** — `wp.os.dock` exposes two methods, both no-ops on a rail that isn't decked, so you can call them without first checking the user's layout: + +```javascript +wp.os.dock.getActiveDeck(); // 'wordpress' | 'apps' | 'station' | … | null +wp.os.dock.setActiveDeck( 'apps' ); +``` + +The pick is remembered per rail under the `desktop-mode-dock-deck` localStorage key. + +Three OS-settings keys, all readable via `getOsSettings()` and writable via `updateOsSettings()` — and all three round-trip through the `desktop_mode_os_settings` user meta like every other preference, so they follow the user across browsers and devices: + +| Key | Values | Where | +|---|---|---| +| `dockDecksEnabled` | `boolean` (default `false`) — master switch; off means one undivided row | Appearance → Dock groups | +| `dockFavorites` | `string[]` — canonical item ids, in the order they were starred. Populates the Favorites deck | Right-click a dock tile → *Add to favorites* | +| `dockDeckFollowFocus` | `boolean` (default `false`) — switch the rail to the deck holding a window whenever that window takes focus | Appearance → Dock groups | + +`dockDecksEnabled` is off by default because decking trades "every tile is on screen" for "the rail fits" — worth it once there are enough tiles to feel the crowding, and not a call to make on a user's behalf before then. `dockDeckFollowFocus` is off on the [event-driven framework](./event-driven-framework.md)'s rule: the shell is a transport, not a UX policy maker, and a rail that reshuffles under the pointer because a window took focus is a policy the user did not ask for. With it off, the deck says so with its indicator dot and waits to be picked. + +Writing `dockFavorites` through `updateOsSettings()` repaints the rail synchronously, so a plugin can seed a starred set without a reload: + +```javascript +const snap = wp.os.getOsSettings(); +wp.os.updateOsSettings( { + dockFavorites: [ ...snap.dockFavorites, 'edit.php' ], +} ); +``` **`DockHookContextBase`** (shared by both context types): diff --git a/includes/os-settings.php b/includes/os-settings.php index 7450a4dc7..95150c358 100644 --- a/includes/os-settings.php +++ b/includes/os-settings.php @@ -259,6 +259,22 @@ function openstation_default_os_settings() { // the list keep their server-supplied position appended after // the listed ones. Unknown ids are tolerated. 'dockOrder' => array(), + // Whether the bottom dock folds its tiles into decks — + // Favorites, WordPress, Apps, OpenStation — and shows one at a + // time behind a tab strip. Opt-in: decking trades "every tile + // is on screen" for "the rail fits", which is worth it only + // once there are enough tiles to feel the crowding. + 'dockDecksEnabled' => false, + // Item ids the user has starred, in the order they starred + // them. Populates the Favorites deck; empty means no Favorites + // tab at all, since a deck matching nothing is dropped. Same + // shape and same sanitizer as `dockOrder`. + 'dockFavorites' => array(), + // Whether the decked dock switches itself to the deck holding a + // newly focused window. Off by default — the deck marks itself + // with an indicator dot and waits to be picked, rather than the + // shell reshuffling the rail under the pointer. + 'dockDeckFollowFocus' => false, // Persisted desktop position for every dock item the user has // promoted to the wallpaper via `itemVisibility[id]=desktop|both`. // Keyed by item id, value is `{ x: int, y: int }`. The JS @@ -309,6 +325,48 @@ function openstation_save_os_settings( $user_id, $settings ) { return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean ); } +/** + * Sanitizes an ordered list of shell item ids. + * + * Backs both `dockOrder` (the user's dock ordering) and + * `dockFavorites` (the tiles they starred) — same shape, same charset, + * same dedupe and same cap, so they share one implementation rather + * than running near-copies that can drift apart. + * + * Most ids are `sanitize_key()`-clean dock slugs, but cross-rail tiles + * the user promoted carry a rail-synthesis prefix (`desktop:` / + * `dock:`, built by `src/settings/item-placement.ts`). + * `sanitize_key()` strips the colon, which silently breaks the JS + * match on reload and can collide with an unrelated id — so the colon + * (and hyphen and underscore) are allowed while everything outside the + * JS id charset is still rejected. + * + * @param mixed $raw Raw list from the client or user meta. + * @return array Deduped list of clean ids, capped at 256. + */ +function openstation_sanitize_item_id_list( $raw ) { + $out = array(); + if ( ! is_array( $raw ) ) { + return $out; + } + $seen = array(); + foreach ( $raw as $id ) { + if ( ! is_string( $id ) || '' === $id ) { + continue; + } + $slug = (string) preg_replace( '/[^a-z0-9_:-]+/', '', strtolower( $id ) ); + if ( '' === $slug || isset( $seen[ $slug ] ) ) { + continue; + } + $seen[ $slug ] = true; + $out[] = $slug; + if ( count( $out ) >= 256 ) { + break; + } + } + return $out; +} + /** * Sanitizes a raw OS settings payload. * @@ -655,6 +713,14 @@ function openstation_sanitize_os_settings( $raw ) { ? (bool) $raw['showDesktopOnWallpaperClick'] : $defaults['showDesktopOnWallpaperClick']; + $dock_decks_enabled = isset( $raw['dockDecksEnabled'] ) + ? (bool) $raw['dockDecksEnabled'] + : $defaults['dockDecksEnabled']; + + $dock_deck_follow_focus = isset( $raw['dockDeckFollowFocus'] ) + ? (bool) $raw['dockDeckFollowFocus'] + : $defaults['dockDeckFollowFocus']; + $mio_enabled = isset( $raw['mioEnabled'] ) ? (bool) $raw['mioEnabled'] : $defaults['mioEnabled']; @@ -704,31 +770,15 @@ function openstation_sanitize_os_settings( $raw ) { } } - // dockOrder — ordered list of item ids. Most are sanitize_key()- - // clean dock slugs, but cross-rail tiles the user promoted carry a - // rail-synthesis prefix (`desktop:` / `dock:`, built by - // src/settings/item-placement.ts). sanitize_key() strips the colon, - // which silently breaks the JS order match on reload and can collide - // with an unrelated id — so allow the colon (and hyphen/underscore) - // while still rejecting anything outside the JS id charset. - $dock_order = array(); - if ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) { - $seen = array(); - foreach ( $raw['dockOrder'] as $id ) { - if ( ! is_string( $id ) || '' === $id ) { - continue; - } - $slug = (string) preg_replace( '/[^a-z0-9_:-]+/', '', strtolower( $id ) ); - if ( '' === $slug || isset( $seen[ $slug ] ) ) { - continue; - } - $seen[ $slug ] = true; - $dock_order[] = $slug; - if ( count( $dock_order ) >= 256 ) { - break; - } - } - } + // dockOrder / dockFavorites — ordered lists of item ids, same shape + // and same guarantees, so they share one sanitizer rather than + // running near-copies that can drift apart. + $dock_order = openstation_sanitize_item_id_list( + isset( $raw['dockOrder'] ) ? $raw['dockOrder'] : null + ); + $dock_favorites = openstation_sanitize_item_id_list( + isset( $raw['dockFavorites'] ) ? $raw['dockFavorites'] : null + ); // dockPromotedPositions — map. // Persisted positions for synthetic dock-promoted placements, so @@ -810,6 +860,9 @@ function openstation_sanitize_os_settings( $raw ) { 'foldersSharingEnabled' => $folders_sharing_enabled, 'itemVisibility' => $item_visibility, 'dockOrder' => $dock_order, + 'dockDecksEnabled' => $dock_decks_enabled, + 'dockFavorites' => $dock_favorites, + 'dockDeckFollowFocus' => $dock_deck_follow_focus, 'dockPromotedPositions' => $dock_promoted_positions, ); } diff --git a/src/api/facade.ts b/src/api/facade.ts index f1f836ced..92c8f8951 100644 --- a/src/api/facade.ts +++ b/src/api/facade.ts @@ -551,6 +551,17 @@ export function buildPublicApi( deps: BuildPublicApiDeps ): OpenStationPublicApi ) .slice( 0, 256 ); } + if ( Array.isArray( patch.dockFavorites ) ) { + osSettings.state.dockFavorites = patch.dockFavorites + .filter( + ( v ): v is string => + typeof v === 'string' && v !== '', + ) + .slice( 0, 256 ); + } + if ( typeof patch.dockDecksEnabled === 'boolean' ) { + osSettings.state.dockDecksEnabled = patch.dockDecksEnabled; + } if ( patch.dockPromotedPositions && typeof patch.dockPromotedPositions === 'object' @@ -616,7 +627,13 @@ export function buildPublicApi( deps: BuildPublicApiDeps ): OpenStationPublicApi typeof patch.desktopLayout === 'string' || typeof patch.dockPlacement === 'string' || typeof patch.dockRailRenderer === 'string' || - typeof patch.desktopTheme === 'string' + typeof patch.desktopTheme === 'string' || + // `apply()` is what writes `data-os-decks` on the + // shell, and `DockDecks.sync()` reads it there — so a + // deck toggle that skipped this would flip the state + // and leave the rail painting the old answer until + // something else happened to re-apply. + typeof patch.dockDecksEnabled === 'boolean' ) { osSettings.apply(); } @@ -631,7 +648,16 @@ export function buildPublicApi( deps: BuildPublicApiDeps ): OpenStationPublicApi // grid pick up the new placement synchronously with the // write — no F5 required for "Hide from dock" / "Also show // on desktop" picks from the right-click menu. - if ( patch.itemVisibility || patch.dockOrder ) { + // Starring a tile and toggling decks both change which + // tiles the rail shows, and both arrive through this + // writer (the tile's right-click menu, the Appearance + // toggle) — so they need the same synchronous repaint. + if ( + patch.itemVisibility || + patch.dockOrder || + patch.dockFavorites || + typeof patch.dockDecksEnabled === 'boolean' + ) { layoutDispatcher?.refresh(); } }, diff --git a/src/dock-decks/index.ts b/src/dock-decks/index.ts new file mode 100644 index 000000000..8328fa833 --- /dev/null +++ b/src/dock-decks/index.ts @@ -0,0 +1,1085 @@ +/** + * OpenStation — dock decks. + * + * The bottom dock is a single horizontal pill, and a pill has a width. + * A site with a dozen plugins overruns it, and the rail's answer used + * to be a hidden horizontal scroll: tiles existed, but only if you + * knew to swipe for them. Two faint dividers were the only hint that + * the row had structure at all. + * + * A deck is one of those clusters, promoted from "a gap between tiles" + * to "the thing the rail is currently showing". Exactly one deck is on + * screen at a time; a strip of tabs at the leading edge of the pill + * names it and offers the others. Three ship: + * + * - **WordPress** — core admin menus (`isCore !== false`). + * - **Apps** — plugin-contributed menus (`isCore === false`). + * - **OpenStation** — every JS-registered system tile: OS Settings, + * the recycle bin, plugin-owned native windows, Exit OpenStation. + * + * Those three are exactly the three clusters the un-decked rail already + * drew separators between, so nothing moves — the groups the user could + * already see are simply the groups they can now switch between. + * + * **Decks are a bottom-rail affordance only.** A left or right rail + * is a column with the shell's full height to spend and scrolls + * honestly; folding it into decks would hide tiles that already fit. + * `Dock` constructs this collaborator when its orientation is + * `'bottom'` and destroys it on any flip away. + * + * **The rail degrades to its old self.** With fewer than two non-empty + * decks there is nothing to switch between, so the tab strip is + * removed and `data-os-deck-active` is cleared — a clean install with + * no plugin menus paints exactly as it did before this module existed. + * + * Hiding a deck must not hide what it was trying to tell you, so a + * tab carries the state of the tiles behind it: an accent dot when the + * deck holds an open window, and the sum of its tiles' badges as a + * number. See {@link DockDecks.refreshIndicators}. + * + * Public surface: the `os.dock.decks` filter (add, rename, reorder, or + * drop a deck) and the `os.dock.deck-changed` action. Both documented + * in `docs/dock-customization.md`. + */ + +import { applyFilters, doAction, HOOKS } from '../hooks'; +import { __ } from '../i18n'; +import type { + DockHookContextBase, + DockItem, + SystemDockItem, +} from '../dock'; + +/** + * Where the active deck is remembered, per rail. The value is a JSON + * object keyed by rail discriminator (`taskbar`, `dock`) so the two + * rails a Classic layout puts on screen never fight over one slot. + * + * `desktop-mode-*` rather than `openstation-*`: web-storage keys are + * frozen data — see the table in `AGENTS.md`. + */ +export const DOCK_DECK_STORAGE_KEY = 'desktop-mode-dock-deck'; + +/** + * A deck: one named cluster of dock tiles, and the predicates that + * decide which tiles belong to it. + * + * A deck with neither predicate matches nothing and is dropped as + * empty — which is the sane reading of "a group with no membership + * rule", not something worth an error. + * + * @public + */ +export interface DockDeck { + /** Stable id. Persisted, and written to `data-os-deck`. */ + id: string; + /** Tab label. Also the tab's accessible name. */ + label: string; + /** + * Tab glyph — a dashicons class (`dashicons-admin-plugins`), or a + * URL / `data:` URI painted as a mask so it takes the tab's ink + * colour like every other dock glyph does. + */ + icon: string; + /** Sort order along the strip. Lower is closer to the leading edge. */ + order: number; + /** True when this menu tile belongs to this deck. */ + matchItem?: ( item: DockItem ) => boolean; + /** True when this system tile belongs to this deck. */ + matchSystem?: ( item: SystemDockItem ) => boolean; +} + +/** + * Payload of the `os.dock.deck-changed` action. + * + * @public + */ +export interface DockDeckChangeContext extends DockHookContextBase { + /** The deck now on screen. */ + deckId: string; + /** The deck that just left, or `null` on the first paint. */ + previousDeckId: string | null; + /** + * How the change was triggered. `'restore'` is the boot-time + * read of the persisted pick; `'auto'` is the follow-focus + * setting moving the rail on the user's behalf. + */ + reason: 'click' | 'keyboard' | 'wheel' | 'swipe' | 'restore' | 'auto'; +} + +/** What {@link DockDecks.sync} needs to see to partition the rail. */ +export interface DockDeckSyncInput { + items: DockItem[]; + tiles: ReadonlyMap< string, HTMLElement >; + systemItems: SystemDockItem[]; + systemTiles: ReadonlyMap< string, HTMLElement >; +} + +/** Wiring {@link DockDecks} takes from its host `Dock`. */ +export interface DockDecksDeps { + /** The dock element. Carries `data-os-deck-active`. */ + container: HTMLElement; + /** `.os-dock__scroll` — where menu tiles live. */ + itemHost: HTMLElement; + /** `.os-dock__pinned` — where system tiles live. */ + systemHost: HTMLElement; + /** Rail discriminator, used as the persistence slot. */ + rail: string; + /** Base for the hook payloads this module fires. */ + hookContext: () => DockHookContextBase; + /** + * Anchor the rail's shared tooltip over an element. The strip + * borrows the dock's own tooltip rather than a native `title`: + * the tabs sit in the same row as the tiles, and one hover + * surface answering in two different visual languages an inch + * apart is the tell that a control was bolted on. + */ + showTooltip: ( el: HTMLElement, text: string ) => void; + hideTooltip: () => void; +} + +/** Read the live OS-settings snapshot, or `null` before boot finishes. */ +function osSettings(): { dockFavorites?: string[] } | null { + const w = window as unknown as { + wp?: { os?: { getOsSettings?: () => { dockFavorites?: string[] } } }; + }; + try { + return w.wp?.os?.getOsSettings?.() ?? null; + } catch { + return null; + } +} + +/** + * The decks the shell ships. Built fresh on every call because the + * labels are translated AND because Favorites reads live state — a + * module-level constant would freeze both whatever locale happened to + * load first and whatever the starred set was at boot. + */ +function builtInDecks(): DockDeck[] { + // A Set, because this is consulted once per tile per partition + // pass and the starred list can hold a few hundred ids. + const favorites = new Set( osSettings()?.dockFavorites ?? [] ); + return [ + { + id: 'favorites', + label: __( 'Favorites' ), + icon: 'dashicons-star-filled', + // First on the strip, and first claim on every tile: a + // starred tile is starred *instead of* living with its + // provenance group, which is the entire point of starring + // it. Empty until the user stars something, and a deck + // matching nothing is dropped — so this costs a `Set` + // lookup per tile and no pixels. + order: 5, + matchItem: ( item ) => favorites.has( item.id ), + matchSystem: ( item ) => favorites.has( item.id ), + }, + { + id: 'wordpress', + label: __( 'WordPress' ), + icon: 'dashicons-wordpress-alt', + order: 10, + // `!== false`, not `=== true`: an item that never got the + // server-side classification is core by default, which is + // the same reading `Dock.render()`'s separator uses. + matchItem: ( item ) => item.isCore !== false, + }, + { + id: 'apps', + // "Plugins", not "Apps" — the id stays `apps` because it + // is what the user's remembered deck is stored under and + // what `os.dock.decks` subscribers match on. The label is + // the part anyone reads, and next to WordPress, Favorites + // and OpenStation it wants a word of comparable length; + // "Apps" sat short enough in that row to read as a + // different kind of thing. + label: __( 'Plugins' ), + icon: 'dashicons-admin-plugins', + order: 20, + matchItem: ( item ) => item.isCore === false, + }, + { + id: 'station', + label: __( 'OpenStation' ), + icon: 'dashicons-screenoptions', + order: 30, + matchSystem: () => true, + }, + ]; +} + +/** Read the whole persisted map, tolerating anything malformed. */ +function readStore(): Record< string, string > { + try { + const raw = window.localStorage.getItem( DOCK_DECK_STORAGE_KEY ); + if ( ! raw ) { + return {}; + } + const parsed: unknown = JSON.parse( raw ); + if ( ! parsed || typeof parsed !== 'object' || Array.isArray( parsed ) ) { + return {}; + } + const out: Record< string, string > = {}; + for ( const [ key, value ] of Object.entries( + parsed as Record< string, unknown >, + ) ) { + if ( typeof value === 'string' ) { + out[ key ] = value; + } + } + return out; + } catch { + // Private-browsing quota errors and hand-edited values both + // land here; an unremembered deck is a fine outcome for both. + return {}; + } +} + +function writeStore( rail: string, deckId: string ): void { + try { + const store = readStore(); + store[ rail ] = deckId; + window.localStorage.setItem( + DOCK_DECK_STORAGE_KEY, + JSON.stringify( store ), + ); + } catch { + // Non-fatal — the deck simply won't survive a reload. + } +} + +/** + * Whether the user has opted the rail into decks. + * + * Off by default: decking trades "every tile is on screen" for "the + * rail fits", which is a good trade once you have the tiles to feel + * the crowding and a bad one before then. The toggle lives in + * OpenStation Preferences → Appearance → Dock groups and reaches here + * as an attribute written by `OsSettings.apply()`, so a flip lands on + * the live rail through the dispatcher's refresh without a rebuild. + * + * Absent attribute reads as off, which is also what the shell looks + * like for the frame before the first `apply()` runs. + */ +function decksEnabled(): boolean { + return ( + document.getElementById( 'os-shell' )?.getAttribute( 'data-os-decks' ) === + '1' + ); +} + +/** + * Whether the rail should move itself to the deck holding a newly + * focused window. + * + * Off unless the user turned it on in OpenStation Preferences → + * Appearance. The framework is a transport, not a UX policy maker + * (see `docs/event-driven-framework.md`); a rail that reshuffles under + * the pointer because a window took focus is exactly the kind of + * heuristic that rule exists to keep out of the default. The setting + * writes the attribute from `OsSettings.apply()`, so boot, every save + * and the rollback after a failed save all land here. + */ +function followFocusEnabled(): boolean { + return ( + document + .getElementById( 'os-shell' ) + ?.getAttribute( 'data-os-deck-follow-focus' ) === '1' + ); +} + +/** + * Paint a deck tab's glyph. Mirrors the tile-icon contract in a + * deliberately small way: a `dashicons-*` class becomes a dashicon + * span, anything else is treated as an image and painted as a mask so + * it inherits the tab's `color` — which is what lets one tab go from + * muted to Void-on-mesh when it becomes the active one. + */ +function buildDeckGlyph( icon: string ): HTMLElement { + if ( icon.startsWith( 'dashicons-' ) ) { + const span = document.createElement( 'span' ); + span.className = `dashicons ${ icon }`; + span.setAttribute( 'aria-hidden', 'true' ); + return span; + } + const span = document.createElement( 'span' ); + span.className = 'os-dock__deck-mask'; + span.setAttribute( 'aria-hidden', 'true' ); + span.style.setProperty( '--os-deck-mask', `url("${ icon }")` ); + return span; +} + +/** + * The deck controller for one bottom rail. + * + * Owns the tab strip and the `data-os-deck` stamps; owns nothing + * about the tiles themselves. `Dock` keeps building tiles exactly as + * it always did and calls {@link sync} afterwards — which is what + * keeps the hover-peek, the constellation flyout, drag-reorder and + * every decoration hook working untouched. Visibility is CSS, driven + * by one attribute on the dock. + */ +export class DockDecks { + private deps: DockDecksDeps; + private strip: HTMLElement | null = null; + /** Tab elements by deck id, for indicator updates without a re-query. */ + private tabs: Map< string, HTMLElement > = new Map(); + /** Decks that currently hold at least one tile, in strip order. */ + private live: DockDeck[] = []; + private activeId: string | null = null; + /** Set once the starting state has been painted — see `applyVisibility`. */ + private ready = false; + /** + * Reentrancy guard. `setActive` repaints the tabs, repainting the + * tabs refreshes the indicators, and refreshing the indicators can + * trigger follow-focus — which calls `setActive`. The cycle does + * terminate on its own (the second pass finds the deck already + * active), but it runs the whole indicator sweep twice per switch + * for nothing. + */ + private inFollowFocus = false; + /** In-flight frame handle for {@link trackPlate}. */ + private plateFrame = 0; + /** Keeps the plate under the active tab when the strip is re-laid out. */ + private stripResize: ResizeObserver | null = null; + private detachers: Array< () => void > = []; + + constructor( deps: DockDecksDeps ) { + this.deps = deps; + this.bindGestures(); + } + + /** The deck currently on screen, or `null` while the rail is un-decked. */ + public getActive(): string | null { + return this.activeId; + } + + /** + * Re-partition the rail. + * + * Called after every path that can change what is on the rail: + * the constructor's first render, a live menu refresh + * (`replaceItems`), and system tiles arriving or leaving. Cheap + * enough to call unconditionally — the work is a walk of the tile + * list plus, when the set of non-empty decks actually changed, a + * rebuild of a three-button strip. + */ + public sync( input: DockDeckSyncInput ): void { + // Opted out. Drop everything this module has put on the rail + // and leave — including the stamps, or two thirds of the tiles + // stay hidden behind a feature that is no longer on. + if ( ! decksEnabled() ) { + this.teardownStrip(); + this.activeId = null; + delete this.deps.container.dataset.osDeckActive; + this.stampNone( input ); + this.applyVisibility(); + return; + } + + const ctx = this.deps.hookContext(); + const decks = applyFilters< DockDeck[] >( + HOOKS.DOCK_DECKS, + builtInDecks(), + ctx, + ) + .filter( ( deck ) => !! deck && typeof deck.id === 'string' ) + .slice() + .sort( ( a, b ) => ( a.order ?? 0 ) - ( b.order ?? 0 ) ); + + // Stamp every tile with the first deck that claims it. First + // rather than last so a plugin prepending a narrower deck + // (order 5, "Favourites") wins the tiles it names without + // having to also rewrite the built-ins' predicates. + const populated = new Set< string >(); + + const stamp = ( + el: HTMLElement | undefined, + deckId: string | null, + ): void => { + if ( ! el ) { + return; + } + if ( deckId ) { + el.dataset.osDeck = deckId; + populated.add( deckId ); + } else { + delete el.dataset.osDeck; + } + }; + + for ( const item of input.items ) { + const deck = decks.find( ( d ) => d.matchItem?.( item ) === true ); + stamp( input.tiles.get( item.id ), deck ? deck.id : null ); + } + for ( const item of input.systemItems ) { + const deck = decks.find( ( d ) => d.matchSystem?.( item ) === true ); + stamp( input.systemTiles.get( item.id ), deck ? deck.id : null ); + } + + this.live = decks.filter( ( d ) => populated.has( d.id ) ); + + // Nothing to switch between — take the whole affordance off + // screen and let the rail be what it was. + if ( this.live.length < 2 ) { + this.teardownStrip(); + this.activeId = null; + delete this.deps.container.dataset.osDeckActive; + this.applyVisibility(); + return; + } + + this.buildStrip(); + + // Resolve the deck to show: the current one if it survived, + // else the remembered one, else the leading deck. + const remembered = readStore()[ this.deps.rail ]; + const next = + ( this.activeId && + this.live.some( ( d ) => d.id === this.activeId ) && + this.activeId ) || + ( this.live.some( ( d ) => d.id === remembered ) && remembered ) || + this.live[ 0 ].id; + + if ( next !== this.activeId ) { + // `persist: false` — this is the rail catching up with what + // is on it (boot, or a deck emptied by a deactivation), not + // the user picking. Writing here would overwrite their + // remembered deck with a fallback they never chose. + // + // The boot paint doesn't animate either, but that is + // `applyVisibility`'s job now (it suppresses transitions + // for the first frame) rather than a flag threaded through + // here — the tiles collapse and expand through CSS, so + // there is no imperative entrance left to skip. + this.setActive( next, 'restore', { persist: false } ); + } else { + // Same deck, but tiles moved: freshly-rendered ones have + // no visibility class yet, and the strip may have just + // been rebuilt without its active state. + this.applyVisibility(); + this.paintActiveTab(); + } + } + + /** + * Show a deck. + * + * A no-op when the deck is already on screen or isn't currently + * live, so callers (gesture handlers, follow-focus, the settings + * round-trip) can fire freely without guarding. + */ + public setActive( + deckId: string, + reason: DockDeckChangeContext[ 'reason' ] = 'click', + opts: { persist?: boolean } = {}, + ): void { + if ( deckId === this.activeId ) { + return; + } + if ( ! this.live.some( ( d ) => d.id === deckId ) ) { + return; + } + const previous = this.activeId; + + this.activeId = deckId; + this.deps.container.dataset.osDeckActive = deckId; + this.applyVisibility(); + this.paintActiveTab(); + + if ( opts.persist !== false ) { + writeStore( this.deps.rail, deckId ); + } + + doAction( HOOKS.DOCK_DECK_CHANGED, { + ...this.deps.hookContext(), + deckId, + previousDeckId: previous, + reason, + } as DockDeckChangeContext ); + } + + /** + * Move one deck along the strip. Does not wrap: the strip is three + * items long and wrapping past either end reads as a glitch rather + * than as navigation. + */ + public step( direction: 1 | -1, reason: DockDeckChangeContext[ 'reason' ] ): void { + if ( ! this.activeId ) { + return; + } + const at = this.live.findIndex( ( d ) => d.id === this.activeId ); + const next = at + direction; + if ( at < 0 || next < 0 || next >= this.live.length ) { + return; + } + this.setActive( this.live[ next ].id, reason ); + } + + /** + * Push the state of the hidden tiles onto their tabs. + * + * Called from `Dock.updateActiveStates()`, so it runs on exactly + * the events that can change what a tile is saying: window + * lifecycle, desktop switches, and the explicit + * `os.dock.refresh-active` escape hatch. Badges set through + * `Dock.setBadge()` land here too — that method repaints active + * states after mutating the badge node. + * + * Aggregate badges appear on INACTIVE tabs only. The active deck's + * tiles are on screen carrying their own counts, and a tab + * repeating their sum two inches away is the same number twice. + */ + public refreshIndicators(): void { + if ( ! this.strip ) { + return; + } + for ( const deck of this.live ) { + const tab = this.tabs.get( deck.id ); + if ( ! tab ) { + continue; + } + const tiles = this.tilesIn( deck.id ); + const hasOpen = tiles.some( ( t ) => + t.classList.contains( 'os-dock__item--active' ), + ); + const hasFocused = tiles.some( ( t ) => + t.classList.contains( 'os-dock__item--focused' ), + ); + tab.classList.toggle( 'os-dock__deck--has-open', hasOpen ); + tab.classList.toggle( 'os-dock__deck--has-focused', hasFocused ); + + const total = + deck.id === this.activeId + ? 0 + : tiles.reduce( ( sum, tile ) => { + const badge = tile.querySelector< HTMLElement >( + '.os-dock__badge', + ); + const n = Number.parseInt( + badge?.textContent ?? '', + 10, + ); + return sum + ( Number.isFinite( n ) ? n : 0 ); + }, 0 ); + this.paintTabBadge( tab, total ); + } + + if ( ! this.inFollowFocus && followFocusEnabled() ) { + this.inFollowFocus = true; + try { + this.followFocus(); + } finally { + this.inFollowFocus = false; + } + } + } + + /** Detach listeners and remove the strip. Idempotent. */ + public destroy(): void { + for ( const off of this.detachers ) { + off(); + } + this.detachers = []; + this.teardownStrip(); + this.activeId = null; + this.live = []; + // Clear the stamps before dropping the attribute: a rail that + // flips to a vertical placement keeps its tiles, and a leftover + // `--deck-off` would hide two thirds of them forever — and + // leave them `inert`, which is the half that no amount of CSS + // would put right. + this.applyVisibility(); + delete this.deps.container.dataset.osDeckActive; + delete this.deps.container.dataset.osDeckInit; + } + + // ----------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------- + + /** + * Strip every deck stamp. The visibility class is keyed off + * `activeId` and would already be clear, but `data-os-deck` is + * documented surface a plugin may be reading — leaving it behind + * on an opted-out rail would advertise a grouping that isn't + * happening. + */ + private stampNone( input: DockDeckSyncInput ): void { + for ( const el of [ + ...input.tiles.values(), + ...input.systemTiles.values(), + ] ) { + delete el.dataset.osDeck; + } + } + + /** + * Put exactly one deck on screen. + * + * Done in JS rather than in a stylesheet because CSS cannot + * compare two attributes — there is no way to say "hide the tiles + * whose `data-os-deck` differs from the dock's + * `data-os-deck-active`" without writing one rule per deck id, + * which would work for the three built-ins and silently fail for + * every deck a plugin adds through the `os.dock.decks` filter. + * + * A tile with no deck stamp is always visible: it belongs to no + * group, so no group can be hiding it. + */ + private applyVisibility(): void { + const all = [ + ...this.deps.itemHost.querySelectorAll< HTMLElement >( + '.os-dock__item', + ), + ...this.deps.systemHost.querySelectorAll< HTMLElement >( + '.os-dock__item', + ), + ]; + for ( const tile of all ) { + const deck = tile.dataset.osDeck; + const off = !! this.activeId && !! deck && deck !== this.activeId; + tile.classList.toggle( 'os-dock__item--deck-off', off ); + // A collapsed tile is zero pixels wide and fully + // transparent, but its button is still a button — without + // this, tabbing through the shell walks every tile on + // every deck. `inert` rather than a CSS + // `visibility: hidden`, which would have to be delayed + // until the collapse finished and would tie this rule to + // the duration token. + tile.inert = off; + } + + // The first partition is the rail's starting state, not a + // switch. Suppressing transitions for one frame is what stops + // the dock opening on every tile it has and then visibly + // folding two thirds of them away. + if ( ! this.ready ) { + this.ready = true; + this.deps.container.dataset.osDeckInit = ''; + const clear = (): void => { + delete this.deps.container.dataset.osDeckInit; + }; + if ( typeof requestAnimationFrame === 'function' ) { + requestAnimationFrame( () => requestAnimationFrame( clear ) ); + } else { + clear(); + } + } + } + + /** Every tile currently stamped for a deck, in DOM order. */ + private tilesIn( deckId: string ): HTMLElement[] { + const selector = `.os-dock__item[data-os-deck="${ deckId }"]`; + return [ + ...this.deps.itemHost.querySelectorAll< HTMLElement >( selector ), + ...this.deps.systemHost.querySelectorAll< HTMLElement >( selector ), + ]; + } + + /** + * Build (or rebuild) the tab strip. + * + * Rebuilt wholesale rather than reconciled: it is at most a handful + * of buttons, it only changes when the set of non-empty decks + * changes, and a full rebuild is the version that cannot leave a + * stale tab behind after a plugin deactivation empties a deck. + */ + private buildStrip(): void { + const signature = this.live.map( ( d ) => `${ d.id }:${ d.label }` ).join( '|' ); + if ( this.strip && this.strip.dataset.signature === signature ) { + return; + } + this.teardownStrip(); + + const strip = document.createElement( 'div' ); + strip.className = 'os-dock__decks'; + strip.dataset.signature = signature; + strip.setAttribute( 'role', 'tablist' ); + strip.setAttribute( 'aria-orientation', 'horizontal' ); + strip.setAttribute( 'aria-label', __( 'Dock groups' ) ); + + // The travelling selection. First child so it paints under the + // tabs; `aria-hidden` because the selection it draws is + // already stated by `aria-selected` on the tab it is under. + const plate = document.createElement( 'div' ); + plate.className = 'os-dock__deck-plate'; + plate.setAttribute( 'aria-hidden', 'true' ); + strip.appendChild( plate ); + + for ( const deck of this.live ) { + const tab = document.createElement( 'button' ); + tab.type = 'button'; + tab.className = 'os-dock__deck'; + tab.dataset.deck = deck.id; + tab.setAttribute( 'role', 'tab' ); + tab.setAttribute( 'aria-selected', 'false' ); + tab.tabIndex = -1; + // The label is visible on the active tab and clipped to + // zero width on the others, so it cannot be the accessible + // name on its own — a collapsed label still needs to + // announce. `aria-label` states it unconditionally and + // `aria-hidden` on the visual span stops the duplicate. + tab.setAttribute( 'aria-label', deck.label ); + // Which region of the rail this tab reveals. Menu decks + // paint into the scroll wrapper, system decks into the + // pinned one. + // `matchItem` first: a deck that claims both cohorts + // (Favorites does) paints most of itself into the menu + // wrapper, so that is the region the tab reveals. + const host = deck.matchItem + ? this.deps.itemHost + : this.deps.systemHost; + if ( host.id ) { + tab.setAttribute( 'aria-controls', host.id ); + } + + tab.appendChild( buildDeckGlyph( deck.icon ) ); + const label = document.createElement( 'span' ); + label.className = 'os-dock__deck-label'; + label.textContent = deck.label; + label.setAttribute( 'aria-hidden', 'true' ); + tab.appendChild( label ); + + tab.addEventListener( 'click', () => { + this.setActive( deck.id, 'click' ); + this.deps.hideTooltip(); + tab.focus(); + } ); + + // Naming a collapsed tab. The nicer answer — reveal the + // label under the pointer — is the one thing this strip + // cannot do: the label is what makes the active tab wide, + // so animating one open on hover would shove the whole + // rail sideways under a pointer that is mid-click. The + // active tab is already named and stays silent. + tab.addEventListener( 'pointerenter', () => { + if ( ! tab.classList.contains( 'os-dock__deck--active' ) ) { + this.deps.showTooltip( tab, deck.label ); + } + } ); + tab.addEventListener( 'pointerleave', () => + this.deps.hideTooltip(), + ); + + strip.appendChild( tab ); + this.tabs.set( deck.id, tab ); + } + + strip.addEventListener( 'keydown', ( e ) => this.onStripKeydown( e ) ); + + const separator = document.createElement( 'div' ); + separator.className = 'os-dock__separator os-dock__separator--decks'; + separator.setAttribute( 'aria-hidden', 'true' ); + + this.deps.container.insertBefore( + separator, + this.deps.container.firstChild, + ); + this.deps.container.insertBefore( strip, separator ); + this.strip = strip; + + // Anything that re-lays the strip out without changing the + // active deck still moves the plate's target: the Dock size + // preference resizes every glyph, a locale change relabels the + // active tab, a desktop theme swaps the icon set. Cheaper and + // more complete than subscribing to each of those. + if ( typeof ResizeObserver === 'function' ) { + this.stripResize = new ResizeObserver( () => this.positionPlate() ); + this.stripResize.observe( strip ); + } + + this.paintActiveTab(); + } + + private teardownStrip(): void { + this.stripResize?.disconnect(); + this.stripResize = null; + if ( typeof cancelAnimationFrame === 'function' ) { + cancelAnimationFrame( this.plateFrame ); + } + this.plateFrame = 0; + this.strip?.remove(); + this.strip = null; + this.tabs.clear(); + this.deps.container + .querySelectorAll( ':scope > .os-dock__separator--decks' ) + .forEach( ( el ) => el.remove() ); + } + + /** + * Reflect the active deck onto the strip: `aria-selected`, the + * `--active` class the mesh hangs off, and the roving tabindex + * that keeps the strip a single stop on the way through the shell. + */ + private paintActiveTab(): void { + for ( const [ id, tab ] of this.tabs ) { + const on = id === this.activeId; + tab.classList.toggle( 'os-dock__deck--active', on ); + tab.setAttribute( 'aria-selected', on ? 'true' : 'false' ); + tab.tabIndex = on ? 0 : -1; + } + this.trackPlate(); + this.refreshIndicators(); + } + + /** + * Write the plate's target geometry. + * + * The plate is ONE element that travels between tabs rather than a + * fill that switches off on one and on at the next — the same + * distinction the window tab strip's plate is built on, and for + * the same reason: a fill that switches has to cross-fade a mesh + * into nothing, and every frame in between belongs to neither + * state. Nothing cross-fades here. The surface moves, and the ink + * flips underneath it. + * + * `data-placed` gates the transition. A width of zero means layout + * has not run yet, and placing the plate off that measurement + * would teach it a wrong origin to travel from — it would fly in + * from the strip's leading edge the first time the dock paints. + */ + private positionPlate(): void { + const strip = this.strip; + if ( ! strip ) { + return; + } + const tab = this.activeId ? this.tabs.get( this.activeId ) : null; + if ( ! tab ) { + return; + } + // Read both before writing either: `offsetWidth` forces a + // layout flush, and interleaving reads with the custom-property + // writes would flush twice per frame for nothing. + const x = tab.offsetLeft; + const w = tab.offsetWidth; + strip.style.setProperty( '--_deck-plate-x', `${ x }px` ); + strip.style.setProperty( '--_deck-plate-w', `${ w }px` ); + if ( w > 0 ) { + strip.dataset.platePlaced = ''; + } + } + + /** + * Follow the active tab for as long as the strip is still moving + * under it. + * + * A single measurement after the class flip would be wrong, and + * this is the part worth reading twice: the label reveal animates + * the tab's own width, so at the instant the flip lands the + * incoming tab is still collapsed and sitting where the collapsed + * layout put it. Its final box does not exist yet to be measured. + * + * So the plate is given a moving target. It starts travelling + * toward the incoming tab's *current* box while that tab grows and + * slides toward the plate; the two converge. The alternative — + * snapping the labels so the layout settles in one frame — would + * settle it, and would also make the whole pill jump width and + * re-centre in that frame, since the dock is `width: fit-content; + * margin: 0 auto`. Moving the plate is cheaper than moving the + * dock. + * + * Bounded by frame count rather than a timer so it costs nothing + * in a background tab, where rAF simply stops. + */ + private trackPlate(): void { + if ( typeof requestAnimationFrame !== 'function' ) { + this.positionPlate(); + return; + } + cancelAnimationFrame( this.plateFrame ); + // ~1s at 60Hz, comfortably past the label reveal that + // `--os-dock-deck-slide` drives (720ms shipped). Tracking a + // few frames longer than needed costs two layout reads each; + // stopping short would strand the plate at whatever width the + // tab happened to have when the loop gave up — which is the + // failure this margin exists to buy off, so keep it ahead of + // the token if that token ever slows down again. + let frames = 60; + const step = (): void => { + this.positionPlate(); + if ( --frames > 0 ) { + this.plateFrame = requestAnimationFrame( step ); + } + }; + // Called, not scheduled: the first placement has to land in + // this task so the strip's very first paint sets + // `data-plate-placed` without the plate having had a frame at + // zero width to travel out of. `step` schedules its own + // successor, so there is exactly one chain in flight. + step(); + } + + private paintTabBadge( tab: HTMLElement, count: number ): void { + const existing = tab.querySelector< HTMLElement >( + ':scope > .os-dock__deck-badge', + ); + if ( count <= 0 ) { + existing?.remove(); + return; + } + const display = count > 99 ? '99+' : String( count ); + if ( existing ) { + if ( existing.textContent !== display ) { + existing.textContent = display; + } + return; + } + const badge = document.createElement( 'span' ); + badge.className = 'os-dock__deck-badge'; + badge.textContent = display; + // Not announced: the tab's own `aria-label` names the deck, + // and the count is restated by the tiles the moment the deck + // is opened. A second live number here reads as noise. + badge.setAttribute( 'aria-hidden', 'true' ); + tab.appendChild( badge ); + } + + /** + * Arrow / Home / End inside the strip, with AUTOMATIC activation. + * + * The opposite call from the window tab strip, and for the reason + * that one gives: activation there loads an admin page, so + * arrowing past eight tabs must not fire eight loads. Switching a + * deck toggles an attribute. Nothing is fetched, nothing is + * mounted, and manual activation would mean every keyboard user + * pressing Enter after every arrow for no benefit. + */ + private onStripKeydown( e: KeyboardEvent ): void { + if ( e.altKey || e.ctrlKey || e.metaKey ) { + return; + } + let target: string | null = null; + switch ( e.key ) { + case 'ArrowRight': + case 'ArrowDown': + this.step( 1, 'keyboard' ); + target = this.activeId; + break; + case 'ArrowLeft': + case 'ArrowUp': + this.step( -1, 'keyboard' ); + target = this.activeId; + break; + case 'Home': + target = this.live[ 0 ]?.id ?? null; + if ( target ) { + this.setActive( target, 'keyboard' ); + } + break; + case 'End': + target = this.live[ this.live.length - 1 ]?.id ?? null; + if ( target ) { + this.setActive( target, 'keyboard' ); + } + break; + default: + return; + } + e.preventDefault(); + if ( target ) { + this.tabs.get( target )?.focus(); + } + } + + /** + * Wheel and swipe across the rail. + * + * Both defer to a scrolling deck. A deck wide enough to overflow + * the pill still owns its horizontal scroll, and stealing that + * gesture would make the tiles past the edge unreachable by the + * only means that ever reached them — trading one hidden-tile + * problem for the same one. + */ + private bindGestures(): void { + const { container, itemHost } = this.deps; + + const overflowing = (): boolean => + itemHost.scrollWidth - itemHost.clientWidth > 1; + + let wheelLock = 0; + const onWheel = ( e: WheelEvent ): void => { + if ( ! this.strip || overflowing() ) { + return; + } + const delta = + Math.abs( e.deltaX ) > Math.abs( e.deltaY ) ? e.deltaX : e.deltaY; + if ( Math.abs( delta ) < 8 ) { + return; + } + const now = e.timeStamp; + // One deck per gesture. A trackpad flick delivers a long + // tail of decaying deltas; without a lock a single swipe + // would run the whole strip. + if ( now - wheelLock < 420 ) { + return; + } + wheelLock = now; + e.preventDefault(); + this.step( delta > 0 ? 1 : -1, 'wheel' ); + }; + container.addEventListener( 'wheel', onWheel, { passive: false } ); + this.detachers.push( () => + container.removeEventListener( 'wheel', onWheel ), + ); + + let swipeFrom: { x: number; y: number; id: number } | null = null; + const onDown = ( e: PointerEvent ): void => { + if ( e.pointerType !== 'touch' || ! this.strip || overflowing() ) { + swipeFrom = null; + return; + } + swipeFrom = { x: e.clientX, y: e.clientY, id: e.pointerId }; + }; + const onUp = ( e: PointerEvent ): void => { + if ( ! swipeFrom || e.pointerId !== swipeFrom.id ) { + return; + } + const dx = e.clientX - swipeFrom.x; + const dy = e.clientY - swipeFrom.y; + swipeFrom = null; + // Horizontal, and decisively so — a 48px drag that also + // moved 40px vertically is someone scrolling the page. + if ( Math.abs( dx ) < 48 || Math.abs( dx ) < Math.abs( dy ) * 1.5 ) { + return; + } + this.step( dx < 0 ? 1 : -1, 'swipe' ); + }; + container.addEventListener( 'pointerdown', onDown ); + container.addEventListener( 'pointerup', onUp ); + container.addEventListener( 'pointercancel', onUp ); + this.detachers.push( () => { + container.removeEventListener( 'pointerdown', onDown ); + container.removeEventListener( 'pointerup', onUp ); + container.removeEventListener( 'pointercancel', onUp ); + } ); + } + + /** + * Opt-in: move the rail to the deck holding the focused window. + * + * Reads the tiles' own `--focused` class rather than asking the + * window manager, so it stays true to whatever the dock decided + * "focused" means — including the id-derivation fallbacks in + * `updateActiveStates()` that a naive baseId lookup here would + * miss. + */ + private followFocus(): void { + if ( ! this.activeId ) { + return; + } + for ( const deck of this.live ) { + if ( deck.id === this.activeId ) { + continue; + } + const hit = this.tilesIn( deck.id ).some( ( t ) => + t.classList.contains( 'os-dock__item--focused' ), + ); + if ( hit ) { + this.setActive( deck.id, 'auto' ); + return; + } + } + } +} diff --git a/src/dock.ts b/src/dock.ts index 76c4f498b..5bd8c906c 100644 --- a/src/dock.ts +++ b/src/dock.ts @@ -20,6 +20,7 @@ import { import { applyIconMask } from './desktop-themes/paint-tinted-icon'; import { slotForTileId } from './desktop-themes/slots'; import { attachDockPeek } from './dock-peek'; +import { DockDecks } from './dock-decks'; import { tryOpenExternalUrl } from './external-url'; import { openItemVisibilityMenu } from './item-visibility-menu-loader'; import { @@ -305,6 +306,14 @@ export class Dock { /** Unique hooks-bus namespace per instance for clean teardown. */ private hooksNamespace: string; + /** + * Deck controller — the bottom rail's answer to a pill narrower + * than its contents. Present only in the `'bottom'` orientation; + * a vertical rail is a column with the shell's whole height to + * spend and scrolls honestly. See `src/dock-decks/index.ts`. + */ + private decks: DockDecks | null = null; + private static instanceCounter = 0; /** @@ -367,6 +376,12 @@ export class Dock { scroll.className = 'os-dock__scroll'; const pinned = document.createElement( 'div' ); pinned.className = 'os-dock__pinned'; + // Ids so the deck tabs can name the region each one reveals + // via `aria-controls`. Derived from the rail's own id, which + // is unique per rail — two docks coexist in Classic. + const hostBase = container.id || `os-dock-${ Dock.instanceCounter }`; + scroll.id = `${ hostBase }-items`; + pinned.id = `${ hostBase }-pinned`; container.appendChild( scroll ); container.appendChild( pinned ); this.itemHost = scroll; @@ -392,10 +407,85 @@ export class Dock { } document.body.appendChild( this.tooltip ); + this.syncDeckController(); this.render(); this.bindWindowEvents(); } + /** + * Bring the deck controller in line with the current orientation. + * + * Constructed for `'bottom'`, torn down for anything else. Kept + * as its own method because three paths need it: the constructor, + * {@link setOrientation}, and (defensively) any future rebuild — + * and each of them must not end up with two controllers stacking + * gesture listeners on the same container. + */ + private syncDeckController(): void { + if ( this.orientation === 'bottom' ) { + if ( ! this.decks ) { + this.decks = new DockDecks( { + container: this.container, + itemHost: this.itemHost, + systemHost: this.systemHost, + rail: this.rail, + hookContext: () => this.buildHookContextBase(), + showTooltip: ( el, text ) => { + this.positionTooltip( el, text ); + this.tooltip.classList.add( + 'os-dock__tooltip--visible', + ); + }, + hideTooltip: () => + this.tooltip.classList.remove( + 'os-dock__tooltip--visible', + ), + } ); + } + return; + } + this.decks?.destroy(); + this.decks = null; + } + + /** + * Hand the deck controller a fresh view of what is on the rail. + * + * Called after every path that adds or removes a tile. A no-op + * when there is no controller (vertical rails), so callers don't + * have to know which orientation they're in. + */ + private syncDecks(): void { + this.decks?.sync( { + items: this.items, + tiles: this.itemElements, + systemItems: this.systemItems, + systemTiles: this.systemItemElements, + } ); + } + + /** + * The deck currently on screen, or `null` when this rail isn't + * decked (any vertical placement, or a bottom rail whose tiles + * all fall into one group). + * + * @public + */ + public getActiveDeck(): string | null { + return this.decks?.getActive() ?? null; + } + + /** + * Show a deck by id. Silently ignored when the rail isn't decked + * or the id isn't one of the live decks, so a plugin can call it + * without first checking the layout the user happens to be in. + * + * @public + */ + public setActiveDeck( deckId: string ): void { + this.decks?.setActive( deckId, 'click' ); + } + /** * Replace the menu-derived tile list with a fresh one, preserving * any JS-registered system tiles. Used by the live menu-refresh @@ -433,6 +523,11 @@ export class Dock { 'data-os-dock-placement', orientation, ); + // Decks are a bottom-rail affordance; a flip either builds the + // controller and re-partitions, or tears it down and lets every + // tile back onto the rail. + this.syncDeckController(); + this.syncDecks(); this.tooltip.classList.remove( 'os-dock__tooltip--above', 'os-dock__tooltip--before', @@ -531,6 +626,10 @@ export class Dock { } ); } + // Re-partition BEFORE the active-state sweep: that sweep asks + // the deck controller to repaint its tab indicators, and it + // can only answer for tiles it has already stamped. + this.syncDecks(); this.updateActiveStates(); doAction( HOOKS.DOCK_AFTER_RENDER, { @@ -583,6 +682,10 @@ export class Dock { this.systemSeparator = null; } + // The OpenStation deck may have just emptied — re-partitioning + // is what drops its tab and moves the rail onto a live deck. + this.syncDecks(); + doAction( HOOKS.DOCK_ITEM_REMOVED, { id, placement: this.rail } ); } @@ -879,6 +982,7 @@ export class Dock { this._paintArt( tile, item.id, systemArt ); } this.systemHost.appendChild( tile ); + this.syncDecks(); this.updateActiveStates(); doAction( HOOKS.DOCK_TILE_RENDERED, { @@ -958,6 +1062,8 @@ export class Dock { } ); } + this.syncDecks(); + doAction( HOOKS.DOCK_AFTER_RENDER, { ...base, items: this.items, @@ -1008,6 +1114,29 @@ export class Dock { // third arg is intentionally omitted. primary.addEventListener( 'click', () => item.onOpen() ); + // Right-click → the same menu a menu tile gets. System tiles + // went without one for a long time, on the reasoning that + // most of them must not be hideable — OpenStation Preferences + // is how you reach the screen that would put it back. That + // reasoning covers the hide/move entries, not the menu: a + // star is non-destructive and belongs on every icon in the + // dock. `placeable` (the same flag that decides whether the + // tile is offered in Apps & Icons) is what gates the + // destructive half, so a load-bearing tile opens a menu with + // the star and the settings shortcut and nothing that can + // strand the user. + tile.addEventListener( 'contextmenu', ( ev: MouseEvent ) => { + ev.preventDefault(); + openItemVisibilityMenu( { + x: ev.clientX, + y: ev.clientY, + id: item.id, + title: item.title, + surface: 'dock', + placeable: !! item.placeable, + } ); + } ); + tile.appendChild( primary ); this.bindTooltipFiltered( tile, item.title, ctx ); @@ -2319,6 +2448,8 @@ export class Dock { teardown(); } this.peekTeardowns.clear(); + this.decks?.destroy(); + this.decks = null; this.tooltip.remove(); while ( this.container.firstChild ) { this.container.removeChild( this.container.firstChild ); @@ -2434,6 +2565,12 @@ export class Dock { // dock instances: two docks setting the same class doesn't // double-fire. this.updateShowDesktopBodyClass(); + + // Push what the tiles now say onto the deck tabs — the open + // dot and the aggregate badge for whichever decks are off + // screen. Runs last so it reads the classes just written + // above rather than the previous pass's. + this.decks?.refreshIndicators(); } /** diff --git a/src/hooks.ts b/src/hooks.ts index 764483a20..11b0e5817 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -913,6 +913,40 @@ export const HOOKS = { * {@link DOCK_BEFORE_RENDER}. */ DOCK_AFTER_RENDER: 'os.dock.after-render', + /** + * Filter, resolves the list of decks a bottom rail folds itself + * into — the named clusters its leading tab strip switches + * between. Signature: + * `( decks: DockDeck[], detail: DockHookContextBase ) => DockDeck[]`. + * + * The shell ships three: `wordpress` (core menus), `apps` (plugin + * menus), `station` (system tiles). Reorder by `order`, rename by + * `label`, drop one by filtering it out, or add your own with a + * `matchItem` / `matchSystem` predicate. A tile goes to the FIRST + * deck in sorted order whose predicate claims it, so a narrow deck + * registered at a low `order` takes precedence without having to + * rewrite the built-ins. + * + * A deck matching no tile is dropped as empty. With fewer than two + * decks left the strip is removed entirely and the rail paints as + * one undivided row — which is also how to switch decks off. + * + * Runs on every partition pass (boot, live menu refresh, system + * tiles arriving or leaving), so keep it cheap and pure. + */ + DOCK_DECKS: 'os.dock.decks', + /** + * Action, fires after the visible deck changes. Payload + * `DockDeckChangeContext` — the base dock context plus `deckId`, + * `previousDeckId` (`null` on the first paint) and `reason` + * (`'click' | 'keyboard' | 'wheel' | 'swipe' | 'restore' | 'auto'`). + * + * `'restore'` is the rail resolving which deck to show without the + * user asking — boot, or a deactivation emptying the deck they + * were on. `'auto'` is the follow-focus preference moving the rail + * on their behalf. + */ + DOCK_DECK_CHANGED: 'os.dock.deck-changed', /** * Action a plugin *fires* (rather than listens to) when the state * behind a tile's active dot has changed for a reason the dock diff --git a/src/item-visibility-menu.ts b/src/item-visibility-menu.ts index a14f0c965..15f3bf823 100644 --- a/src/item-visibility-menu.ts +++ b/src/item-visibility-menu.ts @@ -37,6 +37,7 @@ import type { OsSettingsSnapshot } from './settings/registry'; interface OpenStationShim { getOsSettings?: () => OsSettingsSnapshot; + dock?: { getActiveDeck?: () => string | null } | null; updateOsSettings?: ( patch: Partial< OsSettingsSnapshot >, opts?: { windowId?: string }, @@ -77,6 +78,49 @@ function writeVisibility( api.updateOsSettings( { itemVisibility: next } ); } +/** + * The starred list after starring or unstarring one id. + * + * Appends rather than inserts: the Favorites deck reads this list in + * order, so the newest star lands at the end and the set the user + * built stays in the sequence they built it. Starring an id that is + * already there moves it to the end rather than duplicating it — + * a duplicate would render the tile twice in the deck. + * + * Pure so the ordering contract can be tested without stubbing the + * shell API, matching {@link computeHideTarget}. + */ +export function computeFavorites( + current: readonly string[], + canonicalId: string, + on: boolean, +): string[] { + const without = current.filter( ( id ) => id !== canonicalId ); + return on ? [ ...without, canonicalId ] : without; +} + +/** + * Star or unstar a tile. + * + * The write goes through the public `updateOsSettings` writer for the + * same reason `writeVisibility` does — that writer sanitizes, saves, + * and asks the layout dispatcher for a repaint, so the deck appears + * or empties in the same frame as the pick. + */ +function writeFavorite( canonicalId: string, on: boolean ): void { + const api = getApi(); + if ( ! api?.getOsSettings || ! api?.updateOsSettings ) { + return; + } + api.updateOsSettings( { + dockFavorites: computeFavorites( + api.getOsSettings().dockFavorites ?? [], + canonicalId, + on, + ), + } ); +} + /** * The item's native rail, derived from the rail-synthesis prefix on * the DOM id. A bare id means the tile is rendered on its native rail @@ -136,6 +180,23 @@ export interface OpenItemVisibilityMenuOpts { title: string; /** Which surface the user right-clicked on. */ surface: 'dock' | 'desktop'; + /** + * Whether this item may be hidden or moved between rails. + * + * Defaults to `true`, which is right for every admin-menu tile. + * System tiles pass their own `placeable` flag, and most of them + * are `false`: OpenStation Preferences is how you reach the screen + * that would put it back, and "Hide everywhere" on it is close + * enough to a one-way door to be worth not offering. Those tiles + * still get the menu — starring is non-destructive and belongs on + * every icon in the dock — they just get it without the + * hide/move half. + * + * Mirrors `SystemDockItem.placeable`, and for the same reason it + * exists there: the visibility override is still honoured if + * something else writes it, this only controls what is offered. + */ + placeable?: boolean; /** * Plugin file (e.g. `woocommerce/woocommerce.php`) when the item is * owned by an active, deactivatable plugin. When non-null the menu @@ -242,30 +303,59 @@ function openItemVisibilityMenuImmediate( | { kind: 'separator' }; const options: MenuOption[] = []; + // Undefined means "an ordinary tile" — every admin-menu tile omits + // it. Only the system-tile call site passes it, and passes `false` + // for the load-bearing ones. + const placeable = opts.placeable !== false; if ( opts.surface === 'dock' ) { + // Starring leads the menu, and it is the only entry here that + // is purely additive — everything below it hides, moves or + // deactivates something. It sits above the separator-free run + // of those so the destructive gradient still reads top to + // bottom. + // + // Offered whether or not the user has decks turned on: a star + // set while the rail is undivided is exactly what makes + // turning decks on worth doing, and the alternative is an + // option that appears and disappears depending on a setting + // two screens away. + const isFavorite = ( + getApi()?.getOsSettings?.().dockFavorites ?? [] + ).includes( canonical ); options.push( { - id: 'hide-from-dock', - label: __( 'Hide from dock' ), - icon: 'dashicons-hidden', - onPick: () => - writeVisibility( - canonical, - computeHideTarget( - canonical, - nativeRail, - 'dock', - getApi()?.getOsSettings?.().itemVisibility ?? {}, - ), - ), + id: isFavorite ? 'unfavorite' : 'favorite', + label: isFavorite + ? __( 'Remove from favorites' ) + : __( 'Add to favorites' ), + icon: isFavorite ? 'dashicons-star-empty' : 'dashicons-star-filled', + onPick: () => writeFavorite( canonical, ! isFavorite ), } ); - if ( currentPlacement !== 'both' ) { + if ( placeable ) { + options.push( { kind: 'separator' } ); options.push( { - id: 'show-on-desktop-too', - label: __( 'Also show on desktop' ), - icon: 'dashicons-desktop', - onPick: () => writeVisibility( canonical, 'both' ), + id: 'hide-from-dock', + label: __( 'Hide from dock' ), + icon: 'dashicons-hidden', + onPick: () => + writeVisibility( + canonical, + computeHideTarget( + canonical, + nativeRail, + 'dock', + getApi()?.getOsSettings?.().itemVisibility ?? {}, + ), + ), } ); + if ( currentPlacement !== 'both' ) { + options.push( { + id: 'show-on-desktop-too', + label: __( 'Also show on desktop' ), + icon: 'dashicons-desktop', + onPick: () => writeVisibility( canonical, 'both' ), + } ); + } } } else { options.push( { @@ -292,13 +382,15 @@ function openItemVisibilityMenuImmediate( } ); } } - options.push( { - id: 'hide-everywhere', - label: __( 'Hide everywhere' ), - icon: 'dashicons-no', - danger: true, - onPick: () => writeVisibility( canonical, 'hidden' ), - } ); + if ( placeable ) { + options.push( { + id: 'hide-everywhere', + label: __( 'Hide everywhere' ), + icon: 'dashicons-no', + danger: true, + onPick: () => writeVisibility( canonical, 'hidden' ), + } ); + } options.push( { id: 'open-settings', diff --git a/src/settings/constants.ts b/src/settings/constants.ts index 24c75efb2..22692fc7f 100644 --- a/src/settings/constants.ts +++ b/src/settings/constants.ts @@ -272,6 +272,14 @@ export const DEFAULTS: OsSettingsState = { // opt-in Beta posture; cap-gated on `edit_posts` server-side. nativeCommentsEnabled: false, showDesktopOnWallpaperClick: false, + // Opt-in. Decking trades "every tile is on screen" for "the rail + // fits" — worth it once you have the tiles to feel the crowding, + // and not something to decide on a user's behalf before then. + dockDecksEnabled: false, + dockFavorites: [], + // The dock stays where the user left it. See the type's docblock + // for why this is not the default. + dockDeckFollowFocus: false, mioEnabled: false, // No opinions: the user has not been to "Make it yours" yet, so // they get whatever Mio the site ships. diff --git a/src/settings/index.ts b/src/settings/index.ts index a3ff0e5aa..92bdc38c4 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -222,6 +222,8 @@ export class OsSettings implements SettingsCtx { foldersSharingEnabled: this.state.foldersSharingEnabled, itemVisibility: { ...this.state.itemVisibility }, dockOrder: this.state.dockOrder.slice(), + dockDecksEnabled: this.state.dockDecksEnabled, + dockFavorites: this.state.dockFavorites.slice(), dockPromotedPositions: Object.fromEntries( Object.entries( this.state.dockPromotedPositions ).map( ( [ k, v ] ) => [ k, { ...v } ], @@ -403,6 +405,22 @@ export class OsSettings implements SettingsCtx { this.state.desktopLayout, ); + // The two deck preferences, as attributes rather than as + // callbacks threaded through the layout dispatcher: `DockDecks` + // reads them at the moment it would act, so a rail built before + // the user flipped either one picks up the new answer without + // being rebuilt — and boot, every save, and the rollback after + // a failed save all route through apply(), so there is one + // writer. + shell.setAttribute( + 'data-os-decks', + this.state.dockDecksEnabled ? '1' : '0', + ); + shell.setAttribute( + 'data-os-deck-follow-focus', + this.state.dockDeckFollowFocus ? '1' : '0', + ); + // Dock rail renderer pick — push into the registry so the // dispatcher rebuilds the rails when the resolved renderer // changes. Doing this from `apply()` (rather than only on diff --git a/src/settings/panel.ts b/src/settings/panel.ts index 4b2ef5988..283640285 100644 --- a/src/settings/panel.ts +++ b/src/settings/panel.ts @@ -63,6 +63,7 @@ import { buildAdminBarSection } from './sections/admin-bar'; import { buildThemesSection } from './sections/themes'; import { buildAppsIconsSection } from './sections/apps-icons'; import { buildDesktopLayoutSection } from './sections/desktop-layout'; +import { buildDockDecksSection } from './sections/dock-decks'; import { buildDockSizeSection } from './sections/dock-size'; import { buildWindowRadiusSection } from './sections/window-radius'; import { buildDockRailRendererSection } from './sections/dock-rail-renderer'; @@ -200,6 +201,7 @@ export function renderOsSettingsPanel( ${ buildAccentSection( ctx ) } ${ buildDesktopLayoutSection( ctx ) } ${ buildDockSizeSection( ctx ) } + ${ buildDockDecksSection( ctx ) } ${ buildWindowRadiusSection( ctx ) } ${ buildAdminBarSection( ctx ) } ${ buildDockRailRendererSection( ctx ) } diff --git a/src/settings/registry.ts b/src/settings/registry.ts index c45577814..cfd28b475 100644 --- a/src/settings/registry.ts +++ b/src/settings/registry.ts @@ -197,6 +197,18 @@ export interface OsSettingsSnapshot { * order. */ dockOrder: string[]; + /** + * Whether the bottom dock folds into decks — one group of tiles on + * screen at a time behind a tab strip. Opt-in; `false` means the + * rail paints as one undivided row. + */ + dockDecksEnabled: boolean; + /** + * Item ids the user has starred, in the order they starred them. + * Populates the Favorites deck. Empty means no Favorites tab — + * a deck matching nothing is dropped. + */ + dockFavorites: string[]; /** * Persisted desktop position (in CSS px) for every dock item the * user has promoted onto the wallpaper. Keyed by dock-item id. diff --git a/src/settings/sections/dock-decks.ts b/src/settings/sections/dock-decks.ts new file mode 100644 index 000000000..9b9b59765 --- /dev/null +++ b/src/settings/sections/dock-decks.ts @@ -0,0 +1,102 @@ +/** + * Dock-decks section — the opt-in, and the one decision it leaves + * open. + * + * A bottom dock can fold its tiles into groups and show one at a time + * (see `src/dock-decks/index.ts`). That is a real change to how the + * rail is read, not a refinement of it — a tile that was on screen + * becomes one click away — so it is off until asked for. + * + * The second toggle answers what should happen when a window in a + * deck you are *not* looking at takes focus. The default is nothing: + * the deck's tab picks up an indicator dot and waits to be clicked, + * which is the framework being a transport rather than a UX policy + * maker. The other answer is just as defensible for anyone who treats + * the dock as a view of what they're doing, which is why it's a + * toggle rather than a decision made for them. It is disabled while + * decks are off — there are no decks to follow into. + * + * Sits next to Dock size in Appearance rather than in Features: it is + * a property of the dock, and someone changing how the dock looks is + * exactly who is about to wonder about this. + */ + +import { __ } from '../../i18n'; +import { html, render } from '../../ui/core'; +import type { SettingsCtx } from '../types'; + +export function buildDockDecksSection( ctx: SettingsCtx ): HTMLElement { + /* + * `apply()` BEFORE `save()`, which is the opposite of every other + * section here and is load-bearing for exactly one of these two + * toggles. + * + * `save()` fires the os-settings subscribers synchronously, and + * the one in `desktop.ts` calls `layoutDispatcher.refresh()` → + * `Dock.replaceItems()` → `DockDecks.sync()`. `sync()` reads + * `data-os-decks` off the shell, and `apply()` is what writes it. + * In the usual order the rail would repaint against the PREVIOUS + * value and only catch up on the next unrelated settings change. + * + * Nothing in `apply()` writes state, so running it first is safe. + */ + const commit = (): void => { + ctx.apply(); + ctx.save(); + paint(); + }; + + const onDecksToggle = ( e: Event ): void => { + ctx.state.dockDecksEnabled = + ( e as CustomEvent ).detail?.checked === true; + commit(); + }; + + const onFollowFocusToggle = ( e: Event ): void => { + ctx.state.dockDeckFollowFocus = + ( e as CustomEvent ).detail?.checked === true; + commit(); + }; + + const wrapper = document.createElement( 'div' ); + const paint = (): void => + render( + html` + +
+ +

+ ${ __( + 'Off by default: every icon stays on screen, and a dock wider than the viewport scrolls. Only applies to a dock on the bottom edge — a side dock has the height to show everything. Star an icon from its right-click menu to give it a Favorites tab.', + ) } +

+
+
+ +

+ ${ __( + 'Switch the dock to the group holding a window whenever that window takes focus, so the icon you would reach for next is always on screen. Off by default: the group keeps an indicator dot instead, and the dock stays where you left it.', + ) } +

+
+
+ `, + wrapper, + ); + paint(); + return wrapper; +} diff --git a/src/settings/state.ts b/src/settings/state.ts index f1bc42224..7e76898f1 100644 --- a/src/settings/state.ts +++ b/src/settings/state.ts @@ -265,6 +265,14 @@ function _parseRaw( parsed: Partial ): OsSettingsState { typeof parsed.showDesktopOnWallpaperClick === 'boolean' ? parsed.showDesktopOnWallpaperClick : DEFAULTS.showDesktopOnWallpaperClick, + dockDecksEnabled: + typeof parsed.dockDecksEnabled === 'boolean' + ? parsed.dockDecksEnabled + : DEFAULTS.dockDecksEnabled, + dockDeckFollowFocus: + typeof parsed.dockDeckFollowFocus === 'boolean' + ? parsed.dockDeckFollowFocus + : DEFAULTS.dockDeckFollowFocus, mioEnabled: typeof parsed.mioEnabled === 'boolean' ? parsed.mioEnabled @@ -287,6 +295,10 @@ function _parseRaw( parsed: Partial ): OsSettingsState { : DEFAULTS.foldersSharingEnabled, itemVisibility: sanitizeItemVisibility( parsed.itemVisibility ), dockOrder: sanitizeDockOrder( parsed.dockOrder ), + // Same shape and the same guarantees as `dockOrder` — a + // deduped list of item ids — so it takes the same sanitizer + // rather than a near-copy that could drift from it. + dockFavorites: sanitizeDockOrder( parsed.dockFavorites ), dockPromotedPositions: sanitizeDockPromotedPositions( parsed.dockPromotedPositions, ), @@ -524,6 +536,7 @@ function _cloneState( state: OsSettingsState ): OsSettingsState { nativePostsHiddenColumns: state.nativePostsHiddenColumns.slice(), itemVisibility: { ...state.itemVisibility }, dockOrder: state.dockOrder.slice(), + dockFavorites: state.dockFavorites.slice(), dockPromotedPositions: Object.fromEntries( Object.entries( state.dockPromotedPositions ).map( ( [ k, v ] ) => [ k, diff --git a/src/settings/types.ts b/src/settings/types.ts index cac0b5c01..d9207041a 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -296,6 +296,40 @@ export interface OsSettingsState { * wallpaper do nothing. Per-user. */ showDesktopOnWallpaperClick: boolean; + /** + * Whether the bottom dock folds its tiles into decks — WordPress, + * Apps, OpenStation, Favorites — and shows one at a time behind a + * tab strip. + * + * Off by default. Decking is a real change to how the rail is + * read, not a refinement of it: a tile that was on screen is now + * one click away, and that is a trade worth making only for + * someone who has enough tiles to feel the crowding. Opting in is + * how they say so. `OsSettings.apply()` writes it to the shell's + * `data-os-decks` attribute; `DockDecks.sync()` reads it there. + * Per-user. + */ + dockDecksEnabled: boolean; + /** + * Canonical ids of the dock tiles the user has starred, in the + * order they starred them. Populates the Favorites deck; empty + * means no Favorites tab at all, since a deck matching nothing is + * dropped. Written from the dock tile's right-click menu. + * Per-user. + */ + dockFavorites: string[]; + /** + * Whether the bottom dock switches itself to the deck holding a + * newly focused window. + * + * Off by default, deliberately. The framework is a transport, not + * a UX policy maker (`docs/event-driven-framework.md`) — a rail + * that reshuffles under the pointer because a window took focus is + * the shell deciding on the user's behalf. With this off, the deck + * holding that window says so with its indicator dot and waits to + * be picked. Per-user. + */ + dockDeckFollowFocus: boolean; /** * Whether Mio, the desk companion, is on. Toggled from Mio's dock * tile; the shell lazy-loads `assets/js/mio[.min].js` the first diff --git a/tests/phpunit/tests/osSettings.php b/tests/phpunit/tests/osSettings.php index e07cb7bb8..ab128e3ae 100644 --- a/tests/phpunit/tests/osSettings.php +++ b/tests/phpunit/tests/osSettings.php @@ -408,6 +408,113 @@ public function test_sanitize_normalizes_dock_order_ids() { ); } + // ──────────────────────────────────────────────────────────────── + // Dock decks — the bottom rail folding into one-at-a-time groups, + // the tiles the user starred, and whether the rail follows the + // focused window into its group. + // + // These live in the same user meta blob as every other preference, + // and the sanitizer is an allowlist: a key it does not name is + // dropped on the way to the database. That failure mode is + // invisible from the JS side — localStorage keeps the value, so + // the setting appears to work until the user opens the site in + // another browser and finds it reset. + // ──────────────────────────────────────────────────────────────── + + /** + * @covers ::openstation_default_os_settings + */ + public function test_dock_deck_defaults_are_off_and_empty() { + $defaults = openstation_default_os_settings(); + $this->assertArrayHasKey( 'dockDecksEnabled', $defaults ); + $this->assertFalse( $defaults['dockDecksEnabled'] ); + $this->assertArrayHasKey( 'dockDeckFollowFocus', $defaults ); + $this->assertFalse( $defaults['dockDeckFollowFocus'] ); + $this->assertArrayHasKey( 'dockFavorites', $defaults ); + $this->assertSame( array(), $defaults['dockFavorites'] ); + } + + /** + * @covers ::openstation_sanitize_os_settings + */ + public function test_sanitize_round_trips_dock_deck_toggles() { + $clean = openstation_sanitize_os_settings( + array( + 'dockDecksEnabled' => true, + 'dockDeckFollowFocus' => true, + ) + ); + $this->assertTrue( $clean['dockDecksEnabled'] ); + $this->assertTrue( $clean['dockDeckFollowFocus'] ); + } + + /** + * Favorites share `dockOrder`'s sanitizer, so they inherit its + * charset, its dedupe and its rail-prefix tolerance — a starred + * tile the user promoted to the wallpaper carries `dock:` / + * `desktop:` exactly as it does in the order list. + * + * @covers ::openstation_sanitize_os_settings + * @covers ::openstation_sanitize_item_id_list + */ + public function test_sanitize_normalizes_dock_favorites() { + $clean = openstation_sanitize_os_settings( + array( + 'dockFavorites' => array( + 'Edit Php', + 'desktop:My-Icon', + 'edit php', + '