mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
15de9d738c
commit
71bd645d59
18 files changed
+1087
-205
No files matched your search
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
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
|
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. -->
|
a box — the ligature never forms and the element renders the literal word. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,cloud,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- gev:template scene-chrome -->
|
<!-- gev:template scene-chrome -->
|
||||||
|
|||||||
@@ -1117,5 +1117,8 @@
|
|||||||
"src/testSupport/shareViewFixtures.mjs",
|
"src/testSupport/shareViewFixtures.mjs",
|
||||||
"src/ui/shareLinks.test.mjs",
|
"src/ui/shareLinks.test.mjs",
|
||||||
"src/ui/shareButton.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"
|
||||||
]
|
]
|
||||||
@@ -762,6 +762,7 @@
|
|||||||
"src/ui/radioTunerModel.js",
|
"src/ui/radioTunerModel.js",
|
||||||
"src/ui/recordingControls.js",
|
"src/ui/recordingControls.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/rightRailTabs.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/actionButton.js",
|
"src/ui/actionButton.js",
|
||||||
@@ -2287,6 +2288,7 @@
|
|||||||
"src/ui/radioTunerModel.js",
|
"src/ui/radioTunerModel.js",
|
||||||
"src/ui/recordingControls.js",
|
"src/ui/recordingControls.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/rightRailTabs.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/actionButton.js",
|
"src/ui/actionButton.js",
|
||||||
@@ -3352,6 +3354,7 @@
|
|||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/rightRailTabs.js",
|
||||||
"src/ui/uiLifetime.js",
|
"src/ui/uiLifetime.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ test('Escape collapses an expanded panel and returns focus to its disclosure', (
|
|||||||
assert.equal(fixture.event.propagationStopped, true);
|
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', () => {
|
test('Escape on an expanded panel disclosure closes it without retaining focus', () => {
|
||||||
const fixture = panelFixture({ onDisclosure: true });
|
const fixture = panelFixture({ onDisclosure: true });
|
||||||
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
|
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
|
||||||
|
|||||||
@@ -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(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
|
||||||
assert.match(
|
assert.match(
|
||||||
rightRail,
|
rightRail,
|
||||||
/panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
|
/expanded\.find\(\(panel\) => panel\.id === preferredPanelId\)/,
|
||||||
'the latest explicitly opened right panel must receive primary allocation',
|
'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(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(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
|
||||||
assert.match(leftRail, /--left-panel-allocated-height/);
|
assert.match(leftRail, /--left-panel-allocated-height/);
|
||||||
assert.match(rightRail, /--right-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(
|
assert.match(
|
||||||
ui,
|
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',
|
'responsive auto-collapse must serialize the explicit expanded preference',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -7,29 +7,16 @@ import {
|
|||||||
shouldHideCollapsedRightPanels,
|
shouldHideCollapsedRightPanels,
|
||||||
} from './rightRailPolicy.js';
|
} from './rightRailPolicy.js';
|
||||||
|
|
||||||
test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => {
|
test('the tab strip is the launcher in every HUD layout, so collapsed right-rail panels hide', () => {
|
||||||
assert.equal(shouldHideCollapsedRightPanels({
|
for (const hudVariant of ['tactical', 'operator', 'minimal', 'cyber', 'full']) {
|
||||||
hudVariant: 'tactical',
|
for (const hasExpandedPanel of [true, false]) {
|
||||||
hasExpandedPanel: true,
|
assert.equal(
|
||||||
}), true);
|
shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }),
|
||||||
});
|
true,
|
||||||
|
`${hudVariant} with${hasExpandedPanel ? '' : 'out'} an expanded panel`,
|
||||||
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('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ test('explicit Cyber opening collapses peers and claims their restoration lanes'
|
|||||||
assert.equal(f.context.classList.contains('collapsed'), true);
|
assert.equal(f.context.classList.contains('collapsed'), true);
|
||||||
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
|
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
|
||||||
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
|
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 });
|
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; }
|
} finally { globalThis.document = prior; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,17 +71,25 @@ test('opening already-expanded Radio reveals Context and closes Display', () =>
|
|||||||
} finally { globalThis.document = prior; }
|
} 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']) {
|
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
|
||||||
const f = fixture(theme), prior = globalThis.document;
|
const prior = globalThis.document;
|
||||||
globalThis.document = f.doc;
|
const explicit = fixture(theme);
|
||||||
|
globalThis.document = explicit.doc;
|
||||||
try {
|
try {
|
||||||
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
|
explicit.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||||
f.owner.setPanelCollapsed('pp-toggles', false, options);
|
explicit.owner.setPanelCollapsed('cctv-panel', false, { explicit: true });
|
||||||
f.owner.setPanelCollapsed('cctv-panel', false, options);
|
assert.equal(explicit.display.classList.contains('collapsed'), true, theme);
|
||||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
assert.equal(explicit.cctv.classList.contains('collapsed'), false, theme);
|
||||||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
} finally { globalThis.document = prior; }
|
||||||
if (theme === 'cyber') assert.deepEqual(f.saved, []);
|
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; }
|
} finally { globalThis.document = prior; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -208,7 +208,7 @@ export function initMobileShell({
|
|||||||
return (
|
return (
|
||||||
classes.contains('collapsed') &&
|
classes.contains('collapsed') &&
|
||||||
!classes.contains('layout-auto-collapsed') &&
|
!classes.contains('layout-auto-collapsed') &&
|
||||||
!classes.contains('cyber-accordion-collapsed')
|
!classes.contains('rail-owner-collapsed')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+20
-9
@@ -8,6 +8,7 @@ import {
|
|||||||
collapsePanelOnEscape,
|
collapsePanelOnEscape,
|
||||||
createHoverDisclosure,
|
createHoverDisclosure,
|
||||||
} from './panelDisclosure.js';
|
} from './panelDisclosure.js';
|
||||||
|
import { createRightRailTabs } from './rightRailTabs.js';
|
||||||
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||||
{ id: 'control-panel', pinnable: true },
|
{ id: 'control-panel', pinnable: true },
|
||||||
{ id: 'location-bar', pinnable: true },
|
{ id: 'location-bar', pinnable: true },
|
||||||
@@ -102,6 +103,16 @@ export class PanelChrome {
|
|||||||
for (const control of this._panelDisclosureControls || [])
|
for (const control of this._panelDisclosureControls || [])
|
||||||
control.destroy();
|
control.destroy();
|
||||||
this._panelDisclosureControls = [];
|
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();
|
const targets = new Map();
|
||||||
document
|
document
|
||||||
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
.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.
|
// restore the user's explicit expanded preference at its own viewport.
|
||||||
const collapsed =
|
const collapsed =
|
||||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||||
panelEl.classList.contains('cyber-accordion-collapsed')
|
panelEl.classList.contains('rail-owner-collapsed')
|
||||||
? false
|
? false
|
||||||
: panelEl.classList.contains('collapsed');
|
: panelEl.classList.contains('collapsed');
|
||||||
// A panel the phone shell only borrowed for a sheet shares its own state.
|
// 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 nextCollapsed = Boolean(collapsed);
|
||||||
const wasAutoCollapsed =
|
const wasAutoCollapsed =
|
||||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||||
panelEl.classList.contains('cyber-accordion-collapsed');
|
panelEl.classList.contains('rail-owner-collapsed');
|
||||||
// A user opening a Cyber rail panel owns the whole accordion, including
|
// The right rail is a set of vertical tabs: a user opening one panel owns
|
||||||
// peers that were only presentation-collapsed during a saved-state restore.
|
// the rail, including peers that were only presentation-collapsed during
|
||||||
|
// a saved-state restore.
|
||||||
if (
|
if (
|
||||||
explicit &&
|
explicit &&
|
||||||
!restore &&
|
!restore &&
|
||||||
!nextCollapsed &&
|
!nextCollapsed &&
|
||||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
this._rightPanelStack &&
|
||||||
panelEl.parentElement === this._rightPanelStack &&
|
panelEl.parentElement === this._rightPanelStack &&
|
||||||
// Phone sheets show several rail panels at once.
|
// Phone sheets show several rail panels at once.
|
||||||
!document.body?.classList?.contains('mobile-shell')
|
!document.body?.classList?.contains('mobile-shell')
|
||||||
@@ -539,10 +551,7 @@ export class PanelChrome {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
panelEl.classList.remove(
|
panelEl.classList.remove('layout-auto-collapsed', 'rail-owner-collapsed');
|
||||||
'layout-auto-collapsed',
|
|
||||||
'cyber-accordion-collapsed',
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
!nextCollapsed &&
|
!nextCollapsed &&
|
||||||
this.cockpitView?.active &&
|
this.cockpitView?.active &&
|
||||||
@@ -665,6 +674,8 @@ export class PanelChrome {
|
|||||||
this._disposed = true;
|
this._disposed = true;
|
||||||
this._lifetime.destroy();
|
this._lifetime.destroy();
|
||||||
this._panelLayout.destroy();
|
this._panelLayout.destroy();
|
||||||
|
this._rightRailTabs?.destroy();
|
||||||
|
this._rightRailTabs = null;
|
||||||
for (const control of this._panelDisclosureControls || [])
|
for (const control of this._panelDisclosureControls || [])
|
||||||
control.destroy();
|
control.destroy();
|
||||||
this._panelDisclosureControls = [];
|
this._panelDisclosureControls = [];
|
||||||
|
|||||||
@@ -62,9 +62,14 @@ export function collapsePanelOnEscape(
|
|||||||
const disclosure =
|
const disclosure =
|
||||||
panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) ||
|
panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) ||
|
||||||
panel.querySelector(`[data-collapse-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 =
|
const escapedFromDisclosure =
|
||||||
event.target === disclosure || disclosure?.contains?.(event.target);
|
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 });
|
else disclosure?.focus?.({ preventScroll: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,7 +304,6 @@ export class PanelLayoutController {
|
|||||||
hud: this.readHud(),
|
hud: this.readHud(),
|
||||||
preferredPanelId: this._rightStackPreferredPanelId,
|
preferredPanelId: this._rightStackPreferredPanelId,
|
||||||
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
|
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
|
||||||
onRetry: () => this._scheduleRightPanelLayout(),
|
|
||||||
leftStack: this._leftPanelStack,
|
leftStack: this._leftPanelStack,
|
||||||
displayPanel: this._ppToggles,
|
displayPanel: this._ppToggles,
|
||||||
readDisplayScrollTop: this.readDisplayScrollTop,
|
readDisplayScrollTop: this.readDisplayScrollTop,
|
||||||
|
|||||||
+10
-11
@@ -62,17 +62,16 @@ export function resolveHudRailLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tactical HUD gives an expanded right-rail panel the whole control lane.
|
* The right rail's tab strip (rightRailTabs.js) is the launcher in every HUD
|
||||||
* Other HUD layouts keep collapsed launchers visible for quick switching.
|
* 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 {object} [input] Current rail state.
|
||||||
* @param {string} input.hudVariant Active HUD layout variant.
|
* @param {string} [input.hudVariant] Active HUD layout variant.
|
||||||
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
|
* @param {boolean} [input.hasExpandedPanel] Whether any rail panel is expanded.
|
||||||
* @returns {boolean} Whether collapsed sibling launchers should be hidden.
|
* @returns {boolean} Whether collapsed sibling panels should be hidden.
|
||||||
*/
|
*/
|
||||||
export function shouldHideCollapsedRightPanels({
|
export function shouldHideCollapsedRightPanels(input = {}) {
|
||||||
hudVariant,
|
void input;
|
||||||
hasExpandedPanel,
|
return true;
|
||||||
}) {
|
|
||||||
return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
|
|
||||||
}
|
}
|
||||||
+91
-65
@@ -20,33 +20,30 @@ test('Display uses an inner scroll body only in Cyber', () => {
|
|||||||
assert.equal(displayPanelScroller(null), null);
|
assert.equal(displayPanelScroller(null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
|
test('every HUD layout keeps one expanded owner, hides its peers and never reopens them', () => {
|
||||||
for (const mobile of [false]) {
|
for (const variant of ['tactical', 'operator', 'minimal', 'cyber']) {
|
||||||
const f = fixture('right', {
|
const f = fixture('right', { hud: { visible: true, variant } });
|
||||||
mobile,
|
|
||||||
hud: { visible: true, variant: 'cyber' },
|
|
||||||
});
|
|
||||||
f.expand(f.first, 250);
|
f.expand(f.first, 250);
|
||||||
f.expand(f.second, 250);
|
f.expand(f.second, 250);
|
||||||
f.options.preferredPanelId = f.second.id;
|
f.options.preferredPanelId = f.second.id;
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true);
|
assert.equal(
|
||||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
f.first.classList.contains('rail-owner-collapsed'),
|
||||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
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();
|
f.run();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.first.classList.contains('collapsed'),
|
f.first.classList.contains('collapsed'),
|
||||||
true,
|
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.options.preferredPanelId = f.second.id;
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
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);
|
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,8 +247,10 @@ for (const side of ['left', 'right']) {
|
|||||||
);
|
);
|
||||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||||
});
|
});
|
||||||
test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => {
|
}
|
||||||
const f = fixture(side);
|
|
||||||
|
test('left constrained layout preserves the preferred panel and requests another pass', () => {
|
||||||
|
const f = fixture('left');
|
||||||
f.expand(f.first, 900);
|
f.expand(f.first, 900);
|
||||||
f.expand(f.second, 900);
|
f.expand(f.second, 900);
|
||||||
f.options.preferredPanelId = 'second';
|
f.options.preferredPanelId = 'second';
|
||||||
@@ -260,16 +259,31 @@ for (const side of ['left', 'right']) {
|
|||||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
||||||
assert.deepEqual(f.collapsed, ['first']);
|
assert.deepEqual(f.collapsed, ['first']);
|
||||||
assert.equal(f.retries(), 1);
|
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' } });
|
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.first.classList.add('layout-auto-collapsed');
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||||
assert.equal(f.second.classList.contains('collapsed'), true);
|
assert.equal(f.second.classList.contains('collapsed'), true);
|
||||||
assert.deepEqual(f.collapsed, ['first']);
|
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', () => {
|
test('right layout uses keyboard focus when there is no preferred panel', () => {
|
||||||
const f = fixture('right');
|
const f = fixture('right');
|
||||||
@@ -277,7 +291,7 @@ test('right layout uses keyboard focus when there is no preferred panel', () =>
|
|||||||
f.expand(f.second, 900);
|
f.expand(f.second, 900);
|
||||||
f.options.documentRef.activeElement = f.second;
|
f.options.documentRef.activeElement = f.second;
|
||||||
f.run();
|
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);
|
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.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
||||||
}
|
}
|
||||||
assert.deepEqual(modes.slice(1), Array(9).fill(true));
|
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.retries(), 0);
|
||||||
assert.equal(f.cctv.writes.filter(([op]) => op === 'remove').length, 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', () => {
|
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
|
||||||
const f = thrashingRightRail();
|
const f = thrashingRightRail();
|
||||||
f.run();
|
f.run();
|
||||||
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
|
// The owner is the whole need: it moves 420 -> 450 -> 420.
|
||||||
for (const height of [368, 398, 368, 398, 368]) {
|
for (const height of [420, 450, 420, 450, 420]) {
|
||||||
f.cctv.intrinsicHeight = height;
|
f.cctv.intrinsicHeight = height;
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
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();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||||
f.cctv.intrinsicHeight = 365;
|
f.cctv.intrinsicHeight = 417;
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
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();
|
const f = thrashingRightRail();
|
||||||
f.options.windowRef.innerHeight = 1000;
|
f.options.windowRef.innerHeight = 1000;
|
||||||
f.options.leftStack.rect.top = 500;
|
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();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||||
f.cctv.intrinsicHeight = 409;
|
f.cctv.intrinsicHeight = 461;
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
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();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||||||
f.cctv.intrinsicHeight = 347;
|
f.cctv.intrinsicHeight = 399;
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
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 f = thrashingRightRail({ weatherOpen: true });
|
||||||
const modes = [];
|
const modes = [];
|
||||||
for (let pass = 0; pass < 10; pass++) {
|
for (let pass = 0; pass < 10; pass++) {
|
||||||
f.run();
|
f.run();
|
||||||
modes.push(f.stack.classList.contains('layout-focus'));
|
modes.push(f.stack.classList.contains('layout-focus'));
|
||||||
}
|
}
|
||||||
assert.deepEqual(f.collapsed, ['weather-panel']);
|
assert.deepEqual(
|
||||||
assert.equal(f.retries(), 1);
|
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));
|
assert.deepEqual(modes.slice(1), Array(9).fill(true));
|
||||||
});
|
// Reopening the peer makes it the owner only through an explicit preference.
|
||||||
|
f.weather.classList.remove('collapsed', 'rail-owner-collapsed');
|
||||||
test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => {
|
|
||||||
const f = thrashingRightRail({ weatherOpen: true });
|
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.retries(), 1);
|
assert.equal(f.weather.classList.contains('collapsed'), true);
|
||||||
f.weather.classList.remove('collapsed', 'layout-auto-collapsed');
|
f.options.preferredPanelId = 'weather-panel';
|
||||||
|
f.weather.classList.remove('collapsed', 'rail-owner-collapsed');
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.retries(), 1);
|
assert.equal(f.weather.classList.contains('collapsed'), false);
|
||||||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
assert.equal(f.cctv.classList.contains('rail-owner-collapsed'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right rail restores presentation even when intrinsic measurement throws', () => {
|
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']) {
|
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 } });
|
const f = fixture('right', { hud: { visible: true, variant } });
|
||||||
f.expand(f.first, 900);
|
f.expand(f.first, 900);
|
||||||
f.expand(f.second, 900);
|
f.expand(f.second, 900);
|
||||||
for (let pass = 0; pass < 10; pass++) f.run();
|
for (let pass = 0; pass < 10; pass++) f.run();
|
||||||
assert.equal(f.retries(), 0);
|
assert.equal(f.retries(), 0);
|
||||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
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 ──────────────────────────────────────────
|
// ── 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 f = fixture('right');
|
||||||
const weather = element('weather-panel', { height: 300 });
|
const weather = element('weather-panel', { height: 300 });
|
||||||
weather.computed.display = 'none';
|
weather.computed.display = 'none';
|
||||||
@@ -704,10 +728,12 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity
|
|||||||
|
|
||||||
f.run();
|
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.stack.dataset.expandedCount, '0');
|
||||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
assert.equal(f.first.getAttribute('aria-hidden'), 'true');
|
||||||
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
|
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
|
||||||
|
assert.equal(weather.classList.contains('collapsed'), false);
|
||||||
|
assert.equal(weather.getAttribute('aria-hidden'), undefined);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||||||
'',
|
'',
|
||||||
@@ -726,17 +752,18 @@ test('right layout still restores panels that exclusive presentation hides', ()
|
|||||||
: node.computed.display,
|
: node.computed.display,
|
||||||
});
|
});
|
||||||
f.expand(f.first, 250);
|
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.stack.classList.add('layout-exclusive');
|
||||||
|
|
||||||
f.run();
|
f.run();
|
||||||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||||
assert.equal(f.second.getAttribute('aria-hidden'), '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();
|
f.run();
|
||||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
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']);
|
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(
|
const alone = parseFloat(
|
||||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
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.hidden = false;
|
||||||
imagery.scrollHeight = 300;
|
imagery.scrollHeight = 300;
|
||||||
imagery.rect.height = 300;
|
imagery.rect.height = 300;
|
||||||
const retriesBefore = f.retries();
|
const retriesBefore = f.retries();
|
||||||
f.run();
|
f.run();
|
||||||
assert.ok(
|
assert.deepEqual(f.collapsed, ['recent-imagery-panel']);
|
||||||
f.collapsed.includes('recent-imagery-panel'),
|
assert.equal(imagery.classList.contains('rail-owner-collapsed'), true);
|
||||||
'focus mode collapses the later panel and asks for another pass',
|
assert.equal(f.retries(), retriesBefore);
|
||||||
);
|
|
||||||
assert.equal(f.retries(), retriesBefore + 1);
|
|
||||||
f.run();
|
|
||||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||||
assert.ok(
|
assert.equal(
|
||||||
parseFloat(
|
parseFloat(
|
||||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||||
) <= alone,
|
),
|
||||||
|
alone,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
+19
-77
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { allocatePanelStackHeights } from '../panelStackLayout.js';
|
||||||
allocatePanelStackHeights,
|
|
||||||
panelStackAutoCollapseIndices,
|
|
||||||
} from '../panelStackLayout.js';
|
|
||||||
import {
|
import {
|
||||||
resolveHudRailLayout,
|
resolveHudRailLayout,
|
||||||
shouldHideCollapsedRightPanels,
|
shouldHideCollapsedRightPanels,
|
||||||
@@ -9,8 +6,6 @@ import {
|
|||||||
|
|
||||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||||
|
|
||||||
const pendingCollapseRetries = new WeakSet();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the left rail's top will settle, not where it is mid-transition:
|
* 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
|
* 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.
|
* Measure and place the right panel rail for one synchronous layout pass.
|
||||||
* The caller owns scheduling, obstacle selection, disclosure preferences and
|
* 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 {object} options Live DOM and caller policy.
|
||||||
* @param {HTMLElement} options.stack Rail element.
|
* @param {HTMLElement} options.stack Rail element.
|
||||||
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
|
* @param {Iterable<HTMLElement>} 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 {{visible: boolean, variant: string}} options.hud Current HUD presentation.
|
||||||
* @param {string} options.preferredPanelId Most recently opened panel.
|
* @param {string} options.preferredPanelId Most recently opened panel.
|
||||||
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
|
* @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 {Function} [options.getComputedStyle] Optional DOM style reader override.
|
||||||
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
|
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
|
||||||
* @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll.
|
* @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll.
|
||||||
@@ -58,7 +54,6 @@ export function layoutRightPanelRail({
|
|||||||
hud,
|
hud,
|
||||||
preferredPanelId,
|
preferredPanelId,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onRetry,
|
|
||||||
leftStack,
|
leftStack,
|
||||||
displayPanel,
|
displayPanel,
|
||||||
readDisplayScrollTop,
|
readDisplayScrollTop,
|
||||||
@@ -66,8 +61,6 @@ export function layoutRightPanelRail({
|
|||||||
getComputedStyle = (element) => windowRef.getComputedStyle(element),
|
getComputedStyle = (element) => windowRef.getComputedStyle(element),
|
||||||
}) {
|
}) {
|
||||||
if (!stack) return;
|
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.
|
// 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
|
// Exclusive presentation hides collapsed panels itself; those stay in the
|
||||||
@@ -81,19 +74,13 @@ export function layoutRightPanelRail({
|
|||||||
!panel.hidden &&
|
!panel.hidden &&
|
||||||
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
|
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
|
||||||
);
|
);
|
||||||
if (!hud.visible || hud.variant !== 'tactical') {
|
// One expanded owner: the latest explicit disclosure, else the panel that
|
||||||
for (const panel of panels.filter((item) =>
|
// holds keyboard focus, else the first. Peers are presentation-collapsed
|
||||||
item.classList.contains('layout-auto-collapsed'),
|
// without overwriting saved panel preferences; the marker is distinct from
|
||||||
)) {
|
// the left rail's space-based collapse. Phones present the rail as tab
|
||||||
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
// sheets that show several panels at once, so they get every panel back.
|
||||||
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.
|
|
||||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||||
if (hud.variant === 'cyber' && !isMobile) {
|
if (!isMobile) {
|
||||||
const expanded = panels.filter(
|
const expanded = panels.filter(
|
||||||
(panel) => !panel.classList.contains('collapsed'),
|
(panel) => !panel.classList.contains('collapsed'),
|
||||||
);
|
);
|
||||||
@@ -103,13 +90,13 @@ export function layoutRightPanelRail({
|
|||||||
expanded[0];
|
expanded[0];
|
||||||
for (const panel of expanded) {
|
for (const panel of expanded) {
|
||||||
if (panel === owner) continue;
|
if (panel === owner) continue;
|
||||||
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
|
panel.classList.add('collapsed', 'rail-owner-collapsed');
|
||||||
onCollapse(panel);
|
onCollapse(panel);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
|
if (!panel.classList.contains('rail-owner-collapsed')) continue;
|
||||||
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
|
panel.classList.remove('collapsed', 'rail-owner-collapsed');
|
||||||
onCollapse(panel);
|
onCollapse(panel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,14 +168,8 @@ export function layoutRightPanelRail({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-collapsed headers contribute the same need even when exclusive
|
// Exclusive presentation hides collapsed panels, so only visible panels
|
||||||
// presentation hides them. Manually collapsed launchers keep their policy.
|
// take room in the lane.
|
||||||
const measuredPanels = panels.filter(
|
|
||||||
(panel) =>
|
|
||||||
!exclusive ||
|
|
||||||
!panel.classList.contains('collapsed') ||
|
|
||||||
panel.classList.contains('layout-auto-collapsed'),
|
|
||||||
);
|
|
||||||
const visiblePanels = panels.filter(
|
const visiblePanels = panels.filter(
|
||||||
(panel) => !exclusive || !panel.classList.contains('collapsed'),
|
(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.
|
// Do not toggle layout classes here: the class observer schedules new passes.
|
||||||
stack.setAttribute('data-rail-measuring', '');
|
stack.setAttribute('data-rail-measuring', '');
|
||||||
try {
|
try {
|
||||||
for (const panel of measuredPanels) {
|
for (const panel of visiblePanels) {
|
||||||
naturalHeights.set(
|
naturalHeights.set(
|
||||||
panel,
|
panel,
|
||||||
Math.max(
|
Math.max(
|
||||||
@@ -223,11 +204,11 @@ export function layoutRightPanelRail({
|
|||||||
}
|
}
|
||||||
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
||||||
const naturalHeight =
|
const naturalHeight =
|
||||||
measuredPanels.reduce(
|
visiblePanels.reduce(
|
||||||
(total, panel) => total + naturalHeights.get(panel),
|
(total, panel) => total + naturalHeights.get(panel),
|
||||||
0,
|
0,
|
||||||
) +
|
) +
|
||||||
gap * Math.max(0, measuredPanels.length - 1);
|
gap * Math.max(0, visiblePanels.length - 1);
|
||||||
const layout = resolveHudRailLayout({
|
const layout = resolveHudRailLayout({
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
panelHeight: naturalHeight,
|
panelHeight: naturalHeight,
|
||||||
@@ -254,26 +235,9 @@ export function layoutRightPanelRail({
|
|||||||
: total,
|
: total,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const expandedPanelsInDomOrder = visiblePanels.filter(
|
const expandedPanels = visiblePanels.filter(
|
||||||
(panel) => !panel.classList.contains('collapsed'),
|
(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(
|
const expandedAvailableHeight = Math.max(
|
||||||
0,
|
0,
|
||||||
safeBottom -
|
safeBottom -
|
||||||
@@ -285,28 +249,6 @@ export function layoutRightPanelRail({
|
|||||||
naturalHeights: expandedPanels.map((panel) => naturalHeights.get(panel)),
|
naturalHeights: expandedPanels.map((panel) => naturalHeights.get(panel)),
|
||||||
availableHeight: expandedAvailableHeight,
|
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
|
// Write-if-changed. This pass runs on the 500 ms stats cadence. Rewriting
|
||||||
// an unchanged allocation wakes the world-overlay occluder observer and
|
// an unchanged allocation wakes the world-overlay occluder observer and
|
||||||
// defeats parked-idle render savings. Measurement leaves these values intact.
|
// defeats parked-idle render savings. Measurement leaves these values intact.
|
||||||
|
|||||||
@@ -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<string, {tab: HTMLElement, label: HTMLElement}>} */
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -34,4 +34,5 @@
|
|||||||
@import './src/ui/styles/segmented-control.css';
|
@import './src/ui/styles/segmented-control.css';
|
||||||
@import './src/ui/styles/cjk.css';
|
@import './src/ui/styles/cjk.css';
|
||||||
@import './src/ui/styles/mobile-shell.css';
|
@import './src/ui/styles/mobile-shell.css';
|
||||||
|
@import './src/ui/styles/right-rail-tabs.css';
|
||||||
@import './src/ui/styles/cyber.css';
|
@import './src/ui/styles/cyber.css';
|
||||||
Reference in new issue
Block a user