feat(ui): draw the open right rail tab and its panel as one surface

The strip traces the joined silhouette of the open panel and its tab as an SVG path (rounded corners, concave fillets; Cyber's chamfered frame and a square tab) 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 casts the drop shadow. The panel card and the tab draw no surface of their own, so the junction has no seam, doubled edge or second blur region. Cyber's generic selected-button treatment is kept off the tab.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-27 17:47:34 +04:00
1 parent 65c1ae67f2
commit 0a52e94671
5 files changed
+352 -179

No files matched your search

+14 -14
View File
@@ -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 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. 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 open tab and its panel are one surface, like a folder tab, and the other
the glass surface and one outline, and the other tabs are borderless labels. tabs are borderless labels. The strip measures the open panel and tab,
The panel card keeps its surface but draws no border; the rail's `::before` traces their joined silhouette as an SVG path (`railJoinPath`: rounded panel
draws the outline around the card with a notch where the tab joins, the open corners, the tab's outer corners, concave fillets where the tab meets the
tab closes the shape with its own top, right and bottom borders, and two panel's edge; Cyber's chamfered frame with its calibration bay and a square
concave fillets round the inner corners (Cyber's chamfered frames join tab instead) and keeps a join element under the panels: one layer blurs and
square, and its strip starts below the frame's upper chamfer). The strip tints the backdrop inside the path, an SVG strokes the outline along it and
publishes the join on the rail as inline custom properties, re-measured another SVG casts the drop shadow, so the junction has no seam, doubled edge
through a ResizeObserver and on strip scroll: `--rail-tab-top` and or second blur region. The open panel's card and the tab draw no surface,
`--rail-tab-bottom` place the notch and set the open panel's minimum height, border, shadow or blur of their own. The join is re-measured through a
so a short panel such as Context still reaches its tab, and ResizeObserver and on strip scroll; `--rail-tab-bottom` on the rail sets the
`--rail-panel-height` sizes the outline, since the rail is taller than its open panel's minimum height, so a short panel such as Context still reaches
panel in the focus layout. The first tab starts level with the panel's top its tab. A first tab level with the panel's top shares its top edge, with no
edge, so the outline's top runs straight into it with no corner. corner between them; Cyber's strip starts below the frame's upper chamfer.
## Phone layout ## Phone layout
Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 354 KiB

+186 -7
View File
@@ -7,6 +7,11 @@
* asks the owner (setPanelCollapsed) for every change, so persistence, share * asks the owner (setPanelCollapsed) for every change, so persistence, share
* links and the rail layout pass are untouched. Phones hide the strip and keep * links and the rail layout pass are untouched. Phones hide the strip and keep
* their sheet shell (mobileShell.js). * 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'; 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'; 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 * 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. * 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. */ /** The tab label: the panel's own header title, already translated. */
function panelTitle(panel) { function panelTitle(panel) {
return ( return (
@@ -87,6 +194,26 @@ export function createRightRailTabs({
strip.setAttribute('aria-label', t('Panels')); strip.setAttribute('aria-label', t('Panels'));
rail.setAttribute(RIGHT_RAIL_TABS_ATTRIBUTE, 'vertical'); 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<string, {tab: HTMLElement, label: HTMLElement}>} */ /** @type {Map<string, {tab: HTMLElement, label: HTMLElement}>} */
const entries = new Map(); const entries = new Map();
const observed = new WeakSet(); 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 * Publish the join: the open tab's extent on the rail (--rail-tab-top,
* tab's extent (--rail-tab-top, --rail-tab-bottom) and the open panel's * --rail-tab-bottom, rail pixels, for the panel's minimum height) and the
* height (--rail-panel-height), in rail pixels. The panel outline is drawn * joined silhouette on the join element's layers. The rail itself can be
* at that height with a notch at the tab, and the panel reaches down to it. * taller than its panel in the focus layout, so the join is sized from
* The rail itself can be taller than its panel in the focus layout. * the panel, extended down to cover the tab if the panel is shorter.
*/ */
function placeJoin(tab, panel) { function placeJoin(tab, panel) {
const style = rail.style; const style = rail.style;
@@ -144,13 +277,55 @@ export function createRightRailTabs({
if (!tab || !Number.isFinite(tab.offsetTop)) { if (!tab || !Number.isFinite(tab.offsetTop)) {
style.removeProperty('--rail-tab-top'); style.removeProperty('--rail-tab-top');
style.removeProperty('--rail-tab-bottom'); style.removeProperty('--rail-tab-bottom');
style.removeProperty('--rail-panel-height'); join.hidden = true;
return; return;
} }
const top = tab.offsetTop - (strip.scrollTop || 0); const top = tab.offsetTop - (strip.scrollTop || 0);
style.setProperty('--rail-tab-top', `${top}px`); style.setProperty('--rail-tab-top', `${top}px`);
style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}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. */ /** 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 // tab lands inside the open panel. The rail owner moves panels in at
// start-up; keep the strip first afterwards too. // start-up; keep the strip first afterwards too.
if (rail.firstElementChild !== strip) rail.prepend(strip); if (rail.firstElementChild !== strip) rail.prepend(strip);
if (join.parentElement !== rail) rail.append(join);
const panels = railTabPanels(rail); const panels = railTabPanels(rail);
const focusOwner = const focusOwner =
panels.find((panel) => !panel.classList.contains('collapsed')) || panels.find((panel) => !panel.classList.contains('collapsed')) ||
@@ -218,10 +394,12 @@ export function createRightRailTabs({
strip.addEventListener('scroll', onScroll, { passive: true }); strip.addEventListener('scroll', onScroll, { passive: true });
observer?.observe(rail, { childList: true }); observer?.observe(rail, { childList: true });
rail.prepend(strip); rail.prepend(strip);
rail.append(join);
sync(); sync();
return { return {
element: strip, element: strip,
join,
sync, sync,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
@@ -232,6 +410,7 @@ export function createRightRailTabs({
strip.removeEventListener('keydown', onKeydown); strip.removeEventListener('keydown', onKeydown);
strip.removeEventListener('scroll', onScroll); strip.removeEventListener('scroll', onScroll);
strip.remove(); strip.remove();
join.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE); rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
placeJoin(null, null); placeJoin(null, null);
entries.clear(); entries.clear();
+59 -3
View File
@@ -5,6 +5,7 @@ import {
RIGHT_RAIL_TABS_ID, RIGHT_RAIL_TABS_ID,
createRightRailTabs, createRightRailTabs,
nextRailTabIndex, nextRailTabIndex,
railJoinPath,
railTabPanels, railTabPanels,
} from './rightRailTabs.js'; } 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')); 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 f = fixture();
const props = new Map(); const props = new Map();
f.rail.style = { f.rail.style = {
setProperty: (key, value) => props.set(key, value), setProperty: (key, value) => props.set(key, value),
removeProperty: (key) => props.delete(key), 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'); const tab = f.tabFor('cctv-panel');
tab.offsetTop = 86; tab.offsetTop = 86;
tab.offsetHeight = 56; tab.offsetHeight = 56;
f.cctv.offsetHeight = 300;
f.strip.scrollTop = 0; f.strip.scrollTop = 0;
f.cctv.classList.remove('collapsed'); f.cctv.classList.remove('collapsed');
f.tabs.sync(); 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-top', '86px'],
['--rail-tab-bottom', '142px'], ['--rail-tab-bottom', '142px'],
['--rail-panel-height', '300px'],
], ],
); );
f.strip.scrollTop = 20; 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.cctv.classList.add('collapsed');
f.tabs.sync(); f.tabs.sync();
assert.deepEqual([...props], [], 'no open panel, no join'); 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', () => { test('destroy removes the strip and stops listening', () => {
+93 -155
View File
@@ -4,40 +4,35 @@
exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip
is the only launcher. Phones keep the sheet shell (mobile-shell.css). 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 The open tab and its panel are one surface. The strip builds a join
outline. The panel card keeps its glass surface but draws no border of its element (.right-rail-join) under the panels from the measured geometry:
own; the rail's ::before draws the outline around the card with a notch one path traces the silhouette of the panel with the tab attached to its
where the tab joins, the open tab closes the shape with its own top, right right edge (rounded corners, concave fillets at the junction; Cyber's
and bottom borders, and two concave fillets (the tab's ::before/::after) chamfered frame instead). The glass layer blurs and tints the backdrop
round the inner corners. The strip publishes the join on the rail inside that path, an SVG strokes the outline along it and another SVG
(rightRailTabs.js): --rail-tab-top/--rail-tab-bottom place the notch and casts the drop shadow, so there is no seam, no doubled edge and no second
set the panel's minimum height, so a short panel still reaches its tab, blur region where the tab meets the panel. The open panel's card and the
and --rail-panel-height sizes the outline, since the rail itself can be tab draw no surface of their own; the other tabs are borderless labels.
taller than its panel. The other tabs are borderless labels. `data-rail-tabs` arrives on the The strip also publishes --rail-tab-bottom on the rail so a short panel
rail with the strip, so a rail without it keeps its legacy geometry; the grows down to its tab. `data-rail-tabs` arrives on the rail with the
selectors carry it so they outrank the Cyber rail rules that follow this strip, so a rail without it keeps its legacy geometry; the selectors carry
sheet. */ it so they outrank the Cyber rail rules that follow this sheet. */
#right-context-rail[data-rail-tabs] { #right-context-rail[data-rail-tabs] {
--right-rail-panel-width: 330px; --right-rail-panel-width: 330px;
--right-rail-display-width: var(--display-panel-expanded-width);
--right-rail-tabs-width: 34px; --right-rail-tabs-width: 34px;
/* The bridge the open tab reaches across to its panel. */ /* The bridge the open tab reaches across to its panel. */
--right-rail-tabs-gap: 8px; --right-rail-tabs-gap: 8px;
--right-rail-tabs-inset: calc( --right-rail-tabs-inset: calc(
var(--right-rail-tabs-width) + var(--right-rail-tabs-gap) 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-tab-radius: 10px;
--right-rail-fillet: 8px; --right-rail-fillet: 8px;
--right-rail-corner: var(--panel-radius); --right-rail-join-style: round;
--right-rail-notch-top: max( --right-rail-join-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
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. */ /* Lower fillet, a short straight run, then the panel's corner. */
--right-rail-panel-cover: calc( --right-rail-panel-cover: calc(
var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px + var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px +
@@ -58,8 +53,8 @@
box-sizing: border-box; box-sizing: border-box;
width: var(--right-rail-tabs-inset); width: var(--right-rail-tabs-inset);
max-height: var(--right-stack-max-height, 70vh); max-height: var(--right-stack-max-height, 70vh);
/* Room for the open tab's bridge on the left and its lower fillet below. */ /* Room for the open tab's bridge on the left. */
padding: 0 0 calc(var(--right-rail-fillet) + 1px) var(--right-rail-tabs-gap); padding-left: var(--right-rail-tabs-gap);
overflow: hidden auto; overflow: hidden auto;
scrollbar-width: none; scrollbar-width: none;
pointer-events: auto; pointer-events: auto;
@@ -79,7 +74,7 @@
width: var(--right-rail-tabs-width); width: var(--right-rail-tabs-width);
min-height: 56px; min-height: 56px;
padding: 12px 0; padding: 12px 0;
border: 1px solid transparent; border: 0;
border-radius: var(--right-rail-tab-radius); border-radius: var(--right-rail-tab-radius);
background: transparent; background: transparent;
color: var(--text-dim); color: var(--text-dim);
@@ -92,7 +87,6 @@
cursor: pointer; cursor: pointer;
transition: transition:
background-color var(--transition-fast), background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast); color var(--transition-fast);
} }
@@ -108,113 +102,66 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* The open tab: the panel's surface continued across the bridge, closed by /* The open tab is the label and hit area over the join's surface; it spans
its own top, right and bottom borders. The label stays in the strip's the bridge too, with the label kept in the strip's column. */
column. */
.right-rail-tab[aria-selected='true'] { .right-rail-tab[aria-selected='true'] {
z-index: 1;
width: calc(var(--right-rail-tabs-width) + var(--right-rail-tabs-gap)); width: calc(var(--right-rail-tabs-width) + var(--right-rail-tabs-gap));
margin-left: calc(-1 * var(--right-rail-tabs-gap)); margin-left: calc(-1 * var(--right-rail-tabs-gap));
padding-left: 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; 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); color: var(--accent);
text-shadow: none; text-shadow: none;
} }
/* Concave fillets where the tab's top and bottom edges meet the panel's /* ── The join: surface, outline and shadow of panel plus tab ──────────── */
right edge. Each is a square outside the tab, overlapping the panel's .right-rail-join {
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; position: absolute;
top: 0; top: 0;
right: var(--right-rail-tabs-inset); left: 0;
z-index: 130; z-index: -1;
box-sizing: border-box; width: 100%;
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; pointer-events: none;
} }
/* Display is narrower than the rail and sits against the strip. */ .right-rail-join[hidden],
body:not(.mobile-shell) body.mobile-shell #right-context-rail > .right-rail-join {
#right-context-rail[data-rail-tabs]:has( display: none;
> #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 .right-rail-join > * {
runs straight into the tab's top border, so no corner and no upper fillet. */ position: absolute;
body:not(.mobile-shell) inset: 0;
#right-context-rail[data-rail-tabs]:has( width: 100%;
> #right-rail-tabs > .right-rail-tab:first-child[aria-selected='true'] height: 100%;
)::before { overflow: visible;
border-top-right-radius: 0;
} }
#right-rail-tabs > .right-rail-tab:first-child[aria-selected='true']::before { /* A black silhouette casts the shadow; its clip keeps only the outside. */
content: none; .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. .right-rail-join-shadow path {
Cyber draws Display's and Context's frames on a ::before. */ 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) body:not(.mobile-shell)
#right-context-rail[data-rail-tabs] #right-context-rail[data-rail-tabs]
> #pp-toggles:not(.collapsed), > #pp-toggles:not(.collapsed),
@@ -237,6 +184,18 @@ body:not(.mobile-shell)
> [data-panel-id]:not(.collapsed) > [data-panel-id]:not(.collapsed)
> :is(.global-context-panel-inner, [data-panel-surface])::before { > :is(.global-context-panel-inner, [data-panel-surface])::before {
border-color: transparent; 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 /* 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 skin ────────────────────────────────────────────────────────── */
/* Cyber panels are chamfered frames (--cyber-panel-frame), not rounded /* Cyber panels are chamfered frames (--cyber-panel-frame), not rounded
cards: square joins, no fillets, and the strip starts below the frame's cards: square joins, no fillets, no blur, and the strip starts below the
upper chamfer so the first tab meets a straight edge. */ 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] { :root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
--right-rail-panel-width: 312px; --right-rail-panel-width: 312px;
--right-rail-display-width: var(--cyber-right-panel-width); --right-rail-tab-radius: 0px;
--right-rail-tab-radius: var(--btn-radius);
--right-rail-fillet: 0px; --right-rail-fillet: 0px;
--right-rail-corner: 10px; --right-rail-corner: 10px;
--right-rail-notch-top: max(10px, var(--rail-tab-top, 10px)); --right-rail-join-style: chamfer;
--right-rail-notch-bottom: min( --right-rail-join-shadow: 0 10px 28px rgba(0, 0, 0, 0.42);
calc(100% - 10px),
var(--rail-tab-bottom, 10px)
);
width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset)); width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset));
} }
@@ -281,49 +237,31 @@ body:not(.mobile-shell)
padding-top: 10px; 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 { :root[data-ui-theme='cyber'] .right-rail-tab {
border-radius: var(--btn-radius); border-radius: var(--btn-radius);
} }
:root[data-ui-theme='cyber'] .right-rail-tab:not([aria-selected='true']):hover { :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'] { :root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'],
border-color: var(--glass-border); :root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true']:hover {
border-radius: 0 var(--btn-radius) var(--btn-radius) 0; border-radius: 0;
background: var(--cyber-panel-surface); background-color: transparent;
box-shadow: -3px 0 0 var(--cyber-red) inset; box-shadow: -3px 0 0 var(--cyber-red) inset;
backdrop-filter: none;
-webkit-backdrop-filter: none;
color: var(--cyber-red-bright); color: var(--cyber-red-bright);
} }
:root[data-ui-theme='cyber'] :root[data-ui-theme='cyber'] .right-rail-join-glass {
body:not(.mobile-shell) box-shadow:
#right-context-rail[data-rail-tabs]:has( 3px 0 0 rgba(196, 84, 84, 0.2) inset,
> [data-panel-id]:not(.collapsed):not([hidden]) -1px 0 0 rgba(226, 235, 236, 0.05) inset;
)::before { backdrop-filter: none;
border-radius: 0; -webkit-backdrop-filter: none;
/* --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 ───────────────────────────────────────── */ /* ── Phones keep the sheet shell ───────────────────────────────────────── */