fix(ui): right rail strip never scrolls: tabs shrink in proportion and clip their labels

A lane too short for the tab strip, even after the rail has climbed toward the HUD corner, shrank the strip into a scrollbar. The tabs now give way in proportion to their labels (flex 0 1 auto, min-height 0) and clip them at the end with a short fade; the join keeps corners no larger than half of a shrunken tab. The strip's scroll handling in placeJoin, the scroll listener, railJoinPath's tab-less path and the scroller marker are gone with the scrolling.

The expanded Radio Enable's reveal of the directory and Play lands its smooth scroll on scrollend when a rail layout pass, measuring the panels mid-scroll, puts the Context scroller's offset back and cancels it; the Radio browser gate stopped there since the strip-fit commit.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-28 11:23:57 +04:00
1 parent bff1d0eb29
commit 84c204b7c4
7 files changed
+95 -157

No files matched your search

+1 -2
View File
@@ -2,8 +2,7 @@
Imagery while shown) is a vertical tab strip with sideways labels instead of
an accordion of collapsed launcher cards: at most one panel is open in every
desktop HUD layout, and the open tab and its panel share one surface and
outline, like a folder tab. On a short window the rail rises toward the HUD
corner above it so the whole strip stays in view. A first-time visitor on a
outline, like a folder tab. On a short window the rail rises toward the HUD corner above it and the tabs shrink, clipping their labels, rather than scroll. A first-time visitor on a
desktop finds Display open; a stored choice still wins. The panels' own
collapse arrows are gone on desktop: the tab opens and closes a panel, and
Escape inside one closes it and returns focus to its tab; the strip is a
+5 -3
View File
@@ -1487,7 +1487,9 @@ gives every owner-collapsed panel back. Stable Display allocation avoids
unnecessary style writes.
The compact Radio player is independent of Context: its launcher toggles it
whether Context is collapsed or open, and only an outside pointer, Escape, its
close button or its Details button closes it, never a layout pass.
close button or its Details button closes it, never a layout pass. The
expanded Enable's reveal of the directory and Play lands its smooth scroll
even when a rail layout pass, measuring the panels, cuts it short.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so the owner
rule can still give them back.
@@ -1511,8 +1513,8 @@ the DOM so Tab from the open tab reaches the tool slot and then the panel.
Labels are the panels' own translated header titles. The rail's lane shares
the left rail's top; when it is too short for the strip, the rail climbs toward
the HUD corner above as far as the strip needs (`src/ui/rightPanelRail.js`),
and only a lane still shorter scrolls the strip, with a thin scrollbar and the
open tab kept in view. Under the tabs the strip's
and a lane still shorter shrinks the tabs in proportion, clipping their
labels at the end; the strip never scrolls. Under the tabs the strip's
column keeps a tool slot (`#right-rail-tools`) that holds the compact Radio
launcher on desktop: `src/ui/radioPanelPlacement.js` moves it there from
Context's header, and back for the phone sheet, so the compact player stays one
+6
View File
@@ -184,6 +184,12 @@ test('only the expanded Radio Enable gesture requests the contained post-enable
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
// A rail layout pass measuring the panels mid-scroll cancels the smooth
// reveal; the helper lands it at the target when the scroll ends short.
assert.match(
radioControlsSource,
/scroller\.addEventListener\(\s*'scrollend',[\s\S]*?Math\.abs\(scroller\.scrollTop - next\) >= 1\)\s*scroller\.scrollTop = next;/,
);
});
test('right-rail context entry is transactional: activation result gates the mode', () => {
+12
View File
@@ -106,6 +106,18 @@ export class RadioControls {
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
// A rail layout pass that measures the panels mid-scroll puts the scroll
// offset back where it was, which cancels the smooth scroll: land it when
// the scroll ends short.
if (!reducedMotion)
scroller.addEventListener(
'scrollend',
() => {
if (!this.destroyed && Math.abs(scroller.scrollTop - next) >= 1)
scroller.scrollTop = next;
},
{ once: true },
);
return true;
}
+44 -85
View File
@@ -75,9 +75,8 @@ export function railTabPanels(rail) {
*
* @param {object} g
* @param {{x0: number, y0: number, x1: number, y1: number}} g.panel
* @param {{top: number, bottom: number, right: number}|null} g.tab The tab's
* extent; its left edge is the panel's right edge. Null draws the panel
* alone.
* @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.
@@ -100,24 +99,21 @@ export function railJoinPath({
const y0 = panel.y0 + inset;
const x1 = panel.x1 - inset;
const y1 = panel.y1 - inset;
// No tab (it is scrolled out of the strip): the panel alone.
const ty0 = tab ? tab.top + inset : NaN;
const ty1 = tab ? tab.bottom - inset : NaN;
const tx1 = tab ? tab.right - inset : NaN;
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;
// The tab's edges along the panel stay between the chamfers.
const tyA = Math.max(ty0, y0 + c);
const tyB = Math.max(tyA, Math.min(ty1, y1 - c));
const edge = tab
? [
`L ${P(x1, tyA)}`,
`L ${P(tx1, tyA)}`,
`L ${P(tx1, tyB)}`,
`L ${P(x1, tyB)}`,
]
: [];
const edge = [
`L ${P(x1, tyA)}`,
`L ${P(tx1, tyA)}`,
`L ${P(tx1, tyB)}`,
`L ${P(x1, tyB)}`,
];
return [
`M ${P(x0, y0 + c)}`,
`L ${P(x0 + c, y0)}`,
@@ -165,35 +161,28 @@ export function railJoinPath({
return { fillet, corner: Math.min(R, Math.max(0, room - fillet)) };
};
move(x0 + R, y0);
if (!tab) {
line(x1 - R, y0);
arc(R, 1, x1, y0 + R);
line(x1, y1 - R);
arc(R, 1, x1 - R, y1);
const first = tab.top - panel.y0 < 0.5;
const last = panel.y1 - tab.bottom < 0.5;
const above = fit(ty0 - y0);
const below = fit(y1 - ty1);
if (first) {
line(tx1 - r, y0);
arc(r, 1, tx1, y0 + r);
} else {
const first = tab.top - panel.y0 < 0.5;
const last = panel.y1 - tab.bottom < 0.5;
const above = fit(ty0 - y0);
const below = fit(y1 - ty1);
if (first) {
line(tx1 - r, y0);
arc(r, 1, tx1, y0 + r);
} else {
line(x1 - above.corner, y0);
arc(above.corner, 1, x1, y0 + above.corner);
line(x1, ty0 - above.fillet);
arc(above.fillet, 0, x1 + above.fillet, ty0);
line(tx1 - r, ty0);
arc(r, 1, tx1, ty0 + r);
}
line(tx1, ty1 - r);
arc(r, 1, tx1 - r, ty1);
if (!last) {
line(x1 + below.fillet, ty1);
arc(below.fillet, 0, x1, ty1 + below.fillet);
line(x1, y1 - below.corner);
arc(below.corner, 1, x1 - below.corner, y1);
}
line(x1 - above.corner, y0);
arc(above.corner, 1, x1, y0 + above.corner);
line(x1, ty0 - above.fillet);
arc(above.fillet, 0, x1 + above.fillet, ty0);
line(tx1 - r, ty0);
arc(r, 1, tx1, ty0 + r);
}
line(tx1, ty1 - r);
arc(r, 1, tx1 - r, ty1);
if (!last) {
line(x1 + below.fillet, ty1);
arc(below.fillet, 0, x1, ty1 + below.fillet);
line(x1, y1 - below.corner);
arc(below.corner, 1, x1 - below.corner, y1);
}
line(x0 + R, y1);
arc(R, 1, x0, y1 - R);
@@ -239,10 +228,6 @@ export function createRightRailTabs({
strip.setAttribute('role', 'tablist');
strip.setAttribute('aria-orientation', 'vertical');
strip.setAttribute('aria-label', t('Panels'));
// The rail's measuring pass (rightPanelRail.js) lifts the column's lane
// clamp, which would reset a scrolled strip; the marker has it put the
// offset back.
strip.setAttribute('data-rail-scroller', '');
const tools = documentRef.createElement('div');
tools.id = RIGHT_RAIL_TOOLS_ID;
column.append(strip, tools);
@@ -296,7 +281,7 @@ export function createRightRailTabs({
typeof Observer === 'function' ? new Observer(() => sync()) : null;
// Tab heights follow their labels (fonts, locale) and panel heights their
// content and allocation: re-measure the join when the strip or a panel
// changes size and on strip scroll.
// changes size.
const Resize = documentRef.defaultView?.ResizeObserver;
const resizer =
typeof Resize === 'function' ? new Resize(() => sync()) : null;
@@ -325,28 +310,13 @@ export function createRightRailTabs({
* extended down to cover the tab if that is shorter, but never past the
* rail's lane (--right-stack-max-height).
*/
let shownTab = null;
function placeJoin(tab, panel) {
if (!tab) {
rail.style.removeProperty('--rail-tab-bottom');
join.hidden = true;
shownTab = null;
return;
}
// A short lane makes the strip scroll. A newly opened tab is brought into
// view once; after that the strip scrolls as the user wishes and the join
// follows the visible part of the tab, or the panel alone.
const viewHeight = strip.clientHeight;
const scrolls = Number.isFinite(viewHeight) && viewHeight > 0;
if (scrolls && tab !== shownTab) {
const above = tab.offsetTop - strip.scrollTop;
const below =
tab.offsetTop + tab.offsetHeight - (strip.scrollTop + viewHeight);
if (above < 0) strip.scrollTop += above;
else if (below > 0) strip.scrollTop += below;
}
shownTab = tab;
const top = tab.offsetTop - (strip.scrollTop || 0);
const top = tab.offsetTop;
rail.style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}px`);
const computed = documentRef.defaultView?.getComputedStyle?.(rail);
const corner = token(computed, '--right-rail-corner', 16);
@@ -360,13 +330,8 @@ export function createRightRailTabs({
// column's to the rail.
const stripTop = (column.offsetTop || 0) + (strip.offsetTop || 0);
const stripLeft = (column.offsetLeft || 0) + (strip.offsetLeft || 0);
let tabTop = stripTop + top;
let tabBottom = tabTop + tab.offsetHeight;
if (scrolls) {
tabTop = Math.max(tabTop, stripTop);
tabBottom = Math.min(tabBottom, stripTop + viewHeight);
}
const visible = tabBottom - tabTop >= 1;
const tabTop = stripTop + top;
const tabBottom = tabTop + tab.offsetHeight;
const geometry = {
panel: {
x0: panel.offsetLeft,
@@ -374,20 +339,17 @@ export function createRightRailTabs({
x1: panel.offsetLeft + panel.offsetWidth,
y1: Math.max(
panel.offsetTop + panel.offsetHeight,
visible ? Math.min(lane, tabBottom + fillet + 4 + corner) : 0,
Math.min(lane, tabBottom + fillet + 4 + corner),
),
},
tab: visible
? {
top: tabTop,
bottom: tabBottom,
right: stripLeft + tab.offsetLeft + tab.offsetWidth,
}
: null,
tab: {
top: tabTop,
bottom: tabBottom,
right: stripLeft + tab.offsetLeft + tab.offsetWidth,
},
corner,
tabRadius: visible
? Math.min(tabRadius, (tabBottom - tabTop) / 2)
: tabRadius,
// A tab shrunk by a short strip keeps corners no larger than half of it.
tabRadius: Math.min(tabRadius, (tabBottom - tabTop) / 2),
fillet,
style: chamfer ? 'chamfer' : 'round',
};
@@ -465,10 +427,8 @@ 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(column);
rail.append(join);
@@ -487,7 +447,6 @@ export function createRightRailTabs({
resizer?.disconnect();
strip.removeEventListener('click', onClick);
strip.removeEventListener('keydown', onKeydown);
strip.removeEventListener('scroll', onScroll);
column.remove();
join.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
+17 -58
View File
@@ -213,11 +213,6 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa
assert.deepEqual(f.tabs.tools.children, [], 'the slot starts empty');
assert.equal(f.strip.id, RIGHT_RAIL_TABS_ID);
assert.equal(f.strip.getAttribute('role'), 'tablist');
assert.equal(
f.strip.getAttribute('data-rail-scroller'),
'',
'the rail layout pass keeps the strip scroll offset across measuring',
);
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), 'vertical');
assert.deepEqual(
@@ -372,7 +367,6 @@ test('publishes the open tab extent on the rail and keeps a join element under t
offsetHeight: 300,
offsetWidth: 330,
});
f.strip.scrollTop = 0;
f.cctv.classList.remove('collapsed');
f.tabs.sync();
assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]);
@@ -382,13 +376,6 @@ test('publishes the open tab extent on the rail and keeps a join element under t
glass.style.clipPath,
/^path\('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 78 /,
);
f.strip.scrollTop = 20;
f.tabs.sync();
assert.equal(
props.get('--rail-tab-bottom'),
'122px',
'strip scroll is subtracted',
);
f.cctv.classList.add('collapsed');
f.tabs.sync();
assert.deepEqual([...props], [], 'no open panel, no join');
@@ -517,16 +504,16 @@ test('a rail panel hidden at mount gets its tab when its owner unhides it, from
tabs.destroy();
});
test('a newly opened tab is scrolled into the strip window once; the join follows the visible part', () => {
test('a tab shrunk by a short strip joins with corners no larger than half its height', () => {
const f = fixture();
// The strip's offsets are relative to its column, the column's to the rail.
Object.assign(f.tabs.column, { offsetTop: 0, offsetLeft: 320 });
Object.assign(f.strip, { clientHeight: 100, scrollTop: 0, offsetLeft: 10 });
Object.assign(f.strip, { offsetLeft: 10 });
const tab = f.tabFor('global-context-panel');
Object.assign(tab, {
offsetTop: 150,
offsetLeft: 0,
offsetHeight: 56,
offsetHeight: 12,
offsetWidth: 42,
});
Object.assign(f.context, {
@@ -537,49 +524,13 @@ test('a newly opened tab is scrolled into the strip window once; the join follow
});
f.context.classList.remove('collapsed');
f.tabs.sync();
assert.equal(
f.strip.scrollTop,
106,
'opened: scrolled so the tab bottom meets the window',
);
assert.equal(f.rail.style.props.get('--rail-tab-bottom'), '100px');
assert.equal(f.rail.style.props.get('--rail-tab-bottom'), '162px');
const [, glass] = f.tabs.join.children;
f.strip.scrollTop = 220;
f.tabs.sync();
assert.equal(
f.strip.scrollTop,
220,
'a sync after the user scrolled leaves the strip alone',
);
assert.equal(
glass.style.clipPath,
"path('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 284 A 16 16 0 0 1 314 300 L 16 300 A 16 16 0 0 1 0 284 L 0 16 A 16 16 0 0 1 16 0 Z')",
'the tab is out of view: the join is the panel alone',
);
f.strip.scrollTop = 190;
f.tabs.sync();
assert.match(
glass.style.clipPath,
/^path\('M 16 0 L 364 0 A 8 8 0 0 1 372 8 A 8 8 0 0 1 364 16 L 338 16 A 8 8 0 0 0 330 24 L 330 284 /,
'a tab half in view joins with the visible 16px and smaller corners',
/L 366 150 A 6 6 0 0 1 372 156 A 6 6 0 0 1 366 162 L 338 162 /,
'a 12px tab gets 6px corners',
);
const cctvTab = f.tabFor('cctv-panel');
Object.assign(cctvTab, {
offsetTop: 86,
offsetLeft: 0,
offsetHeight: 56,
offsetWidth: 42,
});
Object.assign(f.cctv, {
offsetTop: 0,
offsetLeft: 0,
offsetHeight: 300,
offsetWidth: 330,
});
f.context.classList.add('collapsed');
f.cctv.classList.remove('collapsed');
f.tabs.sync();
assert.equal(f.strip.scrollTop, 86, 'another tab opens: brought into view');
});
test('railJoinPath shrinks the fillet and corner to the room left, down to a shared edge', () => {
@@ -630,7 +581,7 @@ test('railJoinPath shrinks the fillet and corner to the room left, down to a sha
);
});
test('the stylesheet keeps phones on border-box, folds the strip into narrow desktops, shows a scrollbar and draws the focus ring inside', () => {
test('the stylesheet keeps phones on border-box, folds the strip into narrow desktops, shrinks tabs instead of scrolling and draws the focus ring inside', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
assert.match(
css,
@@ -640,8 +591,16 @@ test('the stylesheet keeps phones on border-box, folds the strip into narrow des
css,
/@media \(min-width: 721px\) and \(max-width: 760px\) \{\s*#right-context-rail\[data-rail-tabs\] \{\s*width: calc\(330px - var\(--right-rail-tabs-inset\)\);/,
);
assert.match(css, /#right-rail-tabs \{[^}]*scrollbar-width: thin;/);
assert.doesNotMatch(css, /#right-rail-tabs::-webkit-scrollbar/);
assert.match(css, /#right-rail-tabs \{[^}]*overflow: hidden;/);
assert.doesNotMatch(
css,
/#right-rail-tabs \{[^}]*(overflow: [^;]*auto|scrollbar-width)/,
);
// The tabs give way in proportion and clip their labels at the end.
assert.match(
css,
/\.right-rail-tab \{[^}]*flex: 0 1 auto;[^}]*align-items: flex-start;[^}]*min-height: 0;[^}]*overflow: hidden;[^}]*mask-image: linear-gradient\(to bottom, #000 calc\(100% - 6px\), transparent\);/,
);
// The rail layout pass measures the column's natural height to climb above
// its baseline before the strip has to scroll.
assert.match(
+10 -9
View File
@@ -88,12 +88,9 @@
min-height: 0;
/* Room for the open tab's bridge on the left. */
padding-left: var(--right-rail-tabs-gap);
overflow: hidden auto;
/* The rail climbs above its baseline to fit the column (rightPanelRail.js);
a lane still shorter overflows the strip: a thin scrollbar says there is
more, and the strip keeps the open tab in view (rightRailTabs.js). */
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
a lane still shorter shrinks the tabs. The strip never scrolls. */
overflow: hidden;
}
#right-rail-tools {
@@ -172,16 +169,20 @@ body:not(.mobile-shell)
outline-offset: -3px;
}
/* A tab shrinks in proportion to its label when the strip is short, and
clips the label at its end, faded over the bottom padding. */
.right-rail-tab {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
flex: 0 1 auto;
align-items: flex-start;
justify-content: center;
box-sizing: border-box;
width: var(--right-rail-tabs-width);
min-height: 40px;
min-height: 0;
padding: 6px 0;
overflow: hidden;
mask-image: linear-gradient(to bottom, #000 calc(100% - 6px), transparent);
border: 0;
border-radius: var(--right-rail-tab-radius);
background: transparent;
@@ -228,9 +229,9 @@ body:not(.mobile-shell)
}
.right-rail-tab {
min-height: 36px;
padding: 4px 0;
letter-spacing: 1.3px;
mask-image: linear-gradient(to bottom, #000 calc(100% - 4px), transparent);
}
#right-rail-tools {