From ff017c99ca9fe5a1508c969b7c78cee1f10cc96e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:55:28 +0400 Subject: [PATCH] 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 --- docs/CURRENT-STATE.md | 16 ++++-------- src/rightRailPolicy.js | 2 -- src/rightRailPolicy.test.mjs | 28 +++----------------- src/ui/panelDisclosure.js | 2 +- src/ui/panelLayoutController.js | 1 - src/ui/panelRailGeometry.js | 12 --------- src/ui/panelRails.js | 5 +--- src/ui/panelRails.test.mjs | 12 --------- src/ui/rightPanelRail.js | 24 ++++------------- src/ui/rightRailTabs.js | 31 +++++++--------------- src/ui/rightRailTabs.test.mjs | 43 ++++++++++++++++++++----------- src/ui/styles/right-rail-tabs.css | 19 ++++---------- 12 files changed, 58 insertions(+), 137 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1553a0fd9..f6f64cfb1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1511,17 +1511,11 @@ 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 -joined silhouette as an SVG path (`railJoinPath`: rounded corners, concave -fillets at the junction; Cyber's chamfered frame with its calibration bay and a -square tab) and keeps a join element under the panels: one layer blurs and -tints the backdrop inside the path, one SVG strokes the outline and another -casts the drop shadow, so the junction has no seam or second blur region. The -open panel's wrapper and card and the tab draw no surface of their own. The -join is re-measured through a ResizeObserver and on strip scroll; -`--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 tab level with the -panel's top shares its top edge; Cyber's strip starts below the frame's upper -chamfer. +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 diff --git a/src/rightRailPolicy.js b/src/rightRailPolicy.js index 02d7972c4..08eab4fb0 100644 --- a/src/rightRailPolicy.js +++ b/src/rightRailPolicy.js @@ -1,5 +1,3 @@ -export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js'; - const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([ 'contacts', 'space-missions', diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 90fa4c4b7..560b6f50f 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -2,35 +2,13 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import { - shouldExpandGlobalContextPanel, - shouldHideCollapsedRightPanels, -} from './rightRailPolicy.js'; +import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js'; -test('the tab strip is the launcher in every HUD layout, so collapsed right-rail panels hide', () => { - for (const hudVariant of ['tactical', 'operator', 'minimal', 'cyber', 'full']) { - for (const hasExpandedPanel of [true, false]) { - assert.equal( - shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }), - true, - `${hudVariant} with${hasExpandedPanel ? '' : 'out'} an expanded panel`, - ); - } - } -}); - -test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { +test('the desktop right rail hides collapsed panels behind the tab strip and marks them aria-hidden', () => { const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); - assert.match( - 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, /const exclusive = !isMobile;/); 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 \{/); }); diff --git a/src/ui/panelDisclosure.js b/src/ui/panelDisclosure.js index dda988d5e..aa8418102 100644 --- a/src/ui/panelDisclosure.js +++ b/src/ui/panelDisclosure.js @@ -68,7 +68,7 @@ export function collapsePanelOnEscape( ); const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); - if (tab) tab.focus?.({ preventScroll: true }); + if (tab) tab.focus({ preventScroll: true }); else if (escapedFromDisclosure) disclosure?.blur?.(); else disclosure?.focus?.({ preventScroll: true }); return true; diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index 8b8e9b036..691974b00 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -301,7 +301,6 @@ export class PanelLayoutController { stack: this._rightPanelStack, obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR), windowRef: window, - hud: this.readHud(), preferredPanelId: this._rightStackPreferredPanelId, onCollapse: (panel) => this._syncPanelCollapseButton(panel), leftStack: this._leftPanelStack, diff --git a/src/ui/panelRailGeometry.js b/src/ui/panelRailGeometry.js index 89057c35b..b5a129c21 100644 --- a/src/ui/panelRailGeometry.js +++ b/src/ui/panelRailGeometry.js @@ -60,15 +60,3 @@ export function resolveHudRailLayout({ constrained: panelHeight > availableHeight, }; } - -/** - * The tab strip (rightRailTabs.js) is the launcher in every HUD layout, so - * collapsed right-rail panels always hide. Callers still pass the rail state - * they lay out. - * @param {{hudVariant?: string, hasExpandedPanel?: boolean}} [input] - * @returns {boolean} - */ -export function shouldHideCollapsedRightPanels(input = {}) { - void input; - return true; -} diff --git a/src/ui/panelRails.js b/src/ui/panelRails.js index a67371cc7..e447d6302 100644 --- a/src/ui/panelRails.js +++ b/src/ui/panelRails.js @@ -1,10 +1,7 @@ export { layoutLeftPanelRail } from './leftPanelRail.js'; export { layoutRightPanelRail } from './rightPanelRail.js'; export { measurePanelNaturalHeight } from './panelMeasurement.js'; -export { - resolveHudRailLayout, - shouldHideCollapsedRightPanels, -} from './panelRailGeometry.js'; +export { resolveHudRailLayout } from './panelRailGeometry.js'; export { allocatePanelStackHeights, panelStackAutoCollapseIndices, diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 152cce1ca..31d6c19cb 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -306,18 +306,6 @@ test('right layout lets a stored or shared choice outrank a panel open only by d f.run(); assert.equal(f.first.classList.contains('rail-owner-collapsed'), true); assert.equal(f.second.classList.contains('collapsed'), false); - const g = fixture('right'); - g.expand(g.first, 900); - g.expand(g.second, 900); - g.first.dataset.collapsedPreference = 'default'; - g.second.dataset.collapsedPreference = 'default'; - g.run(); - assert.equal( - g.first.classList.contains('collapsed'), - false, - 'all default: first wins', - ); - assert.equal(g.second.classList.contains('rail-owner-collapsed'), true); }); for (const collapsed of [true, false]) { diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 24ac479dc..a7b1eaefa 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -1,8 +1,5 @@ import { allocatePanelStackHeights } from '../panelStackLayout.js'; -import { - resolveHudRailLayout, - shouldHideCollapsedRightPanels, -} from './panelRailGeometry.js'; +import { resolveHudRailLayout } from './panelRailGeometry.js'; import { displayPanelScroller } from './displayPanelScroll.js'; @@ -38,7 +35,6 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) { * @param {HTMLElement} options.stack Rail element. * @param {Iterable} options.obstacles Caller-selected obstacle nodes. * @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 {Function} options.onCollapse Update a panel's disclosure chrome. * @param {Function} [options.getComputedStyle] Optional DOM style reader override. @@ -51,7 +47,6 @@ export function layoutRightPanelRail({ stack, obstacles, windowRef, - hud, preferredPanelId, onCollapse, leftStack, @@ -104,19 +99,10 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - const hasExpandedPanel = panels.some( - (panel) => - !panel.classList.contains('collapsed') && - (!isMobile || panel.id !== 'pp-toggles'), - ); - // 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, - }); + // The tab strip is the launcher, so collapsed panels hide. 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; stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { if (exclusive && panel.classList.contains('collapsed')) diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js index 32977c258..999969232 100644 --- a/src/ui/rightRailTabs.js +++ b/src/ui/rightRailTabs.js @@ -185,7 +185,6 @@ export function createRightRailTabs({ } const strip = documentRef.createElement('div'); strip.id = RIGHT_RAIL_TABS_ID; - strip.className = 'right-rail-tabs'; strip.setAttribute('role', 'tablist'); strip.setAttribute('aria-orientation', 'vertical'); strip.setAttribute('aria-label', t('Panels')); @@ -228,7 +227,6 @@ 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; @@ -239,8 +237,8 @@ export function createRightRailTabs({ 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 on any size change and on - // strip scroll. + // content and allocation: re-measure the join when the strip or a panel + // changes size and on strip scroll. const Resize = documentRef.defaultView?.ResizeObserver; const resizer = typeof Resize === 'function' ? new Resize(() => sync()) : null; @@ -262,28 +260,20 @@ export function createRightRailTabs({ }; /** - * Publish the join: the open tab's extent on the rail (--rail-tab-top and - * --rail-tab-bottom, rail pixels; the stylesheet sets the panel's minimum - * height from them) 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. + * 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. */ 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'); + if (!tab) { + rail.style.removeProperty('--rail-tab-bottom'); join.hidden = true; return; } const top = tab.offsetTop - (strip.scrollTop || 0); - style.setProperty('--rail-tab-top', `${top}px`); - style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}px`); - if (!Number.isFinite(panel?.offsetWidth) || !join.style) { - join.hidden = true; - return; - } + 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); @@ -359,7 +349,6 @@ 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); } diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs index 67c81ccfd..f09717804 100644 --- a/src/ui/rightRailTabs.test.mjs +++ b/src/ui/rightRailTabs.test.mjs @@ -24,12 +24,18 @@ function makeDocument() { const el = new EventTarget(); const attrs = new Map(); const classes = new Set(); + const props = new Map(); Object.assign(el, { tagName: tag.toUpperCase(), ownerDocument: doc, children: [], parentElement: null, dataset: {}, + style: { + props, + setProperty: (key, value) => props.set(key, value), + removeProperty: (key) => props.delete(key), + }, hidden: false, textContent: '', tabIndex: -1, @@ -325,11 +331,7 @@ test('a sync that changes nothing keeps the same tab elements in place', () => { test('publishes the open tab extent on the rail and keeps a join element under the panels', () => { const f = fixture(); - const props = new Map(); - f.rail.style = { - setProperty: (key, value) => props.set(key, value), - removeProperty: (key) => props.delete(key), - }; + const { props } = f.rail.style; assert.equal(f.tabs.join.parentElement, f.rail); assert.equal(f.tabs.join.getAttribute('aria-hidden'), 'true'); assert.equal(f.tabs.join.hidden, true, 'nothing open, nothing to join'); @@ -342,28 +344,39 @@ test('publishes the open tab extent on the rail and keeps a join element under t ], ); const tab = f.tabFor('cctv-panel'); - tab.offsetTop = 86; - tab.offsetHeight = 56; + Object.assign(tab, { + offsetTop: 86, + offsetLeft: 0, + offsetHeight: 56, + offsetWidth: 42, + }); + Object.assign(f.cctv, { + offsetTop: 0, + offsetLeft: 0, + offsetHeight: 300, + offsetWidth: 330, + }); f.strip.scrollTop = 0; f.cctv.classList.remove('collapsed'); f.tabs.sync(); - assert.deepEqual( - [...props], - [ - ['--rail-tab-top', '86px'], - ['--rail-tab-bottom', '142px'], - ], + assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]); + assert.equal(f.tabs.join.hidden, false); + const [, glass] = f.tabs.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.strip.scrollTop = 20; f.tabs.sync(); assert.equal( - props.get('--rail-tab-top'), - '66px', + props.get('--rail-tab-bottom'), + '122px', 'strip scroll is subtracted', ); f.cctv.classList.add('collapsed'); f.tabs.sync(); assert.deepEqual([...props], [], 'no open panel, no join'); + assert.equal(f.tabs.join.hidden, true); f.tabs.destroy(); assert.equal(f.tabs.join.parentElement, null); }); diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css index e18e3ed3c..8f089fed5 100644 --- a/src/ui/styles/right-rail-tabs.css +++ b/src/ui/styles/right-rail-tabs.css @@ -15,7 +15,6 @@ selectors carry it to outrank the Cyber rail rules that follow this sheet. */ #right-context-rail[data-rail-tabs] { - --right-rail-panel-width: 330px; --right-rail-tabs-width: 34px; /* The bridge the open tab reaches across to its panel. */ --right-rail-tabs-gap: 8px; @@ -34,7 +33,9 @@ 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)); + /* 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); } @@ -146,7 +147,6 @@ body.mobile-shell #right-context-rail > .right-rail-join { background-image: var(--panel-surface-detail); backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); - clip-path: var(--rail-join-path, none); } .right-rail-join-outline path { @@ -219,15 +219,13 @@ body:not(.mobile-shell) /* 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 and faint right highlight move onto the join's glass. */ + red edge bar moves onto the join's glass. */ :root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] { - --right-rail-panel-width: 312px; --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); - width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); } :root[data-ui-theme='cyber'] #right-rail-tabs { @@ -236,10 +234,6 @@ 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. */ -:root[data-ui-theme='cyber'] .right-rail-tab { - border-radius: var(--btn-radius); -} - :root[data-ui-theme='cyber'] .right-rail-tab:not([aria-selected='true']):hover { background-color: rgba(226, 235, 236, 0.08); box-shadow: none; @@ -247,7 +241,6 @@ body:not(.mobile-shell) :root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'], :root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true']:hover { - border-radius: 0; background-color: transparent; box-shadow: -3px 0 0 var(--cyber-red) inset; color: var(--cyber-red-bright); @@ -265,9 +258,7 @@ body:not(.mobile-shell) } :root[data-ui-theme='cyber'] .right-rail-join-glass { - box-shadow: - 3px 0 0 rgba(196, 84, 84, 0.2) inset, - -1px 0 0 rgba(226, 235, 236, 0.05) inset; + box-shadow: 3px 0 0 rgba(196, 84, 84, 0.2) inset; backdrop-filter: none; -webkit-backdrop-filter: none; }