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
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
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
* 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<string, {tab: HTMLElement, label: HTMLElement}>} */
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();
+59 -3
View File
@@ -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', () => {
+93 -155
View File
@@ -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 ───────────────────────────────────────── */