From 3fb2105fb1239425cf313dcc7b0510edbfd2b792 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 11:08:56 +0400 Subject: [PATCH] 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 --- docs/CURRENT-STATE.md | 51 ++++++-- index.html | 2 +- src/ui/rightRailTabs.js | 38 ++---- src/ui/rightRailTabs.test.mjs | 5 +- src/ui/styles/right-rail-tabs.css | 190 +----------------------------- 5 files changed, 54 insertions(+), 232 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..56bb6004e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/index.html b/index.html index 369ad1074..26cf734fb 100644 --- a/index.html +++ b/index.html @@ -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. --> - + diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js index fae03bd10..2d38ba899 100644 --- a/src/ui/rightRailTabs.js +++ b/src/ui/rightRailTabs.js @@ -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} */ 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(); }, }; diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs index 23998fc18..eff78d5de 100644 --- a/src/ui/rightRailTabs.test.mjs +++ b/src/ui/rightRailTabs.test.mjs @@ -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(); diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css index 76f6bec30..2c1da6fae 100644 --- a/src/ui/styles/right-rail-tabs.css +++ b/src/ui/styles/right-rail-tabs.css @@ -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;