mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(mobile): make the phone header tab strip read as live tabs
On a phone every inactive tab rendered transparent: grey 11px text floating in unmarked gaps, a boxed Alt+N digit in each tab (a phone has no Alt key), names capped at 50px so a shared `w1-` prefix was most of what showed, and the tab that did not fit was chopped mid-word against the connection dot. The strip looked like a row of disabled labels. Phone block of mobile.css only: - Every header tab is a chip, filled and bordered from the skin's --control-* tokens, name in --text at weight 500. Written `:where(.header) .session-tab` so it stays at (0,1,0): the per-colour left border still wins, and sidebar layout (where the list leaves the header) is untouched. - The Alt+N digit is hidden in the header; inactive tabs drop their empty .tab-actions container, which padded the chip's right side. - Name cap 50px -> 80px, status dot 4px -> 6px, strip gap 2px -> 6px. - Scroll-driven edge fade: a mask on the strip whose widths follow its own inline scroll timeline (registered @property lengths), so the clipped tab dissolves into the edge. No JS; a strip that does not overflow gets no mask, and browsers without scroll timelines keep the old hard edge. The tap-zone arithmetic comment is updated for the numberless phone tabs and the bigger dot (the required reserve drops from 38px to 36px; the 44px min-width stays). test/mobile-tab-strip-chips.test.ts pins the (0,1,0) selector, the top-level @property registration and the timeline-after-shorthand order, each of which fails silently otherwise. test/mobile/tabs.test.ts follows the new name cap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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)
|
||||
============================================================================ */
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user