Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 355 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2386,3 +2386,358 @@ describe('the landing hero demo (TASK-147)', () => {
expect(landingPage.match(/v2-landing__shot-bar/g) ?? []).toHaveLength(1);
});
});

// ---------------------------------------------------------------------------
// The authenticated shell's height chain (TASK-157).
//
// Both defects this block covers are cascade outcomes, not missing text: the
// desktop one is the content div auto-placing into the `auto` banner row instead
// of the `1fr` row, and the phone one is a 0,2,0 `height: 100vh` sitting LATER in
// the sheet than every 0,2,0 `height: 100%` that should bound it. Presence cannot
// see either — `not.toContain('100vh')` passes while a duplicate rule at EOF
// repaints the phone, and `toContain('grid-row: 2')` passes while a later rule
// resets it to auto. So this block parses the sheet into rules carrying their
// at-rule context, specificity and document order, and asserts on the WINNER of
// the small cascade it can resolve — the same move as the #1868 specificity
// comparison, one level up. (ux-lead measured the defect in a browser at 390 and
// 1200; jsdom has no layout engine, so what is pinned here is the cascade, never
// the rendered box.)
//
// Stated limits, so the next reader does not over-read this: the parser handles
// simple selectors, max-/min-width conditions, and the `height` / `grid-row`
// declarations asserted on. It does not resolve percentages against containing
// blocks, does not model `!important`, and treats unmodelled media features
// (hover, prefers-reduced-motion) as applying — the conservative direction for a
// guard whose job is to catch a rule that WINS. The chain matcher at the end of
// this block walks the descendant axis for class-only compounds, and treats `>`
// as descendant; every chain it is asked about is single-child, so the two agree.
describe('the authenticated shell keeps its panes inside the banner row (TASK-157)', () => {
const v2 = read('../v2.css');

type ParsedRule = {
selector: string;
body: string;
media: string[];
order: number;
classes: string[];
specificity: number[];
};

const stripComments = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, '');

const classTokens = (compound: string): string[] => (
compound.match(/\.[A-Za-z0-9_-]+/g) ?? []
).map((token) => token.slice(1));

const lastCompound = (selector: string): string => (
selector.split(/\s+|>/).filter(Boolean).pop() ?? ''
);

// (ids, classes/attributes/pseudo-classes, elements). Enough for this sheet:
// the rules in dispute are 0,1,0 / 0,2,0 / 0,3,0 and no id or `!important` is
// involved.
const specificityOf = (selector: string): number[] => {
const ids = (selector.match(/#[A-Za-z0-9_-]+/g) ?? []).length;
const classes = (selector.match(/\.[A-Za-z0-9_-]+/g) ?? []).length
+ (selector.match(/\[[^\]]*\]/g) ?? []).length
+ ((selector.match(/:(?!:)[a-z-]+/g) ?? []).length);
const elements = (selector
.replace(/::?[a-z-]+(\([^)]*\))?/g, ' ')
.replace(/[.#][A-Za-z0-9_-]+/g, ' ')
.replace(/\[[^\]]*\]/g, ' ')
.match(/\b[a-z][a-z0-9-]*\b/g) ?? []).length;
return [ids, classes, elements];
};

const parseCascade = (css: string): ParsedRule[] => {
const rules: ParsedRule[] = [];
let order = 0;
const walk = (text: string, media: string[]): void => {
let i = 0;
while (i < text.length) {
const open = text.indexOf('{', i);
if (open < 0) return;
const head = text.slice(i, open).trim();
let depth = 1;
let j = open + 1;
while (j < text.length && depth > 0) {
if (text[j] === '{') depth += 1;
else if (text[j] === '}') depth -= 1;
j += 1;
}
const inner = text.slice(open + 1, j - 1);
if (head.startsWith('@')) {
walk(inner, media.concat([head]));
} else {
const parts = head.split(',');
let p = 0;
while (p < parts.length) {
const selector = parts[p].trim();
if (selector) {
rules.push({
selector,
body: inner,
media,
order,
classes: classTokens(lastCompound(selector)),
specificity: specificityOf(selector),
});
order += 1;
}
p += 1;
}
}
i = j;
}
};
walk(stripComments(css), []);
return rules;
};

const declarations = (body: string): Record<string, string> => {
const found: Record<string, string> = {};
body.split(';').forEach((entry) => {
const at = entry.indexOf(':');
if (at < 0) return;
found[entry.slice(0, at).trim()] = entry.slice(at + 1).trim();
});
return found;
};

// A rule applies at a width unless an at-rule it sits inside rules that width
// out. Conditions the guard does not model are treated as applying.
const appliesAt = (rule: ParsedRule, width: number): boolean => rule.media.every((at) => {
const max = at.match(/max-width:\s*(\d+)px/);
const min = at.match(/min-width:\s*(\d+)px/);
if (max && width > Number(max[1])) return false;
if (min && width < Number(min[1])) return false;
return true;
});

const outranks = (a: ParsedRule, b: ParsedRule): boolean => {
for (let i = 0; i < 3; i += 1) {
if (a.specificity[i] !== b.specificity[i]) return a.specificity[i] > b.specificity[i];
}
return a.order > b.order;
};

const winnerOf = (rules: ParsedRule[]): ParsedRule => rules.reduce(
(best, rule) => (outranks(rule, best) ? rule : best),
);

const rules = parseCascade(v2);

// The main content pane is rendered as `class="v2-pane v2-pane--main"`, so a
// rule can only match it if its last compound requires no other class.
const MAIN_PANE = new Set(['v2-pane', 'v2-pane--main']);
const mainPaneRules = rules.filter((rule) => rule.classes.length > 0
&& rule.classes.every((token) => MAIN_PANE.has(token)));

const BOUNDED_PANE_SELECTOR = '.v2-authenticated-shell__content .v2-shell .v2-pane';

test('the authenticated content always takes the shell\'s second grid row', () => {
const contentRules = rules.filter(
(rule) => rule.classes.includes('v2-authenticated-shell__content')
&& declarations(rule.body)['grid-row'] !== undefined,
);
// Non-vacuity: the assertions below are about a winner, and a winner chosen
// from an empty set asserts nothing.
expect(contentRules.length).toBeGreaterThan(0);
const winner = winnerOf(contentRules);
expect(declarations(winner.body)['grid-row']).toBe('2');
expect(winner.selector).toContain('v2-authenticated-shell__content');
});

test('no pane of the authenticated shell takes the viewport height on a phone', () => {
const atPhone = mainPaneRules.filter((rule) => declarations(rule.body)['height'] !== undefined
&& appliesAt(rule, 390));
const viewportHeight = atPhone.filter(
(rule) => /^100(?:\.0)?(?:vh|dvh|svh)$/.test(declarations(rule.body)['height']),
);
const bounded = atPhone.filter((rule) => declarations(rule.body)['height'] === '100%');
// Non-vacuity, both directions: a filter shaped "every 100vh rule is outranked"
// passes loudest when no 100vh rule is found, and a fix that deleted the
// bounding rule would otherwise pass by having nothing to compare.
expect(viewportHeight.length).toBeGreaterThan(0);
expect(bounded.length).toBeGreaterThan(0);

viewportHeight.forEach((rule) => {
const dominator = bounded.find((candidate) => outranks(candidate, rule));
// Named, not counted: a bare `dominated: false` reports that something is
// wrong without saying which rule now wins the phone.
expect({
selector: rule.selector,
height: declarations(rule.body)['height'],
outrankedBy: dominator ? dominator.selector : null,
}).toEqual({
selector: rule.selector,
height: declarations(rule.body)['height'],
outrankedBy: expect.any(String),
});
});

const winner = winnerOf(atPhone);
expect(declarations(winner.body)['height']).toBe('100%');
expect(winner.selector).toBe(BOUNDED_PANE_SELECTOR);
});

test('the bounded pane rule stays a phone rule', () => {
// It must not apply at desktop, where the pane keeps its 16px gutter
// (`.v2-authenticated-shell .v2-pane--main { height: calc(100% - 16px) }`).
//
// Stated on purpose: this requires the SPECIFICITY-scoped form, not merely a
// rule that happens to win. A same-specificity `.v2-shell .v2-pane {
// height: 100% }` added after the 100vh rule also bounds the phone today —
// and is the shape that has failed twice here, because the next rule written
// later takes the width back. Measured as m7 in the mutation campaign: it reds
// this pair deliberately.
const scoped = rules.filter((rule) => rule.selector === BOUNDED_PANE_SELECTOR);
expect(scoped.length).toBeGreaterThan(0);
expect(scoped.filter((rule) => appliesAt(rule, 390)).length).toBeGreaterThan(0);
expect(scoped.filter((rule) => appliesAt(rule, 1200))).toHaveLength(0);
});

test('the selectors this guard ranks are the ones the components render', () => {
// The chain the winning selector walks: content div > shell > pane. Checked
// against the components so the guard cannot pass on a chain the app does
// not build.
expect(read('../V2App.tsx')).toContain('v2-authenticated-shell__content');
expect(read('../components/V2Layout.tsx')).toContain("'v2-shell'");
expect(read('../components/V2Thread.tsx')).toContain('"v2-pane v2-pane--main"');
expect(read('../components/V2FeaturePage.tsx')).toContain('v2-pane v2-pane--main');
});

// ---- the phone pods list: the tab bar has to stay reachable -----------------
//
// ux-lead's design gate failed the first head here (390, `/v2`, verified user):
// 0 of 4 tabs hit-testable, on a short list and a long one. The height chain
// asserted above is still true under the fix — the aside is still `100%` —
// because what changed is the BOX that percentage resolves against, and the
// aside's stacking. So the same parser is asked two different questions: what
// wins `padding-bottom` on the list shell, and what wins `z-index` on the list
// aside. Neither is visible to a presence check.

// Class-only compounds. Anything carrying a pseudo or an attribute is skipped
// rather than mis-ranked, which is the honest direction: the alternative is a
// matcher that guesses.
const MODELABLE_COMPOUND = /^[A-Za-z0-9_.-]+$/;

// Outermost first; each entry is the class set of one rendered element.
const LIST_CHAIN = [
['v2-authenticated-shell__content'],
['v2-shell', 'v2-shell--list'],
['v2-pane', 'v2-pods-aside', 'v2-pods-aside--page'],
];
const TAB_BAR = [['v2-mobile-tabs']];

const matchesChain = (selector: string, chain: string[][]): boolean => {
const compounds = selector.split(/\s*>\s*|\s+/).filter(Boolean);
if (compounds.length === 0 || !compounds.every((c) => MODELABLE_COMPOUND.test(c))) {
return false;
}
let from = 0;
let i = 0;
while (i < compounds.length) {
const wanted = classTokens(compounds[i]);
let found = -1;
let j = from;
while (j < chain.length) {
const rendered = chain[j];
let all = true;
let k = 0;
while (k < wanted.length) {
if (!rendered.includes(wanted[k])) { all = false; break; }
k += 1;
}
if (all) { found = j; break; }
j += 1;
}
if (found < 0) return false;
from = found + 1;
i += 1;
}
return true;
};

const chainRules = (chain: string[][], width: number): ParsedRule[] => rules.filter(
(rule) => appliesAt(rule, width) && matchesChain(rule.selector, chain),
);

// `padding-bottom`, or the `padding` shorthand it may be written as. A guard
// that read only the longhand would pass while the shorthand took the space
// back — and the shorthand is how this rule is written.
const bottomPadding = (body: string): string | undefined => {
const found = declarations(body);
if (found['padding-bottom'] !== undefined) return found['padding-bottom'];
if (found.padding === undefined) return undefined;
const parts = found.padding.split(/\s+/).filter(Boolean);
// 2 values are vertical|horizontal, 3 are top|horizontal|bottom, 4 are
// top|right|bottom|left — so the bottom is the LAST value in 3- and 4-value
// form, and the first in 1- and 2-value form. Getting this wrong reads a
// `padding: 0 0 56px` as no reservation at all, which is how it was caught.
const bottomIndex: Record<number, number> = { 1: 0, 2: 0, 3: 2, 4: 2 };
const at = bottomIndex[parts.length];
return at === undefined ? undefined : parts[at];
};

test('the phone list reserves the tab bar\'s own height inside its box', () => {
const shellRules = chainRules(LIST_CHAIN, 390)
.filter((rule) => bottomPadding(rule.body) !== undefined);
// Non-vacuity, both parts: a winner of an empty set asserts nothing, and the
// specificity claim below needs a one-class competitor to be about.
expect(shellRules.length).toBeGreaterThan(1);
const oneClass = shellRules.filter((rule) => rule.specificity[1] <= 1);
expect(oneClass.length).toBeGreaterThan(0);

const barRules = chainRules(TAB_BAR, 390)
.filter((rule) => declarations(rule.body)['height'] !== undefined);
expect(barRules.length).toBeGreaterThan(0);
const barHeight = declarations(winnerOf(barRules).body)['height'];

const winner = winnerOf(shellRules);
// Derived, not restated: the reserved space must equal the bar it reserves,
// so a bar that grows reds this without anyone editing two numbers.
expect(bottomPadding(winner.body)).toBe(barHeight);
expect(bottomPadding(winner.body)).toMatch(/^[1-9]\d*px$/);
// …and it has to win on SPECIFICITY rather than on coming later. A one-class
// rule of the same name below it takes the padding back — the shape that has
// failed twice in this sheet, and why ux-lead's spec asks for the two-class
// selector. Mutation m4 reds this line and only this line.
expect(winner.specificity[1]).toBeGreaterThan(winnerOf(oneClass).specificity[1]);
});

test('the list aside does not stack over the tab bar', () => {
const asideRules = chainRules(LIST_CHAIN, 390)
.filter((rule) => declarations(rule.body)['z-index'] !== undefined);
// Non-vacuity: the reset needs something to beat. The drawer rule that
// stacks the aside at 60 must be in the set, or this ranks a winner among
// rules that never disagreed.
expect(asideRules.length).toBeGreaterThan(1);
expect(asideRules.map((rule) => declarations(rule.body)['z-index'])).toContain('60');

const barRules = chainRules(TAB_BAR, 390)
.filter((rule) => declarations(rule.body)['z-index'] !== undefined);
expect(barRules.length).toBeGreaterThan(0);
const barZ = Number(declarations(winnerOf(barRules).body)['z-index']);
expect(Number.isNaN(barZ)).toBe(false);

const aside = winnerOf(asideRules);
const z = declarations(aside.body)['z-index'];
// `auto` never forms a stacking context; any number at or above the bar's own
// z-index paints over it. The bar is fixed, so being under it in paint order
// is the whole of being tappable.
const stacks = z !== 'auto' && Number(z) >= barZ;
// Named, not boolean: a bare `stacks: true` says something is wrong without
// saying which rule now wins the list.
expect({ selector: aside.selector, z, stacks }).toEqual({
selector: aside.selector, z, stacks: false,
});
});

test('the list chain this guard ranks is the chain the components render', () => {
const layout = read('../components/V2Layout.tsx');
expect(layout).toContain('v2-shell v2-shell--list');
expect(layout).toContain('<V2MobileTabs');
expect(read('../components/V2PodsSidebar.tsx')).toContain("' v2-pods-aside--page'");
});
});
Loading
Loading