diff --git a/changelog.d/1425-right-rail-vertical-tabs.md b/changelog.d/1425-right-rail-vertical-tabs.md index a1231690a..3cefa9f1e 100644 --- a/changelog.d/1425-right-rail-vertical-tabs.md +++ b/changelog.d/1425-right-rail-vertical-tabs.md @@ -1,14 +1,13 @@ - The right rail (Display, CCTV, Street Level, Context, plus Weather and Recent - Imagery while shown) is a vertical tab strip with sideways labels instead of - an accordion of collapsed launcher cards: at most one panel is open in every - desktop HUD layout, and the open tab and its panel share one surface and - outline, like a folder tab. On a short window the rail rises toward the HUD - corner above it, and the tabs then share the room equally 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. The compact Radio - launcher moves from Context's header to the bottom of the strip on desktop - and always opens the compact player, also while Context is open; the + 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. The compact + Radio launcher moves from Context's header to the bottom of the strip on + desktop and always opens the compact player, also while Context is open; the player's Details button leads to the Radio section. Saved preferences, share links and the phone sheet shell are unchanged (gekh, #1425). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61e7ff232..ae6d13b52 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1498,8 +1498,8 @@ rule can still give them back. 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 sideways -tab per panel at the rail's right edge, the open panel beside it. A tab +(`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 @@ -1509,11 +1509,12 @@ 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 tool slot and then the panel. -Labels are the panels' own translated header titles. 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 a lane still shorter is shared equally -between the tabs, so the long labels end up one height and clip; the strip -never scrolls. Under the tabs the strip's +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. Under the tabs the strip's column keeps a tool slot (`#right-rail-tools`) that holds the compact Radio launcher on desktop: `src/ui/radioPanelPlacement.js` moves it there from Context's header, and back for the phone sheet, so the compact player stays one @@ -1524,7 +1525,7 @@ 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 labels. The strip measures the open panel and tab, traces their +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 @@ -1533,7 +1534,7 @@ 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 labels are launchers. Phones keep it closed, a share link decides for +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. diff --git a/docs/media/right-rail-tabs.png b/docs/media/right-rail-tabs.png index 5d49a9c26..299516e9d 100644 Binary files a/docs/media/right-rail-tabs.png and b/docs/media/right-rail-tabs.png differ diff --git a/index.html b/index.html index 26cf734fb..b318cd7e9 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/rightPanelRail.js b/src/ui/rightPanelRail.js index c6bbf2a77..e004fb9cc 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -175,8 +175,7 @@ export function layoutRightPanelRail({ .map((node) => [node, node.scrollTop || 0]) .filter(([, top]) => top > 0); const naturalHeights = new Map(); - // The tab strip's column stands beside the panels; the measuring override - // gives it its natural height. + // 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 @@ -223,7 +222,7 @@ export function layoutRightPanelRail({ 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 shrinks the tabs (styles/right-rail-tabs.css). + // 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; diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js index da9a39bef..a6d4507b7 100644 --- a/src/ui/rightRailTabs.js +++ b/src/ui/rightRailTabs.js @@ -1,5 +1,5 @@ /** - * Vertical tab strip for the right context rail: one sideways tab per rail + * 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 @@ -21,6 +21,19 @@ 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. @@ -177,7 +190,7 @@ export function railJoinPath({ return d.join(' '); } -/** The tab label: the panel's own header title, already translated. */ +/** The tab's name: the panel's own header title, already translated. */ function panelTitle(panel) { return ( panel @@ -230,7 +243,7 @@ export function createRightRailTabs({ outline.append(outlinePath); join.append(shadow, glass, outline); - /** @type {Map} */ + /** @type {Map} */ const entries = new Map(); const observed = new WeakSet(); let destroyed = false; @@ -244,10 +257,12 @@ export function createRightRailTabs({ tab.dataset.railTab = panel.id; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', panel.id); - const label = documentRef.createElement('span'); - label.className = 'right-rail-tab-label'; - tab.append(label); - entry = { tab, label }; + 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; }; @@ -256,9 +271,8 @@ export function createRightRailTabs({ documentRef.defaultView?.MutationObserver ?? globalThis.MutationObserver; const observer = typeof Observer === 'function' ? new Observer(() => sync()) : null; - // Tab heights follow their labels (fonts, locale) and panel heights their - // content and allocation: re-measure the join when the strip or a panel - // changes size. + // 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; @@ -325,8 +339,7 @@ export function createRightRailTabs({ right: stripLeft + tab.offsetLeft + tab.offsetWidth, }, corner, - // A tab shrunk by a short strip keeps corners no larger than half of it. - tabRadius: Math.min(tabRadius, (tabBottom - tabTop) / 2), + tabRadius, fillet, style: chamfer ? 'chamfer' : 'round', }; @@ -342,7 +355,7 @@ export function createRightRailTabs({ join.hidden = false; } - /** Reconcile tabs with the rail: order, visibility, selection, labels. */ + /** 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 @@ -365,13 +378,13 @@ export function createRightRailTabs({ let cursor = strip.firstElementChild; let open = null; for (const panel of panels) { - const { tab, label } = entryFor(panel); + 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); - if (label.textContent !== name) label.textContent = name; + 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}'), { diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs index ddaf2944a..4b72c8bc6 100644 --- a/src/ui/rightRailTabs.test.mjs +++ b/src/ui/rightRailTabs.test.mjs @@ -2,7 +2,9 @@ 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, @@ -57,10 +59,10 @@ function fixture() { const join = rail.querySelector('.right-rail-join'); const tabFor = (id) => strip.children.find((tab) => tab.dataset.railTab === id); - const labelOf = (tab) => + const iconOf = (tab) => tab.children.find((child) => - child.classList.contains('right-rail-tab-label'), - ).textContent; + child.classList.contains('right-rail-tab-icon'), + ); return { doc, rail, @@ -75,11 +77,11 @@ function fixture() { join, calls, tabFor, - labelOf, + iconOf, }; } -test('builds one tab per visible rail panel, in rail order, labelled from the panel header', () => { +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], @@ -98,22 +100,39 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa assert.deepEqual( f.strip.children.map((tab) => [ tab.dataset.railTab, - f.labelOf(tab), + f.iconOf(tab).textContent, + tab.getAttribute('aria-label'), tab.getAttribute('aria-controls'), ]), [ - ['pp-toggles', 'DISPLAY', 'pp-toggles'], - ['cctv-panel', 'CCTV', 'cctv-panel'], - ['global-context-panel', 'CONTEXT', 'global-context-panel'], + ['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( @@ -255,15 +274,6 @@ test('publishes the open tab extent on the rail and keeps a join element under t glass.style.clipPath, /^path\('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 78 /, ); - // A short strip shrinks the tab: its corners stay within half its height. - tab.offsetHeight = 12; - f.tabs.sync(); - assert.equal(props.get('--rail-tab-bottom'), '98px'); - assert.match( - glass.style.clipPath, - /L 366 86 A 6 6 0 0 1 372 92 A 6 6 0 0 1 366 98 L 338 98 /, - 'a 12px tab gets 6px corners', - ); f.cctv.classList.add('collapsed'); f.tabs.sync(); assert.deepEqual([...props], [], 'no open panel, no join'); @@ -431,7 +441,7 @@ test('railJoinPath shrinks the fillet and corner to the room left, down to a sha ); }); -test('the stylesheet keeps phones on border-box, folds the strip into narrow desktops, shrinks tabs instead of scrolling and draws the focus ring inside', () => { +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, @@ -441,38 +451,24 @@ test('the stylesheet keeps phones on border-box, folds the strip into narrow des css, /@media \(min-width: 721px\) and \(max-width: 760px\) \{\s*#right-context-rail\[data-rail-tabs\] \{\s*width: calc\(330px - var\(--right-rail-tabs-inset\)\);/, ); - assert.match(css, /#right-rail-tabs \{[^}]*overflow: hidden;/); + // 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-tabs \{[^}]*(overflow: [^;]*auto|scrollbar-width)/, + /\.right-rail-tab[^{]*\{[^}]*(writing-mode|mask-image)/, ); - // The tabs give way to a short strip and clip their labels at the end. - assert.match( + // The column keeps its natural height: every tab stays whole, nothing + // scrolls or clips; only the strip and the slot take the pointer. + assert.doesNotMatch( css, - /\.right-rail-tab \{[^}]*flex: 1 1 0%;[^}]*align-items: flex-start;[^}]*min-height: 0;[^}]*max-height: max-content;[^}]*overflow: hidden;[^}]*mask-image: linear-gradient\(to bottom, #000 calc\(100% - 6px\), transparent\);/, - ); - // The column stands the lane's full height so the strip's size is definite - // and a zero basis shares it; only the strip and the slot take the pointer. - assert.match( - css, - /#right-rail-column \{[^}]*height: var\(--right-stack-max-height, 70vh\);[^}]*pointer-events: none;/, + /#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-tools \{[^}]*pointer-events: auto;/); - assert.doesNotMatch( - css, - /#right-rail-column \{[^}]*max-height: var\(--right-stack-max-height/, - ); - // The rail layout pass measures the column's natural height to climb above - // its baseline before the strip has to scroll. - assert.match( - css, - /#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*height: auto !important;/, - ); - assert.match( - css, - /#right-rail-tabs > \.right-rail-tab:focus-visible \{\s*outline-offset: -3px;/, - ); assert.match(css, /--right-rail-panel-cover: min\(/); // The column hides on phones, where the Radio launcher is back in Context's // header; in the slot the launcher's popover opens upward and to the left. diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css index 22d772e91..b4809e96e 100644 --- a/src/ui/styles/right-rail-tabs.css +++ b/src/ui/styles/right-rail-tabs.css @@ -1,5 +1,5 @@ /* ── Right rail as vertical tabs ─────────────────────────────────────────── - A sideways tab strip (rightRailTabs.js) hugs the rail's right edge and the + 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. The compact Radio launcher, which rides in Context's @@ -12,7 +12,7 @@ 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 labels. `data-rail-tabs` arrives on + 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. */ @@ -58,9 +58,9 @@ } /* The strip's column: the tablist, then a tool slot for the compact Radio - launcher (radioPanelPlacement.js). It stands the lane's full height, which - makes the strip's size definite for the tabs' share (.right-rail-tab); only - the strip and the slot take the pointer. */ + launcher (radioPanelPlacement.js). It keeps its natural height, so every + tab stays whole even past a short lane; only the strip and the slot take + the pointer. */ #right-rail-column { position: absolute; top: 0; @@ -70,28 +70,17 @@ flex-direction: column; box-sizing: border-box; width: var(--right-rail-tabs-inset); - height: var(--right-stack-max-height, 70vh); pointer-events: none; } -/* The layout pass reads the column's natural height, to climb when the lane - is too short for it (rightPanelRail.js). */ -#right-context-rail[data-rail-measuring] > #right-rail-column { - height: auto !important; -} - #right-rail-tabs { position: relative; display: flex; - flex: 0 1 auto; flex-direction: column; gap: 4px; box-sizing: border-box; - min-height: 0; /* Room for the open tab's bridge on the left. */ padding-left: var(--right-rail-tabs-gap); - /* Never a scrollbar: a lane too short shrinks the tabs (.right-rail-tab). */ - overflow: hidden; pointer-events: auto; } @@ -110,7 +99,7 @@ display: none; } -/* In the slot the launcher is one more label in the column: no card, the +/* In the slot the launcher is one more icon in the column: no card, the tab's corner, the accent while the radio plays. Its popover opens to the left over the panel, bottom-aligned so it stays inside the lane. */ #right-rail-tools .context-radio-dock { @@ -163,38 +152,22 @@ body:not(.mobile-shell) display: none; } -/* The tab's mask covers its own box only: the focus ring draws inside it. */ -#right-rail-tabs > .right-rail-tab:focus-visible { - outline-offset: -3px; -} - -/* A short strip is shared out equally: the zero basis hands every tab the - same part, a tab's own length caps it, and a short label's leftover goes - to the longer ones; with room each tab is its own length. The basis is - 0%, not 0, so the strip still sizes to its labels. The label clips at the - end, faded over the bottom padding. */ +/* 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: 1 1 0%; - align-items: flex-start; + flex: 0 0 auto; + align-items: center; justify-content: center; box-sizing: border-box; width: var(--right-rail-tabs-width); - min-height: 0; - max-height: max-content; - padding: 6px 0; - overflow: hidden; - mask-image: linear-gradient(to bottom, #000 calc(100% - 6px), transparent); + height: var(--right-rail-tabs-width); + padding: 0; border: 0; border-radius: var(--right-rail-tab-radius); background: transparent; color: var(--text-dim); - font-family: var(--font-mono); - font-size: 9px; - line-height: 1; - letter-spacing: 1.6px; - text-transform: uppercase; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); cursor: pointer; transition: @@ -202,11 +175,9 @@ body:not(.mobile-shell) color var(--transition-fast); } -.right-rail-tab-label { - display: block; - writing-mode: vertical-rl; - text-orientation: mixed; - white-space: nowrap; +.right-rail-tab-icon { + color: inherit; + font-size: 19px; } .right-rail-tab:not([aria-selected='true']):hover { @@ -214,8 +185,8 @@ body:not(.mobile-shell) color: var(--text-primary); } -/* The open tab is the label and hit area over the join's surface; it spans - the bridge too, with the label kept in the strip's column. */ +/* 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)); @@ -331,7 +302,7 @@ body:not(.mobile-shell) } /* Cyber's generic hover/selected button treatment (cyber.css) would paint - the tabs; the open tab stays a label on the shared surface. */ + 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;