fix(ui): right rail tabs: review fixes, launcher in the strip, strip never scrolls on common windows

Review fixes P1.1-P2.7: a panel hidden at mount gets its tab when revealed; any live opening of a rail panel makes it the owner, restores do not; a short lane scrolls the strip with a thin scrollbar and brings a newly opened tab into view once; the join and the panel cover stay inside the lane and the fillet shrinks to the room left; a share link carries an owner-collapsed peer as collapsed so the recipient opens the sender's panel; border-box on phones; the strip's column folds into the panel width at 721-760px; the focus ring draws inside the tab.

The compact Radio launcher moves from Context's header into a tool slot under the tabs on desktop (back into the header for the phone sheet) and always toggles the compact player, also while Context is open; the player's Details button leads to the Radio section. The jump-to-section role, its two labels and the #804 closings of the player on Context expansion and rail layout passes are gone with it.

The rail panels' own collapse arrows are hidden on desktop: the tab opens and closes a panel, Escape inside closes it and returns focus to the tab.

The tabs are denser and the rail climbs toward the HUD corner above its left-rail baseline when the strip's column is taller than the lane, so the strip no longer scrolls on windows down to about 680px tall; the measuring pass keeps the strip's scroll offset. scripts/qa-radio.mjs expectations follow the launcher's new role.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-28 11:02:15 +04:00
1 parent ff017c99ca
commit bff1d0eb29
24 files changed
+804 -379

No files matched your search

+10 -4
View File
@@ -2,7 +2,13 @@
Imagery while shown) is a vertical tab strip with sideways labels instead of
an accordion of collapsed launcher cards: at most one panel is open in every
desktop HUD layout, and the open tab and its panel share one surface and
outline, like a folder tab. A first-time visitor on a desktop finds Display
open; a stored choice still wins. The strip is a keyboard-navigable tablist
and Escape inside a panel returns focus to its tab. Saved preferences, share
links and the phone sheet shell are unchanged (gekh, #1425).
outline, like a folder tab. On a short window the rail rises toward the HUD
corner above it so the whole strip stays in view. A first-time visitor on a
desktop finds Display open; a stored choice still wins. The panels' own
collapse arrows are gone on desktop: the tab opens and closes a panel, and
Escape inside one closes it and returns focus to its tab; the strip is a
keyboard-navigable tablist. The compact Radio launcher moves from Context's
header to the bottom of the strip on desktop and always opens the compact
player, also while Context is open; the player's Details button leads to the
Radio section. Saved preferences, share links and the phone sheet shell are
unchanged (gekh, #1425).
+24 -11
View File
@@ -1478,14 +1478,16 @@ The left rail's automatic collapse remains presentation only and prioritizes
the latest explicit panel. The right rail keeps one expanded owner (the latest
explicit panel, else the focused one, else one with a stored or shared choice
over one open only by default, else the first) and presentation-collapses its
peers with `rail-owner-collapsed`, which share links and the phone shell read
as "expanded". Mobile layout still releases desktop allocation styles and gives
every owner-collapsed panel back. Stable Display allocation avoids unnecessary
style writes.
Context expansion through the panel owner or adaptive right-rail restoration
closes an open compact Radio disclosure before Context/Space Missions becomes
visible. The mini remains available while Context is collapsed. This fixes the
Radio-overlap path reported in #804 without changing z-index or layout policy.
peers with `rail-owner-collapsed`, which the phone shell reads as "expanded"
and a share link carries as collapsed, so a recipient opens the panel the
sender sees. Any live opening of a rail panel, with or without the explicit
flag (a camera click, Street Level switching on, a radio pick), makes that
panel the owner. Mobile layout still releases desktop allocation styles and
gives every owner-collapsed panel back. Stable Display allocation avoids
unnecessary style writes.
The compact Radio player is independent of Context: its launcher toggles it
whether Context is collapsed or open, and only an outside pointer, Escape, its
close button or its Details button closes it, never a layout pass.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so the owner
rule can still give them back.
@@ -1500,11 +1502,22 @@ panels hidden in every HUD layout. A tab toggles its panel through
`setPanelCollapsed` with the explicit flag, so opening one collapses the others
and saved preferences, share links and the Context/Radio ownership rules are
unchanged; the panels' `collapsed` classes remain the only state, mirrored
through a MutationObserver. The strip is a vertical `role="tablist"`: Up/Down
through a MutationObserver. On desktop the panels' own header collapse
buttons are hidden: the tab is the one control, Escape its keyboard twin.
The strip is a vertical `role="tablist"`: Up/Down
wrap, Home/End jump, the open tab (else the first) is the tab stop, Escape
inside a panel returns focus to its tab, and the strip precedes the panels in
the DOM so Tab from the open tab enters the panel. Labels are the panels' own
translated header titles. The strip marks the rail with `data-rail-tabs`, which
the DOM so Tab from the open tab reaches the tool slot and then the panel.
Labels are the panels' own translated header titles. The rail's lane shares
the left rail's top; when it is too short for the strip, the rail climbs toward
the HUD corner above as far as the strip needs (`src/ui/rightPanelRail.js`),
and only a lane still shorter scrolls the strip, with a thin scrollbar and the
open tab kept in view. Under the tabs the strip's
column keeps a tool slot (`#right-rail-tools`) that holds the compact Radio
launcher on desktop: `src/ui/radioPanelPlacement.js` moves it there from
Context's header, and back for the phone sheet, so the compact player stays one
click away; the player's Details button opens the Radio section inside Context.
The strip marks the rail with `data-rail-tabs`, which
the stylesheet keys the strip's column on; a rail without the strip keeps its
plain geometry. Phones hide the strip and keep the sheet shell; Cockpit hides
the rail and the strip with it.
+29 -21
View File
@@ -610,9 +610,9 @@ async function main() {
&& enablingPresentation.presentationActive === false
&& enablingPresentation.markerSourceVisible === false
&& enablingPresentation.full === 'ENABLING'
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
&& enablingPresentation.disclosureControls === 'radio-panel'
&& enablingPresentation.disclosureExpanded === 'true'
&& enablingPresentation.disclosure === 'Open compact Radio controls'
&& enablingPresentation.disclosureControls === 'context-radio-mini'
&& enablingPresentation.disclosureExpanded === 'false'
&& enablingPresentation.compactPower === 'ENABLING'
&& enablingPresentation.layerState === 'ENABLING'
&& /enabling/i.test(enablingPresentation.message)
@@ -1633,7 +1633,8 @@ async function main() {
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
gapLeftOfLauncher: Math.round(toggleRect.left - miniRect.right),
bottomOffset: Math.round(miniRect.bottom - toggleRect.bottom),
};
});
check(
@@ -1642,7 +1643,11 @@ async function main() {
JSON.stringify(compact),
);
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
check(
'compact Radio controls open left of the launcher in the strip, bottom-aligned',
compact.gapLeftOfLauncher > 0 && Math.abs(compact.bottomOffset) <= 1,
`gap ${compact.gapLeftOfLauncher}px, bottom offset ${compact.bottomOffset}px`,
);
const compactCloseDisclosure = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
const close = document.getElementById('context-radio-mini-close-btn');
@@ -4510,6 +4515,7 @@ async function main() {
const expandedContextRadioBefore = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._setRadioDisclosure(false);
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
scroller.scrollTop = 0;
const camera = gev.viewer.camera.positionWC;
@@ -4521,26 +4527,24 @@ async function main() {
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await page.waitForFunction((priorScroll) => (
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
const expandedContextRadioAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
const launcher = document.getElementById('context-radio-toggle-btn');
const radio = document.getElementById('radio-panel');
const mini = document.getElementById('context-radio-mini');
const miniRect = mini.getBoundingClientRect();
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
const camera = gev.viewer.camera.positionWC;
return {
ariaControls: launcher.getAttribute('aria-controls'),
expanded: launcher.getAttribute('aria-expanded'),
label: launcher.getAttribute('aria-label'),
radioCollapsed: radio.classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
contextCollapsed: document.getElementById('global-context-panel').classList.contains('collapsed'),
compactHidden: mini.hidden,
compactVisible: miniRect.width > 0 && miniRect.height > 0,
scrollTop: scroller.scrollTop,
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
@@ -4553,14 +4557,15 @@ async function main() {
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
);
check(
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
expandedContextRadioAfter.ariaControls === 'radio-panel'
'expanded Context Radio icon opens the compact player over Context without playback/camera theft',
expandedContextRadioAfter.ariaControls === 'context-radio-mini'
&& expandedContextRadioAfter.expanded === 'true'
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
&& !expandedContextRadioAfter.radioCollapsed
&& expandedContextRadioAfter.compactHidden
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
&& expandedContextRadioAfter.label === 'Close compact Radio controls'
&& expandedContextRadioAfter.radioCollapsed
&& !expandedContextRadioAfter.contextCollapsed
&& !expandedContextRadioAfter.compactHidden
&& expandedContextRadioAfter.compactVisible
&& expandedContextRadioAfter.scrollTop === expandedContextRadioBefore.scrollTop
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
&& expandedContextRadioCameraDelta < 0.01
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
@@ -4571,9 +4576,12 @@ async function main() {
cameraDelta: expandedContextRadioCameraDelta,
}),
);
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.getElementById('context-radio-mini').hidden);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('radio-panel', false);
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
});
-2
View File
@@ -2441,8 +2441,6 @@
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
"New scene name": "Nombre de la nueva escena",
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
"Detailed outline unavailable": "Contorno detallado no disponible",
-2
View File
@@ -2482,8 +2482,6 @@
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
"New scene name": "Название новой сцены",
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
"Detailed outline unavailable": "Подробный контур недоступен",
-2
View File
@@ -1859,8 +1859,6 @@
"STATION UNAVAILABLE": "电台不可用",
"Station unavailable after directory refresh": "目录刷新后该电台不可用",
"Radio could not {action} cleanly": "电台未能正常{action}",
"Go to expanded Radio section": "前往展开的电台区域",
"Expand Radio section in Context": "在态势面板中展开电台区域",
"Close compact Radio controls": "关闭精简电台控件",
"Open compact Radio controls": "打开精简电台控件",
"SYNC": "同步",
+2 -2
View File
@@ -202,8 +202,8 @@ 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\('rail-owner-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
'responsive auto-collapse must serialize the explicit expanded preference',
/const collapsed = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
'the left rail\'s responsive auto-collapse serializes the expanded preference; a right-rail peer hidden behind the owner travels as collapsed',
);
assert.match(
ui,
+8 -14
View File
@@ -361,32 +361,26 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.match(enableMethod, /toggleRadio\(this\._contextRadioMiniEnableBtn\)/);
assert.match(disclosureBindings, /contextRadioMiniCloseBtn[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: true \}\)/);
assert.match(disclosureBindings, /contextRadioDetailsBtn[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)/);
const launcherBinding = disclosureBindings.slice(0, disclosureBindings.indexOf('contextRadioMiniCloseBtn'));
assert.match(
disclosureBindings,
/contextRadioToggleBtn[\s\S]*?!contextPanel\.classList\.contains\('collapsed'\)[\s\S]*?setRadioDisclosure\(false\)[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)[\s\S]*?_revealRadioPanelInsideContext/,
launcherBinding,
/setRadioDisclosure\(\s*!this\._contextRadioDock\?\.classList\.contains\('disclosure-open'\),?\s*\)/,
);
assert.doesNotMatch(launcherBinding, /global-context-panel|setPanelCollapsed|_revealRadioPanelInsideContext/);
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
test('the Radio launcher always toggles the compact player, whatever Context shows', () => {
assert.doesNotMatch(radioControlsSource, /_revealRadioPanelInsideContext/);
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
assert.match(syncMethod, /aria-expanded',\s*String\(compactOpen\)/);
assert.doesNotMatch(syncMethod, /global-context-panel|radio-panel|aria-controls/);
const renderMethod = radioPresentation;
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
assert.doesNotMatch(renderMethod, /compact Radio controls/);
-4
View File
@@ -115,18 +115,14 @@ export class StyleManager extends ShellFacade {
this._panelChrome = new PanelChrome({
elements: {
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
_contextRadioDock: this._contextRadioDock,
_leftPanelStack: this._leftPanelStack,
_locationSearch: this._locationSearch,
_ppToggles: this._ppToggles,
_rightPanelStack: this._rightPanelStack,
},
operations: {
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
_syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args),
_syncContextRadioLauncherState: (...args) =>
this._syncContextRadioLauncherState(...args),
_showToast: (...args) => this._showToast(...args),
},
readHud: () => this.hud,
+15
View File
@@ -41,6 +41,21 @@ function fixture(theme = 'cyber') {
};
}
test('any live opening of a rail panel makes it the preferred owner; restores do not', () => {
const f = fixture('tactical'), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('cctv-panel', false, {});
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'cctv-panel', 'a camera click opens CCTV without the explicit flag');
assert.equal(f.display.classList.contains('collapsed'), true, 'peers are left to the rail pass, not persisted');
f.owner.setPanelCollapsed('radio-panel', false, {});
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a radio pick owns the rail through Context');
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
f.owner.setPanelCollapsed('cctv-panel', false, { restore: true, persist: false });
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a restore never claims ownership');
} finally { globalThis.document = prior; }
});
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
+1 -1
View File
@@ -742,7 +742,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
);
assert.match(
rightRail,
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/,
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?layout = resolve\(alignedTop\);/,
);
assert.match(
cyberStyles,
+12 -42
View File
@@ -72,8 +72,7 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) =>
this._syncRailPanelCollapseButton(panel),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -303,19 +302,6 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
_syncRailPanelCollapseButton(panelEl) {
// Adaptive rail layout may directly restore an auto-collapsed Context
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
if (
panelEl?.id === 'global-context-panel' &&
!panelEl.classList.contains('collapsed') &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
this._syncPanelCollapseButton(panelEl);
}
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
@@ -382,9 +368,6 @@ export class PanelChrome {
String(!collapsed),
);
}
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
this._syncContextRadioLauncherState();
}
}
_buildSharePanelState() {
@@ -392,13 +375,13 @@ export class PanelChrome {
for (const spec of SHARE_PANEL_STATE_SPECS) {
const panelEl = document.getElementById(spec.id);
if (!panelEl) continue;
// Responsive auto-collapse is presentation only; the recipient should
// restore the user's explicit expanded preference at its own viewport.
const collapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('rail-owner-collapsed')
? false
: panelEl.classList.contains('collapsed');
// The left rail's responsive auto-collapse is presentation only; the
// recipient restores the user's expanded preference at its own viewport.
// A right-rail peer hidden behind the owner travels as collapsed, so the
// recipient opens the panel the sender sees.
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
? false
: panelEl.classList.contains('collapsed');
// A panel the phone shell only borrowed for a sheet shares its own state.
const entry = {
id: spec.id,
@@ -502,7 +485,10 @@ export class PanelChrome {
) {
this._panelLayout._leftStackPreferredPanelId = null;
}
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
// Any live opening of a rail panel makes it the owner, including the
// ones a camera click, Street Level or a radio pick request without the
// explicit flag: the rail's one-owner pass would otherwise hide it again.
if (!restore && !nextCollapsed && rightOwnerPanel) {
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
} else if (
explicit &&
@@ -529,15 +515,6 @@ export class PanelChrome {
syncShare,
});
}
// A repeated Context-open request must clear a stale compact Radio mini
// even when no collapsed class changes.
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
@@ -563,13 +540,6 @@ export class PanelChrome {
this.cockpitView.setContextCollapsed(true);
}
}
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
+10 -79
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelChrome } from './panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
function classList(initial = []) {
const values = new Set(initial);
@@ -20,7 +19,10 @@ function classList(initial = []) {
};
}
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
// The compact Radio player floats beside the strip and may stay open over an
// expanded Context: opening Context, by request or by the adaptive rail
// restore, leaves it alone.
test('a repeated Context-open request leaves an open compact Radio player alone', (t) => {
const priorDocument = globalThis.document;
const contextPanel = {
id: 'global-context-panel',
@@ -42,7 +44,6 @@ test('opening an already-open Context still closes a stale compact Radio disclos
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(open);
radioDock.classList.toggle('disclosure-open', open);
},
_leftPanelStack: null,
_rightPanelStack: null,
@@ -63,76 +64,11 @@ test('opening an already-open Context still closes a stale compact Radio disclos
false,
);
assert.deepEqual(calls, [false]);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
assert.deepEqual(calls, []);
assert.equal(radioDock.classList.contains('disclosure-open'), true);
});
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.requestAnimationFrame = priorRaf;
});
// Valid starting state: the tactical rail automatically collapsed Context,
// and the user opened the compact Radio controls from its header.
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const radioDock = { classList: classList(['disclosure-open']) };
const radioMini = { hidden: false };
const calls = [];
const chrome = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
radioDock.classList.toggle('disclosure-open', open);
radioMini.hidden = !open;
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
};
const layout = {
destroyed: false,
_rightStackLayoutFrame: null,
_rightStackReconsiderAutoCollapse: false,
_rightPanelStack: {
querySelectorAll: (selector) =>
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
},
_syncPanelCollapseButton(panel) {
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
},
_syncRightPanelAdaptiveLayout() {
calls.push('layout');
},
};
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
reconsiderAutoCollapse: true,
});
assert.equal(contextPanel.classList.contains('collapsed'), true);
assert.equal(radioMini.hidden, false);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
assert.equal(radioMini.hidden, true);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
test('adaptive rail restore reveals Context without touching the compact player', (t) => {
const priorDocument = globalThis.document;
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
@@ -166,7 +102,6 @@ test('real PanelChrome constructor wires rail restore to compact Radio owner', (
operations: {
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
dock.classList.toggle('disclosure-open', open);
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
@@ -187,14 +122,10 @@ test('real PanelChrome constructor wires rail restore to compact Radio owner', (
reconsiderAutoCollapse: true,
});
assert.equal(typeof nextFrame, 'function');
assert.equal(dock.classList.contains('disclosure-open'), true);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(dock.classList.contains('disclosure-open'), false);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
assert.equal(dock.classList.contains('disclosure-open'), true);
assert.deepEqual(calls, ['button:global-context-panel', 'layout']);
});
+8 -3
View File
@@ -13,6 +13,13 @@ function listenerScope() {
};
}
/** The strip tab that controls a right-rail panel, if the panel has one. */
function railTabFor(panel) {
return panel.ownerDocument?.querySelector?.(
`[role="tab"][aria-controls="${panel.id}"]`,
);
}
/** Bind collapse buttons and bubbling Escape to an existing panel. */
export function bindPanelDisclosure({
panel,
@@ -63,9 +70,7 @@ export function collapsePanelOnEscape(
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 tab = railTabFor(panel);
const escapedFromDisclosure =
event.target === disclosure || disclosure?.contains?.(event.target);
if (tab) tab.focus({ preventScroll: true });
+29
View File
@@ -375,6 +375,35 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
assert.equal(f.stack.dataset.safeTop, '200.0');
});
test('right rail climbs above the left rail baseline when its tab strip needs the room, no further than the HUD above', () => {
const f = fixture('right');
f.options.leftStack.rect.top = 300;
const above = element('above', { left: 1100, top: 100, height: 50 });
const below = element('below', { left: 1100, top: 600, height: 50 });
f.options.obstacles = [above, below];
const column = element('column', { height: 380 });
f.stack.querySelector = (selector) =>
selector.includes('#right-rail-column') ? column : null;
// The gap is 10.8 at 900px: the lane 300..589.2 is 90.8 short of the column.
f.run();
assert.equal(f.stack.dataset.safeTop, '209.2');
assert.equal(f.stack.dataset.safeBottom, '589.2');
column.rect.height = 600;
f.run();
assert.equal(
f.stack.dataset.safeTop,
'160.8',
'the HUD corner above stops the climb',
);
column.rect.height = 200;
f.run();
assert.equal(
f.stack.dataset.safeTop,
'300.0',
'a column that fits leaves the baseline alone',
);
});
// The left rail's `top` animates after the right pass runs, and nothing
// re-runs it when the transition lands: the right rail must read the end value.
function animatingLeftRail({ keyframes = true } = {}) {
-11
View File
@@ -410,17 +410,6 @@ export function bindRadioControls() {
() => void toggleRadio(this._cockpitRadioEnableBtn),
);
this.listen(this._contextRadioToggleBtn, 'click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector(
'[data-collapse-target="radio-panel"]',
),
});
return;
}
setRadioDisclosure(
!this._contextRadioDock?.classList.contains('disclosure-open'),
);
+1 -83
View File
@@ -109,94 +109,12 @@ export class RadioControls {
return true;
}
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!this._radioPanel ||
this._radioPanel.classList.contains('collapsed')
)
return false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (
this.destroyed ||
contextPanel.classList.contains('collapsed') ||
this._radioPanel.classList.contains('collapsed')
)
return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the launcher truthful for the active theme and Context route. */
/** Keep the launcher's ARIA state matched to the compact player. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = globalThis.document?.getElementById(
'global-context-panel',
);
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
if (contextExpanded) {
const radioExpanded = Boolean(
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
);
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(radioExpanded),
);
const label = t(
radioExpanded
? msg('Go to expanded Radio section')
: msg('Expand Radio section in Context'),
);
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
// This launcher toggles the compact player even when the detailed panel
// is open elsewhere. Its ARIA state must describe that same disclosure.
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
this._contextRadioToggleBtn.setAttribute(
'aria-controls',
'context-radio-mini',
);
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(compactOpen),
+12 -2
View File
@@ -1,4 +1,9 @@
/** Keep Radio in Context in every theme, reusing the same playback controls. */
/**
* Keep Radio in Context in every theme, reusing the same playback controls.
* The compact launcher (the dock) sits in the right rail's tool slot under the
* tab strip on desktop, where a collapsed Context is hidden, and in Context's
* header on phones, where the sheet shows that header.
*/
export function syncRadioPanelPlacement(doc) {
if (!doc?.getElementById) return;
const panel = doc.getElementById('radio-panel');
@@ -11,7 +16,12 @@ export function syncRadioPanelPlacement(doc) {
const header = context?.querySelector('.panel-header');
if (!panel || !parent || !header || !dock || !mini) return;
if (panel.parentElement !== parent) parent.append(panel);
if (dock.parentElement !== header)
const slot = doc.getElementById('right-rail-tools');
const phone = Boolean(doc.body?.classList?.contains?.('mobile-shell'));
if (slot && !phone) {
if (dock.parentElement !== slot) slot.append(dock);
} else if (dock.parentElement !== header) {
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
}
if (mini.parentElement !== dock) dock.append(mini);
}
+53
View File
@@ -94,6 +94,59 @@ test('all themes keep the same Radio controls nested in Context', () => {
assert.equal(mini.parentElement, dock);
});
test('the compact launcher sits in the rail tool slot on desktop and in Context header on phones', () => {
const panel = node(),
context = node(),
dock = node(),
mini = node(),
slot = node();
const inner = node(),
header = node(),
collapse = node();
context.selectors['.global-context-panel-inner'] = inner;
context.selectors['.panel-header'] = header;
header.selectors['.panel-collapse-btn'] = collapse;
inner.append(panel);
header.append(dock);
header.append(collapse);
dock.append(mini);
const nodes = {
'radio-panel': panel,
'global-context-panel': context,
'context-radio-dock': dock,
'context-radio-mini': mini,
'right-rail-tools': slot,
};
let phone = false;
const doc = {
documentElement: { dataset: {} },
body: { classList: { contains: (name) => phone && name === 'mobile-shell' } },
getElementById: (id) => nodes[id],
};
syncRadioPanelPlacement(doc);
assert.deepEqual(slot.children, [dock], 'desktop: the launcher is in the slot');
assert.deepEqual(header.children, [collapse]);
assert.equal(mini.parentElement, dock);
const moves = [dock.moves, mini.moves];
syncRadioPanelPlacement(doc);
assert.deepEqual([dock.moves, mini.moves], moves, 'no mutation feedback loop');
phone = true;
syncRadioPanelPlacement(doc);
assert.deepEqual(slot.children, []);
assert.deepEqual(
header.children,
[dock, collapse],
'phone: back in the header, before the collapse button',
);
assert.equal(mini.parentElement, dock);
phone = false;
syncRadioPanelPlacement(doc);
assert.deepEqual(slot.children, [dock], 'desktop again: back in the slot');
delete nodes['right-rail-tools'];
syncRadioPanelPlacement(doc);
assert.deepEqual(header.children, [dock, collapse], 'no slot: the header');
});
test('placement is inert before the complete shell exists', () => {
assert.doesNotThrow(() => syncRadioPanelPlacement(null));
assert.doesNotThrow(() =>
+26 -12
View File
@@ -30,7 +30,9 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
* The caller owns scheduling, obstacle selection, disclosure preferences and
* 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.
* presentation-collapsed and hidden, and the owner takes the whole lane. The
* lane's top follows the left rail's, lifted toward the HUD above when the tab
* strip's column (rightRailTabs.js) is taller than the lane.
* @param {object} options Live DOM and caller policy.
* @param {HTMLElement} options.stack Rail element.
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
@@ -172,6 +174,10 @@ export function layoutRightPanelRail({
.map((node) => [node, node.scrollTop || 0])
.filter(([, top]) => top > 0);
const naturalHeights = new Map();
// The tab strip's column stands beside the panels at its own height; the
// measuring override lifts its lane clamp too.
const column = stack.querySelector?.(':scope > #right-rail-column');
let columnHeight = 0;
// Remove live height allocations through a synchronous CSS override. Keeping
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
// Do not toggle layout classes here: the class observer schedules new passes.
@@ -187,6 +193,7 @@ export function layoutRightPanelRail({
),
);
}
columnHeight = column?.getBoundingClientRect().height || 0;
} finally {
stack.removeAttribute('data-rail-measuring');
for (const [node, top] of scrollers)
@@ -199,18 +206,25 @@ export function layoutRightPanelRail({
0,
) +
gap * Math.max(0, visiblePanels.length - 1);
const layout = resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
laneLeft: stackRect.left,
laneRight: stackRect.right,
obstacles: obstacleRects,
baseTop: alignedTop,
baseBottom: viewportHeight * 0.96,
gap: safeGap,
align: 'start',
});
const resolve = (baseTop) =>
resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
laneLeft: stackRect.left,
laneRight: stackRect.right,
obstacles: obstacleRects,
baseTop,
baseBottom: viewportHeight * 0.96,
gap: safeGap,
align: 'start',
});
let layout = resolve(alignedTop);
if (!layout) return;
// A lane too short for the strip's column: the rail climbs toward the HUD
// above, as far as the strip needs and the obstacles allow. A lane still
// shorter scrolls the strip (rightRailTabs.js).
const shortfall = columnHeight - (layout.safeBottom - layout.safeTop);
if (shortfall > 0) layout = resolve(alignedTop - shortfall) || layout;
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus');
+148 -55
View File
@@ -10,10 +10,16 @@
* their joined silhouette as an SVG path (railJoinPath) and keeps a join
* element under the panels whose layers blur, tint, outline and shadow that
* shape (styles/right-rail-tabs.css).
*
* The strip sits in a column with a tool slot under it. The compact Radio
* launcher lives in the slot on desktop (radioPanelPlacement.js), where the
* collapsed Context that carries it otherwise is hidden.
*/
import { msg, t } from '../i18n/index.js';
export const RIGHT_RAIL_TABS_ID = 'right-rail-tabs';
export const RIGHT_RAIL_COLUMN_ID = 'right-rail-column';
export const RIGHT_RAIL_TOOLS_ID = 'right-rail-tools';
/**
* Marks the rail while its strip is mounted; styles/right-rail-tabs.css keys
@@ -69,8 +75,9 @@ export function railTabPanels(rail) {
*
* @param {object} g
* @param {{x0: number, y0: number, x1: number, y1: number}} g.panel
* @param {{top: number, bottom: number, right: number}} g.tab The tab's
* extent; its left edge is the panel's right edge.
* @param {{top: number, bottom: number, right: number}|null} g.tab The tab's
* extent; its left edge is the panel's right edge. Null draws the panel
* alone.
* @param {number} [g.corner] Panel corner radius, or the chamfer size.
* @param {number} [g.tabRadius] The tab's outer corner radius.
* @param {number} [g.fillet] Concave fillet radius at the junction.
@@ -93,12 +100,24 @@ export function railJoinPath({
const y0 = panel.y0 + inset;
const x1 = panel.x1 - inset;
const y1 = panel.y1 - inset;
const ty0 = tab.top + inset;
const ty1 = tab.bottom - inset;
const tx1 = tab.right - inset;
// No tab (it is scrolled out of the strip): the panel alone.
const ty0 = tab ? tab.top + inset : NaN;
const ty1 = tab ? tab.bottom - inset : NaN;
const tx1 = tab ? tab.right - inset : NaN;
if (style === 'chamfer') {
const c = corner;
const cx = (panel.x0 + panel.x1) / 2;
// The tab's edges along the panel stay between the chamfers.
const tyA = Math.max(ty0, y0 + c);
const tyB = Math.max(tyA, Math.min(ty1, y1 - c));
const edge = tab
? [
`L ${P(x1, tyA)}`,
`L ${P(tx1, tyA)}`,
`L ${P(tx1, tyB)}`,
`L ${P(x1, tyB)}`,
]
: [];
return [
`M ${P(x0, y0 + c)}`,
`L ${P(x0 + c, y0)}`,
@@ -108,10 +127,7 @@ export function railJoinPath({
`L ${P(cx + 25, y0)}`,
`L ${P(x1 - c, y0)}`,
`L ${P(x1, y0 + c)}`,
`L ${P(x1, ty0)}`,
`L ${P(tx1, ty0)}`,
`L ${P(tx1, ty1)}`,
`L ${P(x1, ty1)}`,
...edge,
`L ${P(x1, y1 - c)}`,
`L ${P(x1 - c, y1)}`,
`L ${P(x0 + c, y1)}`,
@@ -122,36 +138,68 @@ export function railJoinPath({
const R = Math.max(0, corner - inset);
const r = Math.max(0, tabRadius - inset);
const f = fillet + inset;
// sweep 1 turns clockwise (convex), 0 counter-clockwise (concave).
const arc = (radius, sweep, x, y) =>
`A ${n(radius)} ${n(radius)} 0 0 ${sweep} ${P(x, y)}`;
const first = tab.top - panel.y0 < 0.5;
const d = [`M ${P(x0 + R, y0)}`];
if (first) {
d.push(`L ${P(tx1 - r, y0)}`, arc(r, 1, tx1, y0 + r));
const d = [];
let at = null;
const same = (x, y) => at && at[0] === n(x) && at[1] === n(y);
const move = (x, y) => {
d.push(`M ${P(x, y)}`);
at = [n(x), n(y)];
};
const line = (x, y) => {
if (same(x, y)) return;
d.push(`L ${P(x, y)}`);
at = [n(x), n(y)];
};
// sweep 1 turns clockwise (convex), 0 counter-clockwise (concave); a zero
// radius is a plain corner.
const arc = (radius, sweep, x, y) => {
if (!(radius > 0)) return line(x, y);
d.push(`A ${n(radius)} ${n(radius)} 0 0 ${sweep} ${P(x, y)}`);
at = [n(x), n(y)];
};
// The run of panel edge above or below the tab may be shorter than a fillet
// plus a corner: shrink them to what fits, down to a straight edge shared
// with the panel's top or bottom.
const fit = (room) => {
const fillet = Math.min(f, Math.max(0, room - R));
return { fillet, corner: Math.min(R, Math.max(0, room - fillet)) };
};
move(x0 + R, y0);
if (!tab) {
line(x1 - R, y0);
arc(R, 1, x1, y0 + R);
line(x1, y1 - R);
arc(R, 1, x1 - R, y1);
} else {
d.push(
`L ${P(x1 - R, y0)}`,
arc(R, 1, x1, y0 + R),
`L ${P(x1, ty0 - f)}`,
arc(f, 0, x1 + f, ty0),
`L ${P(tx1 - r, ty0)}`,
arc(r, 1, tx1, ty0 + r),
);
const first = tab.top - panel.y0 < 0.5;
const last = panel.y1 - tab.bottom < 0.5;
const above = fit(ty0 - y0);
const below = fit(y1 - ty1);
if (first) {
line(tx1 - r, y0);
arc(r, 1, tx1, y0 + r);
} else {
line(x1 - above.corner, y0);
arc(above.corner, 1, x1, y0 + above.corner);
line(x1, ty0 - above.fillet);
arc(above.fillet, 0, x1 + above.fillet, ty0);
line(tx1 - r, ty0);
arc(r, 1, tx1, ty0 + r);
}
line(tx1, ty1 - r);
arc(r, 1, tx1 - r, ty1);
if (!last) {
line(x1 + below.fillet, ty1);
arc(below.fillet, 0, x1, ty1 + below.fillet);
line(x1, y1 - below.corner);
arc(below.corner, 1, x1 - below.corner, y1);
}
}
d.push(
`L ${P(tx1, ty1 - r)}`,
arc(r, 1, tx1 - r, ty1),
`L ${P(x1 + f, ty1)}`,
arc(f, 0, x1, ty1 + f),
`L ${P(x1, y1 - R)}`,
arc(R, 1, x1 - R, y1),
`L ${P(x0 + R, y1)}`,
arc(R, 1, x0, y1 - R),
`L ${P(x0, y0 + R)}`,
arc(R, 1, x0 + R, y0),
'Z',
);
line(x0 + R, y1);
arc(R, 1, x0, y1 - R);
line(x0, y0 + R);
arc(R, 1, x0 + R, y0);
d.push('Z');
return d.join(' ');
}
@@ -171,7 +219,8 @@ function panelTitle(panel) {
* @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed
* Owner callback; the strip never touches panel classes itself.
* @param {Document} [input.documentRef]
* @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
* @returns {{ element: HTMLElement, column: HTMLElement, tools: HTMLElement,
* join: HTMLElement, sync: () => void, destroy: () => void }}
*/
export function createRightRailTabs({
rail,
@@ -183,11 +232,20 @@ export function createRightRailTabs({
'Right rail tabs require a rail, a document and setPanelCollapsed',
);
}
const column = documentRef.createElement('div');
column.id = RIGHT_RAIL_COLUMN_ID;
const strip = documentRef.createElement('div');
strip.id = RIGHT_RAIL_TABS_ID;
strip.setAttribute('role', 'tablist');
strip.setAttribute('aria-orientation', 'vertical');
strip.setAttribute('aria-label', t('Panels'));
// The rail's measuring pass (rightPanelRail.js) lifts the column's lane
// clamp, which would reset a scrolled strip; the marker has it put the
// offset back.
strip.setAttribute('data-rail-scroller', '');
const tools = documentRef.createElement('div');
tools.id = RIGHT_RAIL_TOOLS_ID;
column.append(strip, tools);
rail.setAttribute(RIGHT_RAIL_TABS_ATTRIBUTE, 'vertical');
// The join: shadow under, glass, outline over; all shaped by one path.
@@ -264,25 +322,51 @@ export function createRightRailTabs({
* rail pixels; the stylesheet sets the panel's minimum height from it) and
* the joined silhouette on the join's layers. The rail can be taller than
* its panel in the focus layout, so the join is sized from the panel,
* extended down to cover the tab if that is shorter.
* extended down to cover the tab if that is shorter, but never past the
* rail's lane (--right-stack-max-height).
*/
let shownTab = null;
function placeJoin(tab, panel) {
if (!tab) {
rail.style.removeProperty('--rail-tab-bottom');
join.hidden = true;
shownTab = null;
return;
}
// A short lane makes the strip scroll. A newly opened tab is brought into
// view once; after that the strip scrolls as the user wishes and the join
// follows the visible part of the tab, or the panel alone.
const viewHeight = strip.clientHeight;
const scrolls = Number.isFinite(viewHeight) && viewHeight > 0;
if (scrolls && tab !== shownTab) {
const above = tab.offsetTop - strip.scrollTop;
const below =
tab.offsetTop + tab.offsetHeight - (strip.scrollTop + viewHeight);
if (above < 0) strip.scrollTop += above;
else if (below > 0) strip.scrollTop += below;
}
shownTab = tab;
const top = tab.offsetTop - (strip.scrollTop || 0);
rail.style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}px`);
const computed = documentRef.defaultView?.getComputedStyle?.(rail);
const corner = token(computed, '--right-rail-corner', 16);
const tabRadius = token(computed, '--right-rail-tab-radius', 10);
const fillet = token(computed, '--right-rail-fillet', 8);
const lane = token(computed, '--right-stack-max-height', Infinity);
const chamfer =
computed?.getPropertyValue?.('--right-rail-join-style').trim() ===
'chamfer';
const tabTop = (strip.offsetTop || 0) + top;
const tabBottom = tabTop + tab.offsetHeight;
// Tab offsets are relative to the strip, the strip's to the column, the
// column's to the rail.
const stripTop = (column.offsetTop || 0) + (strip.offsetTop || 0);
const stripLeft = (column.offsetLeft || 0) + (strip.offsetLeft || 0);
let tabTop = stripTop + top;
let tabBottom = tabTop + tab.offsetHeight;
if (scrolls) {
tabTop = Math.max(tabTop, stripTop);
tabBottom = Math.min(tabBottom, stripTop + viewHeight);
}
const visible = tabBottom - tabTop >= 1;
const geometry = {
panel: {
x0: panel.offsetLeft,
@@ -290,16 +374,20 @@ export function createRightRailTabs({
x1: panel.offsetLeft + panel.offsetWidth,
y1: Math.max(
panel.offsetTop + panel.offsetHeight,
tabBottom + fillet + 4 + corner,
visible ? Math.min(lane, tabBottom + fillet + 4 + corner) : 0,
),
},
tab: {
top: tabTop,
bottom: tabBottom,
right: (strip.offsetLeft || 0) + tab.offsetLeft + tab.offsetWidth,
},
tab: visible
? {
top: tabTop,
bottom: tabBottom,
right: stripLeft + tab.offsetLeft + tab.offsetWidth,
}
: null,
corner,
tabRadius,
tabRadius: visible
? Math.min(tabRadius, (tabBottom - tabTop) / 2)
: tabRadius,
fillet,
style: chamfer ? 'chamfer' : 'round',
};
@@ -318,11 +406,15 @@ export function createRightRailTabs({
/** 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);
// The column precedes its panels in the tab order, so Tab from the open
// tab reaches the tool slot and then the open panel. The rail owner moves
// panels in at start-up; keep the column first afterwards too.
if (rail.firstElementChild !== column) rail.prepend(column);
if (join.parentElement !== rail) rail.append(join);
// Hidden panels too: Weather and Recent Imagery arrive hidden and their
// owners reveal them by attribute, which must create their tabs.
for (const panel of rail.children)
if (panel.matches?.('[data-panel-id]')) observe(panel);
const panels = railTabPanels(rail);
const focusOwner =
panels.find((panel) => !panel.classList.contains('collapsed')) ||
@@ -344,7 +436,6 @@ export function createRightRailTabs({
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) {
@@ -379,12 +470,14 @@ export function createRightRailTabs({
strip.addEventListener('keydown', onKeydown);
strip.addEventListener('scroll', onScroll, { passive: true });
observer?.observe(rail, { childList: true });
rail.prepend(strip);
rail.prepend(column);
rail.append(join);
sync();
return {
element: strip,
column,
tools,
join,
sync,
destroy() {
@@ -395,7 +488,7 @@ export function createRightRailTabs({
strip.removeEventListener('click', onClick);
strip.removeEventListener('keydown', onKeydown);
strip.removeEventListener('scroll', onScroll);
strip.remove();
column.remove();
join.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
placeJoin(null, null);
+255 -5
View File
@@ -1,8 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import {
RIGHT_RAIL_COLUMN_ID,
RIGHT_RAIL_TABS_ATTRIBUTE,
RIGHT_RAIL_TABS_ID,
RIGHT_RAIL_TOOLS_ID,
createRightRailTabs,
nextRailTabIndex,
railJoinPath,
@@ -197,11 +200,24 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa
const f = fixture();
assert.equal(
f.rail.children[0],
f.strip,
'the tablist precedes its panels in the tab order',
f.tabs.column,
'the column precedes its panels in the tab order',
);
assert.equal(f.tabs.column.id, RIGHT_RAIL_COLUMN_ID);
assert.deepEqual(
f.tabs.column.children,
[f.strip, f.tabs.tools],
'the tablist, then the tool slot',
);
assert.equal(f.tabs.tools.id, RIGHT_RAIL_TOOLS_ID);
assert.deepEqual(f.tabs.tools.children, [], 'the slot starts empty');
assert.equal(f.strip.id, RIGHT_RAIL_TABS_ID);
assert.equal(f.strip.getAttribute('role'), 'tablist');
assert.equal(
f.strip.getAttribute('data-rail-scroller'),
'',
'the rail layout pass keeps the strip scroll offset across measuring',
);
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), 'vertical');
assert.deepEqual(
@@ -425,12 +441,246 @@ test('railJoinPath traces the panel with the tab attached: corners, fillets, tab
);
});
test('destroy removes the strip and stops listening', () => {
/** A browser-like MutationObserver over the fake tree. */
function installObservers(doc) {
const live = [];
class FakeMutationObserver {
constructor(callback) {
this.callback = callback;
this.targets = [];
live.push(this);
}
observe(target, options) {
this.targets.push({ target, options });
}
disconnect() {
this.targets = [];
}
}
doc.defaultView = { MutationObserver: FakeMutationObserver };
return {
attributeChanged(target, name) {
for (const observer of live) {
const hit = observer.targets.some(
({ target: observed, options }) =>
observed === target &&
options.attributes &&
(!options.attributeFilter ||
options.attributeFilter.includes(name)),
);
if (hit)
observer.callback([
{ type: 'attributes', target, attributeName: name },
]);
}
},
};
}
test('a rail panel hidden at mount gets its tab when its owner unhides it, from the observers alone', () => {
const doc = makeDocument();
const { attributeChanged } = installObservers(doc);
const rail = doc.createElement('aside');
rail.id = 'right-context-rail';
const display = panel(doc, 'pp-toggles', {
title: 'DISPLAY',
titleClass: 'pp-header-label',
collapsed: false,
});
const cctv = panel(doc, 'cctv-panel', { title: 'CCTV' });
const recent = panel(doc, 'recent-imagery-panel', {
title: 'RECENT IMAGERY',
hidden: true,
});
const context = panel(doc, 'global-context-panel', { title: 'CONTEXT' });
rail.append(display, cctv, recent, context);
doc.body.append(rail);
const tabs = createRightRailTabs({
rail,
documentRef: doc,
setPanelCollapsed() {},
});
const ids = () => tabs.element.children.map((tab) => tab.dataset.railTab);
assert.deepEqual(ids(), ['pp-toggles', 'cctv-panel', 'global-context-panel']);
// Recent Imagery's layer turns on: its owner removes `hidden`, then the
// rail pass marks it collapsed behind the Display owner.
recent.hidden = false;
attributeChanged(recent, 'hidden');
recent.classList.add('rail-owner-collapsed');
attributeChanged(recent, 'class');
assert.deepEqual(ids(), [
'pp-toggles',
'cctv-panel',
'recent-imagery-panel',
'global-context-panel',
]);
tabs.destroy();
});
test('a newly opened tab is scrolled into the strip window once; the join follows the visible part', () => {
const f = fixture();
// The strip's offsets are relative to its column, the column's to the rail.
Object.assign(f.tabs.column, { offsetTop: 0, offsetLeft: 320 });
Object.assign(f.strip, { clientHeight: 100, scrollTop: 0, offsetLeft: 10 });
const tab = f.tabFor('global-context-panel');
Object.assign(tab, {
offsetTop: 150,
offsetLeft: 0,
offsetHeight: 56,
offsetWidth: 42,
});
Object.assign(f.context, {
offsetTop: 0,
offsetLeft: 0,
offsetHeight: 300,
offsetWidth: 330,
});
f.context.classList.remove('collapsed');
f.tabs.sync();
assert.equal(
f.strip.scrollTop,
106,
'opened: scrolled so the tab bottom meets the window',
);
assert.equal(f.rail.style.props.get('--rail-tab-bottom'), '100px');
const [, glass] = f.tabs.join.children;
f.strip.scrollTop = 220;
f.tabs.sync();
assert.equal(
f.strip.scrollTop,
220,
'a sync after the user scrolled leaves the strip alone',
);
assert.equal(
glass.style.clipPath,
"path('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 284 A 16 16 0 0 1 314 300 L 16 300 A 16 16 0 0 1 0 284 L 0 16 A 16 16 0 0 1 16 0 Z')",
'the tab is out of view: the join is the panel alone',
);
f.strip.scrollTop = 190;
f.tabs.sync();
assert.match(
glass.style.clipPath,
/^path\('M 16 0 L 364 0 A 8 8 0 0 1 372 8 A 8 8 0 0 1 364 16 L 338 16 A 8 8 0 0 0 330 24 L 330 284 /,
'a tab half in view joins with the visible 16px and smaller corners',
);
const cctvTab = f.tabFor('cctv-panel');
Object.assign(cctvTab, {
offsetTop: 86,
offsetLeft: 0,
offsetHeight: 56,
offsetWidth: 42,
});
Object.assign(f.cctv, {
offsetTop: 0,
offsetLeft: 0,
offsetHeight: 300,
offsetWidth: 330,
});
f.context.classList.add('collapsed');
f.cctv.classList.remove('collapsed');
f.tabs.sync();
assert.equal(f.strip.scrollTop, 86, 'another tab opens: brought into view');
});
test('railJoinPath shrinks the fillet and corner to the room left, down to a shared edge', () => {
const base = {
panel: { x0: 0, y0: 0, x1: 330, y1: 160 },
corner: 16,
tabRadius: 10,
fillet: 8,
};
const tight = railJoinPath({
...base,
tab: { top: 60, bottom: 148, right: 372 },
});
assert.match(
tight,
/A 10 10 0 0 1 362 148 L 330 148 A 12 12 0 0 1 318 160 L 16 160 /,
'12px below the tab: no fillet, a 12px corner',
);
const flush = railJoinPath({
...base,
tab: { top: 60, bottom: 160, right: 372 },
});
assert.match(
flush,
/A 10 10 0 0 1 362 160 L 16 160 A 16 16 0 0 1 0 144 /,
'a tab level with the panel bottom shares its bottom edge',
);
const high = railJoinPath({
...base,
tab: { top: 12, bottom: 100, right: 372 },
});
assert.match(
high,
/^M 16 0 L 318 0 A 12 12 0 0 1 330 12 L 362 12 A 10 10 0 0 1 372 22 /,
'12px above the tab: no fillet, a 12px corner',
);
const chamfer = railJoinPath({
...base,
panel: { x0: 0, y0: 0, x1: 312, y1: 160 },
tab: { top: 4, bottom: 158, right: 372 },
corner: 10,
style: 'chamfer',
});
assert.match(
chamfer,
/ L 312 10 L 372 10 L 372 150 L 312 150 L 312 150 L 302 160 /,
"Cyber keeps the tab's edges between the chamfers",
);
});
test('the stylesheet keeps phones on border-box, folds the strip into narrow desktops, shows a scrollbar and draws the focus ring inside', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
assert.match(
css,
/body\.mobile-shell #right-context-rail \{[^}]*box-sizing: border-box;/,
);
assert.match(
css,
/@media \(min-width: 721px\) and \(max-width: 760px\) \{\s*#right-context-rail\[data-rail-tabs\] \{\s*width: calc\(330px - var\(--right-rail-tabs-inset\)\);/,
);
assert.match(css, /#right-rail-tabs \{[^}]*scrollbar-width: thin;/);
assert.doesNotMatch(css, /#right-rail-tabs::-webkit-scrollbar/);
// The rail layout pass measures the column's natural height to climb above
// its baseline before the strip has to scroll.
assert.match(
css,
/#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*max-height: none !important;/,
);
assert.match(
css,
/#right-rail-tabs > \.right-rail-tab:focus-visible \{\s*outline-offset: -3px;/,
);
assert.match(css, /--right-rail-panel-cover: min\(/);
// The column hides on phones, where the Radio launcher is back in Context's
// header; in the slot the launcher's popover opens upward and to the left.
assert.match(
css,
/body\.mobile-shell #right-context-rail > #right-rail-column \{\s*display: none;/,
);
assert.doesNotMatch(
css,
/body\.mobile-shell #right-context-rail > #right-rail-tabs/,
);
assert.match(
css,
/#right-rail-tools \.context-radio-mini \{\s*top: auto;\s*right: calc\(100% \+ var\(--right-rail-tabs-gap\) \+ 6px\);\s*bottom: 0;/,
);
assert.match(css, /#right-rail-tools:empty \{\s*display: none;/);
assert.match(
css,
/body:not\(\.mobile-shell\)\s*#right-context-rail\[data-rail-tabs\]\s*:is\(\s*\[data-collapse-target='pp-toggles'\],\s*\[data-collapse-target='cctv-panel'\],\s*\[data-collapse-target='street-level-panel'\],\s*\[data-collapse-target='global-context-panel'\],\s*\[data-collapse-target='weather-panel'\],\s*\[data-collapse-target='recent-imagery-panel'\]\s*\) \{\s*display: none;/,
'the rail panels own collapse buttons are hidden on desktop, the Radio section keeps its own',
);
});
test('destroy removes the column 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);
assert.equal(f.tabs.column.parentElement, null);
assert.equal(f.rail.children.includes(f.tabs.column), false);
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), null);
fire(tab, 'click');
assert.deepEqual(f.calls, []);
-2
View File
@@ -26,8 +26,6 @@ const KEYS = [
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a photo',
'Exit cockpit to fly to a shared view',
'Go to expanded Radio section',
'Expand Radio section in Context',
'New scene name',
'Delete scene "{scene}" and all shots?',
'Detailed outline unavailable',
+161 -22
View File
@@ -2,7 +2,9 @@
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).
is the only launcher. The compact Radio launcher, which rides in Context's
header, moves to a slot under the tabs so it survives a collapsed Context.
Phones keep the sheet shell (mobile-shell.css).
The open tab and its panel are one surface. The strip builds a join element
(.right-rail-join) under the panels: one path traces the panel with the tab
@@ -28,10 +30,14 @@
--right-rail-fillet: 8px;
--right-rail-join-style: round;
--right-rail-join-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
/* Lower fillet, a short straight run, then the panel's corner. */
--right-rail-panel-cover: calc(
var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px +
var(--right-rail-corner)
/* Lower fillet, a short straight run, then the panel's corner, within the
rail's lane. */
--right-rail-panel-cover: min(
calc(
var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px +
var(--right-rail-corner)
),
var(--right-stack-max-height, 70vh)
);
/* The theme's rail width stays the panel width; the strip's column is
padding beside it. */
@@ -39,28 +45,133 @@
padding-right: var(--right-rail-tabs-inset);
}
#right-rail-tabs {
/* Just above the phone breakpoint the strip's column would push the rail into
the left rail: fold it into the panel width instead. */
@media (min-width: 721px) and (max-width: 760px) {
#right-context-rail[data-rail-tabs] {
width: calc(330px - var(--right-rail-tabs-inset));
}
:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
width: calc(312px - var(--right-rail-tabs-inset));
}
}
/* The strip's column: the tablist, then a tool slot that holds the compact
Radio launcher on desktop (radioPanelPlacement.js). */
#right-rail-column {
position: absolute;
top: 0;
right: 0;
z-index: 120;
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
width: var(--right-rail-tabs-inset);
max-height: var(--right-stack-max-height, 70vh);
/* Room for the open tab's bridge on the left. */
padding-left: var(--right-rail-tabs-gap);
overflow: hidden auto;
scrollbar-width: none;
pointer-events: auto;
}
#right-rail-tabs::-webkit-scrollbar {
/* The layout pass reads the column's natural height with the lane clamp
lifted, to climb when the lane is too short for it (rightPanelRail.js). */
#right-context-rail[data-rail-measuring] > #right-rail-column {
max-height: none !important;
}
#right-rail-tabs {
position: relative;
display: flex;
flex: 0 1 auto;
flex-direction: column;
gap: 4px;
box-sizing: border-box;
min-height: 0;
/* Room for the open tab's bridge on the left. */
padding-left: var(--right-rail-tabs-gap);
overflow: hidden auto;
/* The rail climbs above its baseline to fit the column (rightPanelRail.js);
a lane still shorter overflows the strip: a thin scrollbar says there is
more, and the strip keeps the open tab in view (rightRailTabs.js). */
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
}
#right-rail-tools {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
margin-top: 6px;
padding-left: var(--right-rail-tabs-gap);
}
#right-rail-tools:empty {
display: none;
}
/* In the slot the launcher is one more label in the column: no card, the
tab's corner, the accent while the radio plays. Its popover opens to the
left over the panel, bottom-aligned so it stays inside the lane. */
#right-rail-tools .context-radio-dock {
width: var(--right-rail-tabs-width);
}
#right-rail-tools .context-radio-toggle {
width: var(--right-rail-tabs-width);
height: var(--right-rail-tabs-width);
border: 0;
border-radius: var(--right-rail-tab-radius);
color: var(--text-dim);
background: transparent;
box-shadow: none;
}
#right-rail-tools .context-radio-toggle .material-symbols-outlined {
color: inherit;
font-size: 19px;
}
#right-rail-tools .context-radio-toggle:hover,
#right-rail-tools .context-radio-toggle[aria-expanded='true'] {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
}
#right-rail-tools .context-radio-toggle:focus-visible {
outline-offset: -3px;
}
#right-rail-tools .context-radio-toggle.active {
color: var(--accent);
}
#right-rail-tools .context-radio-mini {
top: auto;
right: calc(100% + var(--right-rail-tabs-gap) + 6px);
bottom: 0;
}
/* The tabs open and close the rail's panels; the headers' own collapse
buttons would only duplicate them. Phones keep the sheet shell's buttons. */
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
:is(
[data-collapse-target='pp-toggles'],
[data-collapse-target='cctv-panel'],
[data-collapse-target='street-level-panel'],
[data-collapse-target='global-context-panel'],
[data-collapse-target='weather-panel'],
[data-collapse-target='recent-imagery-panel']
) {
display: none;
}
/* The strip clips overflow, so the focus ring draws inside the tab. */
#right-rail-tabs > .right-rail-tab:focus-visible {
outline-offset: -3px;
}
.right-rail-tab {
position: relative;
display: flex;
@@ -69,8 +180,8 @@
justify-content: center;
box-sizing: border-box;
width: var(--right-rail-tabs-width);
min-height: 56px;
padding: 12px 0;
min-height: 40px;
padding: 6px 0;
border: 0;
border-radius: var(--right-rail-tab-radius);
background: transparent;
@@ -78,7 +189,7 @@
font-family: var(--font-mono);
font-size: 9px;
line-height: 1;
letter-spacing: 2.2px;
letter-spacing: 1.6px;
text-transform: uppercase;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
cursor: pointer;
@@ -110,6 +221,23 @@
text-shadow: none;
}
/* Short windows: tighter tabs before the strip has to scroll. */
@media (max-height: 720px) {
#right-rail-tabs {
gap: 3px;
}
.right-rail-tab {
min-height: 36px;
padding: 4px 0;
letter-spacing: 1.3px;
}
#right-rail-tools {
margin-top: 4px;
}
}
/* ── The join: surface, outline and shadow of panel plus tab ──────────── */
.right-rail-join {
position: absolute;
@@ -195,12 +323,9 @@ body:not(.mobile-shell)
opacity: 0.08;
}
/* The panel reaches at least to its tab's lower fillet and corner. The
wrapper carries the floor for the rail's allocated (focus) heights and the
card for its natural height. */
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed),
/* The card reaches at least to its tab's lower fillet and corner. In the
focus layout the rail's allocation bounds the wrapper; the card's floor is
what the measuring pass sees. */
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
@@ -246,6 +371,17 @@ body:not(.mobile-shell)
color: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber'] #right-rail-tools .context-radio-toggle:hover,
:root[data-ui-theme='cyber']
#right-rail-tools
.context-radio-toggle[aria-expanded='true'] {
background-color: rgba(226, 235, 236, 0.08);
}
:root[data-ui-theme='cyber'] #right-rail-tools .context-radio-toggle.active {
color: var(--cyber-red-bright);
}
/* Cyber gives every .panel-collapsible wrapper a surface with an inset ring
(cyber.css); beside the open tab that ring would show as a line. */
:root[data-ui-theme='cyber']
@@ -264,10 +400,13 @@ body:not(.mobile-shell)
}
/* ── Phones keep the sheet shell ───────────────────────────────────────── */
/* The content-box rail is a desktop measure; sheets size their box as before. */
body.mobile-shell #right-context-rail {
box-sizing: border-box;
padding-right: 0;
}
body.mobile-shell #right-context-rail > #right-rail-tabs {
/* The launcher is back in Context's header there (radioPanelPlacement.js). */
body.mobile-shell #right-context-rail > #right-rail-column {
display: none;
}