feat(ui): join the open right rail tab to its panel

The open tab and its panel share one glass surface and one outline, like a folder tab: the panel card draws no border of its own, the rail's ::before draws the outline with a notch at the tab, the tab closes the shape with its own borders and two concave fillets round the inner corners. The strip publishes the open tab's extent and the panel's height on the rail; a short panel grows to reach its tab. Cyber joins square below its chamfer. Unselected tabs are plain labels.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-27 17:26:59 +04:00
1 parent f2e23249eb
commit 65c1ae67f2
6 files changed
+345 -45

No files matched your search

+5 -4
View File
@@ -1,7 +1,8 @@
- The right rail (Display, CCTV, Street Level, Context, plus Weather and Recent
Imagery while shown) is a vertical tab strip with sideways labels instead of
an accordion of collapsed launcher cards: the open panel sits beside the
strip and at most one panel is open in every desktop HUD layout. The tabs
are a keyboard-navigable tablist, Escape inside a panel returns focus to its
tab, and saved preferences and share links are unchanged. Phones keep their
sheet shell (gekh, #1425).
strip and at most one panel is open in every desktop HUD layout. The open
tab and its panel share one surface and one outline, like a folder tab; the
other tabs are plain labels. The tabs are a keyboard-navigable tablist,
Escape inside a panel returns focus to its tab, and saved preferences and
share links are unchanged. Phones keep their sheet shell (gekh, #1425).
+15
View File
@@ -1509,6 +1509,21 @@ 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.
## Phone layout
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 362 KiB

+42 -2
View File
@@ -104,6 +104,7 @@ 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;
@@ -113,15 +114,45 @@ export function createRightRailTabs({
documentRef.defaultView?.MutationObserver ?? globalThis.MutationObserver;
const observer =
typeof Observer === 'function' ? new Observer(() => sync()) : null;
// Tab heights follow their labels (fonts, locale) and panel heights their
// content and the rail's allocation, so the join is re-measured whenever
// the strip, a tab or a panel changes size or the strip scrolls.
const Resize = documentRef.defaultView?.ResizeObserver;
const resizer =
typeof Resize === 'function' ? new Resize(() => sync()) : null;
resizer?.observe(strip);
const observe = (panel) => {
if (!observer || observed.has(panel)) return;
if (observed.has(panel)) return;
observed.add(panel);
observer.observe(panel, {
resizer?.observe(panel);
observer?.observe(panel, {
attributes: true,
attributeFilter: ['class', 'hidden'],
});
};
/**
* 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.
*/
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');
style.removeProperty('--rail-panel-height');
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`);
}
/** Reconcile tabs with the rail: order, visibility, selection, labels. */
function sync() {
if (destroyed) return;
@@ -136,11 +167,13 @@ export function createRightRailTabs({
// Move a tab only when it is out of place: reinserting the focused tab
// would drop keyboard focus on every class change.
let cursor = strip.firstElementChild;
let open = null;
for (const panel of panels) {
const { tab, label } = entryFor(panel);
if (tab !== cursor) strip.insertBefore(tab, cursor);
else cursor = cursor.nextElementSibling;
const collapsed = panel.classList.contains('collapsed');
if (!collapsed && !open) open = { tab, panel };
const name = panelTitle(panel);
if (label.textContent !== name) label.textContent = name;
tab.setAttribute('aria-selected', String(!collapsed));
@@ -153,9 +186,11 @@ 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);
}
placeJoin(open?.tab, open?.panel);
}
const onClick = (event) => {
@@ -177,8 +212,10 @@ export function createRightRailTabs({
event.preventDefault();
visible[next].focus();
};
const onScroll = () => sync();
strip.addEventListener('click', onClick);
strip.addEventListener('keydown', onKeydown);
strip.addEventListener('scroll', onScroll, { passive: true });
observer?.observe(rail, { childList: true });
rail.prepend(strip);
sync();
@@ -190,10 +227,13 @@ export function createRightRailTabs({
if (destroyed) return;
destroyed = true;
observer?.disconnect();
resizer?.disconnect();
strip.removeEventListener('click', onClick);
strip.removeEventListener('keydown', onKeydown);
strip.removeEventListener('scroll', onScroll);
strip.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
placeJoin(null, null);
entries.clear();
},
};
+34
View File
@@ -322,6 +322,40 @@ 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', () => {
const f = fixture();
const props = new Map();
f.rail.style = {
setProperty: (key, value) => props.set(key, value),
removeProperty: (key) => props.delete(key),
};
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();
assert.deepEqual(
[...props],
[
['--rail-tab-top', '86px'],
['--rail-tab-bottom', '142px'],
['--rail-panel-height', '300px'],
],
);
f.strip.scrollTop = 20;
f.tabs.sync();
assert.equal(
props.get('--rail-tab-top'),
'66px',
'strip scroll is subtracted',
);
f.cctv.classList.add('collapsed');
f.tabs.sync();
assert.deepEqual([...props], [], 'no open panel, no join');
});
test('destroy removes the strip and stops listening', () => {
const f = fixture();
const tab = f.tabFor('cctv-panel');
+249 -39
View File
@@ -4,36 +4,62 @@
exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip
is the only launcher. Phones keep the sheet shell (mobile-shell.css).
One glass card per tab, labels read top to bottom like the HUD edge text.
`data-rail-tabs` arrives on the rail with the strip (rightRailTabs.js), 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 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. */
#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;
--right-rail-tabs-gap: 10px;
/* 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)
);
--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))
);
/* 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 +
var(--right-rail-corner)
);
width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset));
padding-right: var(--right-rail-tabs-inset);
}
:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
--right-rail-panel-width: 312px;
width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset));
}
#right-rail-tabs {
position: absolute;
top: 0;
right: 0;
z-index: 120;
display: flex;
flex-direction: column;
gap: 6px;
width: var(--right-rail-tabs-width);
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);
overflow: hidden auto;
scrollbar-width: none;
pointer-events: auto;
@@ -49,28 +75,24 @@
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 100%;
box-sizing: border-box;
width: var(--right-rail-tabs-width);
min-height: 56px;
padding: 12px 0;
border: 1px solid var(--glass-border);
border-radius: 9px;
background: var(--glass-bg);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid transparent;
border-radius: var(--right-rail-tab-radius);
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 9px;
line-height: 1;
letter-spacing: 2.2px;
text-transform: uppercase;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast);
}
@@ -81,41 +103,229 @@
white-space: nowrap;
}
.right-rail-tab:hover {
border-color: var(--glass-border-hover);
.right-rail-tab:not([aria-selected='true']):hover {
background: rgba(255, 255, 255, 0.06);
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. */
.right-rail-tab[aria-selected='true'] {
border-color: var(--accent);
background: var(--accent-dim);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
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: '';
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%
);
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));
}
/* 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-tabs > .right-rail-tab:first-child[aria-selected='true']::before {
content: none;
}
/* 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. */
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> #pp-toggles:not(.collapsed),
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> #pp-toggles:not(.collapsed)::before,
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
> :is(
.cctv-panel-inner,
.street-level-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner,
[data-panel-surface]
),
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
> :is(.global-context-panel-inner, [data-panel-surface])::before {
border-color: transparent;
}
/* The panel reaches at least to its tab's lower fillet and corner. The
wrapper carries the floor for the rail's allocated (focus) heights and the
card for its natural height. */
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed),
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
> :is(
.cctv-panel-inner,
.street-level-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner,
[data-panel-surface]
) {
min-height: var(--right-rail-panel-cover);
}
/* ── 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. */
: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-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)
);
width: calc(var(--right-rail-panel-width) + var(--right-rail-tabs-inset));
}
:root[data-ui-theme='cyber'] #right-rail-tabs {
padding-top: 10px;
}
:root[data-ui-theme='cyber'] .right-rail-tab {
border-color: var(--glass-border);
border-radius: var(--btn-radius);
background-color: var(--cyber-panel-surface);
background-image: var(--cyber-panel-surface-detail);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.32) inset,
0 8px 20px rgba(0, 0, 0, 0.28);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:root[data-ui-theme='cyber'] .right-rail-tab:not([aria-selected='true']):hover {
background: rgba(226, 235, 236, 0.08);
}
:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'] {
border-color: var(--cyber-red);
background-color: var(--cyber-panel-surface);
box-shadow:
3px 0 0 var(--cyber-red) inset,
0 8px 20px rgba(0, 0, 0, 0.28);
border-color: var(--glass-border);
border-radius: 0 var(--btn-radius) var(--btn-radius) 0;
background: var(--cyber-panel-surface);
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)
);
}
/* ── Phones keep the sheet shell ───────────────────────────────────────── */
body.mobile-shell #right-context-rail {
padding-right: 0;