test(tabs): static CI guard that no rail or sidebar clamp out-ranks the rename unclamp (#534 landing)

The behavioural check for the detailed-rail rename clamp (#534, #526) lives in test/inline-rename.test.ts, a browser suite the CI gate does not run. This pins the cascade from styles.css itself, from computed selector specificity and source order, so a later clamp rule cannot silently out-rank the shared unclamp again. Mutation-checked: deleting the detailed-rail twin fails exactly that case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-05 19:51:59 +02:00
parent ff94637718
commit 566365e127
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Static guard: the inline rename editor is never line-clamped.
*
* A vertical rail or sidebar row clamps its name (2 lines, 3 in the detailed
* rail), and one shared rule unclamps `.tab-name.tab-name-renaming` so the
* editor can lay out as a flex row. A clamping rule MORE specific than that
* shared rule wins over it, which is how the detailed rail shipped with its
* 3-line clamp around the editor (#534, fixed alongside #526). The behavioural
* check lives in test/inline-rename.test.ts, a browser suite the CI gate does
* not run, so this pins the cascade from the stylesheet itself: every rule that
* clamps a rail or sidebar `.tab-name` must be out-ranked by an unclamp rule,
* either the shared one or its own `.tab-name-renaming` twin.
*/
import { readFileSync } from 'node:fs';
import postcss, { type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
type Specificity = [number, number, number];
function compare(a: Specificity, b: Specificity): number {
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
return 0;
}
const IDENT = /^-?(?:[\w-]|\\.)+/;
function closingParen(selector: string, open: number): number {
let depth = 0;
for (let i = open; i < selector.length; i++) {
if (selector[i] === '(') depth++;
else if (selector[i] === ')' && --depth === 0) return i;
}
return selector.length - 1;
}
/**
* Specificity of one complex selector, with :is()/:not()/:has() taking their
* most specific argument and :where() counting nothing. Hand-rolled because
* postcss (a dependency) splits selector lists but does not parse selectors;
* checked against postcss-selector-parser over every selector in styles.css.
*/
function specificityOf(selector: string): Specificity {
const total: Specificity = [0, 0, 0];
let i = 0;
while (i < selector.length) {
const ch = selector[i];
if (ch === '[') {
total[1]++;
i = selector.indexOf(']', i) + 1;
} else if (ch === '#' || ch === '.') {
total[ch === '#' ? 0 : 1]++;
i += 1 + (selector.slice(i + 1).match(IDENT)?.[0].length ?? 0);
} else if (ch === ':') {
const element = selector[i + 1] === ':';
const start = i + (element ? 2 : 1);
const name = selector.slice(start).match(IDENT)?.[0] ?? '';
i = start + name.length;
let args: string | null = null;
if (selector[i] === '(') {
const end = closingParen(selector, i);
args = selector.slice(i + 1, end);
i = end + 1;
}
if (element) total[2]++;
else if (name === 'where') continue;
else if (['is', 'not', 'has'].includes(name) && args !== null) {
const max = postcss.list
.comma(args)
.map(specificityOf)
.sort((a, b) => compare(b, a))[0] ?? [0, 0, 0];
for (let k = 0; k < 3; k++) total[k] += max[k];
} else total[1]++;
} else if (/[A-Za-z_]/.test(ch)) {
total[2]++;
i += selector.slice(i).match(IDENT)?.[0].length ?? 1;
} else i++;
}
return total;
}
type Entry = { selector: string; specificity: Specificity; order: number; rule: Rule };
/** Every complex selector in the stylesheet, flattened, with its source order. */
function entries(): Entry[] {
const out: Entry[] = [];
let order = 0;
postcss.parse(STYLES_CSS).walkRules((rule) => {
order++;
for (const selector of rule.selectors) {
out.push({ selector: selector.replace(/\s+/g, ' ').trim(), specificity: specificityOf(selector), order, rule });
}
});
return out;
}
function declares(rule: Rule, prop: string): string | null {
let value: string | null = null;
rule.walkDecls(prop, (decl) => {
value = decl.value.trim();
});
return value;
}
const unclamps = (rule: Rule) =>
['-webkit-line-clamp', 'line-clamp'].every((prop) => ['unset', 'none'].includes(declares(rule, prop) ?? ''));
describe('inline rename editor is never line-clamped', () => {
const all = entries();
const clamping = all.filter(
(e) =>
/\.tab-name$/.test(e.selector) &&
/\.tab-rail|\.session-sidebar/.test(e.selector) &&
/^\d+$/.test(declares(e.rule, '-webkit-line-clamp') ?? '')
);
const shared = all.filter((e) => e.selector.startsWith(':is(') && e.selector.endsWith('.tab-name.tab-name-renaming'));
it('finds the clamped rail rows and the shared unclamp rule', () => {
// The base rail row and the detailed rail card both clamp; if this drops to
// zero the selectors moved and the guard below is checking nothing.
expect(clamping.length).toBeGreaterThanOrEqual(2);
expect(shared).toHaveLength(1);
expect(unclamps(shared[0].rule)).toBe(true);
});
it.each(clamping.map((e) => [e.selector, e] as const))('%s is out-ranked while renaming', (_selector, clamp) => {
const twin = all.filter((e) => e.selector === `${clamp.selector}.tab-name-renaming` && unclamps(e.rule));
const winners = [...shared, ...twin].filter((u) => {
const byWeight = compare(u.specificity, clamp.specificity);
return byWeight > 0 || (byWeight === 0 && u.order > clamp.order);
});
expect(winners.map((w) => w.selector)).not.toEqual([]);
});
});