Compare commits

...
Author SHA1 Message Date
German Khokhlov 26fa413c27 Merge branch 'german/right-rail-vertical-tabs' of github.com:bilawalsidhu/gods-eye-view into worktree-right-rail-vertical-tabs 2026-10-30 21:03:44 +04:00
German Khokhlov 9e2d11d8d3 Merge origin/main into the right rail tabs
Main removed the compact Radio player and its Context header launcher
(66e19f0c) and the Operator HUD layout (8b2c6993). The right rail follows:

- The tool slot under the tabs (#right-rail-tools) and its launcher styles
  are gone; the strip's column holds only the tablist.
- Main's Radio placement, bindings and markup tests are taken as they are;
  the Details button reveal and the launcher state sync go with the player.
- The Radio reveal after Enable keeps the branch's scrollend landing, folded
  back into main's single method.
- A radio pick still reopens a closed Context in every theme (the early
  owner reveal in panelChrome), so main's non-Cyber block is dropped.
- The one-owner rail tests run over Tactical, Minimal and Cyber.
- CURRENT-STATE and the changelog fragment no longer mention the compact
  launcher or the slot.
2026-10-30 21:03:18 +04:00
German Khokhlov 073f79c22a Merge branch 'main' into german/right-rail-vertical-tabs 2026-10-29 17:01:48 +04:00
German KhokhlovandClaude Opus 5.5 3fdfe43a11 i18n: drop the two Radio launcher strings from zh-Hant
The Traditional Chinese catalog came in with main after this branch removed
the "Go to expanded Radio section" and "Expand Radio section in Context"
labels, so the merge left them stale (npm run i18n -- --prune).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:39:54 +04:00
German Khokhlov 6599a893ed Merge remote-tracking branch 'origin/main' into worktree-right-rail-vertical-tabs 2026-10-29 14:37:22 +04:00
German KhokhlovandClaude Opus 5.5 8396fd3dc7 feat(ui): upright icon tabs on the right rail, never shrunk or clipped
The rail tabs show one Material Symbols glyph each (Display tune, CCTV
videocam, Street Level streetview, Context travel_explore, Weather cloud,
Recent Imagery satellite_alt) in a fixed 34px square, the size of the Radio
launcher under them. The panel's translated title is the tab's aria-label and
tooltip. The sideways labels, the equal share of a short strip, the end fade
and the column's lane cap are gone: every tab stays whole, and on the shortest
windows the strip runs past the lane instead of clipping. The glyphs join the
icon font subset, with a test that keeps them there. The screenshot is
refreshed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:00:54 +04:00
German KhokhlovandClaude Opus 5.5 33ccdad744 fix(ui): right rail review follow-ups for Radio reveals and CCTV restores
- A Radio pick reopens a closed Context around an expanded Radio section in
  every HUD layout, not only Cyber.
- The Radio reveal's scrollend landing gives way to a user scroll, key or
  pointer, and stops after two seconds.
- The compact player's Details button scrolls the Radio section and its
  Enable into view inside Context.
- A camera layer switched on by a share link or a saved session no longer
  opens the CCTV panel over the restored tab; a user switch-on still does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:00:44 +04:00
German KhokhlovandClaude Fable 5.1 8c788474db refactor(ui): trim the right rail tabs surface, share one DOM fake and one phone breakpoint
rightRailTabs.js exports only createRightRailTabs, nextRailTabIndex and railJoinPath and returns element, sync and destroy; the id constants, railTabPanels, the argument guard and the createElementNS fallback existed for the tests alone. The sub-720px density tier and the slot's focus ring go; the five rail cards carry data-rail-card so the stylesheet lists them once.

src/ui/mobileShellContract.js holds the phone shell's media query and class, dependency-free so the rail layout and panel packages can read them; rightPanelRail.js, panelPositionControls.js and radioPanelPlacement.js use it instead of their own literals. src/testSupport/fakeDom.mjs replaces the two hand-made DOM fakes of the rail and strip tests; the rail tests now expect null for a missing attribute, as in the DOM. The two Radio gate checks of the removed several-panels rail policy are gone.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 15:17:25 +04:00
German KhokhlovandClaude Fable 5.1 e05d6a29b4 docs: tighten the right rail follow-up comments and drop a one-use helper
Comments that still spoke of the strip scrolling or of a lifted max-height say what the code does now; the tab and column comments are shorter; the changelog bullet is wrapped. panelDisclosure.js is back to its reviewed shape: railTabFor had one caller left after the header click handoff was reverted.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 12:53:17 +04:00
German KhokhlovandClaude Fable 5.1 5cc63fefb0 refactor(ui): share a short right rail strip in CSS alone
The fitTabs pass and its lane observer go: the column stands the lane's full height, which makes the strip's size definite, and the tabs take flex 1 1 0% with max-height max-content, so flexbox hands the strip's height out in equal parts, a tab's own length caps its part, and what a short label leaves goes to the longer ones. The basis is 0%, not 0, so the strip's content size, which sizes the column, still comes from the labels. Only the strip and the tool slot take the pointer; the measuring pass sets the column back to height auto so the rail's climb sees the natural column. Same heights as the JS pass at every viewport checked.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 12:35:16 +04:00
German KhokhlovandClaude Fable 5.1 48602e4400 fix(ui): a short right rail strip is shared between the tabs at one height
Proportional shrinking clipped every label by the same fraction, so CCTV lost half its letters while STREET LEVEL kept most of them. The strip now divides the lane less the tool slot equally (fitTabs in rightRailTabs.js): a tab shorter than its share keeps its height and the rest is shared again, so the long labels end up the same height and clip alike; tabs that fit keep their natural height. A style observer on the rail re-fits on a new lane, and the measuring pass lifts the shared heights so the rail's climb still sees the natural column.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 12:14:53 +04:00
German KhokhlovandClaude Fable 5.1 f4285b8fb2 docs: refresh the right rail tabs screenshot after the review follow-ups
Tactical with CCTV open, Tactical with Context open, Cyber with Street Level open, headless at 1440x900: no header collapse arrows, the compact Radio launcher under the tabs, no strip scrollbar.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 11:28:35 +04:00
German KhokhlovandClaude Fable 5.1 84c204b7c4 fix(ui): right rail strip never scrolls: tabs shrink in proportion and clip their labels
A lane too short for the tab strip, even after the rail has climbed toward the HUD corner, shrank the strip into a scrollbar. The tabs now give way in proportion to their labels (flex 0 1 auto, min-height 0) and clip them at the end with a short fade; the join keeps corners no larger than half of a shrunken tab. The strip's scroll handling in placeJoin, the scroll listener, railJoinPath's tab-less path and the scroller marker are gone with the scrolling.

The expanded Radio Enable's reveal of the directory and Play lands its smooth scroll on scrollend when a rail layout pass, measuring the panels mid-scroll, puts the Context scroller's offset back and cancels it; the Radio browser gate stopped there since the strip-fit commit.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 11:23:57 +04:00
German KhokhlovandClaude Fable 5.1 bff1d0eb29 fix(ui): right rail tabs: review fixes, launcher in the strip, strip never scrolls on common windows
Review fixes P1.1-P2.7: a panel hidden at mount gets its tab when revealed; any live opening of a rail panel makes it the owner, restores do not; a short lane scrolls the strip with a thin scrollbar and brings a newly opened tab into view once; the join and the panel cover stay inside the lane and the fillet shrinks to the room left; a share link carries an owner-collapsed peer as collapsed so the recipient opens the sender's panel; border-box on phones; the strip's column folds into the panel width at 721-760px; the focus ring draws inside the tab.

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

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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 11:02:15 +04:00
German KhokhlovandClaude Fable 5.1 ff017c99ca refactor(ui): drop the dead right rail policy and unused tab join hooks
shouldHideCollapsedRightPanels had become a constant: collapsed right-rail panels always hide behind the tab strip on desktop, so the function, its re-exports, the hud option of layoutRightPanelRail and the test of the constant go. The rail takes the theme's width as its content box instead of restating it per theme. Remove the unread --rail-tab-top property, dead CSS (an unset clip-path variable, an invisible Cyber highlight, redundant radius rules), an unused strip class, per-tab resize observation and guards that only served the test double, which now carries a style object.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 18:55:28 +04:00
German KhokhlovandClaude Fable 5.1 cf37e80b7f docs: tighten the right rail tabs comments, docs and changelog
Drop change-history wording, shorten the module, stylesheet and rail comments, and document the owner rule's stored-over-default step in CURRENT-STATE. No runtime change.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 18:37:15 +04:00
German KhokhlovandClaude Fable 5.1 f383174f3e feat(ui): open Display on a first desktop visit to the tabbed rail
With every panel closed nothing shows that the sideways labels are launchers, so Display starts expanded for a first-time visitor on a desktop viewport; phones keep it collapsed and a share link decides for itself. A stored choice still wins. When the rail picks its one 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.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 18:26:46 +04:00
German KhokhlovandClaude Fable 5.1 575ff3678f fix(ui): Cyber right rail wrapper draws no surface beside the open tab
Cyber dresses every .panel-collapsible wrapper as a surface with an inset ring, so a panel other than Display showed that ring as a line along its edge beside the open tab and a slightly different tone from the tab. Under the tabs the join is the only surface.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 18:10:28 +04:00
German KhokhlovandClaude Fable 5.1 0a52e94671 feat(ui): draw the open right rail tab and its panel as one surface
The strip traces the joined silhouette of the open panel and its tab as an SVG path (rounded corners, concave fillets; Cyber's chamfered frame and a square tab) and keeps a join element under the panels: one layer blurs and tints the backdrop inside the path, an SVG strokes the outline along it and another casts the drop shadow. The panel card and the tab draw no surface of their own, so the junction has no seam, doubled edge or second blur region. Cyber's generic selected-button treatment is kept off the tab.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 17:47:34 +04:00
German KhokhlovandClaude Fable 5.1 65c1ae67f2 feat(ui): join the open right rail tab to its panel
The open tab and its panel share one glass surface and one outline, like a folder tab: the panel card draws no border of its own, the rail's ::before draws the outline with a notch at the tab, the tab closes the shape with its own borders and two concave fillets round the inner corners. The strip publishes the open tab's extent and the panel's height on the rail; a short panel grows to reach its tab. Cyber joins square below its chamfer. Unselected tabs are plain labels.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 17:26:59 +04:00
German KhokhlovandClaude Fable 5.1 f2e23249eb docs: screenshot of the right rail tabs for #1425
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 12:12:23 +04:00
German KhokhlovandClaude Fable 5.1 e0a2a5d68e docs: changelog fragment for the right rail tabs (#1425)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 11:11:00 +04:00
German KhokhlovandClaude Fable 5.1 3fb2105fb1 feat(ui): keep one tab presentation and document the right rail tabs
Drop the four alternative strip presentations (cards-up, spine, icons,
minimal) and the icon glyphs they needed; the strip marks the rail with
data-rail-tabs so a rail without it keeps its legacy geometry. Describe
the tab strip and the desktop one-owner rule in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 11:08:56 +04:00
German KhokhlovandClaude Fable 5.1 71bd645d59 feat(ui): right rail as vertical tabs with sideways labels
Replace the right rail's collapsed launcher cards with a vertical tab
strip: one sideways tab per panel, the open panel beside it, at most one
panel open in every HUD layout. The panels' collapsed classes stay the
source of truth; the strip mirrors them and routes every change through
setPanelCollapsed, so persistence and share links are unchanged. Phones
keep the sheet shell.

Five presentations, picked with data-rail-tabs on the rail: cards-down
(default), cards-up, spine, icons and minimal.

The one-owner rule, previously Cyber-only, now applies to every desktop
layout, so the right rail's Tactical auto-collapse retry path is dead and
removed. Escape inside a panel returns focus to the panel's tab.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 10:37:44 +04:00
41 changed files with 1994 additions and 402 deletions

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
View File
@@ -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
View File
@@ -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 -->
+4 -1
View File
@@ -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"
] ]
+8
View File
@@ -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",
-22
View File
@@ -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),
+6
View File
@@ -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', () => {
+4 -2
View File
@@ -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'\)/);
+7 -6
View File
@@ -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.
+14
View File
@@ -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);
+9 -5
View File
@@ -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,
-2
View File
@@ -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',
+4 -39
View File
@@ -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 \{/);
}); });
+1 -1
View File
@@ -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,
+154
View File
@@ -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;
}
+9
View File
@@ -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,
}, },
}); });
+9
View File
@@ -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);
+20
View File
@@ -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 }]]);
});
+5 -1
View File
@@ -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);
+34 -11
View File
@@ -41,6 +41,21 @@ function fixture(theme = 'cyber') {
}; };
} }
test('any live opening of a rail panel makes it the preferred owner; restores do not', () => {
const f = fixture('tactical'), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('cctv-panel', false, {});
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'cctv-panel', 'a camera click opens CCTV without the explicit flag');
assert.equal(f.display.classList.contains('collapsed'), true, 'peers are left to the rail pass, not persisted');
f.owner.setPanelCollapsed('radio-panel', false, {});
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a radio pick owns the rail through Context');
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
f.owner.setPanelCollapsed('cctv-panel', false, { restore: true, persist: false });
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a restore never claims ownership');
} finally { globalThis.document = prior; }
});
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => { 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; }
} }
}); });
+1 -1
View File
@@ -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,
+6 -3
View File
@@ -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')
); );
}; };
+7
View File
@@ -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
View File
@@ -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);
});
}
+6 -1
View File
@@ -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;
} }
-2
View File
@@ -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,
+10 -9
View File
@@ -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.
-16
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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,
); );
}); });
+23
View File
@@ -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;
} }
+56
View File
@@ -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
View File
@@ -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.
+443
View File
@@ -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();
},
};
}
+491
View File
@@ -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();
});
+288
View File
@@ -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;
}
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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>
+1
View File
@@ -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';