diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 9d58a2612..7ed4da432 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1467,10 +1467,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { test('the zh hero title suffix cannot be split across lines', () => { // TASK-211. The zh hero reads 「与你的___对话」: the rotator swaps the object // and 对话 trails it. At 390px with the "Claude Code" term the suffix - // straddled the wrap — 对 ended line 2 at x=348.5 in a 342px column and 话 - // sat alone on line 3 (measured live; at 320 the 整个团队 term did the same). - // `white-space: nowrap` is what produces the measured fix, so that is what - // is pinned here — jsdom has no line boxes, and the browser is the gate. + // straddled the wrap — the h1's content box ran 24 → 366, 对 fitted + // (348.5), and 话 is 44.3px wide and would have ended 26.8px past the edge, + // so 话 sat alone on line 3 (measured live; at 320 the 整个团队 term did the + // same). `white-space: nowrap` is what produces the measured fix, so that is + // what is pinned here — jsdom has no line boxes, and the browser is the gate. // The companion assertion that the suffix is rendered as its own element // (so this rule has something to bind to) lives in // landingHeroContent.test.tsx; a declaration pin alone would survive the @@ -1478,6 +1479,27 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(landing, '.v2-landing__title-suffix')).toContain('white-space: nowrap'); }); + test('the zh hero suffix takes its own line on a phone', () => { + // TASK-213. Even with the suffix unbreakable (the test above), the zh h1 + // gains a line whenever a term is long enough to push 对话 onto a third + // line: at 320–414 it measures 94.6px under three terms and 140.6px under + // the fourth, so the lede, the CTAs and everything below move 46px once per + // rotation cycle. lily-shen ruled option 1 (2026-09-30 00:23Z): the suffix + // is a block at ≤680, which makes the height the same under every term. + // Scoped with `mediaAt` rather than a whole-sheet read or an `indexOf`: the + // sheet has one (max-width: 680px) block today, and the helper is what makes + // a later one unable to satisfy this assertion by accident — `mediaAt` + // returns '' when the at-rule is absent, so a dropped block reds rather than + // silently matching some other rule. The declaration pin binds to the + // browser gate in ux-lead's sweep; `margin-left: 0` is asserted separately + // from `display: block` because it is what puts 对 on the column's left + // edge (the base rule's .18em indent would otherwise move with it). + const phone = mediaAt(landing, '@media (max-width: 680px) {'); + expect(phone).not.toBe(''); + expect(selectorRuleBody(phone, '.v2-landing__title-suffix')).toContain('display: block'); + expect(selectorRuleBody(phone, '.v2-landing__title-suffix')).toContain('margin-left: 0'); + }); + test('reaction chips baseline-align emoji ink with the count (not box-centering)', () => { // align-items: center centers the spans' layout boxes, but Apple Color // Emoji ink extends below the baseline while digit ink does not, so the diff --git a/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx b/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx index c376b4eac..830bcf5c7 100644 --- a/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx +++ b/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx @@ -134,19 +134,28 @@ describe('V2LandingPage hero content (TASK-152)', () => { expect(zhSuffix.length).toBeGreaterThan(0); await act(async () => { await i18n.changeLanguage('zh-CN'); }); - const zh = renderLanding(); - const node = zh.container.querySelector('.v2-landing__title-suffix'); - expect(node).not.toBeNull(); - expect(node?.textContent).toBe(zhSuffix); - expect(node?.closest('h1.v2-landing__title')).not.toBeNull(); - zh.unmount(); - - // en ships an empty suffix, so no span is rendered there and the rule is - // zh-only by construction — this is the control that keeps the pin from - // quietly becoming an English-hero assertion. - await act(async () => { await i18n.changeLanguage('en'); }); - const en = renderLanding(); - expect(en.container.querySelector('.v2-landing__title-suffix')).toBeNull(); - en.unmount(); + try { + const zh = renderLanding(); + const node = zh.container.querySelector('.v2-landing__title-suffix'); + expect(node).not.toBeNull(); + expect(node?.textContent).toBe(zhSuffix); + expect(node?.closest('h1.v2-landing__title')).not.toBeNull(); + zh.unmount(); + + // en ships an empty suffix, so no span is rendered there and the rule is + // zh-only by construction — this is the control that keeps the pin from + // quietly becoming an English-hero assertion. + await act(async () => { await i18n.changeLanguage('en'); }); + const en = renderLanding(); + expect(en.container.querySelector('.v2-landing__title-suffix')).toBeNull(); + en.unmount(); + } finally { + // TASK-213: the language is the suite's ambient state, so the restore has + // to survive a failure above it. Unguarded, a red assertion in here left + // every later test rendering zh, and their English misses read as defects + // in the page rather than in this test (found by sprint-review on #2033, + // reproduced with a forced-failure probe). + await act(async () => { await i18n.changeLanguage('en'); }); + } }); }); diff --git a/frontend/src/v2/landing/v2-landing.css b/frontend/src/v2/landing/v2-landing.css index bc5f4d95a..3b2814613 100644 --- a/frontend/src/v2/landing/v2-landing.css +++ b/frontend/src/v2/landing/v2-landing.css @@ -63,8 +63,10 @@ margin-left: 0.18em; /* TASK-211: the zh hero is 「与你的___对话」, so this suffix is a word the reader has to see whole. At 390 with the "Claude Code" term the two - characters straddled the wrap — 对 ended line 2 at x=348.5 in a 342px - column and 话 sat alone on line 3. nowrap makes the suffix one unbreakable + characters straddled the wrap: the h1's content box ran 24 → 366, so 对 + fitted (348.5, i.e. 17.5px inside the right edge) and it was 话 — 44.3px + wide, which would have ended at 392.8, 26.8px past it — that sat alone on + line 3. nowrap makes the suffix one unbreakable unit: it moves to the next line as a pair instead of splitting. Measured live at 390 (44px/46px) and again at 320, where the 整个团队 term split it the same way. en ships an empty suffix and renders no span, so this is @@ -1250,6 +1252,18 @@ .v2-landing__footer { padding-top: 32px; } .v2-landing__footer-legal { padding: 16px 0 20px; } .v2-landing__title { font-size: 44px; line-height: 46px; } + /* TASK-213 (lily-shen's ruling, option 1): on a phone the suffix takes its own + line, so the title is 「与你的 / / 对话」 at every width and only the + term rotates. Without this the h1 is 94.6px under three of the four terms + and 140.6px under the fourth at 320–414, so the lede, the CTAs and every + section below jumped 46px once per cycle (measured on main abe19fe0 and + again on #2033). `margin-left: 0` is part of the rule rather than tidying: + the base rule's .18em is the inline separator, and on its own line it would + indent 对 by 7.9px. Specificity is the base rule's (0,1,0) and this block is + later in the sheet, so it wins by order — the same mechanism the TASK-205 + scroll-padding-top above documents. TASK-211's nowrap still applies here; + this rule changes `display`, so the two compose. */ + .v2-landing__title-suffix { display: block; margin-left: 0; } .v2-landing__lede { font-size: 16px; line-height: 26px; } .v2-landing__nav { gap: 14px; } .v2-landing__nav .v2-landing__navlink { display: none; }