mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
167 lines
7.4 KiB
TypeScript
167 lines
7.4 KiB
TypeScript
/**
|
|
* @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'));
|
|
});
|
|
});
|
|
});
|