From d627a1ddc0ff3c506fc5755c42ffb20ff6ccdc05 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:26:49 -0700 Subject: [PATCH 1/2] fix(shell): bound the authenticated content to the shell's second row, and its panes to that box (TASK-157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules, both measured by ux-lead in a browser at 390 and 1200. `.v2-authenticated-shell__content` now takes `grid-row: 2` explicitly. Row 1 is the banner's `auto` row; a verified user has no banner, so the content div was the first grid child and auto-placement put it in that row. Any page shorter than the viewport then shrank the inset card to its own content and ended 178-195px above the bottom gutter instead of 14. At <=760 a pane inside the authenticated shell is `height: 100%` of the content box, not 100vh. That box has already had the verification banner subtracted, so a 100vh pane overflowed by the banner's height and its own `overflow: hidden` left the pod header parked under the banner: back button, title and inspector toggle untappable, and nothing to scroll back. The rule is scoped (0,3,0) so it wins on specificity rather than file order (#1868's lesson), and the 100vh rule it corrects stays in place for the routes outside this shell. Guarded by a cascade-aware block in v2-layout-invariants.test.ts: it parses the sheet into rules carrying at-rule context, specificity and document order, then asserts on the WINNER of the height chain at 390. Presence cannot see either defect — `not.toContain('100vh')` passes while a later duplicate repaints the phone, and `toContain('grid-row: 2')` passes while a later rule resets it. --- .../v2/__tests__/v2-layout-invariants.test.ts | 219 ++++++++++++++++++ frontend/src/v2/v2.css | 23 ++ 2 files changed, 242 insertions(+) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index ccacc74bf..ca57e4a39 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2386,3 +2386,222 @@ 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. +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 => { + const found: Record = {}; + 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'); + }); +}); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 143aa6d1e..f1c0a88b0 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -403,6 +403,14 @@ body.modern-ui.v2-canvas { } .v2-authenticated-shell__content { + /* Row 2 always, not by auto-placement. Row 1 is the `auto` row the banner + sits in, so a verified user (no banner) has the content as the FIRST grid + child and auto-placement put it in that row instead: the inset card then + sized to its own content and any page shorter than the viewport ended + 178-195px above the bottom gutter instead of 14 (TASK-157, ux-lead at + 1200). Row 1 collapses to 0 with nothing in it, which is the same layout + the banner variant already produced. */ + grid-row: 2; min-width: 0; min-height: 0; overflow: hidden; @@ -10041,6 +10049,21 @@ body.modern-ui.v2-canvas { height: 100vh; } + /* A pane INSIDE the authenticated shell is bounded by the shell's content box, + not by the viewport: that box has already had the verify-email banner + subtracted from it, so a 100vh pane overflows below the fold by the banner's + height. With its own `overflow: hidden` the pod header then sits under the + banner — the back button, the title and the inspector toggle all untappable, + and there is nothing to scroll (TASK-157, ux-lead at 390, ux-lead's P1). + Ranked (0,3,0) against the (0,2,0) rule above so it wins on specificity, not + on coming later: the same lesson as #1868, where a tie resolved by file + order silently erased a component's own declarations. The 100vh rule itself + stays — it is the right height outside this shell — and this guard is what + keeps it from winning here. */ + .v2-authenticated-shell__content .v2-shell .v2-pane { + height: 100%; + } + .v2-pods-aside { width: min(86vw, 320px); } From 02c1b7ef11d9e9d0618d591d94470db2f41c6b59 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:09:48 -0700 Subject: [PATCH 2/2] fix(v2): keep the phone pods list's tab bar inside the shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ux-lead's design gate failed `d627a1dd`: at 390 on /v2 a verified user got 0 of 4 tabs hit-testable, short list or long. Two causes, both from the TASK-157 bounding rule reaching a pane it was not aimed at, and one latent one it exposed. The list's aside carries `.v2-pane`, so the (0,3,0) rule that bounds panes inside the authenticated shell also replaced its `calc(100vh - 56px)` with 100% of the shell's content box — a box nothing reserved the tab bar inside. The aside grew the full 844 and ran under the fixed bar. Reserve the bar's own height as padding on the list shell. The two-class selector is required so the padding beats the (0,1,0) `.v2-shell` phone rules on specificity rather than on file order. The drawer reset left `z-index: 60` behind. `.v2-shell--list` is a flex container, and a flex item with a non-auto z-index forms a stacking context even while static, so the aside painted over the bar (fixed, z 40): not visible, not tappable. Complete the reset with `z-index: auto`. `calc(100vh - 56px)` is dropped rather than kept as a fallback — the bounding rule outranks it in every render of this shell, so it was already inert. Main failed this same way whenever the banner was up; the padding fixes that too. Guard: two winner assertions in the TASK-157 block, on the same parser. The reserved space must equal the tab bar's own winning height (derived from the sheet, so a bar that grows reds without anyone editing two numbers) and must win on specificity; the aside's winning z-index must be `auto` or below the bar's. Eight mutations, one red each — including the one that caught a bug in my own shorthand expansion (3 values are top|horizontal|bottom), which is why the padding is read through a resolver instead of by property name. Measured by ux-lead at 390 on a build carrying only these changes: 4/4 tabs, short and long, banner up and down. jsdom has no layout engine, so the suite pins the cascade and never the rendered box. --- .../v2/__tests__/v2-layout-invariants.test.ts | 138 +++++++++++++++++- frontend/src/v2/v2.css | 33 ++++- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index ca57e4a39..5758f13de 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2408,7 +2408,9 @@ describe('the landing hero demo (TASK-147)', () => { // 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. +// 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'); @@ -2604,4 +2606,138 @@ describe('the authenticated shell keeps its panes inside the banner row (TASK-15 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 = { 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('