Merge pull request #504 from Ark0N/fix/phone-tab-strip

fix(mobile): make the phone header tab strip read as live tabs
This commit is contained in:
Codeman maintainer
2026-09-28 16:21:10 +02:00
3 changed files with 287 additions and 19 deletions
+117 -16
View File
@@ -332,6 +332,39 @@ html.mobile-init .file-browser-panel {
}
}
/* Edge fade for the phone's header tab strip (used in the block below).
Registered so the keyframes can interpolate them as lengths; @property is
only valid at the top level, hence out here. */
@property --tab-strip-fade-start {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@property --tab-strip-fade-end {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
/* Driven by the strip's own scroll position, not by time: at the start only the
end edge fades, at the end only the start edge, and in between both. */
@keyframes tab-strip-edge-fade {
0% {
--tab-strip-fade-start: 0px;
--tab-strip-fade-end: 28px;
}
10%,
90% {
--tab-strip-fade-start: 28px;
--tab-strip-fade-end: 28px;
}
100% {
--tab-strip-fade-start: 28px;
--tab-strip-fade-end: 0px;
}
}
/* ============================================================================
Phone Breakpoint (<600px)
============================================================================ */
@@ -652,7 +685,7 @@ html.mobile-init .file-browser-panel {
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 36px;
gap: 2px;
gap: 6px;
padding: 0;
}
@@ -660,6 +693,36 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* Fade the strip's edges while there is more to scroll to, so the tab that
does not fit dissolves into the edge instead of being cut mid-word against
the connection dot. Scroll-driven, no JS: the timeline is the strip's own
inline scroll (keyframes + registered properties above this block). A strip
that does not overflow has an INACTIVE timeline, so the animation applies
nothing and both widths stay at their registered 0px, which is no mask at
all. Browsers without scroll timelines skip the block and keep the hard
edge. Header only: in sidebar layout the same list scrolls vertically. */
@supports (animation-timeline: scroll()) {
.header .session-tabs {
-webkit-mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-strip-fade-start),
#000 calc(100% - var(--tab-strip-fade-end)),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-strip-fade-start),
#000 calc(100% - var(--tab-strip-fade-end)),
transparent
);
/* The shorthand resets animation-timeline, so the timeline comes after. */
animation: tab-strip-edge-fade linear both;
animation-timeline: scroll(self inline);
}
}
/* Smaller tabs for mobile */
.session-tab {
flex-shrink: 0;
@@ -671,10 +734,46 @@ html.mobile-init .file-browser-panel {
border-radius: 4px;
}
/* Smaller status indicator on mobile */
/* Every tab in the header strip is a chip, not only the active one. Left
transparent, the strip read as a row of disabled labels: grey 11px text
floating in unmarked gaps, with nothing saying "tap me". Fill and border
come from the skin's control tokens, so the four light skins (which repaint
the header with --glass-bg) get a matching chip with no override block, and
the active tab's !important fill and border in styles.css still win.
`:where(.header)` keeps this at (0,1,0): the per-colour left border
(`.session-tab[data-color="red"]`, (0,2,0)) must still outrank the
border-color here, and in sidebar layout the list leaves the header, so
its rows are untouched. */
:where(.header) .session-tab {
border-radius: 8px;
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
}
:where(.header) .session-tab .tab-name {
font-weight: 500;
}
/* Only the active tab shows its action icons on a phone (see below), so on
every other tab the container is empty but still a flex item, and its gap
made the chip visibly wider on the right than on the left. */
:where(.header) .session-tab:not(.active) .tab-actions {
display: none;
}
/* The boxed digit is the Alt+1..9 shortcut hint. A phone has no Alt key, so
here it was only a second grey box inside every tab, and 20px of the name's
width. Every header tab is therefore numberless on a phone, which is the
case the active-tab reserve below is already sized for. */
:where(.header) .session-tab .tab-number {
display: none;
}
/* Status dot: 6px so an idle green reads at arm's length (4px was a speck). */
.session-tab .tab-status {
width: 4px;
height: 4px;
width: 6px;
height: 6px;
}
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
@@ -710,9 +809,12 @@ html.mobile-init .file-browser-panel {
opacity: 0.5;
}
/* Truncate tab names more aggressively on mobile */
/* Truncate tab names on mobile. 80px, not the old 50px: session names share
a `w1-` style prefix, and at 50px "w1-ingest-pipeline" became "w1-inge…"
and a clipped tab just "w1-", which says nothing about which session it is.
The 20px the hidden tab number gave back pays for most of the difference. */
.session-tab .tab-name {
max-width: 50px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -726,20 +828,19 @@ html.mobile-init .file-browser-panel {
difference instead, which costs a little strip space on exactly one tab
and keeps tap-to-switch the majority of it.
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
further right. The centre clears the icons when
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
(above), so every phone tab is that case now; a numbered one would sit 10px
further left and hide the problem. The centre clears the icons when
reserved > icons + rightEdge - leftRunUp - gap
= 50 + 9 - 17 - 4 = 38px
= 50 + 9 - 19 - 4 = 36px
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
+ padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1.
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
clears it at 20px, so reasoning from the tabs on screen is exactly what
would put the centre back on the gear. Pinned by
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
Hit testing snaps to whole pixels, so a centre half a pixel short still
lands on the gear: the practical floor was measured at 40px (with the
older 4px dot) and 44px keeps headroom. Pinned by
test/mobile-tab-tap-zones.test.ts. */
.session-tab.active .tab-name {
min-width: 44px;
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview The phone header's tab strip must read as live tabs.
*
* It used to render every inactive tab transparent: grey 11px text floating in
* unmarked gaps, a boxed Alt+N digit in each (a phone has no Alt key), names cut
* to 50px so a shared `w1-` prefix was most of what showed, and the tab that did
* not fit chopped mid-word against the connection dot. On a phone it looked like
* a row of disabled labels.
*
* The fix is four small rules in the phone block of mobile.css, and each has a
* way to be silently undone, which is what this file fences:
*
* - The chip rule is written `:where(.header) .session-tab` so it stays at
* (0,1,0). Written `.header .session-tab` it would be (0,2,0), tie with the
* per-colour `.session-tab[data-color="red"]` left border in styles.css, and
* win on source order (mobile.css loads later): every colour-tagged tab would
* lose its identity stripe.
* - The edge fade is scroll-DRIVEN (no JS). Its two widths must be registered
* with @property to interpolate, and @property is only valid at the top
* level: nested inside the phone @media it is dropped, the keyframes stop
* interpolating, and the fade snaps between states instead of following the
* scroll position.
* - `animation` is a shorthand that resets `animation-timeline`, so the
* timeline must be declared AFTER it or the fade silently becomes a 0s time
* animation.
*
* Parsed with postcss because the declarations live in nested at-rules. The
* rendered result (chips on dark and light skins, the fade at both scroll ends)
* was checked in a browser; this is the cheap regression fence. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type AtRule, type Declaration, type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
const CSS = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile.css'), 'utf8');
const ROOT = postcss.parse(CSS);
const PHONE_QUERY = '(max-width: 599px)';
/** Declarations of the rule matching `selector` inside the phone block (later rules win). */
function phoneDeclarations(selector: string): Record<string, string> {
const found: Record<string, string> = {};
ROOT.walkAtRules('media', (atRule) => {
if (atRule.params !== PHONE_QUERY) return;
atRule.walkRules((rule: Rule) => {
if (!rule.selectors.map((s) => s.trim()).includes(selector)) return;
rule.walkDecls((decl: Declaration) => {
found[decl.prop] = decl.value.trim();
});
});
});
return found;
}
/** The `@property` rule for `name`, wherever it sits. */
function propertyRule(name: string): AtRule | undefined {
let hit: AtRule | undefined;
ROOT.walkAtRules('property', (atRule) => {
if (atRule.params.trim() === name) hit = atRule;
});
return hit;
}
function declsOf(node: AtRule | Rule): Record<string, string> {
const out: Record<string, string> = {};
node.each((child) => {
if (child.type === 'decl') out[child.prop] = child.value.trim();
});
return out;
}
describe('phone header tab strip', () => {
describe('chips', () => {
const chip = phoneDeclarations(':where(.header) .session-tab');
it('gives every header tab a fill and border from the skin control tokens', () => {
// Tokens, not literals: the four light skins repaint the header with
// --glass-bg and define their own --control-* values.
expect(chip.background).toMatch(/^var\(--control-bg/);
expect(chip['border-color']).toMatch(/^var\(--control-border/);
expect(chip.color).toBe('var(--text)');
});
it('keeps the chip selector at (0,1,0) so per-colour borders still win', () => {
// The lookup above only matches the exact `:where(.header)` spelling, so a
// rewrite to `.header .session-tab` leaves it empty and fails here.
expect(Object.keys(chip).length).toBeGreaterThan(0);
expect(phoneDeclarations('.header .session-tab')).toEqual({});
});
it('hides the Alt+N digit, which a phone has no key for', () => {
expect(phoneDeclarations(':where(.header) .session-tab .tab-number').display).toBe('none');
});
it('drops the empty action container on inactive tabs only', () => {
// The active tab's gear and close live in .tab-actions, so the rule must
// stay scoped to :not(.active).
expect(phoneDeclarations(':where(.header) .session-tab:not(.active) .tab-actions').display).toBe('none');
expect(phoneDeclarations(':where(.header) .session-tab .tab-actions')).toEqual({});
});
it('leaves enough name to get past a shared w1- prefix', () => {
const maxWidth = Number.parseFloat(phoneDeclarations('.session-tab .tab-name')['max-width'] ?? '');
expect(maxWidth).toBeGreaterThanOrEqual(72);
});
});
describe('scroll-driven edge fade', () => {
it('registers both fade widths at the top level, as lengths starting at 0px', () => {
for (const name of ['--tab-strip-fade-start', '--tab-strip-fade-end']) {
const rule = propertyRule(name);
expect(rule, `${name} is not registered`).toBeDefined();
// Nested in @media it is invalid and silently ignored.
expect(rule!.parent?.type, `${name} must be top level`).toBe('root');
const d = declsOf(rule!);
expect(d.syntax).toBe("'<length>'");
expect(d['initial-value']).toBe('0px');
}
});
it('fades only the far edge at the start and only the near edge at the end', () => {
let frames: Record<string, Record<string, string>> = {};
ROOT.walkAtRules('keyframes', (atRule) => {
if (atRule.params.trim() !== 'tab-strip-edge-fade') return;
frames = {};
atRule.each((node) => {
if (node.type !== 'rule') return;
for (const sel of node.selectors) frames[sel.trim()] = declsOf(node);
});
});
expect(frames['0%']?.['--tab-strip-fade-start']).toBe('0px');
expect(Number.parseFloat(frames['0%']?.['--tab-strip-fade-end'] ?? '0')).toBeGreaterThan(0);
expect(frames['100%']?.['--tab-strip-fade-end']).toBe('0px');
expect(Number.parseFloat(frames['100%']?.['--tab-strip-fade-start'] ?? '0')).toBeGreaterThan(0);
});
it('masks the header strip behind a scroll-timeline feature check, timeline after the shorthand', () => {
let strip: Rule | undefined;
ROOT.walkAtRules('media', (media) => {
if (media.params !== PHONE_QUERY) return;
media.walkAtRules('supports', (supports) => {
if (!/animation-timeline:\s*scroll\(\)/.test(supports.params)) return;
supports.walkRules((rule) => {
if (rule.selectors.map((s) => s.trim()).includes('.header .session-tabs')) strip = rule;
});
});
});
expect(strip, 'no @supports-gated .header .session-tabs rule in the phone block').toBeDefined();
const props: string[] = [];
const d: Record<string, string> = {};
strip!.each((node) => {
if (node.type !== 'decl') return;
props.push(node.prop);
d[node.prop] = node.value.replace(/\s+/g, ' ').trim();
});
for (const prop of ['mask-image', '-webkit-mask-image']) {
expect(d[prop]).toContain('var(--tab-strip-fade-start)');
expect(d[prop]).toContain('var(--tab-strip-fade-end)');
}
expect(d.animation).toContain('tab-strip-edge-fade');
expect(d['animation-timeline']).toBe('scroll(self inline)');
expect(props.indexOf('animation-timeline')).toBeGreaterThan(props.indexOf('animation'));
});
});
});
+4 -3
View File
@@ -88,9 +88,10 @@ describe('Tab Navigation', () => {
if (tabNameExists) {
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
const maxWidthPx = parseFloat(maxWidth);
// Should be 50px on mobile
expect(maxWidthPx).toBeLessThanOrEqual(60);
expect(maxWidthPx).toBeGreaterThan(0);
// 80px on phones: wide enough to get past a shared `w1-` prefix,
// still short enough that several tabs fit the strip.
expect(maxWidthPx).toBeLessThanOrEqual(96);
expect(maxWidthPx).toBeGreaterThanOrEqual(72);
}
});