mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+117
-16
@@ -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)
|
Phone Breakpoint (<600px)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -652,7 +685,7 @@ html.mobile-init .file-browser-panel {
|
|||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
max-height: 36px;
|
max-height: 36px;
|
||||||
gap: 2px;
|
gap: 6px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +693,36 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: none;
|
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 */
|
/* Smaller tabs for mobile */
|
||||||
.session-tab {
|
.session-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -671,10 +734,46 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-radius: 4px;
|
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 {
|
.session-tab .tab-status {
|
||||||
width: 4px;
|
width: 6px;
|
||||||
height: 4px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
/* 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;
|
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 {
|
.session-tab .tab-name {
|
||||||
max-width: 50px;
|
max-width: 80px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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
|
difference instead, which costs a little strip space on exactly one tab
|
||||||
and keeps tap-to-switch the majority of it.
|
and keeps tap-to-switch the majority of it.
|
||||||
|
|
||||||
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
|
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
|
||||||
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
|
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
|
||||||
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
|
(above), so every phone tab is that case now; a numbered one would sit 10px
|
||||||
further right. The centre clears the icons when
|
further left and hide the problem. The centre clears the icons when
|
||||||
|
|
||||||
reserved > icons + rightEdge - leftRunUp - gap
|
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
|
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.
|
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
|
||||||
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
|
Hit testing snaps to whole pixels, so a centre half a pixel short still
|
||||||
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
|
lands on the gear: the practical floor was measured at 40px (with the
|
||||||
clears it at 20px, so reasoning from the tabs on screen is exactly what
|
older 4px dot) and 44px keeps headroom. Pinned by
|
||||||
would put the centre back on the gear. Pinned by
|
|
||||||
test/mobile-tab-tap-zones.test.ts. */
|
test/mobile-tab-tap-zones.test.ts. */
|
||||||
.session-tab.active .tab-name {
|
.session-tab.active .tab-name {
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,9 +88,10 @@ describe('Tab Navigation', () => {
|
|||||||
if (tabNameExists) {
|
if (tabNameExists) {
|
||||||
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
|
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
|
||||||
const maxWidthPx = parseFloat(maxWidth);
|
const maxWidthPx = parseFloat(maxWidth);
|
||||||
// Should be 50px on mobile
|
// 80px on phones: wide enough to get past a shared `w1-` prefix,
|
||||||
expect(maxWidthPx).toBeLessThanOrEqual(60);
|
// still short enough that several tabs fit the strip.
|
||||||
expect(maxWidthPx).toBeGreaterThan(0);
|
expect(maxWidthPx).toBeLessThanOrEqual(96);
|
||||||
|
expect(maxWidthPx).toBeGreaterThanOrEqual(72);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user