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]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-27 11:08:56 +04:00
1 parent 71bd645d59
commit 3fb2105fb1
5 files changed
+54 -232

No files matched your search

+38 -13
View File
@@ -225,11 +225,13 @@ layouts retain their existing presentation.
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.
Cyber's map-side right rail opens one panel at a time while keeping collapsed
launchers available. Display, CCTV and Context scroll their contents inside fixed
headers and decorative frames. The narrow-screen rail remains scrollable to reach
each panel. Radio retains the shared nested Context player and compact
header disclosure, without relocating playback controls on theme changes.
Cyber skins the map-side right rail's tab strip in its red and slate panel
treatment; the one-panel rule it introduced now belongs to every desktop layout
(see "Right rail tabs" below). Display, CCTV and Context scroll their contents
inside fixed headers and decorative frames. The narrow-screen rail remains
scrollable to reach each panel. Radio retains the shared nested Context player
and compact header disclosure, without relocating playback controls on theme
changes.
Voice help/error popups and Location/Visual Presets pins extend beyond their
Cyber frames without being clipped: the shapes decorate non-interactive
@@ -1472,17 +1474,40 @@ retains observers, frame scheduling, saved/share preferences and Cockpit portals
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.
Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
The left rail's automatic collapse remains presentation only and prioritizes
the latest explicit panel. The right rail keeps one expanded owner (the latest
explicit panel, else the panel holding keyboard focus, else the first) and
presentation-collapses its peers with `rail-owner-collapsed`, which share links
and the phone shell read as "expanded"; it needs no second pass. Mobile layout
still releases desktop allocation styles and gives every owner-collapsed panel
back. Stable Display allocation avoids unnecessary style writes.
Context expansion through the panel owner or adaptive right-rail restoration
closes an open compact Radio disclosure before Context/Space Missions becomes
visible. The mini remains available while Context is collapsed. This fixes the
Radio-overlap path reported in #804 without changing z-index or layout policy.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone.
keeps the collapsed panels its own exclusive presentation hides, so the owner
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 they are shown) is a vertical tab strip
(`src/ui/rightRailTabs.js`, `src/ui/styles/right-rail-tabs.css`): one sideways
tab per panel hugs the rail's right edge, the open panel sits beside it, and
collapsed panels are hidden in every HUD layout, so the strip is the only
launcher. A tab toggles its panel through `setPanelCollapsed` with the explicit
flag, so opening one collapses the others and the saved preference, share link
and Context/Radio ownership rules are unchanged; the panels' `collapsed`
classes remain the only state and the strip mirrors them through a
MutationObserver. The strip is a `role="tablist"` with vertical orientation:
Up/Down wrap, Home/End jump, the open tab (else the first) is the tab stop, and
Escape inside a panel returns focus to that panel's tab. The strip precedes the
panels in the DOM so Tab from the open tab enters the open panel. Labels are
the panels' own translated header titles. The strip marks the rail with
`data-rail-tabs`, which the stylesheet uses to reserve the strip's column; a
rail without the strip keeps its legacy geometry. Phones hide the strip and
keep the sheet shell; Cockpit hides the rail and the strip with it.
## Phone layout
@@ -1519,8 +1544,8 @@ sheet before a panel's own Escape handling, and the dock's hover auto-close does
not apply inside a sheet.
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
right rail neither hides collapsed siblings nor applies Cyber's one-panel
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
right rail neither hides collapsed siblings, shows its tab strip, nor applies
the desktop 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
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.
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,cloud,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<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" />
</head>
<body>
<!-- gev:template scene-chrome -->
+8 -30
View File
@@ -12,25 +12,12 @@ import { msg, t } from '../i18n/index.js';
export const RIGHT_RAIL_TABS_ID = 'right-rail-tabs';
/** Presentation variants, selected through `data-rail-tabs` on the rail. */
export const RIGHT_RAIL_TAB_VARIANTS = Object.freeze([
'cards-down',
'cards-up',
'spine',
'icons',
'minimal',
]);
export const DEFAULT_RIGHT_RAIL_TAB_VARIANT = 'cards-down';
/** Glyphs for the icon variant; every name is in index.html's icon_names. */
const PANEL_ICONS = Object.freeze({
'pp-toggles': 'tune',
'cctv-panel': 'videocam',
'street-level-panel': 'streetview',
'global-context-panel': 'radar',
'weather-panel': 'cloud',
'recent-imagery-panel': 'satellite_alt',
});
/**
* Marks the rail while its strip is mounted; styles/right-rail-tabs.css keys
* the rail's tab geometry on it so a rail without a strip keeps its legacy
* layout.
*/
export const RIGHT_RAIL_TABS_ATTRIBUTE = 'data-rail-tabs';
/**
* The tab keyboard navigation moves to in a vertical tablist: Down and Up
@@ -80,14 +67,12 @@ function panelTitle(panel) {
* @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed
* Owner callback; the strip never touches panel classes itself.
* @param {Document} [input.documentRef]
* @param {string} [input.variant] Initial presentation variant.
* @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
*/
export function createRightRailTabs({
rail,
setPanelCollapsed,
documentRef = rail?.ownerDocument,
variant = DEFAULT_RIGHT_RAIL_TAB_VARIANT,
}) {
if (!rail || !documentRef || typeof setPanelCollapsed !== 'function') {
throw new TypeError(
@@ -100,7 +85,7 @@ export function createRightRailTabs({
strip.setAttribute('role', 'tablist');
strip.setAttribute('aria-orientation', 'vertical');
strip.setAttribute('aria-label', t('Panels'));
if (!rail.dataset.railTabs) rail.dataset.railTabs = variant;
rail.setAttribute(RIGHT_RAIL_TABS_ATTRIBUTE, 'vertical');
/** @type {Map<string, {tab: HTMLElement, label: HTMLElement}>} */
const entries = new Map();
@@ -116,14 +101,6 @@ export function createRightRailTabs({
tab.dataset.railTab = panel.id;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-controls', panel.id);
const icon = PANEL_ICONS[panel.id];
if (icon) {
const glyph = documentRef.createElement('span');
glyph.className = 'material-symbols-outlined right-rail-tab-icon';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = icon;
tab.append(glyph);
}
const label = documentRef.createElement('span');
label.className = 'right-rail-tab-label';
tab.append(label);
@@ -216,6 +193,7 @@ export function createRightRailTabs({
strip.removeEventListener('click', onClick);
strip.removeEventListener('keydown', onKeydown);
strip.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
entries.clear();
},
};
+3 -2
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_RIGHT_RAIL_TAB_VARIANT,
RIGHT_RAIL_TABS_ATTRIBUTE,
RIGHT_RAIL_TABS_ID,
createRightRailTabs,
nextRailTabIndex,
@@ -196,7 +196,7 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa
assert.equal(f.strip.id, RIGHT_RAIL_TABS_ID);
assert.equal(f.strip.getAttribute('role'), 'tablist');
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
assert.equal(f.rail.dataset.railTabs, DEFAULT_RIGHT_RAIL_TAB_VARIANT);
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), 'vertical');
assert.deepEqual(
f.strip.children.map((tab) => [
tab.dataset.railTab,
@@ -328,6 +328,7 @@ test('destroy removes the strip and stops listening', () => {
f.tabs.destroy();
assert.equal(f.strip.parentElement, null);
assert.equal(f.rail.children.includes(f.strip), false);
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), null);
fire(tab, 'click');
assert.deepEqual(f.calls, []);
f.tabs.destroy();
+4 -186
View File
@@ -4,16 +4,10 @@
exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip
is the only launcher. Phones keep the sheet shell (mobile-shell.css).
`data-rail-tabs` on the rail picks a presentation:
cards-down one glass card per tab, labels read top to bottom (default)
cards-up the same cards, labels read bottom to top
spine one continuous bar with an accent edge on the open tab
icons upright icon above a sideways label
minimal bare labels with an accent bar, like the HUD edge text
The attribute arrives with the strip (rightRailTabs.js), so a rail without
it keeps its legacy geometry; the selectors carry it so they outrank the
Cyber rail rules that follow this sheet. */
One glass card per tab, labels read top to bottom like the HUD edge text.
`data-rail-tabs` arrives on the rail with the strip (rightRailTabs.js), so
a rail without it keeps its legacy geometry; the selectors carry it so they
outrank the Cyber rail rules that follow this sheet. */
#right-context-rail[data-rail-tabs] {
--right-rail-panel-width: 330px;
@@ -87,11 +81,6 @@
white-space: nowrap;
}
/* The icon font's own display rule must not reveal the glyph elsewhere. */
.right-rail-tab > .right-rail-tab-icon {
display: none;
}
.right-rail-tab:hover {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
@@ -105,135 +94,6 @@
color: var(--accent);
}
/* ── cards-up: labels read bottom to top ───────────────────────────────── */
#right-context-rail[data-rail-tabs='cards-up'] .right-rail-tab-label {
transform: rotate(180deg);
}
@supports (writing-mode: sideways-lr) {
#right-context-rail[data-rail-tabs='cards-up'] .right-rail-tab-label {
writing-mode: sideways-lr;
transform: none;
}
}
/* ── spine: one bar, accent edge toward the open panel ─────────────────── */
#right-context-rail[data-rail-tabs='spine'] {
--right-rail-tabs-gap: 8px;
}
#right-context-rail[data-rail-tabs='spine'] #right-rail-tabs {
gap: 0;
border: 1px solid var(--glass-border);
border-radius: 10px;
background: var(--glass-bg);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
}
#right-context-rail[data-rail-tabs='spine'] .right-rail-tab {
min-height: 64px;
padding: 16px 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#right-context-rail[data-rail-tabs='spine'] .right-rail-tab + .right-rail-tab {
border-top: 1px solid var(--glass-border);
}
#right-context-rail[data-rail-tabs='spine'] .right-rail-tab:hover {
background: rgba(255, 255, 255, 0.05);
}
#right-context-rail[data-rail-tabs='spine']
.right-rail-tab[aria-selected='true'] {
background: var(--accent-dim);
box-shadow: inset 2px 0 0 var(--accent);
}
/* ── icons: upright glyph over the sideways label ──────────────────────── */
#right-context-rail[data-rail-tabs='icons'] {
--right-rail-tabs-width: 40px;
}
#right-context-rail[data-rail-tabs='icons'] .right-rail-tab {
flex-direction: column;
gap: 6px;
min-height: 64px;
padding: 10px 0 12px;
letter-spacing: 2px;
}
#right-context-rail[data-rail-tabs='icons']
.right-rail-tab
> .right-rail-tab-icon {
display: block;
font-size: 16px;
line-height: 1;
}
/* ── minimal: bare labels with an accent bar, like the HUD edge text ───── */
#right-context-rail[data-rail-tabs='minimal'] {
--right-rail-tabs-width: 26px;
--right-rail-tabs-gap: 14px;
}
#right-context-rail[data-rail-tabs='minimal'] #right-rail-tabs {
gap: 18px;
}
#right-context-rail[data-rail-tabs='minimal'] .right-rail-tab {
min-height: 0;
padding: 4px 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
color: var(--text-secondary);
letter-spacing: 3px;
}
#right-context-rail[data-rail-tabs='minimal'] .right-rail-tab::before {
content: '';
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
width: 2px;
border-radius: 1px;
background: transparent;
transition: background-color var(--transition-fast);
}
#right-context-rail[data-rail-tabs='minimal'] .right-rail-tab:hover {
background: transparent;
color: var(--text-primary);
}
#right-context-rail[data-rail-tabs='minimal']
.right-rail-tab[aria-selected='true'] {
background: transparent;
box-shadow: none;
color: var(--accent);
text-shadow: 0 0 8px var(--accent-glow);
}
#right-context-rail[data-rail-tabs='minimal']
.right-rail-tab[aria-selected='true']::before {
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
/* ── Cyber skin ────────────────────────────────────────────────────────── */
:root[data-ui-theme='cyber'] .right-rail-tab {
border-color: var(--glass-border);
@@ -256,48 +116,6 @@
color: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber']
#right-context-rail[data-rail-tabs='spine']
#right-rail-tabs {
border-radius: var(--btn-radius);
background-color: var(--cyber-panel-surface);
background-image: var(--cyber-panel-surface-detail);
box-shadow: var(--cyber-panel-shadow);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:root[data-ui-theme='cyber']
#right-context-rail[data-rail-tabs='spine']
.right-rail-tab,
:root[data-ui-theme='cyber']
#right-context-rail[data-rail-tabs='minimal']
.right-rail-tab {
background: transparent;
box-shadow: none;
}
:root[data-ui-theme='cyber']
#right-context-rail[data-rail-tabs='spine']
.right-rail-tab[aria-selected='true'] {
background: var(--accent-dim);
box-shadow: inset 3px 0 0 var(--cyber-red);
}
:root[data-ui-theme='cyber']
#right-context-rail[data-rail-tabs='minimal']
.right-rail-tab[aria-selected='true'] {
color: var(--cyber-red-bright);
text-shadow: 0 0 8px var(--accent-glow);
}
:root[data-ui-theme='cyber']
#right-context-rail[data-rail-tabs='minimal']
.right-rail-tab[aria-selected='true']::before {
background: var(--cyber-red);
box-shadow: 0 0 8px var(--accent-glow);
}
/* ── Phones keep the sheet shell ───────────────────────────────────────── */
body.mobile-shell #right-context-rail {
padding-right: 0;