From 71bd645d5908212be8864c0f9a481d0b9af998ba Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 10:37:44 +0400 Subject: [PATCH] feat(ui): right rail as vertical tabs with sideways labels Replace the right rail's collapsed launcher cards with a vertical tab strip: one sideways tab per panel, the open panel beside it, at most one panel open in every HUD layout. The panels' collapsed classes stay the source of truth; the strip mirrors them and routes every change through setPanelCollapsed, so persistence and share links are unchanged. Phones keep the sheet shell. Five presentations, picked with data-rail-tabs on the rail: cards-down (default), cards-up, spine, icons and minimal. The one-owner rule, previously Cyber-only, now applies to every desktop layout, so the right rail's Tactical auto-collapse retry path is dead and removed. Escape inside a panel returns focus to the panel's tab. Co-Authored-By: Claude Fable 5.1 --- index.html | 2 +- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 3 + src/panelEscape.test.mjs | 14 ++ src/panelStackLayout.test.mjs | 12 +- src/rightRailPolicy.test.mjs | 33 +-- src/ui/cyberPanelDisclosure.test.mjs | 30 ++- src/ui/mobileShell.js | 2 +- src/ui/panelChrome.js | 29 ++- src/ui/panelDisclosure.js | 7 +- src/ui/panelLayoutController.js | 1 - src/ui/panelRailGeometry.js | 21 +- src/ui/panelRails.test.mjs | 180 ++++++++------ src/ui/rightPanelRail.js | 96 ++------ src/ui/rightRailTabs.js | 222 +++++++++++++++++ src/ui/rightRailTabs.test.mjs | 350 +++++++++++++++++++++++++++ src/ui/styles/right-rail-tabs.css | 308 +++++++++++++++++++++++ style.css | 1 + 18 files changed, 1099 insertions(+), 217 deletions(-) create mode 100644 src/ui/rightRailTabs.js create mode 100644 src/ui/rightRailTabs.test.mjs create mode 100644 src/ui/styles/right-rail-tabs.css diff --git a/index.html b/index.html index 26cf734fb..369ad1074 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..9c186f8ac 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1117,5 +1117,8 @@ "src/testSupport/shareViewFixtures.mjs", "src/ui/shareLinks.test.mjs", "src/ui/shareButton.test.mjs", - "src/share/shareLinkPath.test.mjs" + "src/share/shareLinkPath.test.mjs", + "src/ui/rightRailTabs.js", + "src/ui/rightRailTabs.test.mjs", + "src/ui/styles/right-rail-tabs.css" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..64f1c430f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -762,6 +762,7 @@ "src/ui/radioTunerModel.js", "src/ui/recordingControls.js", "src/ui/rightPanelRail.js", + "src/ui/rightRailTabs.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", "src/ui/actionButton.js", @@ -2287,6 +2288,7 @@ "src/ui/radioTunerModel.js", "src/ui/recordingControls.js", "src/ui/rightPanelRail.js", + "src/ui/rightRailTabs.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", "src/ui/actionButton.js", @@ -3352,6 +3354,7 @@ "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", + "src/ui/rightRailTabs.js", "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index d3d5cfc6d..05a45c6eb 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -71,6 +71,20 @@ test('Escape collapses an expanded panel and returns focus to its disclosure', ( assert.equal(fixture.event.propagationStopped, true); }); +test('Escape hands focus to the rail tab that controls the panel when one exists', () => { + const fixture = panelFixture(); + let tabFocused = false; + fixture.panel.ownerDocument = { + querySelector: (selector) => ( + selector === '[role="tab"][aria-controls="data-panel"]' ? { focus: () => { tabFocused = true; } } : null + ), + }; + assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true); + assert.equal(fixture.collapsed(), true); + assert.equal(tabFocused, true); + assert.equal(fixture.focused(), false, 'the hidden in-panel disclosure is not focused'); +}); + test('Escape on an expanded panel disclosure closes it without retaining focus', () => { const fixture = panelFixture({ onDisclosure: true }); assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index a13f5c04b..e2c09b973 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -162,11 +162,15 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match( rightRail, - /panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, - 'the latest explicitly opened right panel must receive primary allocation', + /expanded\.find\(\(panel\) => panel\.id === preferredPanelId\)/, + 'the latest explicitly opened right panel must be the one expanded owner', ); assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/); - assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/); + assert.match( + rightRail, + /expanded\.find\(\(panel\) => panel\.contains\(documentRef\?\.activeElement\)\)/, + 'a focused right panel is the fallback owner so presentation collapse never strands focus', + ); assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/); assert.match(leftRail, /--left-panel-allocated-height/); assert.match(rightRail, /--right-panel-allocated-height/); @@ -198,7 +202,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre assert.match( ui, - /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, + /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('rail-owner-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, 'responsive auto-collapse must serialize the explicit expanded preference', ); assert.match( diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..90fa4c4b7 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -7,29 +7,16 @@ import { shouldHideCollapsedRightPanels, } from './rightRailPolicy.js'; -test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'tactical', - hasExpandedPanel: true, - }), true); -}); - -test('collapsed launchers remain when Tactical has no expanded panel', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'tactical', - hasExpandedPanel: false, - }), false); -}); - -test('other HUD layouts keep collapsed right-rail launchers visible', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'minimal', - hasExpandedPanel: true, - }), false); - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'full', - hasExpandedPanel: true, - }), false); +test('the tab strip is the launcher in every HUD layout, so collapsed right-rail panels hide', () => { + for (const hudVariant of ['tactical', 'operator', 'minimal', 'cyber', 'full']) { + for (const hasExpandedPanel of [true, false]) { + assert.equal( + shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }), + true, + `${hudVariant} with${hasExpandedPanel ? '' : 'out'} an expanded panel`, + ); + } + } }); test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs index ae0256d4c..5216bbda6 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -51,9 +51,9 @@ test('explicit Cyber opening collapses peers and claims their restoration lanes' assert.equal(f.context.classList.contains('collapsed'), true); assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed)); assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel')); - f.cctv.classList.add('cyber-accordion-collapsed'); + f.cctv.classList.add('rail-owner-collapsed'); f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true }); - assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false); + assert.equal(f.cctv.classList.contains('rail-owner-collapsed'), false); } finally { globalThis.document = prior; } }); @@ -71,17 +71,25 @@ test('opening already-expanded Radio reveals Context and closes Display', () => } finally { globalThis.document = prior; } }); -test('non-Cyber explicit panels and restored preferences retain their existing policy', () => { +test('every theme collapses rail peers on an explicit open and keeps restored preferences', () => { for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) { - const f = fixture(theme), prior = globalThis.document; - globalThis.document = f.doc; + const prior = globalThis.document; + const explicit = fixture(theme); + globalThis.document = explicit.doc; try { - const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true }; - f.owner.setPanelCollapsed('pp-toggles', false, options); - f.owner.setPanelCollapsed('cctv-panel', false, options); - assert.equal(f.display.classList.contains('collapsed'), false); - assert.equal(f.cctv.classList.contains('collapsed'), false); - if (theme === 'cyber') assert.deepEqual(f.saved, []); + explicit.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + explicit.owner.setPanelCollapsed('cctv-panel', false, { explicit: true }); + assert.equal(explicit.display.classList.contains('collapsed'), true, theme); + assert.equal(explicit.cctv.classList.contains('collapsed'), false, theme); + } finally { globalThis.document = prior; } + const restored = fixture(theme); + globalThis.document = restored.doc; + try { + restored.owner.setPanelCollapsed('pp-toggles', false, { restore: true, persist: false }); + restored.owner.setPanelCollapsed('cctv-panel', false, { restore: true, persist: false }); + assert.equal(restored.display.classList.contains('collapsed'), false, theme); + assert.equal(restored.cctv.classList.contains('collapsed'), false, theme); + assert.deepEqual(restored.saved, [], theme); } finally { globalThis.document = prior; } } }); diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index 2363fd9ed..efd1acedd 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -208,7 +208,7 @@ export function initMobileShell({ return ( classes.contains('collapsed') && !classes.contains('layout-auto-collapsed') && - !classes.contains('cyber-accordion-collapsed') + !classes.contains('rail-owner-collapsed') ); }; diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index d920e8545..442273382 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -8,6 +8,7 @@ import { collapsePanelOnEscape, createHoverDisclosure, } from './panelDisclosure.js'; +import { createRightRailTabs } from './rightRailTabs.js'; const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'control-panel', pinnable: true }, { id: 'location-bar', pinnable: true }, @@ -102,6 +103,16 @@ export class PanelChrome { for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; + // The right rail's launchers are a vertical tab strip; the tabs ask this + // owner for every change so persistence and share links see one path. + this._rightRailTabs?.destroy(); + this._rightRailTabs = this._rightPanelStack + ? createRightRailTabs({ + rail: this._rightPanelStack, + setPanelCollapsed: (panelId, collapsed, options) => + this.setPanelCollapsed(panelId, collapsed, options), + }) + : null; const targets = new Map(); document .querySelectorAll('.panel-collapse-btn[data-collapse-target]') @@ -385,7 +396,7 @@ export class PanelChrome { // restore the user's explicit expanded preference at its own viewport. const collapsed = panelEl.classList.contains('layout-auto-collapsed') || - panelEl.classList.contains('cyber-accordion-collapsed') + panelEl.classList.contains('rail-owner-collapsed') ? false : panelEl.classList.contains('collapsed'); // A panel the phone shell only borrowed for a sheet shares its own state. @@ -443,14 +454,15 @@ export class PanelChrome { const nextCollapsed = Boolean(collapsed); const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed') || - panelEl.classList.contains('cyber-accordion-collapsed'); - // A user opening a Cyber rail panel owns the whole accordion, including - // peers that were only presentation-collapsed during a saved-state restore. + panelEl.classList.contains('rail-owner-collapsed'); + // The right rail is a set of vertical tabs: a user opening one panel owns + // the rail, including peers that were only presentation-collapsed during + // a saved-state restore. if ( explicit && !restore && !nextCollapsed && - document.documentElement?.dataset.uiTheme === 'cyber' && + this._rightPanelStack && panelEl.parentElement === this._rightPanelStack && // Phone sheets show several rail panels at once. !document.body?.classList?.contains('mobile-shell') @@ -539,10 +551,7 @@ export class PanelChrome { } return; } - panelEl.classList.remove( - 'layout-auto-collapsed', - 'cyber-accordion-collapsed', - ); + panelEl.classList.remove('layout-auto-collapsed', 'rail-owner-collapsed'); if ( !nextCollapsed && this.cockpitView?.active && @@ -665,6 +674,8 @@ export class PanelChrome { this._disposed = true; this._lifetime.destroy(); this._panelLayout.destroy(); + this._rightRailTabs?.destroy(); + this._rightRailTabs = null; for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; diff --git a/src/ui/panelDisclosure.js b/src/ui/panelDisclosure.js index ce6b88e17..dda988d5e 100644 --- a/src/ui/panelDisclosure.js +++ b/src/ui/panelDisclosure.js @@ -62,9 +62,14 @@ export function collapsePanelOnEscape( const disclosure = panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) || panel.querySelector(`[data-collapse-target="${panel.id}"]`); + // A right-rail panel collapses out of view; its tab stays and takes focus. + const tab = panel.ownerDocument?.querySelector?.( + `[role="tab"][aria-controls="${panel.id}"]`, + ); const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); - if (escapedFromDisclosure) disclosure?.blur?.(); + if (tab) tab.focus?.({ preventScroll: true }); + else if (escapedFromDisclosure) disclosure?.blur?.(); else disclosure?.focus?.({ preventScroll: true }); return true; } diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index 25b4b3910..8b8e9b036 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -304,7 +304,6 @@ export class PanelLayoutController { hud: this.readHud(), preferredPanelId: this._rightStackPreferredPanelId, onCollapse: (panel) => this._syncPanelCollapseButton(panel), - onRetry: () => this._scheduleRightPanelLayout(), leftStack: this._leftPanelStack, displayPanel: this._ppToggles, readDisplayScrollTop: this.readDisplayScrollTop, diff --git a/src/ui/panelRailGeometry.js b/src/ui/panelRailGeometry.js index 8045286b0..99e5767e0 100644 --- a/src/ui/panelRailGeometry.js +++ b/src/ui/panelRailGeometry.js @@ -62,17 +62,16 @@ export function resolveHudRailLayout({ } /** - * Tactical HUD gives an expanded right-rail panel the whole control lane. - * Other HUD layouts keep collapsed launchers visible for quick switching. + * The right rail's tab strip (rightRailTabs.js) is the launcher in every HUD + * layout, so a collapsed panel never stays visible as a card. The rail state + * argument is accepted for callers that describe what they lay out. * - * @param {object} input Current rail state. - * @param {string} input.hudVariant Active HUD layout variant. - * @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded. - * @returns {boolean} Whether collapsed sibling launchers should be hidden. + * @param {object} [input] Current rail state. + * @param {string} [input.hudVariant] Active HUD layout variant. + * @param {boolean} [input.hasExpandedPanel] Whether any rail panel is expanded. + * @returns {boolean} Whether collapsed sibling panels should be hidden. */ -export function shouldHideCollapsedRightPanels({ - hudVariant, - hasExpandedPanel, -}) { - return hudVariant === 'tactical' && Boolean(hasExpandedPanel); +export function shouldHideCollapsedRightPanels(input = {}) { + void input; + return true; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 270ba61f5..1290ebff6 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -20,33 +20,30 @@ test('Display uses an inner scroll body only in Cyber', () => { assert.equal(displayPanelScroller(null), null); }); -test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => { - for (const mobile of [false]) { - const f = fixture('right', { - mobile, - hud: { visible: true, variant: 'cyber' }, - }); +test('every HUD layout keeps one expanded owner, hides its peers and never reopens them', () => { + for (const variant of ['tactical', 'operator', 'minimal', 'cyber']) { + const f = fixture('right', { hud: { visible: true, variant } }); f.expand(f.first, 250); f.expand(f.second, 250); f.options.preferredPanelId = f.second.id; f.run(); - assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true); - assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal( + f.first.classList.contains('rail-owner-collapsed'), + true, + variant, + ); + assert.equal(f.second.classList.contains('collapsed'), false, variant); + assert.equal( + f.first.getAttribute('aria-hidden'), + 'true', + `${variant}: a collapsed peer hides behind the tab strip`, + ); f.run(); assert.equal( f.first.classList.contains('collapsed'), true, - 'layout does not reopen a peer', + `${variant}: layout does not reopen a peer`, ); - f.options.hud.variant = 'operator'; - f.run(); - assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal( - f.first.classList.contains('cyber-accordion-collapsed'), - false, - ); - assert.equal(f.second.classList.contains('collapsed'), false); } }); @@ -62,7 +59,7 @@ test('Cyber keeps no accordion on phones, where the rail is presented as tab she f.options.preferredPanelId = f.second.id; f.run(); assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false); + assert.equal(f.first.classList.contains('rail-owner-collapsed'), false); assert.equal(f.second.classList.contains('collapsed'), false); }); @@ -250,34 +247,51 @@ for (const side of ['left', 'right']) { ); assert.equal(f.first.getAttribute('aria-hidden'), undefined); }); - test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => { - const f = fixture(side); - f.expand(f.first, 900); - f.expand(f.second, 900); - f.options.preferredPanelId = 'second'; - f.run(); - assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); - assert.deepEqual(f.collapsed, ['first']); - assert.equal(f.retries(), 1); - }); - test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => { - const f = fixture(side, { hud: { visible: false, variant: 'tactical' } }); - f.first.classList.add('layout-auto-collapsed'); - f.run(); - assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.second.classList.contains('collapsed'), true); - assert.deepEqual(f.collapsed, ['first']); - }); } +test('left constrained layout preserves the preferred panel and requests another pass', () => { + const f = fixture('left'); + f.expand(f.first, 900); + f.expand(f.second, 900); + f.options.preferredPanelId = 'second'; + f.run(); + assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); + assert.deepEqual(f.collapsed, ['first']); + assert.equal(f.retries(), 1); +}); + +test('left hidden HUD restores automatic collapse without altering manual collapse', () => { + const f = fixture('left', { hud: { visible: false, variant: 'tactical' } }); + f.first.classList.add('layout-auto-collapsed'); + f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('collapsed'), true); + assert.deepEqual(f.collapsed, ['first']); +}); + +test('right constrained layout keeps the preferred owner and hides its peer in one pass', () => { + const f = fixture('right'); + f.expand(f.first, 900); + f.expand(f.second, 900); + f.options.preferredPanelId = 'second'; + f.run(); + assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('rail-owner-collapsed'), true); + assert.equal(f.first.getAttribute('aria-hidden'), 'true'); + assert.deepEqual(f.collapsed, ['first']); + assert.equal(f.retries(), 0, 'one owner needs no second pass'); + assert.equal(f.stack.dataset.expandedCount, '1'); + assert.equal(f.stack.dataset.layoutMode, 'focus'); +}); + test('right layout uses keyboard focus when there is no preferred panel', () => { const f = fixture('right'); f.expand(f.first, 900); f.expand(f.second, 900); f.options.documentRef.activeElement = f.second; f.run(); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); + assert.equal(f.first.classList.contains('rail-owner-collapsed'), true); assert.equal(f.second.classList.contains('collapsed'), false); }); @@ -491,7 +505,8 @@ for (const focused of [false, true]) { assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined); } assert.deepEqual(modes.slice(1), Array(9).fill(true)); - assert.deepEqual(needs, Array(10).fill('681.0')); + // A collapsed peer is hidden behind the tab strip and takes no room. + assert.deepEqual(needs, Array(10).fill('629.0')); assert.equal(f.retries(), 0); assert.equal(f.cctv.writes.filter(([op]) => op === 'remove').length, 0); }); @@ -500,16 +515,16 @@ for (const focused of [false, true]) { test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => { const f = thrashingRightRail(); f.run(); - // Header + gap contribute 52 px: need moves 420 -> 450 -> 420. - for (const height of [368, 398, 368, 398, 368]) { + // The owner is the whole need: it moves 420 -> 450 -> 420. + for (const height of [420, 450, 420, 450, 420]) { f.cctv.intrinsicHeight = height; f.run(); assert.equal(f.stack.classList.contains('layout-focus'), true); } - f.cctv.intrinsicHeight = 335; // Need 387 < 443.6 - 55.2. + f.cctv.intrinsicHeight = 387; // Need 387 < 443.6 - 55.2. f.run(); assert.equal(f.stack.classList.contains('layout-focus'), false); - f.cctv.intrinsicHeight = 365; + f.cctv.intrinsicHeight = 417; f.run(); assert.equal(f.stack.classList.contains('layout-focus'), false); }); @@ -518,40 +533,44 @@ test('right rail enters focus only above available height and leaves below the f const f = thrashingRightRail(); f.options.windowRef.innerHeight = 1000; f.options.leftStack.rect.top = 500; - f.cctv.intrinsicHeight = 408; // Need equals available (460 px). + f.cctv.intrinsicHeight = 460; // Need equals available (460 px). f.run(); assert.equal(f.stack.classList.contains('layout-focus'), false); - f.cctv.intrinsicHeight = 409; + f.cctv.intrinsicHeight = 461; f.run(); assert.equal(f.stack.classList.contains('layout-focus'), true); - f.cctv.intrinsicHeight = 348; // Need equals the 400 px exit boundary. + f.cctv.intrinsicHeight = 400; // Need equals the 400 px exit boundary. f.run(); assert.equal(f.stack.classList.contains('layout-focus'), true); - f.cctv.intrinsicHeight = 347; + f.cctv.intrinsicHeight = 399; f.run(); assert.equal(f.stack.classList.contains('layout-focus'), false); }); -test('right rail auto-collapse requests only one retry and settles within two passes', () => { +test('right rail with a second expanded panel settles in one pass: the owner keeps the lane', () => { const f = thrashingRightRail({ weatherOpen: true }); const modes = []; for (let pass = 0; pass < 10; pass++) { f.run(); modes.push(f.stack.classList.contains('layout-focus')); } - assert.deepEqual(f.collapsed, ['weather-panel']); - assert.equal(f.retries(), 1); + assert.deepEqual( + f.collapsed, + ['weather-panel'], + 'the peer is collapsed once', + ); + assert.equal(f.weather.classList.contains('rail-owner-collapsed'), true); + assert.equal(f.retries(), 0); assert.deepEqual(modes.slice(1), Array(9).fill(true)); -}); - -test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => { - const f = thrashingRightRail({ weatherOpen: true }); + // Reopening the peer makes it the owner only through an explicit preference. + f.weather.classList.remove('collapsed', 'rail-owner-collapsed'); f.run(); - assert.equal(f.retries(), 1); - f.weather.classList.remove('collapsed', 'layout-auto-collapsed'); + assert.equal(f.weather.classList.contains('collapsed'), true); + f.options.preferredPanelId = 'weather-panel'; + f.weather.classList.remove('collapsed', 'rail-owner-collapsed'); f.run(); - assert.equal(f.retries(), 1); - assert.equal(f.stack.dataset.layoutMode, 'focus'); + assert.equal(f.weather.classList.contains('collapsed'), false); + assert.equal(f.cctv.classList.contains('rail-owner-collapsed'), true); }); test('right rail restores presentation even when intrinsic measurement throws', () => { @@ -595,14 +614,19 @@ test('right rail measuring pass keeps an opted-in scroller position', () => { }); for (const variant of ['minimal', 'full']) { - test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => { + test(`right rail keeps one owner in the ${variant} HUD and settles without a retry`, () => { const f = fixture('right', { hud: { visible: true, variant } }); f.expand(f.first, 900); f.expand(f.second, 900); for (let pass = 0; pass < 10; pass++) f.run(); assert.equal(f.retries(), 0); assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('rail-owner-collapsed'), true); + assert.deepEqual( + f.collapsed, + ['second'], + 'the peer is collapsed once, not on every pass', + ); }); } // ── Narrow-screen rail overflow pin ────────────────────────────────────────── @@ -695,7 +719,7 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', () } }); -test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => { +test('right layout ignores a CSS-hidden expanded panel: it is neither owner nor laid out', () => { const f = fixture('right'); const weather = element('weather-panel', { height: 300 }); weather.computed.display = 'none'; @@ -704,10 +728,12 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity f.run(); - assert.equal(f.stack.classList.contains('layout-exclusive'), false); + assert.equal(f.stack.classList.contains('layout-exclusive'), true); assert.equal(f.stack.dataset.expandedCount, '0'); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - assert.equal(f.second.getAttribute('aria-hidden'), undefined); + assert.equal(f.first.getAttribute('aria-hidden'), 'true'); + assert.equal(f.second.getAttribute('aria-hidden'), 'true'); + assert.equal(weather.classList.contains('collapsed'), false); + assert.equal(weather.getAttribute('aria-hidden'), undefined); assert.equal( weather.style.getPropertyValue('--right-panel-allocated-height'), '', @@ -726,17 +752,18 @@ test('right layout still restores panels that exclusive presentation hides', () : node.computed.display, }); f.expand(f.first, 250); - f.second.classList.add('layout-auto-collapsed'); + f.second.classList.add('rail-owner-collapsed'); f.stack.classList.add('layout-exclusive'); f.run(); assert.equal(f.stack.classList.contains('layout-exclusive'), true); assert.equal(f.second.getAttribute('aria-hidden'), 'true'); - f.options.hud.visible = false; + // Phone sheets show several panels at once: the hidden peer comes back. + f.options.windowRef.matchMedia = () => ({ matches: true }); f.run(); assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.second.classList.contains('layout-auto-collapsed'), false); + assert.equal(f.second.classList.contains('rail-owner-collapsed'), false); assert.deepEqual(f.collapsed, ['second']); }); @@ -764,22 +791,21 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', ( const alone = parseFloat( f.first.style.getPropertyValue('--right-panel-allocated-height'), ); - // Shown (and expanded) it joins the allocation like any other panel. + // Shown (and expanded) it is a peer: the owner keeps the lane and the + // newcomer is presentation-collapsed in the same pass, with no retry. imagery.hidden = false; imagery.scrollHeight = 300; imagery.rect.height = 300; const retriesBefore = f.retries(); f.run(); - assert.ok( - f.collapsed.includes('recent-imagery-panel'), - 'focus mode collapses the later panel and asks for another pass', - ); - assert.equal(f.retries(), retriesBefore + 1); - f.run(); + assert.deepEqual(f.collapsed, ['recent-imagery-panel']); + assert.equal(imagery.classList.contains('rail-owner-collapsed'), true); + assert.equal(f.retries(), retriesBefore); assert.equal(f.stack.dataset.expandedCount, '1'); - assert.ok( + assert.equal( parseFloat( f.first.style.getPropertyValue('--right-panel-allocated-height'), - ) <= alone, + ), + alone, ); }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 5d3e5e40c..7743ed5e1 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -1,7 +1,4 @@ -import { - allocatePanelStackHeights, - panelStackAutoCollapseIndices, -} from '../panelStackLayout.js'; +import { allocatePanelStackHeights } from '../panelStackLayout.js'; import { resolveHudRailLayout, shouldHideCollapsedRightPanels, @@ -9,8 +6,6 @@ import { import { displayPanelScroller } from './displayPanelScroll.js'; -const pendingCollapseRetries = new WeakSet(); - /** * Where the left rail's top will settle, not where it is mid-transition: * nothing re-runs this pass when the `top` animation ends (ResizeObserver @@ -36,7 +31,9 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) { /** * Measure and place the right panel rail for one synchronous layout pass. * The caller owns scheduling, obstacle selection, disclosure preferences and - * persistence. Auto-collapse is presentation only and reports through callbacks. + * persistence. The rail presents its panels as vertical tabs + * (rightRailTabs.js): one owner stays expanded, its peers are + * presentation-collapsed and hidden, and the owner takes the whole lane. * @param {object} options Live DOM and caller policy. * @param {HTMLElement} options.stack Rail element. * @param {Iterable} options.obstacles Caller-selected obstacle nodes. @@ -44,7 +41,6 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) { * @param {{visible: boolean, variant: string}} options.hud Current HUD presentation. * @param {string} options.preferredPanelId Most recently opened panel. * @param {Function} options.onCollapse Update a panel's disclosure chrome. - * @param {Function} options.onRetry Request another pass after automatic collapse. * @param {Function} [options.getComputedStyle] Optional DOM style reader override. * @param {HTMLElement} options.leftStack Rail supplying the shared top baseline. * @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll. @@ -58,7 +54,6 @@ export function layoutRightPanelRail({ hud, preferredPanelId, onCollapse, - onRetry, leftStack, displayPanel, readDisplayScrollTop, @@ -66,8 +61,6 @@ export function layoutRightPanelRail({ getComputedStyle = (element) => windowRef.getComputedStyle(element), }) { if (!stack) return; - // A collapse may schedule one follow-up, which only measures and allocates. - const isCollapseRetry = pendingCollapseRetries.delete(stack); // Other CSS may hide a panel, such as an event view: it is not laid out. // Exclusive presentation hides collapsed panels itself; those stay in the @@ -81,19 +74,13 @@ export function layoutRightPanelRail({ !panel.hidden && (hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'), ); - if (!hud.visible || hud.variant !== 'tactical') { - for (const panel of panels.filter((item) => - item.classList.contains('layout-auto-collapsed'), - )) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - onCollapse(panel); - } - } - // Normalize restored state and theme entry without overwriting saved panel - // preferences. This marker is distinct from Tactical's space-based collapse. - // Phones present the rail as tab sheets that show several panels at once. + // One expanded owner: the latest explicit disclosure, else the panel that + // holds keyboard focus, else the first. Peers are presentation-collapsed + // without overwriting saved panel preferences; the marker is distinct from + // the left rail's space-based collapse. Phones present the rail as tab + // sheets that show several panels at once, so they get every panel back. const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; - if (hud.variant === 'cyber' && !isMobile) { + if (!isMobile) { const expanded = panels.filter( (panel) => !panel.classList.contains('collapsed'), ); @@ -103,13 +90,13 @@ export function layoutRightPanelRail({ expanded[0]; for (const panel of expanded) { if (panel === owner) continue; - panel.classList.add('collapsed', 'cyber-accordion-collapsed'); + panel.classList.add('collapsed', 'rail-owner-collapsed'); onCollapse(panel); } } else { for (const panel of panels) { - if (!panel.classList.contains('cyber-accordion-collapsed')) continue; - panel.classList.remove('collapsed', 'cyber-accordion-collapsed'); + if (!panel.classList.contains('rail-owner-collapsed')) continue; + panel.classList.remove('collapsed', 'rail-owner-collapsed'); onCollapse(panel); } } @@ -181,14 +168,8 @@ export function layoutRightPanelRail({ }); } - // Auto-collapsed headers contribute the same need even when exclusive - // presentation hides them. Manually collapsed launchers keep their policy. - const measuredPanels = panels.filter( - (panel) => - !exclusive || - !panel.classList.contains('collapsed') || - panel.classList.contains('layout-auto-collapsed'), - ); + // Exclusive presentation hides collapsed panels, so only visible panels + // take room in the lane. const visiblePanels = panels.filter( (panel) => !exclusive || !panel.classList.contains('collapsed'), ); @@ -206,7 +187,7 @@ export function layoutRightPanelRail({ // Do not toggle layout classes here: the class observer schedules new passes. stack.setAttribute('data-rail-measuring', ''); try { - for (const panel of measuredPanels) { + for (const panel of visiblePanels) { naturalHeights.set( panel, Math.max( @@ -223,11 +204,11 @@ export function layoutRightPanelRail({ } const gap = parseFloat(getComputedStyle(stack).rowGap) || 0; const naturalHeight = - measuredPanels.reduce( + visiblePanels.reduce( (total, panel) => total + naturalHeights.get(panel), 0, ) + - gap * Math.max(0, measuredPanels.length - 1); + gap * Math.max(0, visiblePanels.length - 1); const layout = resolveHudRailLayout({ viewportHeight, panelHeight: naturalHeight, @@ -254,26 +235,9 @@ export function layoutRightPanelRail({ : total, 0, ); - const expandedPanelsInDomOrder = visiblePanels.filter( + const expandedPanels = visiblePanels.filter( (panel) => !panel.classList.contains('collapsed'), ); - const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => - panel.contains(documentRef.activeElement), - ); - const preferredExpandedPanel = - expandedPanelsInDomOrder.find((panel) => panel.id === preferredPanelId) || - focusedExpandedPanel; - // Match the left lane: allocation order follows the latest explicit - // disclosure, not DOM order. A focused panel is the fallback owner so - // temporary presentation collapse never strands keyboard focus. - const expandedPanels = preferredExpandedPanel - ? [ - preferredExpandedPanel, - ...expandedPanelsInDomOrder.filter( - (panel) => panel !== preferredExpandedPanel, - ), - ] - : expandedPanelsInDomOrder; const expandedAvailableHeight = Math.max( 0, safeBottom - @@ -285,28 +249,6 @@ export function layoutRightPanelRail({ naturalHeights: expandedPanels.map((panel) => naturalHeights.get(panel)), availableHeight: expandedAvailableHeight, }); - // Other HUD variants restore automatic collapse at the start of each pass. - // Do not immediately collapse those panels again and schedule a loop. - const autoCollapseIndices = - hud.visible && hud.variant === 'tactical' - ? panelStackAutoCollapseIndices({ - naturalHeights: expandedPanels.map((panel) => - naturalHeights.get(panel), - ), - allocatedHeights: expandedHeights, - collapseLaterPanels: shouldFocus && hud.variant === 'tactical', - }) - : []; - if (!isCollapseRetry && autoCollapseIndices.length) { - for (const index of autoCollapseIndices) { - const panel = expandedPanels[index]; - panel.classList.add('collapsed', 'layout-auto-collapsed'); - onCollapse(panel); - } - pendingCollapseRetries.add(stack); - onRetry(); - return; - } // Write-if-changed. This pass runs on the 500 ms stats cadence. Rewriting // an unchanged allocation wakes the world-overlay occluder observer and // defeats parked-idle render savings. Measurement leaves these values intact. diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js new file mode 100644 index 000000000..fae03bd10 --- /dev/null +++ b/src/ui/rightRailTabs.js @@ -0,0 +1,222 @@ +/** + * Vertical tab strip for the right context rail. + * + * The strip replaces the rail's collapsed launcher cards: one sideways tab per + * rail panel, the open panel beside the strip, at most one panel open. The + * panels' `collapsed` classes remain the only state. The strip mirrors them and + * asks the owner (setPanelCollapsed) for every change, so persistence, share + * links and the rail layout pass are untouched. Phones hide the strip and keep + * their sheet shell (mobileShell.js). + */ +import { msg, t } from '../i18n/index.js'; + +export const RIGHT_RAIL_TABS_ID = 'right-rail-tabs'; + +/** Presentation variants, selected through `data-rail-tabs` on the rail. */ +export const RIGHT_RAIL_TAB_VARIANTS = Object.freeze([ + 'cards-down', + 'cards-up', + 'spine', + 'icons', + 'minimal', +]); +export const DEFAULT_RIGHT_RAIL_TAB_VARIANT = 'cards-down'; + +/** Glyphs for the icon variant; every name is in index.html's icon_names. */ +const PANEL_ICONS = Object.freeze({ + 'pp-toggles': 'tune', + 'cctv-panel': 'videocam', + 'street-level-panel': 'streetview', + 'global-context-panel': 'radar', + 'weather-panel': 'cloud', + 'recent-imagery-panel': 'satellite_alt', +}); + +/** + * The tab keyboard navigation moves to in a vertical tablist: Down and Up + * wrap, Home and End jump. Any other key keeps the current index. + * @param {string} key KeyboardEvent.key. + * @param {number} index Index of the focused tab. + * @param {number} count Visible tab count. + * @returns {number} Target index, or -1 when there is no tab. + */ +export function nextRailTabIndex(key, index, count) { + if (!(count > 0)) return -1; + const current = Math.max(0, Math.min(count - 1, index)); + switch (key) { + case 'ArrowDown': + return (current + 1) % count; + case 'ArrowUp': + return (current - 1 + count) % count; + case 'Home': + return 0; + case 'End': + return count - 1; + default: + return current; + } +} + +/** The panels a rail presents as tabs, in rail order. */ +export function railTabPanels(rail) { + return [...(rail?.children || [])].filter( + (panel) => panel.matches?.('[data-panel-id]') && !panel.hidden, + ); +} + +/** The tab label: the panel's own header title, already translated. */ +function panelTitle(panel) { + return ( + panel + .querySelector?.('.panel-title, .pp-header-label') + ?.textContent?.trim() || panel.id + ); +} + +/** + * Build the strip inside `rail` and keep it in step with the rail's panels. + * @param {object} input + * @param {HTMLElement} input.rail The right context rail. + * @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed + * Owner callback; the strip never touches panel classes itself. + * @param {Document} [input.documentRef] + * @param {string} [input.variant] Initial presentation variant. + * @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }} + */ +export function createRightRailTabs({ + rail, + setPanelCollapsed, + documentRef = rail?.ownerDocument, + variant = DEFAULT_RIGHT_RAIL_TAB_VARIANT, +}) { + if (!rail || !documentRef || typeof setPanelCollapsed !== 'function') { + throw new TypeError( + 'Right rail tabs require a rail, a document and setPanelCollapsed', + ); + } + const strip = documentRef.createElement('div'); + strip.id = RIGHT_RAIL_TABS_ID; + strip.className = 'right-rail-tabs'; + strip.setAttribute('role', 'tablist'); + strip.setAttribute('aria-orientation', 'vertical'); + strip.setAttribute('aria-label', t('Panels')); + if (!rail.dataset.railTabs) rail.dataset.railTabs = variant; + + /** @type {Map} */ + const entries = new Map(); + const observed = new WeakSet(); + let destroyed = false; + + const entryFor = (panel) => { + let entry = entries.get(panel.id); + if (entry) return entry; + const tab = documentRef.createElement('button'); + tab.type = 'button'; + tab.className = 'right-rail-tab'; + tab.dataset.railTab = panel.id; + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-controls', panel.id); + const icon = PANEL_ICONS[panel.id]; + if (icon) { + const glyph = documentRef.createElement('span'); + glyph.className = 'material-symbols-outlined right-rail-tab-icon'; + glyph.setAttribute('aria-hidden', 'true'); + glyph.textContent = icon; + tab.append(glyph); + } + const label = documentRef.createElement('span'); + label.className = 'right-rail-tab-label'; + tab.append(label); + entry = { tab, label }; + entries.set(panel.id, entry); + return entry; + }; + + const Observer = + documentRef.defaultView?.MutationObserver ?? globalThis.MutationObserver; + const observer = + typeof Observer === 'function' ? new Observer(() => sync()) : null; + const observe = (panel) => { + if (!observer || observed.has(panel)) return; + observed.add(panel); + observer.observe(panel, { + attributes: true, + attributeFilter: ['class', 'hidden'], + }); + }; + + /** Reconcile tabs with the rail: order, visibility, selection, labels. */ + function sync() { + if (destroyed) return; + // The tablist precedes its panels in the tab order, so Tab from the open + // tab lands inside the open panel. The rail owner moves panels in at + // start-up; keep the strip first afterwards too. + if (rail.firstElementChild !== strip) rail.prepend(strip); + const panels = railTabPanels(rail); + const focusOwner = + panels.find((panel) => !panel.classList.contains('collapsed')) || + panels[0]; + // Move a tab only when it is out of place: reinserting the focused tab + // would drop keyboard focus on every class change. + let cursor = strip.firstElementChild; + for (const panel of panels) { + const { tab, label } = entryFor(panel); + if (tab !== cursor) strip.insertBefore(tab, cursor); + else cursor = cursor.nextElementSibling; + const collapsed = panel.classList.contains('collapsed'); + const name = panelTitle(panel); + if (label.textContent !== name) label.textContent = name; + tab.setAttribute('aria-selected', String(!collapsed)); + tab.tabIndex = panel === focusOwner ? 0 : -1; + tab.title = t(collapsed ? msg('Expand {name}') : msg('Collapse {name}'), { + name, + }); + observe(panel); + } + const live = new Set(panels.map((panel) => panel.id)); + for (const [id, entry] of entries) { + if (live.has(id)) continue; + entry.tab.remove(); + entries.delete(id); + } + } + + const onClick = (event) => { + const tab = event.target?.closest?.('[data-rail-tab]'); + if (!tab) return; + const panel = documentRef.getElementById(tab.dataset.railTab); + if (!panel) return; + setPanelCollapsed(panel.id, !panel.classList.contains('collapsed'), { + explicit: true, + }); + }; + const onKeydown = (event) => { + const current = event.target?.closest?.('[data-rail-tab]'); + if (!current) return; + const visible = [...strip.children].filter((tab) => !tab.hidden); + const index = visible.indexOf(current); + const next = nextRailTabIndex(event.key, index, visible.length); + if (next < 0 || next === index) return; + event.preventDefault(); + visible[next].focus(); + }; + strip.addEventListener('click', onClick); + strip.addEventListener('keydown', onKeydown); + observer?.observe(rail, { childList: true }); + rail.prepend(strip); + sync(); + + return { + element: strip, + sync, + destroy() { + if (destroyed) return; + destroyed = true; + observer?.disconnect(); + strip.removeEventListener('click', onClick); + strip.removeEventListener('keydown', onKeydown); + strip.remove(); + entries.clear(); + }, + }; +} diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs new file mode 100644 index 000000000..23998fc18 --- /dev/null +++ b/src/ui/rightRailTabs.test.mjs @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DEFAULT_RIGHT_RAIL_TAB_VARIANT, + RIGHT_RAIL_TABS_ID, + createRightRailTabs, + nextRailTabIndex, + railTabPanels, +} from './rightRailTabs.js'; + +/** Just enough DOM for the strip: tree, classes, attributes, events, focus. */ +function makeDocument() { + const doc = { activeElement: null }; + const findIn = (node, predicate) => { + for (const child of node.children) { + if (predicate(child)) return child; + const nested = findIn(child, predicate); + if (nested) return nested; + } + return null; + }; + doc.createElement = (tag) => { + const el = new EventTarget(); + const attrs = new Map(); + const classes = new Set(); + Object.assign(el, { + tagName: tag.toUpperCase(), + ownerDocument: doc, + children: [], + parentElement: null, + dataset: {}, + hidden: false, + textContent: '', + tabIndex: -1, + title: '', + type: '', + id: '', + classList: { + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + toggle(name, on) { + if (on) classes.add(name); + else classes.delete(name); + }, + }, + setAttribute: (key, value) => attrs.set(key, String(value)), + getAttribute: (key) => attrs.get(key) ?? null, + removeAttribute: (key) => attrs.delete(key), + insertBefore(child, reference) { + child.remove(); + const index = reference ? el.children.indexOf(reference) : -1; + el.children.splice(index < 0 ? el.children.length : index, 0, child); + child.parentElement = el; + return child; + }, + append(...nodes) { + for (const node of nodes) el.insertBefore(node, null); + }, + prepend(...nodes) { + for (const node of nodes.reverse()) + el.insertBefore(node, el.children[0] ?? null); + }, + remove() { + const parent = el.parentElement; + if (!parent) return; + parent.children.splice(parent.children.indexOf(el), 1); + el.parentElement = null; + }, + matches(selector) { + if (selector === '[data-panel-id]') return 'panelId' in el.dataset; + if (selector === '[data-rail-tab]') return 'railTab' in el.dataset; + return false; + }, + closest(selector) { + return el.matches(selector) + ? el + : (el.parentElement?.closest(selector) ?? null); + }, + querySelector(selector) { + const wanted = selector.split(',').map((part) => part.trim().slice(1)); + return findIn(el, (node) => + wanted.some((name) => node.classList.contains(name)), + ); + }, + focus() { + doc.activeElement = el; + }, + }); + Object.defineProperties(el, { + className: { + get: () => [...classes].join(' '), + set(value) { + classes.clear(); + for (const name of String(value).split(/\s+/).filter(Boolean)) + classes.add(name); + }, + }, + firstElementChild: { get: () => el.children[0] ?? null }, + nextElementSibling: { + get: () => { + const siblings = el.parentElement?.children; + if (!siblings) return null; + return siblings[siblings.indexOf(el) + 1] ?? null; + }, + }, + }); + return el; + }; + doc.body = doc.createElement('body'); + doc.getElementById = (id) => findIn(doc.body, (node) => node.id === id); + return doc; +} + +/** Dispatch a bubbling event with `target` fixed to `el`. */ +function fire(el, type, props = {}) { + const event = Object.assign(new Event(type, { cancelable: true }), props); + Object.defineProperty(event, 'target', { value: el }); + for (let node = el; node; node = node.parentElement) + node.dispatchEvent(event); + return event; +} + +function panel( + doc, + id, + { title, titleClass = 'panel-title', collapsed = true, hidden = false }, +) { + const node = doc.createElement('div'); + node.id = id; + node.dataset.panelId = id; + node.hidden = hidden; + if (collapsed) node.classList.add('collapsed'); + const header = doc.createElement('div'); + header.className = 'panel-header'; + const label = doc.createElement('span'); + label.className = titleClass; + label.textContent = title; + header.append(label); + node.append(header); + return node; +} + +function fixture() { + const doc = makeDocument(); + const rail = doc.createElement('aside'); + rail.id = 'right-context-rail'; + const display = panel(doc, 'pp-toggles', { + title: 'DISPLAY', + titleClass: 'pp-header-label', + }); + const cctv = panel(doc, 'cctv-panel', { title: 'CCTV' }); + const weather = panel(doc, 'weather-panel', { + title: 'WEATHER', + collapsed: false, + hidden: true, + }); + const context = panel(doc, 'global-context-panel', { title: 'CONTEXT' }); + rail.append(display, cctv, weather, context); + doc.body.append(rail); + const calls = []; + const tabs = createRightRailTabs({ + rail, + documentRef: doc, + setPanelCollapsed: (...args) => calls.push(args), + }); + const strip = tabs.element; + const tabFor = (id) => + strip.children.find((tab) => tab.dataset.railTab === id); + const labelOf = (tab) => + tab.children.find((child) => + child.classList.contains('right-rail-tab-label'), + ).textContent; + return { + doc, + rail, + display, + cctv, + weather, + context, + tabs, + strip, + calls, + tabFor, + labelOf, + }; +} + +test('builds one tab per visible rail panel, in rail order, labelled from the panel header', () => { + const f = fixture(); + assert.equal( + f.rail.children[0], + f.strip, + 'the tablist precedes its panels in the tab order', + ); + assert.equal(f.strip.id, RIGHT_RAIL_TABS_ID); + assert.equal(f.strip.getAttribute('role'), 'tablist'); + assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical'); + assert.equal(f.rail.dataset.railTabs, DEFAULT_RIGHT_RAIL_TAB_VARIANT); + assert.deepEqual( + f.strip.children.map((tab) => [ + tab.dataset.railTab, + f.labelOf(tab), + tab.getAttribute('aria-controls'), + ]), + [ + ['pp-toggles', 'DISPLAY', 'pp-toggles'], + ['cctv-panel', 'CCTV', 'cctv-panel'], + ['global-context-panel', 'CONTEXT', 'global-context-panel'], + ], + 'a hidden panel (Weather off) has no tab', + ); + for (const tab of f.strip.children) { + assert.equal(tab.getAttribute('role'), 'tab'); + assert.equal(tab.type, 'button'); + } + assert.deepEqual( + railTabPanels(f.rail).map((p) => p.id), + ['pp-toggles', 'cctv-panel', 'global-context-panel'], + ); +}); + +test('mirrors collapsed state into aria-selected, a roving tabindex and titles', () => { + const f = fixture(); + assert.deepEqual( + f.strip.children.map((tab) => tab.getAttribute('aria-selected')), + ['false', 'false', 'false'], + ); + assert.deepEqual( + f.strip.children.map((tab) => tab.tabIndex), + [0, -1, -1], + 'nothing open: the first tab is reachable', + ); + assert.equal(f.tabFor('cctv-panel').title, 'Expand CCTV'); + f.cctv.classList.remove('collapsed'); + f.tabs.sync(); + assert.deepEqual( + f.strip.children.map((tab) => tab.getAttribute('aria-selected')), + ['false', 'true', 'false'], + ); + assert.deepEqual( + f.strip.children.map((tab) => tab.tabIndex), + [-1, 0, -1], + 'the open panel owns the tab stop', + ); + assert.equal(f.tabFor('cctv-panel').title, 'Collapse CCTV'); +}); + +test('a click asks the owner for an explicit toggle and never touches the panel itself', () => { + const f = fixture(); + fire(f.tabFor('cctv-panel'), 'click'); + assert.deepEqual(f.calls, [['cctv-panel', false, { explicit: true }]]); + assert.equal( + f.cctv.classList.contains('collapsed'), + true, + 'the strip leaves the class to the owner', + ); + f.cctv.classList.remove('collapsed'); + f.tabs.sync(); + fire(f.tabFor('cctv-panel'), 'click'); + assert.deepEqual(f.calls.at(-1), ['cctv-panel', true, { explicit: true }]); + fire(f.strip, 'click'); + assert.equal(f.calls.length, 2, 'a click beside the tabs does nothing'); +}); + +test('arrow keys move focus through the visible tabs and wrap; Home and End jump', () => { + assert.equal(nextRailTabIndex('ArrowDown', 2, 3), 0); + assert.equal(nextRailTabIndex('ArrowUp', 0, 3), 2); + assert.equal(nextRailTabIndex('Home', 2, 3), 0); + assert.equal(nextRailTabIndex('End', 0, 3), 2); + assert.equal(nextRailTabIndex('Enter', 1, 3), 1); + assert.equal(nextRailTabIndex('ArrowDown', 0, 0), -1); + const f = fixture(); + const first = f.tabFor('pp-toggles'); + first.focus(); + const down = fire(first, 'keydown', { key: 'ArrowDown' }); + assert.equal(f.doc.activeElement, f.tabFor('cctv-panel')); + assert.equal(down.defaultPrevented, true); + fire(f.doc.activeElement, 'keydown', { key: 'ArrowUp' }); + fire(f.doc.activeElement, 'keydown', { key: 'ArrowUp' }); + assert.equal( + f.doc.activeElement, + f.tabFor('global-context-panel'), + 'Up from the first tab wraps to the last', + ); + const enter = fire(f.doc.activeElement, 'keydown', { key: 'Enter' }); + assert.equal( + enter.defaultPrevented, + false, + 'activation keys stay with the button', + ); + assert.deepEqual(f.calls, [], 'moving focus opens nothing'); +}); + +test('a panel that appears gets its tab in rail order; one that leaves loses it', () => { + const f = fixture(); + f.weather.hidden = false; + f.tabs.sync(); + assert.deepEqual( + f.strip.children.map((tab) => tab.dataset.railTab), + ['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'], + ); + assert.equal(f.tabFor('weather-panel').getAttribute('aria-selected'), 'true'); + const cctvTab = f.tabFor('cctv-panel'); + f.cctv.remove(); + f.tabs.sync(); + assert.deepEqual( + f.strip.children.map((tab) => tab.dataset.railTab), + ['pp-toggles', 'weather-panel', 'global-context-panel'], + ); + assert.equal(cctvTab.parentElement, null); + assert.equal(f.tabFor('pp-toggles').tabIndex, -1); + assert.equal(f.tabFor('weather-panel').tabIndex, 0); +}); + +test('a sync that changes nothing keeps the same tab elements in place', () => { + const f = fixture(); + const before = [...f.strip.children]; + f.tabFor('cctv-panel').focus(); + f.tabs.sync(); + assert.deepEqual([...f.strip.children], before); + assert.equal(f.doc.activeElement, f.tabFor('cctv-panel')); +}); + +test('destroy removes the strip and stops listening', () => { + const f = fixture(); + const tab = f.tabFor('cctv-panel'); + f.tabs.destroy(); + assert.equal(f.strip.parentElement, null); + assert.equal(f.rail.children.includes(f.strip), false); + fire(tab, 'click'); + assert.deepEqual(f.calls, []); + f.tabs.destroy(); +}); + +test('refuses to build without a rail, a document or an owner callback', () => { + const doc = makeDocument(); + assert.throws( + () => createRightRailTabs({ rail: null, setPanelCollapsed() {} }), + TypeError, + ); + assert.throws( + () => + createRightRailTabs({ + rail: doc.createElement('aside'), + documentRef: doc, + }), + TypeError, + ); +}); diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css new file mode 100644 index 000000000..76f6bec30 --- /dev/null +++ b/src/ui/styles/right-rail-tabs.css @@ -0,0 +1,308 @@ +/* ── Right rail as vertical tabs ─────────────────────────────────────────── + A sideways tab strip (rightRailTabs.js) hugs the rail's right edge and the + open panel sits beside it. Panels keep their `collapsed` state; the rail's + exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip + is the only launcher. Phones keep the sheet shell (mobile-shell.css). + + `data-rail-tabs` on the rail picks a presentation: + cards-down one glass card per tab, labels read top to bottom (default) + cards-up the same cards, labels read bottom to top + spine one continuous bar with an accent edge on the open tab + icons upright icon above a sideways label + minimal bare labels with an accent bar, like the HUD edge text + + The attribute arrives with the strip (rightRailTabs.js), so a rail without + it keeps its legacy geometry; the selectors carry it so they outrank the + Cyber rail rules that follow this sheet. */ + +#right-context-rail[data-rail-tabs] { + --right-rail-panel-width: 330px; + --right-rail-tabs-width: 34px; + --right-rail-tabs-gap: 10px; + --right-rail-tabs-inset: calc( + var(--right-rail-tabs-width) + var(--right-rail-tabs-gap) + ); + width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); + padding-right: var(--right-rail-tabs-inset); +} + +:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] { + --right-rail-panel-width: 312px; + width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); +} + +#right-rail-tabs { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 6px; + width: var(--right-rail-tabs-width); + max-height: var(--right-stack-max-height, 70vh); + overflow: hidden auto; + scrollbar-width: none; + pointer-events: auto; +} + +#right-rail-tabs::-webkit-scrollbar { + display: none; +} + +.right-rail-tab { + position: relative; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 100%; + min-height: 56px; + padding: 12px 0; + border: 1px solid var(--glass-border); + border-radius: 9px; + background: var(--glass-bg); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 9px; + line-height: 1; + letter-spacing: 2.2px; + text-transform: uppercase; + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + box-shadow var(--transition-fast), + color var(--transition-fast); +} + +.right-rail-tab-label { + display: block; + writing-mode: vertical-rl; + text-orientation: mixed; + white-space: nowrap; +} + +/* The icon font's own display rule must not reveal the glyph elsewhere. */ +.right-rail-tab > .right-rail-tab-icon { + display: none; +} + +.right-rail-tab:hover { + border-color: var(--glass-border-hover); + background: rgba(255, 255, 255, 0.06); + color: var(--text-primary); +} + +.right-rail-tab[aria-selected='true'] { + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: 0 0 12px rgba(0, 212, 255, 0.15); + color: var(--accent); +} + +/* ── cards-up: labels read bottom to top ───────────────────────────────── */ +#right-context-rail[data-rail-tabs='cards-up'] .right-rail-tab-label { + transform: rotate(180deg); +} + +@supports (writing-mode: sideways-lr) { + #right-context-rail[data-rail-tabs='cards-up'] .right-rail-tab-label { + writing-mode: sideways-lr; + transform: none; + } +} + +/* ── spine: one bar, accent edge toward the open panel ─────────────────── */ +#right-context-rail[data-rail-tabs='spine'] { + --right-rail-tabs-gap: 8px; +} + +#right-context-rail[data-rail-tabs='spine'] #right-rail-tabs { + gap: 0; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: var(--glass-bg); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); +} + +#right-context-rail[data-rail-tabs='spine'] .right-rail-tab { + min-height: 64px; + padding: 16px 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +#right-context-rail[data-rail-tabs='spine'] .right-rail-tab + .right-rail-tab { + border-top: 1px solid var(--glass-border); +} + +#right-context-rail[data-rail-tabs='spine'] .right-rail-tab:hover { + background: rgba(255, 255, 255, 0.05); +} + +#right-context-rail[data-rail-tabs='spine'] + .right-rail-tab[aria-selected='true'] { + background: var(--accent-dim); + box-shadow: inset 2px 0 0 var(--accent); +} + +/* ── icons: upright glyph over the sideways label ──────────────────────── */ +#right-context-rail[data-rail-tabs='icons'] { + --right-rail-tabs-width: 40px; +} + +#right-context-rail[data-rail-tabs='icons'] .right-rail-tab { + flex-direction: column; + gap: 6px; + min-height: 64px; + padding: 10px 0 12px; + letter-spacing: 2px; +} + +#right-context-rail[data-rail-tabs='icons'] + .right-rail-tab + > .right-rail-tab-icon { + display: block; + font-size: 16px; + line-height: 1; +} + +/* ── minimal: bare labels with an accent bar, like the HUD edge text ───── */ +#right-context-rail[data-rail-tabs='minimal'] { + --right-rail-tabs-width: 26px; + --right-rail-tabs-gap: 14px; +} + +#right-context-rail[data-rail-tabs='minimal'] #right-rail-tabs { + gap: 18px; +} + +#right-context-rail[data-rail-tabs='minimal'] .right-rail-tab { + min-height: 0; + padding: 4px 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; + color: var(--text-secondary); + letter-spacing: 3px; +} + +#right-context-rail[data-rail-tabs='minimal'] .right-rail-tab::before { + content: ''; + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: 2px; + border-radius: 1px; + background: transparent; + transition: background-color var(--transition-fast); +} + +#right-context-rail[data-rail-tabs='minimal'] .right-rail-tab:hover { + background: transparent; + color: var(--text-primary); +} + +#right-context-rail[data-rail-tabs='minimal'] + .right-rail-tab[aria-selected='true'] { + background: transparent; + box-shadow: none; + color: var(--accent); + text-shadow: 0 0 8px var(--accent-glow); +} + +#right-context-rail[data-rail-tabs='minimal'] + .right-rail-tab[aria-selected='true']::before { + background: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); +} + +/* ── Cyber skin ────────────────────────────────────────────────────────── */ +:root[data-ui-theme='cyber'] .right-rail-tab { + border-color: var(--glass-border); + border-radius: var(--btn-radius); + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.32) inset, + 0 8px 20px rgba(0, 0, 0, 0.28); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'] { + border-color: var(--cyber-red); + background-color: var(--cyber-panel-surface); + box-shadow: + 3px 0 0 var(--cyber-red) inset, + 0 8px 20px rgba(0, 0, 0, 0.28); + color: var(--cyber-red-bright); +} + +:root[data-ui-theme='cyber'] + #right-context-rail[data-rail-tabs='spine'] + #right-rail-tabs { + border-radius: var(--btn-radius); + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + box-shadow: var(--cyber-panel-shadow); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +:root[data-ui-theme='cyber'] + #right-context-rail[data-rail-tabs='spine'] + .right-rail-tab, +:root[data-ui-theme='cyber'] + #right-context-rail[data-rail-tabs='minimal'] + .right-rail-tab { + background: transparent; + box-shadow: none; +} + +:root[data-ui-theme='cyber'] + #right-context-rail[data-rail-tabs='spine'] + .right-rail-tab[aria-selected='true'] { + background: var(--accent-dim); + box-shadow: inset 3px 0 0 var(--cyber-red); +} + +:root[data-ui-theme='cyber'] + #right-context-rail[data-rail-tabs='minimal'] + .right-rail-tab[aria-selected='true'] { + color: var(--cyber-red-bright); + text-shadow: 0 0 8px var(--accent-glow); +} + +:root[data-ui-theme='cyber'] + #right-context-rail[data-rail-tabs='minimal'] + .right-rail-tab[aria-selected='true']::before { + background: var(--cyber-red); + box-shadow: 0 0 8px var(--accent-glow); +} + +/* ── Phones keep the sheet shell ───────────────────────────────────────── */ +body.mobile-shell #right-context-rail { + padding-right: 0; +} + +body.mobile-shell #right-context-rail > #right-rail-tabs { + display: none; +} diff --git a/style.css b/style.css index 1b55c459f..c3aa1cb31 100644 --- a/style.css +++ b/style.css @@ -34,4 +34,5 @@ @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/cjk.css'; @import './src/ui/styles/mobile-shell.css'; +@import './src/ui/styles/right-rail-tabs.css'; @import './src/ui/styles/cyber.css';