Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 117 additions & 16 deletions src/web/public/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)
============================================================================ */
Expand Down Expand Up @@ -652,14 +685,44 @@ html.mobile-init .file-browser-panel {
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 36px;
gap: 2px;
gap: 6px;
padding: 0;
}

.session-tabs::-webkit-scrollbar {
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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
166 changes: 166 additions & 0 deletions test/mobile-tab-strip-chips.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
});
7 changes: 4 additions & 3 deletions test/mobile/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});

Expand Down
Loading