diff --git a/changelog.d/1425-right-rail-vertical-tabs.md b/changelog.d/1425-right-rail-vertical-tabs.md index 68c8a4651..b9ccd2443 100644 --- a/changelog.d/1425-right-rail-vertical-tabs.md +++ b/changelog.d/1425-right-rail-vertical-tabs.md @@ -1,7 +1,8 @@ - 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: the open panel sits beside the - strip and at most one panel is open in every desktop HUD layout. The tabs - are a keyboard-navigable tablist, Escape inside a panel returns focus to its - tab, and saved preferences and share links are unchanged. Phones keep their - sheet shell (gekh, #1425). + strip and at most one panel is open in every desktop HUD layout. The open + tab and its panel share one surface and one outline, like a folder tab; the + other tabs are plain labels. The tabs are a keyboard-navigable tablist, + Escape inside a panel returns focus to its tab, and saved preferences and + share links are unchanged. Phones keep their sheet shell (gekh, #1425). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 56bb6004e..e2ea84d65 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1509,6 +1509,21 @@ the panels' own translated header titles. The strip marks the rail with 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. +The open tab and its panel read as one shape, like a folder tab: they share +the glass surface and one outline, and the other tabs are borderless labels. +The panel card keeps its surface but draws no border; the rail's `::before` +draws the outline around the card with a notch where the tab joins, the open +tab closes the shape with its own top, right and bottom borders, and two +concave fillets round the inner corners (Cyber's chamfered frames join +square, and its strip starts below the frame's upper chamfer). The strip +publishes the join on the rail as inline custom properties, re-measured +through a ResizeObserver and on strip scroll: `--rail-tab-top` and +`--rail-tab-bottom` place the notch and set the open panel's minimum height, +so a short panel such as Context still reaches its tab, and +`--rail-panel-height` sizes the outline, since the rail is taller than its +panel in the focus layout. The first tab starts level with the panel's top +edge, so the outline's top runs straight into it with no corner. + ## Phone layout At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and diff --git a/docs/media/right-rail-tabs.png b/docs/media/right-rail-tabs.png index 44d95b04e..a219e0989 100644 Binary files a/docs/media/right-rail-tabs.png and b/docs/media/right-rail-tabs.png differ diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js index 2d38ba899..fe6857873 100644 --- a/src/ui/rightRailTabs.js +++ b/src/ui/rightRailTabs.js @@ -104,6 +104,7 @@ export function createRightRailTabs({ const label = documentRef.createElement('span'); label.className = 'right-rail-tab-label'; tab.append(label); + resizer?.observe(tab); entry = { tab, label }; entries.set(panel.id, entry); return entry; @@ -113,15 +114,45 @@ 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 the rail's allocation, so the join is re-measured whenever + // the strip, a tab or a panel changes size or the strip scrolls. + const Resize = documentRef.defaultView?.ResizeObserver; + const resizer = + typeof Resize === 'function' ? new Resize(() => sync()) : null; + resizer?.observe(strip); const observe = (panel) => { - if (!observer || observed.has(panel)) return; + if (observed.has(panel)) return; observed.add(panel); - observer.observe(panel, { + resizer?.observe(panel); + observer?.observe(panel, { attributes: true, attributeFilter: ['class', 'hidden'], }); }; + /** + * Publish the join on the rail for styles/right-rail-tabs.css: the open + * tab's extent (--rail-tab-top, --rail-tab-bottom) and the open panel's + * height (--rail-panel-height), in rail pixels. The panel outline is drawn + * at that height with a notch at the tab, and the panel reaches down to it. + * The rail itself can be taller than its panel in the focus layout. + */ + function placeJoin(tab, panel) { + const style = rail.style; + if (typeof style?.setProperty !== 'function') return; + if (!tab || !Number.isFinite(tab.offsetTop)) { + style.removeProperty('--rail-tab-top'); + style.removeProperty('--rail-tab-bottom'); + style.removeProperty('--rail-panel-height'); + return; + } + const top = tab.offsetTop - (strip.scrollTop || 0); + style.setProperty('--rail-tab-top', `${top}px`); + style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}px`); + style.setProperty('--rail-panel-height', `${panel.offsetHeight}px`); + } + /** Reconcile tabs with the rail: order, visibility, selection, labels. */ function sync() { if (destroyed) return; @@ -136,11 +167,13 @@ export function createRightRailTabs({ // 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, label } = 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-selected', String(!collapsed)); @@ -153,9 +186,11 @@ export function createRightRailTabs({ const live = new Set(panels.map((panel) => panel.id)); for (const [id, entry] of entries) { if (live.has(id)) continue; + resizer?.unobserve(entry.tab); entry.tab.remove(); entries.delete(id); } + placeJoin(open?.tab, open?.panel); } const onClick = (event) => { @@ -177,8 +212,10 @@ export function createRightRailTabs({ event.preventDefault(); visible[next].focus(); }; + const onScroll = () => sync(); strip.addEventListener('click', onClick); strip.addEventListener('keydown', onKeydown); + strip.addEventListener('scroll', onScroll, { passive: true }); observer?.observe(rail, { childList: true }); rail.prepend(strip); sync(); @@ -190,10 +227,13 @@ export function createRightRailTabs({ if (destroyed) return; destroyed = true; observer?.disconnect(); + resizer?.disconnect(); strip.removeEventListener('click', onClick); strip.removeEventListener('keydown', onKeydown); + strip.removeEventListener('scroll', onScroll); strip.remove(); rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE); + placeJoin(null, null); entries.clear(); }, }; diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs index eff78d5de..d1504bcd0 100644 --- a/src/ui/rightRailTabs.test.mjs +++ b/src/ui/rightRailTabs.test.mjs @@ -322,6 +322,40 @@ test('a sync that changes nothing keeps the same tab elements in place', () => { assert.equal(f.doc.activeElement, f.tabFor('cctv-panel')); }); +test('publishes the open tab and panel extent on the rail for the joined outline', () => { + const f = fixture(); + const props = new Map(); + f.rail.style = { + setProperty: (key, value) => props.set(key, value), + removeProperty: (key) => props.delete(key), + }; + const tab = f.tabFor('cctv-panel'); + tab.offsetTop = 86; + tab.offsetHeight = 56; + f.cctv.offsetHeight = 300; + f.strip.scrollTop = 0; + f.cctv.classList.remove('collapsed'); + f.tabs.sync(); + assert.deepEqual( + [...props], + [ + ['--rail-tab-top', '86px'], + ['--rail-tab-bottom', '142px'], + ['--rail-panel-height', '300px'], + ], + ); + f.strip.scrollTop = 20; + f.tabs.sync(); + assert.equal( + props.get('--rail-tab-top'), + '66px', + 'strip scroll is subtracted', + ); + f.cctv.classList.add('collapsed'); + f.tabs.sync(); + assert.deepEqual([...props], [], 'no open panel, no join'); +}); + test('destroy removes the strip and stops listening', () => { const f = fixture(); const tab = f.tabFor('cctv-panel'); diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css index 2c1da6fae..0147a8aee 100644 --- a/src/ui/styles/right-rail-tabs.css +++ b/src/ui/styles/right-rail-tabs.css @@ -4,36 +4,62 @@ exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip is the only launcher. Phones keep the sheet shell (mobile-shell.css). - 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. */ + The open tab and its panel read as one shape with one surface and one + outline. The panel card keeps its glass surface but draws no border of its + own; the rail's ::before draws the outline around the card with a notch + where the tab joins, the open tab closes the shape with its own top, right + and bottom borders, and two concave fillets (the tab's ::before/::after) + round the inner corners. The strip publishes the join on the rail + (rightRailTabs.js): --rail-tab-top/--rail-tab-bottom place the notch and + set the panel's minimum height, so a short panel still reaches its tab, + and --rail-panel-height sizes the outline, since the rail itself can be + taller than its panel. The other tabs are borderless labels. `data-rail-tabs` arrives on the + rail with the strip, 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; + --right-rail-display-width: var(--display-panel-expanded-width); --right-rail-tabs-width: 34px; - --right-rail-tabs-gap: 10px; + /* 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) ); + --right-rail-tab-radius: 10px; + --right-rail-fillet: 8px; + --right-rail-corner: var(--panel-radius); + --right-rail-notch-top: max( + 0px, + calc(var(--rail-tab-top, 0px) - var(--right-rail-fillet)) + ); + --right-rail-notch-bottom: min( + 100%, + calc(var(--rail-tab-bottom, 0px) + var(--right-rail-fillet)) + ); + /* Lower fillet, a short straight run, then the panel's corner. */ + --right-rail-panel-cover: calc( + var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px + + var(--right-rail-corner) + ); width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); padding-right: var(--right-rail-tabs-inset); } -:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] { - --right-rail-panel-width: 312px; - width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); -} - #right-rail-tabs { position: absolute; top: 0; right: 0; + z-index: 120; display: flex; flex-direction: column; gap: 6px; - width: var(--right-rail-tabs-width); + box-sizing: border-box; + width: var(--right-rail-tabs-inset); max-height: var(--right-stack-max-height, 70vh); + /* Room for the open tab's bridge on the left and its lower fillet below. */ + padding: 0 0 calc(var(--right-rail-fillet) + 1px) var(--right-rail-tabs-gap); overflow: hidden auto; scrollbar-width: none; pointer-events: auto; @@ -49,28 +75,24 @@ flex: 0 0 auto; align-items: center; justify-content: center; - width: 100%; + box-sizing: border-box; + width: var(--right-rail-tabs-width); min-height: 56px; padding: 12px 0; - border: 1px solid var(--glass-border); - border-radius: 9px; - 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); + border: 1px solid transparent; + 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: 2.2px; text-transform: uppercase; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); cursor: pointer; transition: background-color var(--transition-fast), border-color var(--transition-fast), - box-shadow var(--transition-fast), color var(--transition-fast); } @@ -81,41 +103,229 @@ white-space: nowrap; } -.right-rail-tab:hover { - border-color: var(--glass-border-hover); +.right-rail-tab:not([aria-selected='true']):hover { background: rgba(255, 255, 255, 0.06); color: var(--text-primary); } +/* The open tab: the panel's surface continued across the bridge, closed by + its own top, right and bottom borders. The label stays in the strip's + column. */ .right-rail-tab[aria-selected='true'] { - border-color: var(--accent); - background: var(--accent-dim); - box-shadow: 0 0 12px rgba(0, 212, 255, 0.15); + z-index: 1; + 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-color: var(--glass-border); + border-left: 0; + border-radius: 0 var(--right-rail-tab-radius) var(--right-rail-tab-radius) 0; + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); color: var(--accent); + text-shadow: none; +} + +/* Concave fillets where the tab's top and bottom edges meet the panel's + right edge. Each is a square outside the tab, overlapping the panel's + border column by one pixel and the tab's border row by one pixel, painted + with the surface beyond a quarter circle and the border along it. */ +.right-rail-tab[aria-selected='true']::before, +.right-rail-tab[aria-selected='true']::after { + content: ''; + position: absolute; + left: -1px; + width: calc(var(--right-rail-fillet) + 1px); + height: calc(var(--right-rail-fillet) + 1px); + pointer-events: none; + background: + radial-gradient( + circle at var(--right-rail-fillet-origin), + transparent calc(var(--right-rail-fillet) - 0.25px), + var(--glass-border) calc(var(--right-rail-fillet) + 0.25px), + var(--glass-border) calc(var(--right-rail-fillet) + 0.75px), + transparent calc(var(--right-rail-fillet) + 1.25px) + ), + radial-gradient( + circle at var(--right-rail-fillet-origin), + transparent calc(var(--right-rail-fillet) - 0.25px), + var(--glass-bg) calc(var(--right-rail-fillet) + 0.25px) + ); +} + +.right-rail-tab[aria-selected='true']::before { + --right-rail-fillet-origin: 100% 0; + top: calc(-1 * (var(--right-rail-fillet) + 1px)); +} + +.right-rail-tab[aria-selected='true']::after { + --right-rail-fillet-origin: 100% 100%; + bottom: calc(-1 * (var(--right-rail-fillet) + 1px)); +} + +/* The outline around the open panel, notched where the tab joins. */ +body:not(.mobile-shell) + #right-context-rail[data-rail-tabs]:has( + > [data-panel-id]:not(.collapsed):not([hidden]) + )::before { + content: ''; + position: absolute; + top: 0; + right: var(--right-rail-tabs-inset); + z-index: 130; + box-sizing: border-box; + width: var(--right-rail-panel-width); + /* The rail can be taller than its panel in the focus layout. */ + height: var(--rail-panel-height, 100%); + border: 1px solid var(--glass-border); + border-radius: var(--right-rail-corner); + clip-path: polygon( + 0 0, + 100% 0, + 100% var(--right-rail-notch-top), + calc(100% - 2px) var(--right-rail-notch-top), + calc(100% - 2px) var(--right-rail-notch-bottom), + 100% var(--right-rail-notch-bottom), + 100% 100%, + 0 100% + ); + pointer-events: none; +} + +/* Display is narrower than the rail and sits against the strip. */ +body:not(.mobile-shell) + #right-context-rail[data-rail-tabs]:has( + > #pp-toggles:not(.collapsed) + )::before { + width: min(var(--right-rail-panel-width), var(--right-rail-display-width)); +} + +/* The first tab starts level with the panel's top edge: the outline's top + runs straight into the tab's top border, so no corner and no upper fillet. */ +body:not(.mobile-shell) + #right-context-rail[data-rail-tabs]:has( + > #right-rail-tabs > .right-rail-tab:first-child[aria-selected='true'] + )::before { + border-top-right-radius: 0; +} + +#right-rail-tabs > .right-rail-tab:first-child[aria-selected='true']::before { + content: none; +} + +/* The open panel's card keeps its surface and leaves the outline to the rail. + Cyber draws Display's and Context's frames 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( + .cctv-panel-inner, + .street-level-panel-inner, + .weather-panel-inner, + .recent-imagery-panel-inner, + .global-context-panel-inner, + [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; +} + +/* The panel reaches at least to its tab's lower fillet and corner. The + wrapper carries the floor for the rail's allocated (focus) heights and the + card for its natural height. */ +body:not(.mobile-shell) + #right-context-rail[data-rail-tabs] + > [data-panel-id]:not(.collapsed), +body:not(.mobile-shell) + #right-context-rail[data-rail-tabs] + > [data-panel-id]:not(.collapsed) + > :is( + .cctv-panel-inner, + .street-level-panel-inner, + .weather-panel-inner, + .recent-imagery-panel-inner, + .global-context-panel-inner, + [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, and the strip starts below the frame's + upper chamfer so the first tab meets a straight edge. */ +:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] { + --right-rail-panel-width: 312px; + --right-rail-display-width: var(--cyber-right-panel-width); + --right-rail-tab-radius: var(--btn-radius); + --right-rail-fillet: 0px; + --right-rail-corner: 10px; + --right-rail-notch-top: max(10px, var(--rail-tab-top, 10px)); + --right-rail-notch-bottom: min( + calc(100% - 10px), + var(--rail-tab-bottom, 10px) + ); + width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); +} + +:root[data-ui-theme='cyber'] #right-rail-tabs { + padding-top: 10px; +} + :root[data-ui-theme='cyber'] .right-rail-tab { - border-color: var(--glass-border); border-radius: var(--btn-radius); - background-color: var(--cyber-panel-surface); - background-image: var(--cyber-panel-surface-detail); - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.32) inset, - 0 8px 20px rgba(0, 0, 0, 0.28); - backdrop-filter: none; - -webkit-backdrop-filter: none; +} + +:root[data-ui-theme='cyber'] .right-rail-tab:not([aria-selected='true']):hover { + background: rgba(226, 235, 236, 0.08); } :root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'] { - border-color: var(--cyber-red); - background-color: var(--cyber-panel-surface); - box-shadow: - 3px 0 0 var(--cyber-red) inset, - 0 8px 20px rgba(0, 0, 0, 0.28); + border-color: var(--glass-border); + border-radius: 0 var(--btn-radius) var(--btn-radius) 0; + background: var(--cyber-panel-surface); + box-shadow: -3px 0 0 var(--cyber-red) inset; + backdrop-filter: none; + -webkit-backdrop-filter: none; color: var(--cyber-red-bright); } +:root[data-ui-theme='cyber'] + body:not(.mobile-shell) + #right-context-rail[data-rail-tabs]:has( + > [data-panel-id]:not(.collapsed):not([hidden]) + )::before { + border-radius: 0; + /* --cyber-panel-frame with the tab's notch on its right edge. */ + clip-path: polygon( + 0 10px, + 10px 0, + calc(50% - 25px) 0, + calc(50% - 18px) 6px, + calc(50% + 18px) 6px, + calc(50% + 25px) 0, + calc(100% - 10px) 0, + 100% 10px, + 100% var(--right-rail-notch-top), + calc(100% - 2px) var(--right-rail-notch-top), + calc(100% - 2px) var(--right-rail-notch-bottom), + 100% var(--right-rail-notch-bottom), + 100% calc(100% - 10px), + calc(100% - 10px) 100%, + 10px 100%, + 0 calc(100% - 10px) + ); +} + /* ── Phones keep the sheet shell ───────────────────────────────────────── */ body.mobile-shell #right-context-rail { padding-right: 0;