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; }