diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e2ea84d65..70ae8455e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1509,20 +1509,20 @@ 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. +The open tab and its panel are one surface, like a folder tab, and the other +tabs are borderless labels. The strip measures the open panel and tab, +traces their joined silhouette as an SVG path (`railJoinPath`: rounded panel +corners, the tab's outer corners, concave fillets where the tab meets the +panel's edge; Cyber's chamfered frame with its calibration bay and a square +tab instead) and keeps a join element under the panels: one layer blurs and +tints the backdrop inside the path, an SVG strokes the outline along it and +another SVG casts the drop shadow, so the junction has no seam, doubled edge +or second blur region. The open panel's card and the tab draw no surface, +border, shadow or blur 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, with no +corner between them; Cyber's strip starts below the frame's upper chamfer. ## Phone layout diff --git a/docs/media/right-rail-tabs.png b/docs/media/right-rail-tabs.png index a219e0989..a6dce46e3 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 fe6857873..caebb0aec 100644 --- a/src/ui/rightRailTabs.js +++ b/src/ui/rightRailTabs.js @@ -7,6 +7,11 @@ * asks the owner (setPanelCollapsed) for every change, so persistence, share * links and the rail layout pass are untouched. Phones hide the strip and keep * their sheet shell (mobileShell.js). + * + * The open tab and its panel are drawn as one surface: the strip measures + * both, traces their joined silhouette as an SVG path (railJoinPath) and keeps + * a join element under the panels whose layers blur, tint, outline and shadow + * that one shape (styles/right-rail-tabs.css). */ import { msg, t } from '../i18n/index.js'; @@ -19,6 +24,8 @@ export const RIGHT_RAIL_TABS_ID = 'right-rail-tabs'; */ export const RIGHT_RAIL_TABS_ATTRIBUTE = 'data-rail-tabs'; +const SVG_NS = 'http://www.w3.org/2000/svg'; + /** * 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. @@ -51,6 +58,106 @@ export function railTabPanels(rail) { ); } +/** + * SVG path of the panel with the open tab attached to its right edge, traced + * clockwise from the top-left corner, in rail pixels. + * + * Round: the panel's corners and the tab's outer corners are convex arcs and + * the two inner corners where the tab's top and bottom edges meet the panel's + * right edge are concave fillets. A tab level with the panel's top shares its + * top edge, so that corner and the upper fillet are dropped. Chamfer (Cyber): + * the frame's 10px chamfers and 6px calibration bay (cyber.css, + * --cyber-panel-frame) and a square tab. `inset` moves the whole outline + * inward; 0.5 centres a one-pixel stroke just inside the edge. + * + * @param {object} g + * @param {{x0: number, y0: number, x1: number, y1: number}} g.panel + * @param {{top: number, bottom: number, right: number}} g.tab The tab's + * extent; its left edge is the panel's right edge. + * @param {number} [g.corner] Panel corner radius, or the chamfer size. + * @param {number} [g.tabRadius] The tab's outer corner radius. + * @param {number} [g.fillet] Concave fillet radius at the junction. + * @param {'round'|'chamfer'} [g.style] + * @param {number} [g.inset] + * @returns {string} + */ +export function railJoinPath({ + panel, + tab, + corner = 16, + tabRadius = 10, + fillet = 8, + style = 'round', + inset = 0, +}) { + const n = (value) => Math.round(value * 100) / 100; + const P = (x, y) => `${n(x)} ${n(y)}`; + const x0 = panel.x0 + inset; + const y0 = panel.y0 + inset; + const x1 = panel.x1 - inset; + const y1 = panel.y1 - inset; + const ty0 = tab.top + inset; + const ty1 = tab.bottom - inset; + const tx1 = tab.right - inset; + if (style === 'chamfer') { + const c = corner; + const cx = (panel.x0 + panel.x1) / 2; + return [ + `M ${P(x0, y0 + c)}`, + `L ${P(x0 + c, y0)}`, + `L ${P(cx - 25, y0)}`, + `L ${P(cx - 18, y0 + 6)}`, + `L ${P(cx + 18, y0 + 6)}`, + `L ${P(cx + 25, y0)}`, + `L ${P(x1 - c, y0)}`, + `L ${P(x1, y0 + c)}`, + `L ${P(x1, ty0)}`, + `L ${P(tx1, ty0)}`, + `L ${P(tx1, ty1)}`, + `L ${P(x1, ty1)}`, + `L ${P(x1, y1 - c)}`, + `L ${P(x1 - c, y1)}`, + `L ${P(x0 + c, y1)}`, + `L ${P(x0, y1 - c)}`, + 'Z', + ].join(' '); + } + const R = Math.max(0, corner - inset); + const r = Math.max(0, tabRadius - inset); + const f = fillet + inset; + // sweep 1 turns clockwise (convex), 0 counter-clockwise (concave). + const arc = (radius, sweep, x, y) => + `A ${n(radius)} ${n(radius)} 0 0 ${sweep} ${P(x, y)}`; + const first = tab.top - panel.y0 < 0.5; + const d = [`M ${P(x0 + R, y0)}`]; + if (first) { + d.push(`L ${P(tx1 - r, y0)}`, arc(r, 1, tx1, y0 + r)); + } else { + d.push( + `L ${P(x1 - R, y0)}`, + arc(R, 1, x1, y0 + R), + `L ${P(x1, ty0 - f)}`, + arc(f, 0, x1 + f, ty0), + `L ${P(tx1 - r, ty0)}`, + arc(r, 1, tx1, ty0 + r), + ); + } + d.push( + `L ${P(tx1, ty1 - r)}`, + arc(r, 1, tx1 - r, ty1), + `L ${P(x1 + f, ty1)}`, + arc(f, 0, x1, ty1 + f), + `L ${P(x1, y1 - R)}`, + arc(R, 1, x1 - R, y1), + `L ${P(x0 + R, y1)}`, + arc(R, 1, x0, y1 - R), + `L ${P(x0, y0 + R)}`, + arc(R, 1, x0 + R, y0), + 'Z', + ); + return d.join(' '); +} + /** The tab label: the panel's own header title, already translated. */ function panelTitle(panel) { return ( @@ -87,6 +194,26 @@ export function createRightRailTabs({ strip.setAttribute('aria-label', t('Panels')); rail.setAttribute(RIGHT_RAIL_TABS_ATTRIBUTE, 'vertical'); + // The join: shadow under, glass, outline over; all shaped by one path. + const svg = (tag) => + documentRef.createElementNS?.(SVG_NS, tag) ?? + documentRef.createElement(tag); + const join = documentRef.createElement('div'); + join.className = 'right-rail-join'; + join.setAttribute('aria-hidden', 'true'); + join.hidden = true; + const shadow = svg('svg'); + shadow.setAttribute('class', 'right-rail-join-shadow'); + const shadowPath = svg('path'); + shadow.append(shadowPath); + const glass = documentRef.createElement('div'); + glass.setAttribute('class', 'right-rail-join-glass'); + const outline = svg('svg'); + outline.setAttribute('class', 'right-rail-join-outline'); + const outlinePath = svg('path'); + outline.append(outlinePath); + join.append(shadow, glass, outline); + /** @type {Map} */ const entries = new Map(); const observed = new WeakSet(); @@ -131,12 +258,18 @@ export function createRightRailTabs({ }); }; + /** A join token from the stylesheet (--right-rail-*), in pixels. */ + const token = (computed, name, fallback) => { + const value = parseFloat(computed?.getPropertyValue?.(name)); + return Number.isFinite(value) ? value : fallback; + }; + /** - * 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. + * Publish the join: the open tab's extent on the rail (--rail-tab-top, + * --rail-tab-bottom, rail pixels, for the panel's minimum height) and the + * joined silhouette on the join element's layers. The rail itself 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 the panel is shorter. */ function placeJoin(tab, panel) { const style = rail.style; @@ -144,13 +277,55 @@ export function createRightRailTabs({ if (!tab || !Number.isFinite(tab.offsetTop)) { style.removeProperty('--rail-tab-top'); style.removeProperty('--rail-tab-bottom'); - style.removeProperty('--rail-panel-height'); + 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`); - style.setProperty('--rail-panel-height', `${panel.offsetHeight}px`); + if (!Number.isFinite(panel?.offsetWidth) || !join.style) { + join.hidden = true; + return; + } + const computed = documentRef.defaultView?.getComputedStyle?.(rail); + const corner = token(computed, '--right-rail-corner', 16); + const tabRadius = token(computed, '--right-rail-tab-radius', 10); + const fillet = token(computed, '--right-rail-fillet', 8); + const chamfer = + computed?.getPropertyValue?.('--right-rail-join-style').trim() === + 'chamfer'; + const tabTop = (strip.offsetTop || 0) + top; + const tabBottom = tabTop + tab.offsetHeight; + const geometry = { + panel: { + x0: panel.offsetLeft, + y0: panel.offsetTop, + x1: panel.offsetLeft + panel.offsetWidth, + y1: Math.max( + panel.offsetTop + panel.offsetHeight, + tabBottom + fillet + 4 + corner, + ), + }, + tab: { + top: tabTop, + bottom: tabBottom, + right: (strip.offsetLeft || 0) + tab.offsetLeft + tab.offsetWidth, + }, + corner, + tabRadius, + fillet, + style: chamfer ? 'chamfer' : 'round', + }; + const shape = railJoinPath(geometry); + const width = rail.clientWidth; + const height = geometry.panel.y1; + join.style.height = `${height}px`; + glass.style.clipPath = `path('${shape}')`; + shadowPath.setAttribute('d', shape); + // Everything but the silhouette: the shadow it casts stays, the fill goes. + shadow.style.clipPath = `path(evenodd, 'M -80 -80 H ${width + 80} V ${height + 80} H -80 Z ${shape}')`; + outlinePath.setAttribute('d', railJoinPath({ ...geometry, inset: 0.5 })); + join.hidden = false; } /** Reconcile tabs with the rail: order, visibility, selection, labels. */ @@ -160,6 +335,7 @@ export function createRightRailTabs({ // tab lands inside the open panel. The rail owner moves panels in at // start-up; keep the strip first afterwards too. if (rail.firstElementChild !== strip) rail.prepend(strip); + if (join.parentElement !== rail) rail.append(join); const panels = railTabPanels(rail); const focusOwner = panels.find((panel) => !panel.classList.contains('collapsed')) || @@ -218,10 +394,12 @@ export function createRightRailTabs({ strip.addEventListener('scroll', onScroll, { passive: true }); observer?.observe(rail, { childList: true }); rail.prepend(strip); + rail.append(join); sync(); return { element: strip, + join, sync, destroy() { if (destroyed) return; @@ -232,6 +410,7 @@ export function createRightRailTabs({ strip.removeEventListener('keydown', onKeydown); strip.removeEventListener('scroll', onScroll); strip.remove(); + join.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 d1504bcd0..67c81ccfd 100644 --- a/src/ui/rightRailTabs.test.mjs +++ b/src/ui/rightRailTabs.test.mjs @@ -5,6 +5,7 @@ import { RIGHT_RAIL_TABS_ID, createRightRailTabs, nextRailTabIndex, + railJoinPath, railTabPanels, } from './rightRailTabs.js'; @@ -322,17 +323,27 @@ 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', () => { +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), }; + 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'); + assert.deepEqual( + f.tabs.join.children.map((layer) => layer.getAttribute('class')), + [ + 'right-rail-join-shadow', + 'right-rail-join-glass', + 'right-rail-join-outline', + ], + ); 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(); @@ -341,7 +352,6 @@ test('publishes the open tab and panel extent on the rail for the joined outline [ ['--rail-tab-top', '86px'], ['--rail-tab-bottom', '142px'], - ['--rail-panel-height', '300px'], ], ); f.strip.scrollTop = 20; @@ -354,6 +364,52 @@ test('publishes the open tab and panel extent on the rail for the joined outline f.cctv.classList.add('collapsed'); f.tabs.sync(); assert.deepEqual([...props], [], 'no open panel, no join'); + f.tabs.destroy(); + assert.equal(f.tabs.join.parentElement, null); +}); + +test('railJoinPath traces the panel with the tab attached: corners, fillets, tab corners', () => { + const geometry = { + panel: { x0: 0, y0: 0, x1: 330, y1: 378 }, + tab: { top: 85, bottom: 141, right: 372 }, + corner: 16, + tabRadius: 10, + fillet: 8, + }; + assert.equal( + railJoinPath(geometry), + 'M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 77 A 8 8 0 0 0 338 85 ' + + 'L 362 85 A 10 10 0 0 1 372 95 L 372 131 A 10 10 0 0 1 362 141 ' + + 'L 338 141 A 8 8 0 0 0 330 149 L 330 362 A 16 16 0 0 1 314 378 ' + + 'L 16 378 A 16 16 0 0 1 0 362 L 0 16 A 16 16 0 0 1 16 0 Z', + ); + assert.equal( + railJoinPath({ ...geometry, tab: { top: 0, bottom: 79, right: 372 } }), + 'M 16 0 L 362 0 A 10 10 0 0 1 372 10 L 372 69 A 10 10 0 0 1 362 79 ' + + 'L 338 79 A 8 8 0 0 0 330 87 L 330 362 A 16 16 0 0 1 314 378 ' + + 'L 16 378 A 16 16 0 0 1 0 362 L 0 16 A 16 16 0 0 1 16 0 Z', + 'a tab level with the top shares the top edge: no corner, no upper fillet', + ); + const stroke = railJoinPath({ ...geometry, inset: 0.5 }); + assert.match(stroke, /^M 16 0\.5 L 314 0\.5 A 15\.5 15\.5 0 0 1 329\.5 16 /); + assert.match( + stroke, + / L 329\.5 77 A 8\.5 8\.5 0 0 0 338 85\.5 /, + 'an inset grows the concave fillets and shrinks the convex corners', + ); + const chamfer = railJoinPath({ + ...geometry, + panel: { x0: 0, y0: 0, x1: 312, y1: 374 }, + corner: 10, + style: 'chamfer', + }); + assert.equal( + chamfer, + 'M 0 10 L 10 0 L 131 0 L 138 6 L 174 6 L 181 0 L 302 0 L 312 10 ' + + 'L 312 85 L 372 85 L 372 141 L 312 141 L 312 364 L 302 374 ' + + 'L 10 374 L 0 364 Z', + 'Cyber: chamfers, the calibration bay and a square tab', + ); }); test('destroy removes the strip and stops listening', () => { diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css index 0147a8aee..eba6e6662 100644 --- a/src/ui/styles/right-rail-tabs.css +++ b/src/ui/styles/right-rail-tabs.css @@ -4,40 +4,35 @@ exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip is the only launcher. Phones keep the sheet shell (mobile-shell.css). - 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. */ + The open tab and its panel are one surface. The strip builds a join + element (.right-rail-join) under the panels from the measured geometry: + one path traces the silhouette of the panel with the tab attached to its + right edge (rounded corners, concave fillets at the junction; Cyber's + chamfered frame instead). The glass layer blurs and tints the backdrop + inside that path, an SVG strokes the outline along it and another SVG + casts the drop shadow, so there is no seam, no doubled edge and no second + blur region where the tab meets the panel. The open panel's card and the + tab draw no surface of their own; the other tabs are borderless labels. + The strip also publishes --rail-tab-bottom on the rail so a short panel + grows down to its tab. `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; /* 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) ); + /* Join geometry, read by rightRailTabs.js: panel corner, the tab's outer + corners, the concave fillets, and round corners or Cyber's chamfers. */ + --right-rail-corner: var(--panel-radius); --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)) - ); + --right-rail-join-style: round; + --right-rail-join-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); /* 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 + @@ -58,8 +53,8 @@ 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); + /* Room for the open tab's bridge on the left. */ + padding-left: var(--right-rail-tabs-gap); overflow: hidden auto; scrollbar-width: none; pointer-events: auto; @@ -79,7 +74,7 @@ width: var(--right-rail-tabs-width); min-height: 56px; padding: 12px 0; - border: 1px solid transparent; + border: 0; border-radius: var(--right-rail-tab-radius); background: transparent; color: var(--text-dim); @@ -92,7 +87,6 @@ cursor: pointer; transition: background-color var(--transition-fast), - border-color var(--transition-fast), color var(--transition-fast); } @@ -108,113 +102,66 @@ 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. */ +/* 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. */ .right-rail-tab[aria-selected='true'] { - 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: ''; +/* ── The join: surface, outline and shadow of panel plus tab ──────────── */ +.right-rail-join { 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% - ); + left: 0; + z-index: -1; + width: 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)); +.right-rail-join[hidden], +body.mobile-shell #right-context-rail > .right-rail-join { + display: none; } -/* 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-join > * { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; } -#right-rail-tabs > .right-rail-tab:first-child[aria-selected='true']::before { - content: none; +/* A black silhouette casts the shadow; its clip keeps only the outside. */ +.right-rail-join-shadow { + filter: drop-shadow(var(--right-rail-join-shadow)); } -/* 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. */ +.right-rail-join-shadow path { + fill: #000; +} + +.right-rail-join-glass { + background-color: var(--glass-bg); + 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 { + fill: none; + stroke: var(--glass-border); + stroke-width: 1; +} + +/* The open panel's card leaves surface, outline and shadow to the join. + Cyber draws Display's and Context's frames, and the surface contract its + card, on a ::before. */ body:not(.mobile-shell) #right-context-rail[data-rail-tabs] > #pp-toggles:not(.collapsed), @@ -237,6 +184,18 @@ body:not(.mobile-shell) > [data-panel-id]:not(.collapsed) > :is(.global-context-panel-inner, [data-panel-surface])::before { border-color: transparent; + background: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +/* The card's glow used to sit under its own surface; keep it as faint. */ +body:not(.mobile-shell) + #right-context-rail[data-rail-tabs] + > [data-panel-id]:not(.collapsed) + > .panel-glow { + opacity: 0.08; } /* The panel reaches at least to its tab's lower fillet and corner. The @@ -261,19 +220,16 @@ body:not(.mobile-shell) /* ── 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. */ + 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. */ :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-tab-radius: 0px; --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) - ); + --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)); } @@ -281,49 +237,31 @@ body:not(.mobile-shell) padding-top: 10px; } +/* 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: rgba(226, 235, 236, 0.08); + background-color: rgba(226, 235, 236, 0.08); + box-shadow: none; } -:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'] { - border-color: var(--glass-border); - border-radius: 0 var(--btn-radius) var(--btn-radius) 0; - background: var(--cyber-panel-surface); +: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; - 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) - ); +: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; + backdrop-filter: none; + -webkit-backdrop-filter: none; } /* ── Phones keep the sheet shell ───────────────────────────────────────── */