diff --git a/assets/css/dock.css b/assets/css/dock.css index 3873d7c45..e2655d169 100644 --- a/assets/css/dock.css +++ b/assets/css/dock.css @@ -1083,6 +1083,606 @@ 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 slide. Driven from JS as a FLIP — the tab is put back + * where it was with an inline transform and released, and this + * is the transition that carries it. */ + transform var( --os-dock-deck-slide, 720ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), + /* + * The ink flips LATE, and the delay is the point. + * + * The plate no longer moves; the tabs move through it. So a + * tab is only over the mesh for the back half of its slide, + * and an ink flip timed to the start would put Void text on + * the dark rail for 300ms — invisible — while the outgoing tab + * stays Void as it slides out from under the plate, equally + * invisible. Delaying both to roughly where they cross means + * each changes colour about when it changes background. + */ + color 200ms ease-out 340ms, + /* No delay and nothing clever: this is the hover response, and + * a hover response that is not immediate is a broken one. */ + opacity var( --os-ui-motion-fast, 140ms ) ease-out, + 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 STAYS PUT. + * + * The mesh is one element rather than a fill painted on whichever tab + * is active — 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. + * + * But unlike the window tab strip's plate, which travels to the + * selected tab, this one is anchored at the strip's trailing end and + * the TABS move through it. That is what puts the selected tab + * directly against the divider and the row of icons it names, so the + * label reads as a heading for what follows rather than as a marker + * floating somewhere in a strip. It also means the loudest thing on + * the rail is the one thing that never moves; the motion belongs to + * the buttons sliding under it. + * + * Its trailing edge is pinned in CSS and its width is the one thing + * written from JS — `syncPlate()` publishes `--_deck-plate-w`, once + * per switch, from a width measured before anything started moving. + * So the plate's trailing edge holds and only its leading edge + * breathes, by however much two labels differ in length. + */ +.os-dock__deck-plate { + position: absolute; + inset-block: 4px 0; + /* + * Pinned to the trailing edge, in CSS, forever. The selected tab + * is always the last one on the strip, so this is where it is — + * and anchoring the plate here rather than positioning it from a + * measured `x` means there is no coordinate to keep in sync and + * nothing that can drift out of it. Only the width is written + * from JS. + */ + inset-inline-end: 0; + width: var( --_deck-plate-w, 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 ) + ); + /* + * Width only. There is no `transform` here any more and that is + * the point: the plate is anchored, so the only thing left to + * animate is how far its leading edge reaches — and it does so on + * the same curve the label unfurls on, from one target written + * once per switch. + */ + transition: + 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 measuring frame. + * + * `measureTabWidths()` opens each tab's label in turn to read the + * width it will settle at. Every one of those reads would come back + * mid-transition without this — whatever the label happened to have + * reached — so the probe is run with the strip flagged and everything + * it touches held still. Set and cleared inside one synchronous + * block; it is never on across a paint. + */ +.os-dock__decks[ data-deck-measuring ] .os-dock__deck, +.os-dock__decks[ data-deck-measuring ] .os-dock__deck-label, +.os-dock__decks[ data-deck-measuring ] .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. + */ +/* + * The unselected tabs stand back. + * + * Not a dimmer ink — actual transparency, so the dock's own surface + * comes through them and they read as *behind* the selection rather + * than as a paler version of it. One tab on this rail is the answer + * to "where am I", and the other three are only offers; at equal + * weight the strip reads as four things competing. + * + * Hovering brings one fully forward. Note what does NOT change on + * hover: `color`. The ink transition carries a 340ms delay for the + * selection flip (see the base rule), and a hover that moved it would + * inherit that delay — you would point at a tab, get nothing for a + * third of a second, and then watch it brighten after you had already + * moved on. Opacity and the wash both answer immediately, and the ink + * is left to mean one thing only: whether this tab is the selected + * one. + */ +.os-dock__deck:not( .os-dock__deck--active ) { + opacity: var( --os-dock-deck-idle-opacity, 0.6 ); +} + +.os-dock__deck:not( .os-dock__deck--active ):hover { + opacity: 1; + background-color: var( + --os-dock-item-bg-hover, + rgba( 242, 82, 252, 0.18 ) + ); +} + +.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 + ); +} + +/* + * No press-scale on a deck tab, deliberately. + * + * It had one, and it was the glitch: `transform` is what the slide + * animates now, so `:active { transform: scale() }` and the FLIP's + * inline `translateX()` were two things writing one property. Pressing + * a tab cancelled or distorted whichever slide was in flight, and the + * scale itself picked up the slide's 720ms — a press that took most of + * a second to sink and another to come back. + * + * A tab does not need it. Pressing one moves the whole strip and + * repaints the rail beside it; that is more feedback than a 4% squash + * could add, and it arrives on the same gesture. + */ + +/* + * 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; + } +} + /* 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..ecab1533d 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -1142,6 +1142,48 @@ 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); + + /* + * How far the unselected deck tabs stand back. + * + * Transparency rather than a dimmer ink, so the dock's own surface + * comes through them and they read as BEHIND the selection instead + * of as a paler copy of it. One tab answers "where am I" and the + * others are only offers; at equal weight the strip reads as four + * things competing for the same job. + * + * Hover takes one back to full. Raise this toward 1 for a flatter + * strip, lower it to push the unselected tabs further back. + */ + --os-dock-deck-idle-opacity: 0.6; + /* * ---- 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..a801e54fb 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. The selected tab is painted last on that strip, hard against the divider, so its label heads the icons that follow it; the mesh fill is anchored there and the tabs slide through it rather than the fill chasing the selection. The rearrangement is a flex `order` swap, so the DOM sequence — and with it `aria-selected`, the roving tabindex and the arrow keys — stays as registered. 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..67973b335 100644 --- a/docs/desktop-themes.md +++ b/docs/desktop-themes.md @@ -652,6 +652,47 @@ 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 | +| `--os-dock-deck-idle-opacity` | How far the *unselected* tabs stand back (default `0.6`). Transparency rather than a dimmer ink, so the dock's surface comes through them; hover takes one back to full | + +Everything a switch moves is on that single token: the tabs sliding +so the selected one lands at the trailing end of the strip, its 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. The mesh fill itself is the one thing that does not move; it is +anchored at that trailing end and only its leading edge breathes as +labels of different lengths pass under 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..1340bba4b 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,83 @@ 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. + +**`order` sets the registered sequence, not the painted one.** The selected tab is always moved to the trailing end of the strip, so its label sits directly against the divider and the row of icons it names; the others hold their registered order to its left. The mesh fill is anchored at that trailing end and does not move — the tabs slide through it. DOM order is never rearranged (it is a flex `order` swap), so `aria-selected`, the roving tabindex and arrow-key navigation all keep reading the sequence you registered. + +```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..c12906ff3 --- /dev/null +++ b/src/dock-decks/index.ts @@ -0,0 +1,1132 @@ +/** + * 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; + /** + * Each tab's width WITH its label open, keyed by deck id. The + * plate is sized from this rather than from a live read, so a + * switch costs one write instead of a per-frame chase. Refreshed + * by {@link measureTabWidths} on every partition pass. + */ + private expanded: Map< string, number > = new Map(); + 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(); + // Re-read every tab's expanded width. `buildStrip` returns + // early when the deck set is unchanged, but the tabs may still + // have been re-laid out under it — a Dock size change, a new + // locale, a theme's icon set — and the plate is sized from + // these numbers. + this.measureTabWidths(); + + // 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; + + this.paintActiveTab(); + } + + private teardownStrip(): void { + this.expanded.clear(); + 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 { + // Where every tab sits right now, read before anything changes. + // The slide below is measured against this. + const before = new Map< HTMLElement, number >(); + const animate = this.strip?.dataset.platePlaced !== undefined; + if ( animate ) { + for ( const tab of this.tabs.values() ) { + before.set( tab, tab.offsetLeft ); + } + } + + 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; + // The selected tab is ALWAYS the last one on the strip, + // which is what puts it directly against the divider and + // the row of icons it names. `order` rather than a DOM + // move: the tabs stay in the document in their registered + // sequence, so `aria-owns`-free tablist semantics, the + // roving tabindex and every `querySelectorAll` on the + // strip keep reading the canonical order. Only the paint + // is rearranged. + tab.style.order = on ? '1' : '0'; + } + + if ( animate ) { + this.slideTabs( before ); + } + this.syncPlate(); + this.refreshIndicators(); + } + + /** + * Slide the tabs to their new places. + * + * Standard FLIP: each tab is put back where it was with an inline + * transform and no transition, then released a frame later so the + * stylesheet's transition carries it home. + * + * The part worth knowing is that this stays correct even though + * the flow it is animating against is ITSELF still moving — the + * outgoing tab's label is collapsing and the incoming one's is + * unfurling for the whole `--os-dock-deck-slide`. A transform is + * relative to wherever flow puts the element, so the painted + * position is `flow(t) + Δ·(1 − ease(t))`: exactly the old spot at + * t=0, exactly the flow position at the end, and a blend of two + * smooth curves in between. Nothing has to be re-measured, and no + * layout is pinned. + * + * Skipped on the strip's first paint (the caller checks + * `data-plate-placed`), where there is no "before" worth sliding + * from and every tab would fly in from its unordered position. + */ + private slideTabs( before: ReadonlyMap< HTMLElement, number > ): void { + const moved: Array< [ HTMLElement, number ] > = []; + for ( const tab of this.tabs.values() ) { + const dx = ( before.get( tab ) ?? tab.offsetLeft ) - tab.offsetLeft; + if ( dx ) { + moved.push( [ tab, dx ] ); + } + } + if ( moved.length === 0 ) { + return; + } + for ( const [ tab, dx ] of moved ) { + tab.style.transition = 'none'; + tab.style.transform = `translateX( ${ dx }px )`; + } + // One forced reflow for the whole set, not one per tab. + void this.strip?.offsetWidth; + for ( const [ tab ] of moved ) { + // Clearing both inline values hands the tab back to the + // stylesheet, which is where its transition lives. + tab.style.transition = ''; + tab.style.transform = ''; + } + } + + /** + * Measure what each tab is *going* to be, once, so that nothing + * has to chase it later. + * + * Every tab has two widths — collapsed, and expanded with its + * label — and only the expanded one matters to the plate. Reading + * it here, with the label reveal suppressed and before anything is + * moving, is what lets the plate be handed a single target per + * switch instead of a fresh one every frame. + * + * Cheap enough to redo on every partition pass, which is also the + * only way it stays right: the Dock size preference resizes every + * glyph, a locale change relabels every tab, a desktop theme swaps + * the icon set, and all three arrive through `sync()`. + */ + private measureTabWidths(): void { + const strip = this.strip; + if ( ! strip ) { + return; + } + strip.dataset.deckMeasuring = ''; + for ( const [ id, tab ] of this.tabs ) { + const wasActive = tab.classList.contains( + 'os-dock__deck--active', + ); + tab.classList.add( 'os-dock__deck--active' ); + this.expanded.set( id, tab.offsetWidth ); + tab.classList.toggle( 'os-dock__deck--active', wasActive ); + } + delete strip.dataset.deckMeasuring; + } + + /** + * Point the plate at the active tab's final width. + * + * The plate does not move. Its trailing edge is pinned to the + * strip's by CSS and the selected tab is always the last one on + * the strip, so the only thing that differs between decks is how + * far its leading edge reaches — a shorter label, a shorter plate. + * + * ONE write per switch, to the measured final width, and the + * stylesheet's transition carries it there on the same curve the + * label unfurls on. This used to re-target every frame off the + * tab's live geometry, and that is what made it wobble: a 720ms + * curve restarting sixty times a second against a moving box is a + * chase, and a chase rubber-bands. The one thing on the rail that + * is meant to hold still looked like the least stable. + * + * `data-plate-placed` gates the transition, so the strip's first + * paint sizes the plate rather than growing it out of nothing. + */ + private syncPlate(): void { + const strip = this.strip; + if ( ! strip || ! this.activeId ) { + return; + } + const w = this.expanded.get( this.activeId ) ?? 0; + if ( w <= 0 ) { + return; + } + strip.style.setProperty( '--_deck-plate-w', `${ w }px` ); + strip.dataset.platePlaced = ''; + } + + 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. + * + * Arrows walk the deck list's own order, not the painted one. The + * painted order moves the selected tab to the end, so "next" in + * visual terms would change meaning with every press — arrowing + * right twice could land you back where you started. The + * registered sequence is the stable mental model and it is what + * `aria-selected` and the DOM both report. + */ + 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', + '