diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 1a5136f4b..5d47f3170 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -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: ''; + inherits: false; + initial-value: 0px; +} + +@property --tab-strip-fade-end { + syntax: ''; + 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; diff --git a/test/mobile-tab-strip-chips.test.ts b/test/mobile-tab-strip-chips.test.ts new file mode 100644 index 000000000..4c4913944 --- /dev/null +++ b/test/mobile-tab-strip-chips.test.ts @@ -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 { + const found: Record = {}; + 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 { + const out: Record = {}; + 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("''"); + 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> = {}; + 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 = {}; + 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')); + }); + }); +}); diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index 33533090f..25270e997 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -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); } });