mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
24
Commits
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
- The right rail (Display, CCTV, Street Level, Context, plus Weather and Recent
|
||||||
|
Imagery while shown) is a vertical strip of icon tabs 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. On a short window the rail rises toward the HUD corner above it
|
||||||
|
rather than scroll. 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, Escape inside one closes it and returns
|
||||||
|
focus to its tab, and the strip is a keyboard-navigable tablist. Saved
|
||||||
|
preferences, share links and the phone sheet shell are unchanged (gekh,
|
||||||
|
#1425).
|
||||||
+68
-14
@@ -252,11 +252,12 @@ layouts retain their existing presentation.
|
|||||||
Chamfered panel expand/collapse controls use an inset keyboard-focus outline and
|
Chamfered panel expand/collapse controls use an inset keyboard-focus outline and
|
||||||
red hover border in both map and cockpit, so clipping does not hide the highlight.
|
red hover border in both map and cockpit, so clipping does not hide the highlight.
|
||||||
|
|
||||||
Cyber's map-side right rail opens one panel at a time while keeping collapsed
|
Cyber skins the map-side right rail's tab strip in its red and slate panel
|
||||||
launchers available. Display, CCTV and Context scroll their contents inside fixed
|
treatment; the one-panel rule applies in every desktop layout (see "Right rail
|
||||||
headers and decorative frames. The narrow-screen rail remains scrollable to reach
|
tabs" below). Display, CCTV and Context scroll their contents
|
||||||
each panel. Radio retains the shared nested Context player without relocating
|
inside fixed headers and decorative frames. The narrow-screen rail remains
|
||||||
playback controls on theme changes.
|
scrollable to reach each panel. Radio retains the shared nested Context player
|
||||||
|
without relocating playback controls on theme changes.
|
||||||
|
|
||||||
Voice help/error popups and Location/Visual Presets pins extend beyond their
|
Voice help/error popups and Location/Visual Presets pins extend beyond their
|
||||||
Cyber frames without being clipped: the shapes decorate non-interactive
|
Cyber frames without being clipped: the shapes decorate non-interactive
|
||||||
@@ -1524,13 +1525,63 @@ retains observers, frame scheduling, saved/share preferences and Cockpit portals
|
|||||||
|
|
||||||
The left rail keeps its measured collapsed heights and obstacle-safe corridor;
|
The left rail keeps its measured collapsed heights and obstacle-safe corridor;
|
||||||
the right rail follows its top baseline and preserves Display's scroll owner.
|
the right rail follows its top baseline and preserves Display's scroll owner.
|
||||||
Automatic collapse remains presentation only, prioritizes the latest explicit
|
The left rail's automatic collapse remains presentation only and prioritizes
|
||||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
the latest explicit panel. The right rail keeps one expanded owner (the latest
|
||||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
explicit panel, else the focused one, else one with a stored or shared choice
|
||||||
writes. This extraction does not change panel positions or layout defaults.
|
over one open only by default, else the first) and presentation-collapses its
|
||||||
|
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 reveal after the expanded Radio Enable lands its smooth scroll even when a
|
||||||
|
rail layout pass cuts it short.
|
||||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
keeps the collapsed panels its own exclusive presentation hides, so the owner
|
||||||
collapse can still be undone.
|
rule can still give them back.
|
||||||
|
|
||||||
|
### Right rail tabs
|
||||||
|
|
||||||
|
On desktop the right rail (Display, CCTV, Street Level, Context, plus Weather
|
||||||
|
and Recent Imagery while shown) is a vertical tab strip
|
||||||
|
(`src/ui/rightRailTabs.js`, `src/ui/styles/right-rail-tabs.css`): one upright
|
||||||
|
icon tab per panel at the rail's right edge, the open panel beside it. A tab
|
||||||
|
toggles its panel through `setPanelCollapsed` with the explicit flag; the
|
||||||
|
panels' `collapsed` classes remain the only state, mirrored through a
|
||||||
|
MutationObserver, and the owner rule above hides the rest. 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 reaches the panel.
|
||||||
|
Glyphs come from `RAIL_TAB_ICONS` (Material Symbols, in the `index.html`
|
||||||
|
subset); a tab's accessible name and tooltip are the panel's translated header
|
||||||
|
title. The tabs keep their size. The lane's top follows the left rail's; a
|
||||||
|
strip taller than the lane lifts the rail toward the HUD corner above
|
||||||
|
(`src/ui/rightPanelRail.js`), and on a lane still shorter the strip runs past
|
||||||
|
its end; the strip never scrolls or clips.
|
||||||
|
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.
|
||||||
|
|
||||||
|
The open tab and its panel are one surface, like a folder tab; the other tabs
|
||||||
|
are borderless icons. The strip measures the open panel and tab, traces their
|
||||||
|
joined silhouette as an SVG path (`railJoinPath`; Cyber gets its chamfered
|
||||||
|
frame) and shapes a join element under the panels with it: glass, outline and
|
||||||
|
shadow in one piece, so the junction has no seam. The panel and the tab draw
|
||||||
|
no surface of their own. `--rail-tab-bottom` on the rail sets the open panel's
|
||||||
|
minimum height, so a short panel such as Context still reaches its tab.
|
||||||
|
|
||||||
|
A first-time visitor on a desktop viewport finds Display open
|
||||||
|
(`src/ui/panelPositionControls.js`): with every panel closed nothing shows that
|
||||||
|
the icons are launchers. Phones keep it closed, a share link decides for
|
||||||
|
itself, and a stored choice wins either way. When the rail picks its owner, a
|
||||||
|
panel the visitor or a share link opened outranks one open only by default, so
|
||||||
|
a stored CCTV choice is not displaced by the Display default.
|
||||||
|
|
||||||
## Phone layout
|
## Phone layout
|
||||||
|
|
||||||
@@ -1567,8 +1618,9 @@ sheet before a panel's own Escape handling, and the dock's hover auto-close does
|
|||||||
not apply inside a sheet.
|
not apply inside a sheet.
|
||||||
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
|
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
|
||||||
playback keeps both so its STOP control in the Scenes panel stays reachable. The
|
playback keeps both so its STOP control in the Scenes panel stays reachable. The
|
||||||
right rail neither hides collapsed siblings nor applies Cyber's one-panel
|
right rail hides no collapsed siblings, shows no tab strip and applies no
|
||||||
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
|
one-panel rule at these widths, since the sheet chooses what shows. The Cesium
|
||||||
|
and Google attribution stays visible above the
|
||||||
voice control, and above an open sheet. The page draws under the notch and
|
voice control, and above an open sheet. The page draws under the notch and
|
||||||
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
|
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
|
||||||
Wider viewports are unchanged, and crossing to them closes any open sheet.
|
Wider viewports are unchanged, and crossing to them closes any open sheet.
|
||||||
@@ -2178,7 +2230,9 @@ which embeds the MAP_KEY.
|
|||||||
> Missions · Environmental · Explore manually**. No layer and no optional API
|
> Missions · Environmental · Explore manually**. No layer and no optional API
|
||||||
> call happens until a tile is clicked. The right-hand DISPLAY rail
|
> call happens until a tile is clicked. The right-hand DISPLAY rail
|
||||||
> (`pp-toggles`) now starts **collapsed** on a first run rather than expanded —
|
> (`pp-toggles`) now starts **collapsed** on a first run rather than expanded —
|
||||||
> a stored collapse state still wins, as before.
|
> a stored collapse state still wins, as before. (Superseded 2026-10-27: with
|
||||||
|
> the right rail tabs DISPLAY starts open on a first desktop run; see "Right
|
||||||
|
> rail tabs".)
|
||||||
>
|
>
|
||||||
> **The ENVIRONMENTAL tile is quakes AND fires** — live USGS earthquakes plus
|
> **The ENVIRONMENTAL tile is quakes AND fires** — live USGS earthquakes plus
|
||||||
> NASA FIRMS active fires (`layerIds: ['earthquakes', 'local-firms']`), with the
|
> NASA FIRMS active fires (`layerIds: ['earthquakes', 'local-firms']`), with the
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
+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,travel_explore,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- gev:template scene-chrome -->
|
<!-- gev:template scene-chrome -->
|
||||||
|
|||||||
@@ -1118,5 +1118,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"
|
||||||
]
|
]
|
||||||
@@ -424,6 +424,7 @@
|
|||||||
"src/ui/displayPanelScroll.js",
|
"src/ui/displayPanelScroll.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/mobileShellContract.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/panelStackLayout.js"
|
"src/panelStackLayout.js"
|
||||||
@@ -823,6 +824,8 @@
|
|||||||
"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/mobileShellContract.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",
|
||||||
@@ -2490,6 +2493,8 @@
|
|||||||
"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/mobileShellContract.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",
|
||||||
@@ -3538,6 +3543,7 @@
|
|||||||
"src/hudLayouts.js",
|
"src/hudLayouts.js",
|
||||||
"src/hudLayoutPolicy.js",
|
"src/hudLayoutPolicy.js",
|
||||||
"src/ui/radioPanelPlacement.js",
|
"src/ui/radioPanelPlacement.js",
|
||||||
|
"src/ui/mobileShellContract.js",
|
||||||
"src/bloom.js",
|
"src/bloom.js",
|
||||||
"src/cockpitVisionPolicy.js",
|
"src/cockpitVisionPolicy.js",
|
||||||
"src/contactsDetectionPolicy.js",
|
"src/contactsDetectionPolicy.js",
|
||||||
@@ -3591,6 +3597,8 @@
|
|||||||
"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/mobileShellContract.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",
|
||||||
|
|||||||
@@ -1779,18 +1779,6 @@ async function main() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
check(
|
|
||||||
'HUD-off desktop lanes keep Data plus Scenes and Display plus Context expanded inside the viewport corridor',
|
|
||||||
multiExpandedPanelLayout.independentLeft
|
|
||||||
&& multiExpandedPanelLayout.independentRight
|
|
||||||
&& multiExpandedPanelLayout.leftCountsAllExpanded
|
|
||||||
&& multiExpandedPanelLayout.rightCountsAllExpanded
|
|
||||||
&& multiExpandedPanelLayout.leftInsideCorridor
|
|
||||||
&& multiExpandedPanelLayout.rightInsideCorridor
|
|
||||||
&& multiExpandedPanelLayout.internalScrollOwned
|
|
||||||
&& multiExpandedPanelLayout.restoredExactly,
|
|
||||||
JSON.stringify(multiExpandedPanelLayout),
|
|
||||||
);
|
|
||||||
check(
|
check(
|
||||||
'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor',
|
'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor',
|
||||||
multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel'
|
multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel'
|
||||||
@@ -1804,16 +1792,6 @@ async function main() {
|
|||||||
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false',
|
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false',
|
||||||
JSON.stringify(multiExpandedPanelLayout.tacticalAutoCollapse),
|
JSON.stringify(multiExpandedPanelLayout.tacticalAutoCollapse),
|
||||||
);
|
);
|
||||||
check(
|
|
||||||
'closing Tactical lane owners restores eligible temporary siblings without changing disclosure truth',
|
|
||||||
multiExpandedPanelLayout.tacticalAutoRestore.dataExpanded
|
|
||||||
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAutoMarkerCleared
|
|
||||||
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAriaExpanded === 'true'
|
|
||||||
&& multiExpandedPanelLayout.tacticalAutoRestore.contextExpanded
|
|
||||||
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAutoMarkerCleared
|
|
||||||
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAriaExpanded === 'true',
|
|
||||||
JSON.stringify(multiExpandedPanelLayout.tacticalAutoRestore),
|
|
||||||
);
|
|
||||||
check(
|
check(
|
||||||
'Tactical Display hides ordinary collapsed CCTV and Context launchers',
|
'Tactical Display hides ordinary collapsed CCTV and Context launchers',
|
||||||
Object.values(multiExpandedPanelLayout.tacticalDisplayExclusive).every(Boolean),
|
Object.values(multiExpandedPanelLayout.tacticalDisplayExclusive).every(Boolean),
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ test('only the expanded Radio Enable gesture requests the contained post-enable
|
|||||||
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||||
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
|
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
|
||||||
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||||
|
// A rail layout pass measuring the panels mid-scroll cancels the smooth
|
||||||
|
// reveal; the helper lands it at the target when the scroll ends short.
|
||||||
|
assert.match(
|
||||||
|
radioControlsSource,
|
||||||
|
/scroller\.addEventListener\(\s*'scrollend',[\s\S]*?Math\.abs\(scroller\.scrollTop - next\) >= 1\)\s*scroller\.scrollTop = next;/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
||||||
|
|||||||
@@ -491,8 +491,10 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
|
|||||||
// to a mobile mode at the SAME breakpoint and removes both the class and the
|
// to a mobile mode at the SAME breakpoint and removes both the class and the
|
||||||
// custom property. Pin that, or the exemption above is unearned.
|
// custom property. Pin that, or the exemption above is unearned.
|
||||||
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
|
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
|
||||||
const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')");
|
const contract = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'mobileShellContract.js'), 'utf8');
|
||||||
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)');
|
assert.match(contract, /MOBILE_SHELL_MEDIA_QUERY = '\(max-width: 720px\)'/, 'the shell contract no longer names (max-width: 720px)');
|
||||||
|
const gate = rail.indexOf('windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY)');
|
||||||
|
assert.ok(gate > 0, 'the rail layout pass no longer keys off the shell breakpoint');
|
||||||
const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40);
|
const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40);
|
||||||
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
|
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
|
||||||
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
|
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
|
||||||
|
|||||||
@@ -983,18 +983,19 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
|||||||
assert.match(keyboard, /addEventListener\('keydown', onKeyDown, true\)/);
|
assert.match(keyboard, /addEventListener\('keydown', onKeyDown, true\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
test('the DISPLAY rail starts open on a first desktop run, and a stored choice wins', () => {
|
||||||
const ui = fs.readFileSync(
|
const ui = fs.readFileSync(
|
||||||
new URL('./ui/panelPositionControls.js', import.meta.url),
|
new URL('./ui/panelPositionControls.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
// The right rail is a strip of label tabs: with every panel closed nothing
|
||||||
// now, so it was opening to offer controls for things already happening —
|
// shows that the labels are launchers, so DISPLAY opens on a first desktop
|
||||||
// while competing with the mission card for the one first impression there is.
|
// run to demonstrate the strip. Phones keep their sheets and a share link
|
||||||
|
// decides for itself.
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/if \(panelId === 'pp-toggles' && stored === null\) collapsed = true;/,
|
/if \(panelId === 'pp-toggles' && stored === null\)\s+collapsed =\s+!allowStored \|\|\s+globalThis\.matchMedia\?\.\(MOBILE_SHELL_MEDIA_QUERY\)\?\.matches === true;/,
|
||||||
'first run must leave DISPLAY collapsed',
|
'first desktop run must open DISPLAY; phones and share links keep it closed',
|
||||||
);
|
);
|
||||||
// The first-run default may only apply when NOTHING is stored: a visitor who
|
// The first-run default may only apply when NOTHING is stored: a visitor who
|
||||||
// opened the rail keeps it open, and one who closed it keeps it closed.
|
// opened the rail keeps it open, and one who closed it keeps it closed.
|
||||||
|
|||||||
@@ -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,8 +202,8 @@ 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 = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||||
'responsive auto-collapse must serialize the explicit expanded preference',
|
'the left rail\'s responsive auto-collapse serializes the expanded preference; a right-rail peer hidden behind the owner travels as collapsed',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js';
|
|
||||||
|
|
||||||
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
|
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
|
||||||
'contacts',
|
'contacts',
|
||||||
'space-missions',
|
'space-missions',
|
||||||
|
|||||||
@@ -2,48 +2,13 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js';
|
||||||
shouldExpandGlobalContextPanel,
|
|
||||||
shouldHideCollapsedRightPanels,
|
|
||||||
} from './rightRailPolicy.js';
|
|
||||||
|
|
||||||
test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => {
|
test('the desktop right rail hides collapsed panels behind the tab strip and marks them aria-hidden', () => {
|
||||||
assert.equal(shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant: 'tactical',
|
|
||||||
hasExpandedPanel: true,
|
|
||||||
}), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapsed launchers remain when Tactical has no expanded panel', () => {
|
|
||||||
assert.equal(shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant: 'tactical',
|
|
||||||
hasExpandedPanel: false,
|
|
||||||
}), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('other HUD layouts keep collapsed right-rail launchers visible', () => {
|
|
||||||
assert.equal(shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant: 'minimal',
|
|
||||||
hasExpandedPanel: true,
|
|
||||||
}), false);
|
|
||||||
assert.equal(shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant: 'full',
|
|
||||||
hasExpandedPanel: true,
|
|
||||||
}), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
|
||||||
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
|
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
|
assert.match(ui, /const isMobile = windowRef\.matchMedia\(MOBILE_SHELL_MEDIA_QUERY\)\.matches/);
|
||||||
assert.match(
|
assert.match(ui, /const exclusive = !isMobile;/);
|
||||||
ui,
|
|
||||||
/!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(
|
|
||||||
ui,
|
|
||||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
|
||||||
);
|
|
||||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||||
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
|
|||||||
assert.doesNotMatch(html, /sl-mark|mly-mark/);
|
assert.doesNotMatch(html, /sl-mark|mly-mark/);
|
||||||
assert.match(
|
assert.match(
|
||||||
html,
|
html,
|
||||||
/<div class="street-level-panel-inner" data-rail-scroller>/,
|
/<div class="street-level-panel-inner" data-rail-scroller data-rail-card>/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Just enough DOM for the rail and strip tests: a tree with classes,
|
||||||
|
* attributes, inline style properties, boxes and events. There is no layout:
|
||||||
|
* a test sets the offsets, rects and computed values it needs.
|
||||||
|
*/
|
||||||
|
export function makeDocument() {
|
||||||
|
const doc = { activeElement: null };
|
||||||
|
const descendants = (node) =>
|
||||||
|
node.children.flatMap((child) => [child, ...descendants(child)]);
|
||||||
|
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();
|
||||||
|
const props = new Map();
|
||||||
|
const writes = [];
|
||||||
|
Object.assign(el, {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
ownerDocument: doc,
|
||||||
|
children: [],
|
||||||
|
parentElement: null,
|
||||||
|
dataset: {},
|
||||||
|
scrollHeight: 0,
|
||||||
|
clientHeight: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
rect: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
|
||||||
|
computed: { display: 'block', visibility: 'visible', opacity: '1' },
|
||||||
|
// Every inline style write, in order, for write-if-changed assertions.
|
||||||
|
writes,
|
||||||
|
style: {
|
||||||
|
props,
|
||||||
|
setProperty(key, value) {
|
||||||
|
writes.push(['set', key, value]);
|
||||||
|
props.set(key, value);
|
||||||
|
},
|
||||||
|
removeProperty(key) {
|
||||||
|
writes.push(['remove', key]);
|
||||||
|
props.delete(key);
|
||||||
|
},
|
||||||
|
getPropertyValue: (key) => props.get(key) || '',
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
getBoundingClientRect: () => el.rect,
|
||||||
|
contains(target) {
|
||||||
|
return (
|
||||||
|
target === el || el.children.some((child) => child.contains(target))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
/** Class selectors only, comma-separated. */
|
||||||
|
querySelector(selector) {
|
||||||
|
const wanted = selector.split(',').map((part) => part.trim().slice(1));
|
||||||
|
return findIn(el, (node) =>
|
||||||
|
wanted.some((name) => node.classList.contains(name)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
/** A simple selector, or one scoped to the children with ':scope >'. */
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
const own = selector.startsWith(':scope > ');
|
||||||
|
const simple = own ? selector.slice(':scope > '.length) : selector;
|
||||||
|
const pool = own ? el.children : descendants(el);
|
||||||
|
return pool.filter((node) => node.matches(simple));
|
||||||
|
},
|
||||||
|
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.createElementNS = (_namespace, tag) => doc.createElement(tag);
|
||||||
|
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`. */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from './shareFollow.js';
|
} from './shareFollow.js';
|
||||||
|
|
||||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||||
|
import { LAYER_RESTORE_ORIGINS } from '../data/layerState.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
describeUnsavedWork,
|
describeUnsavedWork,
|
||||||
@@ -1207,6 +1208,14 @@ export class StyleManager extends ShellFacade {
|
|||||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||||
showToast: (message) => this._showToast(message),
|
showToast: (message) => this._showToast(message),
|
||||||
syncViewport: () => this._syncCctvPanelViewport(),
|
syncViewport: () => this._syncCctvPanelViewport(),
|
||||||
|
// Tells the panel whether a switch-on restores saved or shared state.
|
||||||
|
subscribeEnableRequests: (listener) =>
|
||||||
|
this._dataManager?.subscribeVisibilityRequests?.((change) => {
|
||||||
|
if (change?.layerId === 'cctv' && change.enabled)
|
||||||
|
listener(
|
||||||
|
Object.values(LAYER_RESTORE_ORIGINS).includes(change.origin),
|
||||||
|
);
|
||||||
|
}) || null,
|
||||||
setSplitFlapText,
|
setSplitFlapText,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export class CctvControls {
|
|||||||
this._cctvSummaryTypingTimer = null;
|
this._cctvSummaryTypingTimer = null;
|
||||||
this._lastCctvSummaryText = '';
|
this._lastCctvSummaryText = '';
|
||||||
this._lastSeenCctvActiveId = null;
|
this._lastSeenCctvActiveId = null;
|
||||||
|
this._cctvRestoredEnable = false;
|
||||||
|
this._unsubscribeEnableRequests = null;
|
||||||
this._cctvChipHideTimer = null;
|
this._cctvChipHideTimer = null;
|
||||||
this._cctvChipWasBusy = false;
|
this._cctvChipWasBusy = false;
|
||||||
this._cctvFrameRequestToken = 0;
|
this._cctvFrameRequestToken = 0;
|
||||||
@@ -65,6 +67,11 @@ export class CctvControls {
|
|||||||
this._cctvUnsubscribe?.();
|
this._cctvUnsubscribe?.();
|
||||||
this._cctvUnsubscribe = null;
|
this._cctvUnsubscribe = null;
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
|
this._unsubscribeEnableRequests?.();
|
||||||
|
this._unsubscribeEnableRequests =
|
||||||
|
this.actions.subscribeEnableRequests?.((restored) => {
|
||||||
|
this._cctvRestoredEnable = restored;
|
||||||
|
}) || null;
|
||||||
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
||||||
this._renderCctvState(state),
|
this._renderCctvState(state),
|
||||||
);
|
);
|
||||||
@@ -118,6 +125,8 @@ export class CctvControls {
|
|||||||
this.listeners.abort();
|
this.listeners.abort();
|
||||||
this._cctvUnsubscribe?.();
|
this._cctvUnsubscribe?.();
|
||||||
this._cctvUnsubscribe = null;
|
this._cctvUnsubscribe = null;
|
||||||
|
this._unsubscribeEnableRequests?.();
|
||||||
|
this._unsubscribeEnableRequests = null;
|
||||||
this._calibrationEdit?.(false);
|
this._calibrationEdit?.(false);
|
||||||
this._clearCctvFrame();
|
this._clearCctvFrame();
|
||||||
clearInterval(this._cctvSummaryTypingTimer);
|
clearInterval(this._cctvSummaryTypingTimer);
|
||||||
|
|||||||
@@ -221,3 +221,23 @@ test('disposing during camera enable prevents the delayed focus and future click
|
|||||||
assert.equal(enables, 1);
|
assert.equal(enables, 1);
|
||||||
assert.equal(focuses, 0);
|
assert.equal(focuses, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a restored camera layer leaves its panel to the restored panel state', (t) => {
|
||||||
|
const { controls } = fixture(t);
|
||||||
|
let request;
|
||||||
|
const opened = [];
|
||||||
|
controls.actions.subscribeEnableRequests = (listener) => {
|
||||||
|
request = listener;
|
||||||
|
return () => {};
|
||||||
|
};
|
||||||
|
controls.actions.setPanelCollapsed = (...args) => opened.push(args);
|
||||||
|
controls.actions.syncViewport = () => {};
|
||||||
|
controls.connect();
|
||||||
|
|
||||||
|
request(true);
|
||||||
|
controls._renderCctvState({ enabled: true, activeCameraId: 'a' });
|
||||||
|
assert.deepEqual(opened, []);
|
||||||
|
|
||||||
|
controls._renderCctvState({ enabled: true, activeCameraId: 'b' });
|
||||||
|
assert.deepEqual(opened, [['cctv-panel', false, { explicit: false }]]);
|
||||||
|
});
|
||||||
@@ -58,12 +58,16 @@ export function _renderCctvState(state) {
|
|||||||
if (
|
if (
|
||||||
effectiveActiveId &&
|
effectiveActiveId &&
|
||||||
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
||||||
(!state?.autoHop || isFirstActivation)
|
(!state?.autoHop || isFirstActivation) &&
|
||||||
|
// A restored layer leaves the panel to the restored panel state, so its
|
||||||
|
// first camera does not take the rail from the restored tab.
|
||||||
|
!this._cctvRestoredEnable
|
||||||
) {
|
) {
|
||||||
this.actions.setPanelCollapsed('cctv-panel', false, {
|
this.actions.setPanelCollapsed('cctv-panel', false, {
|
||||||
explicit: Boolean(state?.explicitSelection),
|
explicit: Boolean(state?.explicitSelection),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (effectiveActiveId) this._cctvRestoredEnable = false;
|
||||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||||
|
|
||||||
this._updateCctvSyncChip(state?.loading, enabled);
|
this._updateCctvSyncChip(state?.loading, enabled);
|
||||||
|
|||||||
@@ -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', () => {
|
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
|
||||||
const f = fixture(), prior = globalThis.document;
|
const f = fixture(), prior = globalThis.document;
|
||||||
globalThis.document = f.doc;
|
globalThis.document = f.doc;
|
||||||
@@ -51,9 +66,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 +86,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', 'minimal', 'cyber']) {
|
for (const theme of ['tactical', '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; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -748,7 +748,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
rightRail,
|
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(
|
assert.match(
|
||||||
cyberStyles,
|
cyberStyles,
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
|
|
||||||
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
|
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
|
||||||
import { msg, t } from '../i18n/index.js';
|
import { msg, t } from '../i18n/index.js';
|
||||||
|
import {
|
||||||
|
MOBILE_SHELL_CLASS,
|
||||||
|
MOBILE_SHELL_MEDIA_QUERY,
|
||||||
|
} from './mobileShellContract.js';
|
||||||
|
|
||||||
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
export { MOBILE_SHELL_CLASS, MOBILE_SHELL_MEDIA_QUERY };
|
||||||
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
|
||||||
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
|
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,7 +211,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')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Where the phone shell begins (mobileShell.js), for the rails and panels
|
||||||
|
* that present differently under it. Kept apart from the shell so the rail
|
||||||
|
* layout and panel packages need none of the shell's dependencies.
|
||||||
|
*/
|
||||||
|
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
||||||
|
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
||||||
+28
-29
@@ -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 },
|
||||||
@@ -101,6 +102,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]')
|
||||||
@@ -358,11 +369,11 @@ export class PanelChrome {
|
|||||||
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||||
const panelEl = document.getElementById(spec.id);
|
const panelEl = document.getElementById(spec.id);
|
||||||
if (!panelEl) continue;
|
if (!panelEl) continue;
|
||||||
// Responsive auto-collapse is presentation only; the recipient should
|
// The left rail's responsive auto-collapse is presentation only; the
|
||||||
// restore the user's explicit expanded preference at its own viewport.
|
// recipient restores the user's expanded preference at its own viewport.
|
||||||
const collapsed =
|
// A right-rail peer hidden behind the owner travels as collapsed, so the
|
||||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
// recipient opens the panel the sender sees.
|
||||||
panelEl.classList.contains('cyber-accordion-collapsed')
|
const collapsed = panelEl.classList.contains('layout-auto-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.
|
||||||
@@ -420,14 +431,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')
|
||||||
@@ -467,7 +479,10 @@ export class PanelChrome {
|
|||||||
) {
|
) {
|
||||||
this._panelLayout._leftStackPreferredPanelId = null;
|
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;
|
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
|
||||||
} else if (
|
} else if (
|
||||||
explicit &&
|
explicit &&
|
||||||
@@ -480,7 +495,6 @@ export class PanelChrome {
|
|||||||
// Reveal Radio's owner before the no-op check: the nested section may
|
// Reveal Radio's owner before the no-op check: the nested section may
|
||||||
// already be expanded while its Context parent is closed.
|
// already be expanded while its Context parent is closed.
|
||||||
if (
|
if (
|
||||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
|
||||||
!nextCollapsed &&
|
!nextCollapsed &&
|
||||||
panelId === 'radio-panel' &&
|
panelId === 'radio-panel' &&
|
||||||
document
|
document
|
||||||
@@ -507,10 +521,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 &&
|
||||||
@@ -522,20 +533,6 @@ export class PanelChrome {
|
|||||||
this.cockpitView.setContextCollapsed(true);
|
this.cockpitView.setContextCollapsed(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!nextCollapsed &&
|
|
||||||
panelId === 'radio-panel' &&
|
|
||||||
document.documentElement?.dataset.uiTheme !== 'cyber' &&
|
|
||||||
document
|
|
||||||
.getElementById('global-context-panel')
|
|
||||||
?.classList.contains('collapsed')
|
|
||||||
) {
|
|
||||||
this.setPanelCollapsed('global-context-panel', false, {
|
|
||||||
restore,
|
|
||||||
persist,
|
|
||||||
syncShare,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||||
const otherPanel = document.getElementById('control-panel');
|
const otherPanel = document.getElementById('control-panel');
|
||||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||||
@@ -626,6 +623,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 = [];
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { PanelChrome } from './panelChrome.js';
|
||||||
|
|
||||||
|
function classList(initial = []) {
|
||||||
|
const values = new Set(initial);
|
||||||
|
return {
|
||||||
|
contains: (value) => values.has(value),
|
||||||
|
add: (...items) => items.forEach((item) => values.add(item)),
|
||||||
|
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||||
|
toggle(value, force) {
|
||||||
|
if (force === true) values.add(value);
|
||||||
|
else if (force === false) values.delete(value);
|
||||||
|
else if (values.has(value)) values.delete(value);
|
||||||
|
else values.add(value);
|
||||||
|
return values.has(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A globe station pick opens Radio, which may already be expanded inside a
|
||||||
|
// closed Context.
|
||||||
|
for (const theme of ['tactical', 'cyber']) {
|
||||||
|
test(`${theme}: a radio pick reopens the closed Context around an expanded Radio section`, (t) => {
|
||||||
|
const priorDocument = globalThis.document;
|
||||||
|
const contextPanel = {
|
||||||
|
id: 'global-context-panel',
|
||||||
|
classList: classList(['collapsed']),
|
||||||
|
};
|
||||||
|
const panels = {
|
||||||
|
'global-context-panel': contextPanel,
|
||||||
|
'radio-panel': { id: 'radio-panel', classList: classList() },
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
documentElement: { dataset: { uiTheme: theme } },
|
||||||
|
body: { classList: classList() },
|
||||||
|
getElementById: (id) => panels[id] || null,
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = priorDocument;
|
||||||
|
});
|
||||||
|
const owner = {
|
||||||
|
_leftPanelStack: null,
|
||||||
|
_rightPanelStack: { contains: (el) => el === contextPanel },
|
||||||
|
_panelLayout: {
|
||||||
|
_leftStackPreferredPanelId: null,
|
||||||
|
_rightStackPreferredPanelId: 'pp-toggles',
|
||||||
|
},
|
||||||
|
_syncPanelCollapseButton() {},
|
||||||
|
_scheduleLeftPanelLayout() {},
|
||||||
|
_scheduleRightPanelLayout() {},
|
||||||
|
_savePanelCollapsedState() {},
|
||||||
|
_syncCctvPanelViewport() {},
|
||||||
|
_updateCommandDockTrayStack() {},
|
||||||
|
_lifetime: { frame() {} },
|
||||||
|
shareLinkManager: null,
|
||||||
|
cockpitView: null,
|
||||||
|
};
|
||||||
|
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed;
|
||||||
|
|
||||||
|
owner.setPanelCollapsed('radio-panel', false);
|
||||||
|
|
||||||
|
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,10 +299,8 @@ export class PanelLayoutController {
|
|||||||
stack: this._rightPanelStack,
|
stack: this._rightPanelStack,
|
||||||
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
|
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
|
||||||
windowRef: window,
|
windowRef: window,
|
||||||
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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */
|
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
|
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.js';
|
||||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||||
/**
|
/**
|
||||||
@@ -66,15 +67,15 @@ export class PanelPositionControls {
|
|||||||
// storage unavailable
|
// storage unavailable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// DISPLAY starts COLLAPSED for a first-time visitor, then respects the
|
// DISPLAY starts EXPANDED for a first-time visitor on a desktop viewport:
|
||||||
// user's persisted choice like every other panel.
|
// the right rail is a strip of label tabs (rightRailTabs.js), and with
|
||||||
//
|
// every panel closed nothing shows that the labels are launchers. Phones
|
||||||
// It used to start expanded, to advertise the HUD / DETECT / 3D toggles.
|
// keep it collapsed (their sheets present the rail); a share link decides
|
||||||
// That reason expired when those became ON by default: the rail now opens
|
// for itself. A stored choice wins in both directions.
|
||||||
// to offer controls for things already happening, while competing with the
|
if (panelId === 'pp-toggles' && stored === null)
|
||||||
// first-run mission card for the one first impression there is. A stored
|
collapsed =
|
||||||
// choice still wins in both directions, so anyone who opens it keeps it.
|
!allowStored ||
|
||||||
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
|
globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches === true;
|
||||||
panelEl.classList.toggle('collapsed', collapsed);
|
panelEl.classList.toggle('collapsed', collapsed);
|
||||||
// Bodies that expand themselves on first appearance must not override a
|
// Bodies that expand themselves on first appearance must not override a
|
||||||
// choice the user (or a share link) already made.
|
// choice the user (or a share link) already made.
|
||||||
|
|||||||
@@ -60,19 +60,3 @@ export function resolveHudRailLayout({
|
|||||||
constrained: panelHeight > availableHeight,
|
constrained: panelHeight > availableHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tactical HUD gives an expanded right-rail panel the whole control lane.
|
|
||||||
* Other HUD layouts keep collapsed launchers visible for quick switching.
|
|
||||||
*
|
|
||||||
* @param {object} input Current rail state.
|
|
||||||
* @param {string} input.hudVariant Active HUD layout variant.
|
|
||||||
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
|
|
||||||
* @returns {boolean} Whether collapsed sibling launchers should be hidden.
|
|
||||||
*/
|
|
||||||
export function shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant,
|
|
||||||
hasExpandedPanel,
|
|
||||||
}) {
|
|
||||||
return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
export { layoutLeftPanelRail } from './leftPanelRail.js';
|
export { layoutLeftPanelRail } from './leftPanelRail.js';
|
||||||
export { layoutRightPanelRail } from './rightPanelRail.js';
|
export { layoutRightPanelRail } from './rightPanelRail.js';
|
||||||
export { measurePanelNaturalHeight } from './panelMeasurement.js';
|
export { measurePanelNaturalHeight } from './panelMeasurement.js';
|
||||||
export {
|
export { resolveHudRailLayout } from './panelRailGeometry.js';
|
||||||
resolveHudRailLayout,
|
|
||||||
shouldHideCollapsedRightPanels,
|
|
||||||
} from './panelRailGeometry.js';
|
|
||||||
export {
|
export {
|
||||||
allocatePanelStackHeights,
|
allocatePanelStackHeights,
|
||||||
panelStackAutoCollapseIndices,
|
panelStackAutoCollapseIndices,
|
||||||
|
|||||||
+149
-128
@@ -1,3 +1,4 @@
|
|||||||
|
import { makeDocument } from '../testSupport/fakeDom.mjs';
|
||||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -20,33 +21,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', '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 = 'minimal';
|
|
||||||
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 +60,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,73 +74,26 @@ test('Cyber restored accordion prefers keyboard focus when no explicit owner exi
|
|||||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const doc = makeDocument();
|
||||||
|
/** A rail panel (or any rail-side box) with its measured box and no content. */
|
||||||
function element(
|
function element(
|
||||||
id,
|
id,
|
||||||
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
|
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
|
||||||
) {
|
) {
|
||||||
const classes = new Set(collapsed ? ['collapsed'] : []);
|
const node = doc.createElement('div');
|
||||||
const properties = new Map();
|
node.id = id;
|
||||||
const attributes = new Map();
|
node.dataset.panelId = id;
|
||||||
const writes = [];
|
if (collapsed) node.classList.add('collapsed');
|
||||||
const node = {
|
node.scrollHeight = node.clientHeight = height;
|
||||||
id,
|
node.computed.rowGap = '12px';
|
||||||
children: [],
|
Object.assign(node.rect, {
|
||||||
dataset: {},
|
|
||||||
parentElement: null,
|
|
||||||
scrollHeight: height,
|
|
||||||
clientHeight: height,
|
|
||||||
scrollTop: 0,
|
|
||||||
computed: {
|
|
||||||
display: 'block',
|
|
||||||
visibility: 'visible',
|
|
||||||
opacity: '1',
|
|
||||||
rowGap: '12px',
|
|
||||||
},
|
|
||||||
rect: {
|
|
||||||
top,
|
top,
|
||||||
left,
|
left,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
right: left + width,
|
right: left + width,
|
||||||
bottom: top + height,
|
bottom: top + height,
|
||||||
},
|
});
|
||||||
classList: {
|
|
||||||
contains: (name) => classes.has(name),
|
|
||||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
|
||||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
|
||||||
toggle(name, value) {
|
|
||||||
if (value) classes.add(name);
|
|
||||||
else classes.delete(name);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
setProperty(name, value) {
|
|
||||||
writes.push(['set', name, value]);
|
|
||||||
properties.set(name, value);
|
|
||||||
},
|
|
||||||
getPropertyValue: (name) => properties.get(name) || '',
|
|
||||||
removeProperty(name) {
|
|
||||||
writes.push(['remove', name]);
|
|
||||||
properties.delete(name);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getBoundingClientRect() {
|
|
||||||
return this.rect;
|
|
||||||
},
|
|
||||||
matches: (selector) => selector === '[data-panel-id]',
|
|
||||||
contains(target) {
|
|
||||||
return (
|
|
||||||
target === this || this.children.some((child) => child.contains(target))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
querySelectorAll() {
|
|
||||||
return this.children;
|
|
||||||
},
|
|
||||||
setAttribute: (name, value) => attributes.set(name, value),
|
|
||||||
removeAttribute: (name) => attributes.delete(name),
|
|
||||||
getAttribute: (name) => attributes.get(name),
|
|
||||||
writes,
|
|
||||||
};
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
function fixture(
|
function fixture(
|
||||||
@@ -248,10 +199,12 @@ for (const side of ['left', 'right']) {
|
|||||||
f.first.style.getPropertyValue(`--${side}-panel-allocated-height`),
|
f.first.style.getPropertyValue(`--${side}-panel-allocated-height`),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
assert.equal(f.first.getAttribute('aria-hidden'), null);
|
||||||
});
|
});
|
||||||
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 +213,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 +245,20 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right layout lets a stored or shared choice outrank a panel open only by default', () => {
|
||||||
|
// Display opens by default on a first desktop visit; a visitor who had
|
||||||
|
// opened CCTV keeps CCTV as the owner, whatever the rail order.
|
||||||
|
const f = fixture('right');
|
||||||
|
f.expand(f.first, 900);
|
||||||
|
f.expand(f.second, 900);
|
||||||
|
f.first.dataset.collapsedPreference = 'default';
|
||||||
|
f.second.dataset.collapsedPreference = 'stored';
|
||||||
|
f.run();
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,6 +329,35 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
|
|||||||
assert.equal(f.stack.dataset.safeTop, '200.0');
|
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
|
// 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.
|
// re-runs it when the transition lands: the right rail must read the end value.
|
||||||
function animatingLeftRail({ keyframes = true } = {}) {
|
function animatingLeftRail({ keyframes = true } = {}) {
|
||||||
@@ -438,8 +448,7 @@ function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
|
|||||||
f.options.preferredPanelId = cctv.id;
|
f.options.preferredPanelId = cctv.id;
|
||||||
f.options.displayPanel = display;
|
f.options.displayPanel = display;
|
||||||
const allocation = '--right-panel-allocated-height';
|
const allocation = '--right-panel-allocated-height';
|
||||||
const measuring = () =>
|
const measuring = () => f.stack.getAttribute('data-rail-measuring') !== null;
|
||||||
f.stack.getAttribute('data-rail-measuring') !== undefined;
|
|
||||||
for (const panel of f.stack.children) {
|
for (const panel of f.stack.children) {
|
||||||
panel.parentElement = f.stack;
|
panel.parentElement = f.stack;
|
||||||
panel.intrinsicHeight = panel.rect.height;
|
panel.intrinsicHeight = panel.rect.height;
|
||||||
@@ -488,10 +497,11 @@ for (const focused of [false, true]) {
|
|||||||
modes.push(f.stack.classList.contains('layout-focus'));
|
modes.push(f.stack.classList.contains('layout-focus'));
|
||||||
needs.push(f.stack.dataset.requiredHeight);
|
needs.push(f.stack.dataset.requiredHeight);
|
||||||
assert.equal(f.stack.dataset.availableHeight, '443.6');
|
assert.equal(f.stack.dataset.availableHeight, '443.6');
|
||||||
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
assert.equal(f.stack.getAttribute('data-rail-measuring'), null);
|
||||||
}
|
}
|
||||||
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 +510,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 +528,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', () => {
|
||||||
@@ -560,7 +574,7 @@ test('right rail restores presentation even when intrinsic measurement throws',
|
|||||||
throw new Error('measurement failed');
|
throw new Error('measurement failed');
|
||||||
};
|
};
|
||||||
assert.throws(() => f.run(), /measurement failed/);
|
assert.throws(() => f.run(), /measurement failed/);
|
||||||
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
assert.equal(f.stack.getAttribute('data-rail-measuring'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right rail measuring pass keeps an opted-in scroller position', () => {
|
test('right rail measuring pass keeps an opted-in scroller position', () => {
|
||||||
@@ -595,14 +609,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 ──────────────────────────────────────────
|
||||||
@@ -690,7 +709,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';
|
||||||
@@ -699,10 +718,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'), null);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||||||
'',
|
'',
|
||||||
@@ -721,17 +742,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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -755,26 +777,25 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', (
|
|||||||
imagery.style.getPropertyValue('--right-panel-allocated-height'),
|
imagery.style.getPropertyValue('--right-panel-allocated-height'),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
|
assert.equal(imagery.getAttribute('aria-hidden'), null);
|
||||||
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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -105,6 +105,29 @@ export class RadioControls {
|
|||||||
top: next,
|
top: next,
|
||||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||||
});
|
});
|
||||||
|
// A rail layout pass that measures the panels mid-scroll puts the scroll
|
||||||
|
// offset back where it was, which cancels the smooth scroll: land it when
|
||||||
|
// the scroll ends short, unless the user took the scroll over meanwhile.
|
||||||
|
if (!reducedMotion) {
|
||||||
|
const landing = new AbortController();
|
||||||
|
const signal = AbortSignal.any([
|
||||||
|
this.listeners.signal,
|
||||||
|
landing.signal,
|
||||||
|
AbortSignal.timeout(2000),
|
||||||
|
]);
|
||||||
|
const cancel = () => landing.abort();
|
||||||
|
for (const type of ['keydown', 'wheel', 'pointerdown', 'touchstart'])
|
||||||
|
scroller.addEventListener(type, cancel, { signal, passive: true });
|
||||||
|
scroller.addEventListener(
|
||||||
|
'scrollend',
|
||||||
|
() => {
|
||||||
|
cancel();
|
||||||
|
if (Math.abs(scroller.scrollTop - next) >= 1)
|
||||||
|
scroller.scrollTop = next;
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,3 +171,59 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
|
|||||||
f.cleanup();
|
f.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a user scroll during the Radio reveal is not undone by the scrollend landing', async () => {
|
||||||
|
const prior = {
|
||||||
|
document: globalThis.document,
|
||||||
|
window: globalThis.window,
|
||||||
|
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||||
|
};
|
||||||
|
const rect = (top, bottom) => ({ top, bottom });
|
||||||
|
const scroller = Object.assign(new EventTarget(), {
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 1200,
|
||||||
|
clientHeight: 300,
|
||||||
|
getBoundingClientRect: () => rect(100, 400),
|
||||||
|
scrollTo() {},
|
||||||
|
});
|
||||||
|
const below = (top) => ({
|
||||||
|
getBoundingClientRect: () =>
|
||||||
|
rect(top - scroller.scrollTop, top + 30 - scroller.scrollTop),
|
||||||
|
});
|
||||||
|
const parts = {
|
||||||
|
'.radio-directory-row': below(500),
|
||||||
|
'.radio-transport': below(700),
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
classList: { contains: () => false },
|
||||||
|
querySelector: (sel) =>
|
||||||
|
sel === '.global-context-panel-inner' ? scroller : null,
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
getElementById: (id) => (id === 'global-context-panel' ? context : null),
|
||||||
|
};
|
||||||
|
globalThis.window = { matchMedia: () => ({ matches: false }) };
|
||||||
|
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||||
|
const controls = Object.assign(Object.create(RadioControls.prototype), {
|
||||||
|
destroyed: false,
|
||||||
|
listeners: new AbortController(),
|
||||||
|
_radioPanel: { querySelector: (sel) => parts[sel] ?? null },
|
||||||
|
_radioState: { enabled: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
await controls._revealRadioControlsAfterExplicitEnable({
|
||||||
|
isConnected: true,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
scroller.dispatchEvent(new Event('keydown'));
|
||||||
|
scroller.scrollTop = 0;
|
||||||
|
scroller.dispatchEvent(new Event('scrollend'));
|
||||||
|
assert.equal(scroller.scrollTop, 0);
|
||||||
|
} finally {
|
||||||
|
controls.listeners.abort();
|
||||||
|
Object.assign(globalThis, prior);
|
||||||
|
}
|
||||||
|
});
|
||||||
+45
-99
@@ -1,15 +1,8 @@
|
|||||||
import {
|
import { allocatePanelStackHeights } from '../panelStackLayout.js';
|
||||||
allocatePanelStackHeights,
|
import { resolveHudRailLayout } from './panelRailGeometry.js';
|
||||||
panelStackAutoCollapseIndices,
|
|
||||||
} from '../panelStackLayout.js';
|
|
||||||
import {
|
|
||||||
resolveHudRailLayout,
|
|
||||||
shouldHideCollapsedRightPanels,
|
|
||||||
} from './panelRailGeometry.js';
|
|
||||||
|
|
||||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||||
|
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.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:
|
||||||
@@ -36,15 +29,17 @@ 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. 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 {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.
|
||||||
* @param {Window} options.windowRef Viewport and style reader.
|
* @param {Window} options.windowRef Viewport and style reader.
|
||||||
* @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.
|
||||||
@@ -55,10 +50,8 @@ export function layoutRightPanelRail({
|
|||||||
stack,
|
stack,
|
||||||
obstacles,
|
obstacles,
|
||||||
windowRef,
|
windowRef,
|
||||||
hud,
|
|
||||||
preferredPanelId,
|
preferredPanelId,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onRetry,
|
|
||||||
leftStack,
|
leftStack,
|
||||||
displayPanel,
|
displayPanel,
|
||||||
readDisplayScrollTop,
|
readDisplayScrollTop,
|
||||||
@@ -66,8 +59,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,51 +72,40 @@ 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 focused
|
||||||
for (const panel of panels.filter((item) =>
|
// panel, else one the visitor or a share link chose over one open only by
|
||||||
item.classList.contains('layout-auto-collapsed'),
|
// default (panelPositionControls.js marks each), else the first. Peers are
|
||||||
)) {
|
// presentation-collapsed; the marker is distinct from the left rail's
|
||||||
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
// space-based collapse. Phone sheets show several panels at once, so phones
|
||||||
onCollapse(panel);
|
// get every panel back.
|
||||||
}
|
const isMobile = windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY).matches;
|
||||||
}
|
if (!isMobile) {
|
||||||
// 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;
|
|
||||||
if (hud.variant === 'cyber' && !isMobile) {
|
|
||||||
const expanded = panels.filter(
|
const expanded = panels.filter(
|
||||||
(panel) => !panel.classList.contains('collapsed'),
|
(panel) => !panel.classList.contains('collapsed'),
|
||||||
);
|
);
|
||||||
const owner =
|
const owner =
|
||||||
expanded.find((panel) => panel.id === preferredPanelId) ||
|
expanded.find((panel) => panel.id === preferredPanelId) ||
|
||||||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
|
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
|
||||||
|
expanded.find(
|
||||||
|
(panel) => panel.dataset?.collapsedPreference !== 'default',
|
||||||
|
) ||
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasExpandedPanel = panels.some(
|
// The tab strip is the launcher, so collapsed panels hide. Phones present
|
||||||
(panel) =>
|
// the rail as tab sheets (mobileShell.js), which choose what shows; hiding
|
||||||
!panel.classList.contains('collapsed') &&
|
// collapsed siblings would empty a sheet and its a11y tree.
|
||||||
(!isMobile || panel.id !== 'pp-toggles'),
|
const exclusive = !isMobile;
|
||||||
);
|
|
||||||
// Phones present the rail as tab sheets (mobileShell.js), which choose what
|
|
||||||
// shows; hiding collapsed siblings would empty a sheet and its a11y tree.
|
|
||||||
const exclusive =
|
|
||||||
!isMobile &&
|
|
||||||
shouldHideCollapsedRightPanels({
|
|
||||||
hudVariant: hud.variant,
|
|
||||||
hasExpandedPanel,
|
|
||||||
});
|
|
||||||
stack.classList.toggle('layout-exclusive', exclusive);
|
stack.classList.toggle('layout-exclusive', exclusive);
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
if (exclusive && panel.classList.contains('collapsed'))
|
if (exclusive && panel.classList.contains('collapsed'))
|
||||||
@@ -181,14 +161,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'),
|
||||||
);
|
);
|
||||||
@@ -201,12 +175,15 @@ export function layoutRightPanelRail({
|
|||||||
.map((node) => [node, node.scrollTop || 0])
|
.map((node) => [node, node.scrollTop || 0])
|
||||||
.filter(([, top]) => top > 0);
|
.filter(([, top]) => top > 0);
|
||||||
const naturalHeights = new Map();
|
const naturalHeights = new Map();
|
||||||
|
// The tab strip's column stands beside the panels at its natural height.
|
||||||
|
const column = stack.querySelector?.(':scope > #right-rail-column');
|
||||||
|
let columnHeight = 0;
|
||||||
// Remove live height allocations through a synchronous CSS override. Keeping
|
// Remove live height allocations through a synchronous CSS override. Keeping
|
||||||
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
|
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
|
||||||
// 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(
|
||||||
@@ -216,6 +193,7 @@ export function layoutRightPanelRail({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
columnHeight = column?.getBoundingClientRect().height || 0;
|
||||||
} finally {
|
} finally {
|
||||||
stack.removeAttribute('data-rail-measuring');
|
stack.removeAttribute('data-rail-measuring');
|
||||||
for (const [node, top] of scrollers)
|
for (const [node, top] of scrollers)
|
||||||
@@ -223,23 +201,30 @@ 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 resolve = (baseTop) =>
|
||||||
|
resolveHudRailLayout({
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
panelHeight: naturalHeight,
|
panelHeight: naturalHeight,
|
||||||
laneLeft: stackRect.left,
|
laneLeft: stackRect.left,
|
||||||
laneRight: stackRect.right,
|
laneRight: stackRect.right,
|
||||||
obstacles: obstacleRects,
|
obstacles: obstacleRects,
|
||||||
baseTop: alignedTop,
|
baseTop,
|
||||||
baseBottom: viewportHeight * 0.96,
|
baseBottom: viewportHeight * 0.96,
|
||||||
gap: safeGap,
|
gap: safeGap,
|
||||||
align: 'start',
|
align: 'start',
|
||||||
});
|
});
|
||||||
|
let layout = resolve(alignedTop);
|
||||||
if (!layout) return;
|
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 leaves the strip running past the lane's end.
|
||||||
|
const shortfall = columnHeight - (layout.safeBottom - layout.safeTop);
|
||||||
|
if (shortfall > 0) layout = resolve(alignedTop - shortfall) || layout;
|
||||||
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
|
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
|
||||||
const stabilityBand = Math.max(48, viewportHeight * 0.06);
|
const stabilityBand = Math.max(48, viewportHeight * 0.06);
|
||||||
const wasFocused = stack.classList.contains('layout-focus');
|
const wasFocused = stack.classList.contains('layout-focus');
|
||||||
@@ -254,26 +239,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 +253,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,443 @@
|
|||||||
|
/**
|
||||||
|
* Vertical tab strip for the right context rail: one icon 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).
|
||||||
|
*
|
||||||
|
* The open tab and its panel are drawn as one surface by a join element the
|
||||||
|
* strip shapes from both (railJoinPath); styles/right-rail-tabs.css has the
|
||||||
|
* layers.
|
||||||
|
*/
|
||||||
|
import { msg, t } from '../i18n/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the rail while its strip is mounted; styles/right-rail-tabs.css keys
|
||||||
|
* the tab geometry on it, so a rail without a strip keeps its plain layout.
|
||||||
|
*/
|
||||||
|
const RAIL_TABS_ATTRIBUTE = 'data-rail-tabs';
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each rail panel's tab glyph (Material Symbols). index.html subsets the icon
|
||||||
|
* font, so its `icon_names` must carry every one.
|
||||||
|
*/
|
||||||
|
export const RAIL_TAB_ICONS = Object.freeze({
|
||||||
|
'pp-toggles': 'tune',
|
||||||
|
'cctv-panel': 'videocam',
|
||||||
|
'street-level-panel': 'streetview',
|
||||||
|
'global-context-panel': 'travel_explore',
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG path of the panel with the open tab attached to its right edge, traced
|
||||||
|
* clockwise from the top-left corner, in rail pixels.
|
||||||
|
*
|
||||||
|
* Round: the panel's corners and the tab's outer corners are convex arcs and
|
||||||
|
* the two inner corners where the tab's top and bottom edges meet the panel's
|
||||||
|
* right edge are concave fillets. A tab level with the panel's top shares its
|
||||||
|
* top edge, so that corner and the upper fillet are dropped. Chamfer (Cyber):
|
||||||
|
* the frame's 10px chamfers and 6px calibration bay (cyber.css,
|
||||||
|
* --cyber-panel-frame) and a square tab. `inset` moves the whole outline
|
||||||
|
* inward; 0.5 centres a one-pixel stroke just inside the edge.
|
||||||
|
*
|
||||||
|
* @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 {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.
|
||||||
|
* @param {'round'|'chamfer'} [g.style]
|
||||||
|
* @param {number} [g.inset]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function railJoinPath({
|
||||||
|
panel,
|
||||||
|
tab,
|
||||||
|
corner = 16,
|
||||||
|
tabRadius = 10,
|
||||||
|
fillet = 8,
|
||||||
|
style = 'round',
|
||||||
|
inset = 0,
|
||||||
|
}) {
|
||||||
|
const n = (value) => Math.round(value * 100) / 100;
|
||||||
|
const P = (x, y) => `${n(x)} ${n(y)}`;
|
||||||
|
const x0 = panel.x0 + inset;
|
||||||
|
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;
|
||||||
|
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 = [
|
||||||
|
`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)}`,
|
||||||
|
`L ${P(cx - 25, y0)}`,
|
||||||
|
`L ${P(cx - 18, y0 + 6)}`,
|
||||||
|
`L ${P(cx + 18, y0 + 6)}`,
|
||||||
|
`L ${P(cx + 25, y0)}`,
|
||||||
|
`L ${P(x1 - c, y0)}`,
|
||||||
|
`L ${P(x1, y0 + c)}`,
|
||||||
|
...edge,
|
||||||
|
`L ${P(x1, y1 - c)}`,
|
||||||
|
`L ${P(x1 - c, y1)}`,
|
||||||
|
`L ${P(x0 + c, y1)}`,
|
||||||
|
`L ${P(x0, y1 - c)}`,
|
||||||
|
'Z',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
const R = Math.max(0, corner - inset);
|
||||||
|
const r = Math.max(0, tabRadius - inset);
|
||||||
|
const f = fillet + inset;
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab's name: 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]
|
||||||
|
* @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
|
||||||
|
*/
|
||||||
|
export function createRightRailTabs({
|
||||||
|
rail,
|
||||||
|
setPanelCollapsed,
|
||||||
|
documentRef = rail.ownerDocument,
|
||||||
|
}) {
|
||||||
|
const column = documentRef.createElement('div');
|
||||||
|
column.id = 'right-rail-column';
|
||||||
|
const strip = documentRef.createElement('div');
|
||||||
|
strip.id = 'right-rail-tabs';
|
||||||
|
strip.setAttribute('role', 'tablist');
|
||||||
|
strip.setAttribute('aria-orientation', 'vertical');
|
||||||
|
strip.setAttribute('aria-label', t('Panels'));
|
||||||
|
column.append(strip);
|
||||||
|
rail.setAttribute(RAIL_TABS_ATTRIBUTE, 'vertical');
|
||||||
|
|
||||||
|
// The join: shadow under, glass, outline over; all shaped by one path.
|
||||||
|
const svg = (tag) => documentRef.createElementNS(SVG_NS, tag);
|
||||||
|
const join = documentRef.createElement('div');
|
||||||
|
join.className = 'right-rail-join';
|
||||||
|
join.setAttribute('aria-hidden', 'true');
|
||||||
|
join.hidden = true;
|
||||||
|
const shadow = svg('svg');
|
||||||
|
shadow.setAttribute('class', 'right-rail-join-shadow');
|
||||||
|
const shadowPath = svg('path');
|
||||||
|
shadow.append(shadowPath);
|
||||||
|
const glass = documentRef.createElement('div');
|
||||||
|
glass.setAttribute('class', 'right-rail-join-glass');
|
||||||
|
const outline = svg('svg');
|
||||||
|
outline.setAttribute('class', 'right-rail-join-outline');
|
||||||
|
const outlinePath = svg('path');
|
||||||
|
outline.append(outlinePath);
|
||||||
|
join.append(shadow, glass, outline);
|
||||||
|
|
||||||
|
/** @type {Map<string, {tab: HTMLElement, icon: 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 = documentRef.createElement('span');
|
||||||
|
icon.className = 'right-rail-tab-icon material-symbols-outlined';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
icon.textContent = RAIL_TAB_ICONS[panel.id] || 'more_horiz';
|
||||||
|
tab.append(icon);
|
||||||
|
entry = { tab, icon };
|
||||||
|
entries.set(panel.id, entry);
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Observer =
|
||||||
|
documentRef.defaultView?.MutationObserver ?? globalThis.MutationObserver;
|
||||||
|
const observer =
|
||||||
|
typeof Observer === 'function' ? new Observer(() => sync()) : null;
|
||||||
|
// Panel heights follow their content and allocation: re-measure the join
|
||||||
|
// when the strip or a panel changes size.
|
||||||
|
const Resize = documentRef.defaultView?.ResizeObserver;
|
||||||
|
const resizer =
|
||||||
|
typeof Resize === 'function' ? new Resize(() => sync()) : null;
|
||||||
|
resizer?.observe(strip);
|
||||||
|
const observe = (panel) => {
|
||||||
|
if (observed.has(panel)) return;
|
||||||
|
observed.add(panel);
|
||||||
|
resizer?.observe(panel);
|
||||||
|
observer?.observe(panel, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'hidden'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A join token from the stylesheet (--right-rail-*), in pixels. */
|
||||||
|
const token = (computed, name, fallback) => {
|
||||||
|
const value = parseFloat(computed?.getPropertyValue?.(name));
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish the join: the open tab's bottom on the rail (--rail-tab-bottom,
|
||||||
|
* 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, but never past the
|
||||||
|
* rail's lane (--right-stack-max-height).
|
||||||
|
*/
|
||||||
|
function placeJoin(tab, panel) {
|
||||||
|
if (!tab) {
|
||||||
|
rail.style.removeProperty('--rail-tab-bottom');
|
||||||
|
join.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const top = tab.offsetTop;
|
||||||
|
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';
|
||||||
|
// 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);
|
||||||
|
const tabTop = stripTop + top;
|
||||||
|
const tabBottom = tabTop + tab.offsetHeight;
|
||||||
|
const geometry = {
|
||||||
|
panel: {
|
||||||
|
x0: panel.offsetLeft,
|
||||||
|
y0: panel.offsetTop,
|
||||||
|
x1: panel.offsetLeft + panel.offsetWidth,
|
||||||
|
y1: Math.max(
|
||||||
|
panel.offsetTop + panel.offsetHeight,
|
||||||
|
Math.min(lane, tabBottom + fillet + 4 + corner),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
top: tabTop,
|
||||||
|
bottom: tabBottom,
|
||||||
|
right: stripLeft + tab.offsetLeft + tab.offsetWidth,
|
||||||
|
},
|
||||||
|
corner,
|
||||||
|
tabRadius,
|
||||||
|
fillet,
|
||||||
|
style: chamfer ? 'chamfer' : 'round',
|
||||||
|
};
|
||||||
|
const shape = railJoinPath(geometry);
|
||||||
|
const width = rail.clientWidth;
|
||||||
|
const height = geometry.panel.y1;
|
||||||
|
join.style.height = `${height}px`;
|
||||||
|
glass.style.clipPath = `path('${shape}')`;
|
||||||
|
shadowPath.setAttribute('d', shape);
|
||||||
|
// Everything but the silhouette: the shadow it casts stays, the fill goes.
|
||||||
|
shadow.style.clipPath = `path(evenodd, 'M -80 -80 H ${width + 80} V ${height + 80} H -80 Z ${shape}')`;
|
||||||
|
outlinePath.setAttribute('d', railJoinPath({ ...geometry, inset: 0.5 }));
|
||||||
|
join.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile tabs with the rail: order, visibility, selection, names. */
|
||||||
|
function sync() {
|
||||||
|
if (destroyed) return;
|
||||||
|
// The column precedes its panels in the tab order, so Tab from the open
|
||||||
|
// tab reaches 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 = [...rail.children].filter(
|
||||||
|
(panel) => panel.matches?.('[data-panel-id]') && !panel.hidden,
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
let open = null;
|
||||||
|
for (const panel of panels) {
|
||||||
|
const { tab } = entryFor(panel);
|
||||||
|
if (tab !== cursor) strip.insertBefore(tab, cursor);
|
||||||
|
else cursor = cursor.nextElementSibling;
|
||||||
|
const collapsed = panel.classList.contains('collapsed');
|
||||||
|
if (!collapsed && !open) open = { tab, panel };
|
||||||
|
const name = panelTitle(panel);
|
||||||
|
tab.setAttribute('aria-label', name);
|
||||||
|
tab.setAttribute('aria-selected', String(!collapsed));
|
||||||
|
tab.tabIndex = panel === focusOwner ? 0 : -1;
|
||||||
|
tab.title = t(collapsed ? msg('Expand {name}') : msg('Collapse {name}'), {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
placeJoin(open?.tab, open?.panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(column);
|
||||||
|
rail.append(join);
|
||||||
|
sync();
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: strip,
|
||||||
|
sync,
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
observer?.disconnect();
|
||||||
|
resizer?.disconnect();
|
||||||
|
strip.removeEventListener('click', onClick);
|
||||||
|
strip.removeEventListener('keydown', onKeydown);
|
||||||
|
column.remove();
|
||||||
|
join.remove();
|
||||||
|
rail.removeAttribute(RAIL_TABS_ATTRIBUTE);
|
||||||
|
placeJoin(null, null);
|
||||||
|
entries.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fire, makeDocument } from '../testSupport/fakeDom.mjs';
|
||||||
|
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
RAIL_TAB_ICONS,
|
||||||
|
createRightRailTabs,
|
||||||
|
nextRailTabIndex,
|
||||||
|
railJoinPath,
|
||||||
|
} from './rightRailTabs.js';
|
||||||
|
|
||||||
|
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 column = doc.getElementById('right-rail-column');
|
||||||
|
const join = rail.querySelector('.right-rail-join');
|
||||||
|
const tabFor = (id) =>
|
||||||
|
strip.children.find((tab) => tab.dataset.railTab === id);
|
||||||
|
const iconOf = (tab) =>
|
||||||
|
tab.children.find((child) =>
|
||||||
|
child.classList.contains('right-rail-tab-icon'),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
rail,
|
||||||
|
display,
|
||||||
|
cctv,
|
||||||
|
weather,
|
||||||
|
context,
|
||||||
|
tabs,
|
||||||
|
strip,
|
||||||
|
column,
|
||||||
|
join,
|
||||||
|
calls,
|
||||||
|
tabFor,
|
||||||
|
iconOf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('builds one icon tab per visible rail panel, in rail order, named from the panel header', () => {
|
||||||
|
const f = fixture();
|
||||||
|
assert.equal(
|
||||||
|
f.rail.children[0],
|
||||||
|
f.column,
|
||||||
|
'the column precedes its panels in the tab order',
|
||||||
|
);
|
||||||
|
assert.deepEqual(f.column.children, [f.strip]);
|
||||||
|
assert.equal(f.strip.getAttribute('role'), 'tablist');
|
||||||
|
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
|
||||||
|
assert.equal(f.rail.getAttribute('data-rail-tabs'), 'vertical');
|
||||||
|
assert.deepEqual(
|
||||||
|
f.strip.children.map((tab) => [
|
||||||
|
tab.dataset.railTab,
|
||||||
|
f.iconOf(tab).textContent,
|
||||||
|
tab.getAttribute('aria-label'),
|
||||||
|
tab.getAttribute('aria-controls'),
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
['pp-toggles', 'tune', 'DISPLAY', 'pp-toggles'],
|
||||||
|
['cctv-panel', 'videocam', 'CCTV', 'cctv-panel'],
|
||||||
|
[
|
||||||
|
'global-context-panel',
|
||||||
|
'travel_explore',
|
||||||
|
'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.equal(f.iconOf(tab).getAttribute('aria-hidden'), 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the icon font subset carries every rail tab glyph', () => {
|
||||||
|
const html = readFileSync(
|
||||||
|
new URL('../../index.html', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const subset = /icon_names=([a-z0-9_,]+)/.exec(html)[1].split(',');
|
||||||
|
for (const glyph of Object.values(RAIL_TAB_ICONS))
|
||||||
|
assert.ok(subset.includes(glyph), glyph);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('publishes the open tab extent on the rail and keeps a join element under the panels', () => {
|
||||||
|
const f = fixture();
|
||||||
|
// The strip's offsets are relative to its column, the column's to the rail.
|
||||||
|
Object.assign(f.column, { offsetTop: 0, offsetLeft: 320 });
|
||||||
|
Object.assign(f.strip, { offsetLeft: 10 });
|
||||||
|
const { props } = f.rail.style;
|
||||||
|
assert.equal(f.join.parentElement, f.rail);
|
||||||
|
assert.equal(f.join.getAttribute('aria-hidden'), 'true');
|
||||||
|
assert.equal(f.join.hidden, true, 'nothing open, nothing to join');
|
||||||
|
assert.deepEqual(
|
||||||
|
f.join.children.map((layer) => layer.getAttribute('class')),
|
||||||
|
[
|
||||||
|
'right-rail-join-shadow',
|
||||||
|
'right-rail-join-glass',
|
||||||
|
'right-rail-join-outline',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const tab = f.tabFor('cctv-panel');
|
||||||
|
Object.assign(tab, {
|
||||||
|
offsetTop: 86,
|
||||||
|
offsetLeft: 0,
|
||||||
|
offsetHeight: 56,
|
||||||
|
offsetWidth: 42,
|
||||||
|
});
|
||||||
|
Object.assign(f.cctv, {
|
||||||
|
offsetTop: 0,
|
||||||
|
offsetLeft: 0,
|
||||||
|
offsetHeight: 300,
|
||||||
|
offsetWidth: 330,
|
||||||
|
});
|
||||||
|
f.cctv.classList.remove('collapsed');
|
||||||
|
f.tabs.sync();
|
||||||
|
assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]);
|
||||||
|
assert.equal(f.join.hidden, false);
|
||||||
|
const [, glass] = f.join.children;
|
||||||
|
assert.match(
|
||||||
|
glass.style.clipPath,
|
||||||
|
/^path\('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 78 /,
|
||||||
|
);
|
||||||
|
f.cctv.classList.add('collapsed');
|
||||||
|
f.tabs.sync();
|
||||||
|
assert.deepEqual([...props], [], 'no open panel, no join');
|
||||||
|
assert.equal(f.join.hidden, true);
|
||||||
|
f.tabs.destroy();
|
||||||
|
assert.equal(f.join.parentElement, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('railJoinPath traces the panel with the tab attached: corners, fillets, tab corners', () => {
|
||||||
|
const geometry = {
|
||||||
|
panel: { x0: 0, y0: 0, x1: 330, y1: 378 },
|
||||||
|
tab: { top: 85, bottom: 141, right: 372 },
|
||||||
|
corner: 16,
|
||||||
|
tabRadius: 10,
|
||||||
|
fillet: 8,
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
railJoinPath(geometry),
|
||||||
|
'M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 77 A 8 8 0 0 0 338 85 ' +
|
||||||
|
'L 362 85 A 10 10 0 0 1 372 95 L 372 131 A 10 10 0 0 1 362 141 ' +
|
||||||
|
'L 338 141 A 8 8 0 0 0 330 149 L 330 362 A 16 16 0 0 1 314 378 ' +
|
||||||
|
'L 16 378 A 16 16 0 0 1 0 362 L 0 16 A 16 16 0 0 1 16 0 Z',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
railJoinPath({ ...geometry, tab: { top: 0, bottom: 79, right: 372 } }),
|
||||||
|
'M 16 0 L 362 0 A 10 10 0 0 1 372 10 L 372 69 A 10 10 0 0 1 362 79 ' +
|
||||||
|
'L 338 79 A 8 8 0 0 0 330 87 L 330 362 A 16 16 0 0 1 314 378 ' +
|
||||||
|
'L 16 378 A 16 16 0 0 1 0 362 L 0 16 A 16 16 0 0 1 16 0 Z',
|
||||||
|
'a tab level with the top shares the top edge: no corner, no upper fillet',
|
||||||
|
);
|
||||||
|
const stroke = railJoinPath({ ...geometry, inset: 0.5 });
|
||||||
|
assert.match(stroke, /^M 16 0\.5 L 314 0\.5 A 15\.5 15\.5 0 0 1 329\.5 16 /);
|
||||||
|
assert.match(
|
||||||
|
stroke,
|
||||||
|
/ L 329\.5 77 A 8\.5 8\.5 0 0 0 338 85\.5 /,
|
||||||
|
'an inset grows the concave fillets and shrinks the convex corners',
|
||||||
|
);
|
||||||
|
const chamfer = railJoinPath({
|
||||||
|
...geometry,
|
||||||
|
panel: { x0: 0, y0: 0, x1: 312, y1: 374 },
|
||||||
|
corner: 10,
|
||||||
|
style: 'chamfer',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
chamfer,
|
||||||
|
'M 0 10 L 10 0 L 131 0 L 138 6 L 174 6 L 181 0 L 302 0 L 312 10 ' +
|
||||||
|
'L 312 85 L 372 85 L 372 141 L 312 141 L 312 364 L 302 374 ' +
|
||||||
|
'L 10 374 L 0 364 Z',
|
||||||
|
'Cyber: chamfers, the calibration bay and a square tab',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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('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 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, keeps icon tabs square and whole without scrolling', () => {
|
||||||
|
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\)\);/,
|
||||||
|
);
|
||||||
|
// The tabs keep their size: a square icon, never shrunk or rotated.
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/\.right-rail-tab \{[^}]*flex: 0 0 auto;[^}]*width: var\(--right-rail-tabs-width\);\s*height: var\(--right-rail-tabs-width\);/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
css,
|
||||||
|
/\.right-rail-tab[^{]*\{[^}]*(writing-mode|mask-image)/,
|
||||||
|
);
|
||||||
|
// The column keeps its natural height: every tab stays whole, nothing
|
||||||
|
// scrolls or clips; only the strip takes the pointer.
|
||||||
|
assert.doesNotMatch(
|
||||||
|
css,
|
||||||
|
/#right-rail-(column|tabs) \{[^}]*(max-height|overflow|scrollbar-width)/,
|
||||||
|
);
|
||||||
|
assert.match(css, /#right-rail-column \{[^}]*pointer-events: none;/);
|
||||||
|
assert.match(css, /#right-rail-tabs \{[^}]*pointer-events: auto;/);
|
||||||
|
assert.match(css, /--right-rail-panel-cover: min\(/);
|
||||||
|
// The column hides on phones.
|
||||||
|
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,
|
||||||
|
/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.column.parentElement, null);
|
||||||
|
assert.equal(f.rail.children.includes(f.column), false);
|
||||||
|
assert.equal(f.rail.getAttribute('data-rail-tabs'), null);
|
||||||
|
fire(tab, 'click');
|
||||||
|
assert.deepEqual(f.calls, []);
|
||||||
|
f.tabs.destroy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/* ── Right rail as vertical tabs ───────────────────────────────────────────
|
||||||
|
A strip of icon tabs (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).
|
||||||
|
|
||||||
|
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
|
||||||
|
attached (rounded corners and concave fillets; Cyber's chamfered frame).
|
||||||
|
The glass layer blurs and tints the backdrop inside the path, one SVG
|
||||||
|
strokes the outline and another casts the shadow, so the junction has no
|
||||||
|
seam and no second blur region. The open panel and the tab draw no surface
|
||||||
|
of their own; the other tabs are plain icons. `data-rail-tabs` arrives on
|
||||||
|
the rail with the strip, so a rail without it keeps its plain geometry; the
|
||||||
|
selectors carry it to outrank the Cyber rail rules that follow this sheet. */
|
||||||
|
|
||||||
|
#right-context-rail[data-rail-tabs] {
|
||||||
|
--right-rail-tabs-width: 34px;
|
||||||
|
/* The bridge the open tab reaches across to its panel. */
|
||||||
|
--right-rail-tabs-gap: 8px;
|
||||||
|
--right-rail-tabs-inset: calc(
|
||||||
|
var(--right-rail-tabs-width) + var(--right-rail-tabs-gap)
|
||||||
|
);
|
||||||
|
/* Join geometry, read by rightRailTabs.js: panel corner, the tab's outer
|
||||||
|
corners, the concave fillets, and round corners or Cyber's chamfers. */
|
||||||
|
--right-rail-corner: var(--panel-radius);
|
||||||
|
--right-rail-tab-radius: 10px;
|
||||||
|
--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, 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. */
|
||||||
|
box-sizing: content-box;
|
||||||
|
padding-right: var(--right-rail-tabs-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 keeps its natural height, so every tab stays whole even
|
||||||
|
past a short lane; only the strip takes the pointer. */
|
||||||
|
#right-rail-column {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: var(--right-rail-tabs-inset);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#right-rail-tabs {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Room for the open tab's bridge on the left. */
|
||||||
|
padding-left: var(--right-rail-tabs-gap);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A square icon the strip's width; the panel's name is the tab's accessible
|
||||||
|
name and tooltip. */
|
||||||
|
.right-rail-tab {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: var(--right-rail-tabs-width);
|
||||||
|
height: var(--right-rail-tabs-width);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--right-rail-tab-radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-tab-icon {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-tab:not([aria-selected='true']):hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The open tab is the icon and hit area over the join's surface; it spans
|
||||||
|
the bridge too, with the icon kept in the strip's column. */
|
||||||
|
.right-rail-tab[aria-selected='true'] {
|
||||||
|
width: calc(var(--right-rail-tabs-width) + var(--right-rail-tabs-gap));
|
||||||
|
margin-left: calc(-1 * var(--right-rail-tabs-gap));
|
||||||
|
padding-left: var(--right-rail-tabs-gap);
|
||||||
|
border-radius: 0 var(--right-rail-tab-radius) var(--right-rail-tab-radius) 0;
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The join: surface, outline and shadow of panel plus tab ──────────── */
|
||||||
|
.right-rail-join {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-join[hidden],
|
||||||
|
body.mobile-shell #right-context-rail > .right-rail-join {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-join > * {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A black silhouette casts the shadow; its clip keeps only the outside. */
|
||||||
|
.right-rail-join-shadow {
|
||||||
|
filter: drop-shadow(var(--right-rail-join-shadow));
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-join-shadow path {
|
||||||
|
fill: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-join-glass {
|
||||||
|
background-color: var(--glass-bg);
|
||||||
|
background-image: var(--panel-surface-detail);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-join-outline path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--glass-border);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The open panel's card leaves surface, outline and shadow to the join.
|
||||||
|
Cyber's Display and Context frames and the surface contract's card live on
|
||||||
|
a ::before. */
|
||||||
|
body:not(.mobile-shell)
|
||||||
|
#right-context-rail[data-rail-tabs]
|
||||||
|
> #pp-toggles:not(.collapsed),
|
||||||
|
body:not(.mobile-shell)
|
||||||
|
#right-context-rail[data-rail-tabs]
|
||||||
|
> #pp-toggles:not(.collapsed)::before,
|
||||||
|
body:not(.mobile-shell)
|
||||||
|
#right-context-rail[data-rail-tabs]
|
||||||
|
> [data-panel-id]:not(.collapsed)
|
||||||
|
> :is([data-rail-card], [data-panel-surface]),
|
||||||
|
body:not(.mobile-shell)
|
||||||
|
#right-context-rail[data-rail-tabs]
|
||||||
|
> [data-panel-id]:not(.collapsed)
|
||||||
|
> :is(.global-context-panel-inner, [data-panel-surface])::before {
|
||||||
|
border-color: transparent;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The glow's 0.25 is tuned to sit under the card's surface; without that
|
||||||
|
surface, 0.08 reads the same. */
|
||||||
|
body:not(.mobile-shell)
|
||||||
|
#right-context-rail[data-rail-tabs]
|
||||||
|
> [data-panel-id]:not(.collapsed)
|
||||||
|
> .panel-glow {
|
||||||
|
opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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)
|
||||||
|
> :is([data-rail-card], [data-panel-surface]) {
|
||||||
|
min-height: var(--right-rail-panel-cover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cyber skin ────────────────────────────────────────────────────────── */
|
||||||
|
/* Cyber panels are chamfered frames (--cyber-panel-frame), not rounded
|
||||||
|
cards: square joins, no fillets, no blur, and the strip starts below the
|
||||||
|
frame's upper chamfer so the first tab meets a straight edge. The frame's
|
||||||
|
red edge bar moves onto the join's glass. */
|
||||||
|
:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
|
||||||
|
--right-rail-tab-radius: 0px;
|
||||||
|
--right-rail-fillet: 0px;
|
||||||
|
--right-rail-corner: 10px;
|
||||||
|
--right-rail-join-style: chamfer;
|
||||||
|
--right-rail-join-shadow: 0 10px 28px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme='cyber'] #right-rail-tabs {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cyber's generic hover/selected button treatment (cyber.css) would paint
|
||||||
|
the tabs; the open tab stays an icon on the shared surface. */
|
||||||
|
:root[data-ui-theme='cyber'] .right-rail-tab:not([aria-selected='true']):hover {
|
||||||
|
background-color: rgba(226, 235, 236, 0.08);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'],
|
||||||
|
:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true']:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: -3px 0 0 var(--cyber-red) inset;
|
||||||
|
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']
|
||||||
|
body:not(.mobile-shell)
|
||||||
|
#right-context-rail[data-rail-tabs]
|
||||||
|
> [data-panel-id]:not(.collapsed) {
|
||||||
|
border-color: transparent;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme='cyber'] .right-rail-join-glass {
|
||||||
|
box-shadow: 3px 0 0 rgba(196, 84, 84, 0.2) inset;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The launcher is back in Context's header there (radioPanelPlacement.js). */
|
||||||
|
body.mobile-shell #right-context-rail > #right-rail-column {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<aside id="right-context-rail">
|
<aside id="right-context-rail">
|
||||||
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
|
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
|
||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="weather-panel-inner" data-rail-scroller>
|
<div class="weather-panel-inner" data-rail-scroller data-rail-card>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>WEATHER</span>
|
<span class="panel-title" data-i18n>WEATHER</span>
|
||||||
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
|
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
|
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
|
||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="recent-imagery-panel-inner" data-rail-scroller>
|
<div class="recent-imagery-panel-inner" data-rail-scroller data-rail-card>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
|
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
|
||||||
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
|
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="global-context-panel-inner" data-rail-scroller>
|
<div class="global-context-panel-inner" data-rail-scroller data-rail-card>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>CONTEXT</span>
|
<span class="panel-title" data-i18n>CONTEXT</span>
|
||||||
<span class="panel-divider"></span>
|
<span class="panel-divider"></span>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<!-- CCTV Coverage Panel -->
|
<!-- CCTV Coverage Panel -->
|
||||||
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
|
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
|
||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="cctv-panel-inner" data-rail-scroller>
|
<div class="cctv-panel-inner" data-rail-scroller data-rail-card>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>CCTV</span>
|
<span class="panel-title" data-i18n>CCTV</span>
|
||||||
<span class="panel-divider"></span>
|
<span class="panel-divider"></span>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
|
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
|
||||||
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
|
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
|
||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="street-level-panel-inner" data-rail-scroller>
|
<div class="street-level-panel-inner" data-rail-scroller data-rail-card>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>STREET LEVEL</span>
|
<span class="panel-title" data-i18n>STREET LEVEL</span>
|
||||||
<span class="panel-divider"></span>
|
<span class="panel-divider"></span>
|
||||||
|
|||||||
@@ -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