From 0dafe74ff77b162162ca7b9c50d2e8c141d3b71a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:27:05 -0700 Subject: [PATCH 1/2] feat(v2): trusted, wedge and In action onto Signal (TASK-167 row B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on row A (#2018) rather than main: row B's spec says the 1312 measure comes from row A, and both heads write the same :lang(zh) block, so a base without row A is a guaranteed conflict in the one region the two rows share. Trusted. The strip loses its tint fill and becomes a band between two hairlines, with the label set in mono at the same 12/20 the kickers use. The label copy moves to "Trusted by people from" in both locales. The live logos and the rolling marquee are untouched; the reduced-motion fallback is fixed below. Wedge. Left-aligned, unbordered (the strip above now carries the hairline, so keeping these drew the same rule twice, 80px apart), 56/58 in the display face. Sections. The rhythm is bottom-only now — 120 under each section, 0 on top — and the head, kicker and h2 are owned here for every section below, because the boards draw the same head in rows C-E. The features section takes a modifier class so its head is 40 at both widths. Feature rows. 762 + 64 + 486, which is the whole 1312 measure, with the even rows mirroring both tracks; the copy column owns its spacing; the titles finally use --v2-font-display (the rule named --font-display, which is not defined, so they had been falling back to Plex); and the ✓ glyph is replaced by a 6px square in --v2-text-primary, which is also the last of the off-palette blue. ux-lead's #2019 gate found the gap on the wrong side of the top hairline and the reduced-motion wrap not wrapping; sprint-review's code gate then found the test change missing from the pushed head and the read that was there still vacuous. All three are fixed in this commit. The 94 below the demo frame is now the strip's `margin-top`, not its `padding-top`. Inside the border it was a gap the hairline could not straddle, so the hairline sat on the frame's bottom edge (0 px) and the box measured 178 where the board draws 132. Outside it, the box is the board's 132 min-height and the gap is the gap: 94 / 132 / 36 / 36 at 1440 and 1200, 48 / 132 / 36 / 36 at 760, 48 / 110 / 28 / 28 at 390 (ux-lead's measurements on a production build). The ≤760 override becomes `margin-top: 48px` for the same reason: as `padding-top` it lost to the ≤680 block's `padding: 24px 0`, which is how the 48 disappeared at 390. The reduced-motion fallback put `flex-wrap` on the track, whose only visible child is one nowrap set, so the set ran past the marquee's `overflow: hidden` and clipped 3 / 4 / 7 / 8 of the 11 logos at 1440 / 1200 / 760 / 390. The wrap moves to the set (two classes, because the ≤680 `.v2-landing__trusted-set` rule is one class declared later and would win) and the mask comes off with it — at 390 its fade is 31px wide and would eat the first logo. The track's own `flex-wrap` is deleted rather than left behind: it wrapped nothing, and it was the declaration that let a substring pin read green through the whole defect. The guard that let it ship is replaced, not extended, and the read is now SCOPED rather than sliced. It was `landing.slice(landing.indexOf('prefers-reduced-motion'))` plus `toContain('flex-wrap: wrap')`: the slice starts at the first of the sheet's six reduced-motion blocks and runs to EOF, so it widens as the file grows, and the substring said nothing about which element carried the declaration. It now reads the fallback's own block, brace-matched from the at-rule to its close, selected by the selector inside it (`reducedMotionBlock(landing, '.v2-landing__trusted-track')`), asserts that scope is non-empty, and then reads the three rules by their own selectors — the set carries the wrap, the track must NOT, and both mask declarations are present. Two more holes the arms found in my own new pin, both fixed here rather than argued: `'mask-image: none'` is a substring of `-webkit-mask-image: none;`, so deleting the unprefixed declaration stayed green until both were pinned line-anchored; and `not.toContain('flex-wrap')` was tripped by the rule's own comment explaining the absence, so it is qualified on the declaration (`/flex-wrap\s*:/`) and the comment sits above the rule. Arms, each applied alone and restored byte-identical (sha256 of both files checked after every arm): | arm | result | |---|---| | 94 back to `padding-top` | 2 red — row A's pin and row B's | | `padding: 22px 0` → `padding-bottom: 22px` | 1 red | | ≤760 `margin-top` → `padding-top` | 1 red | | drop the set-wrap rule | 1 red | | delete the unprefixed mask-off declaration | 1 red | | delete the `-webkit-` mask-off declaration | 1 red | | track re-declares `flex-wrap` (the decoy) | 1 red | | **set rule moved into a LATER reduced-motion block** | **1 red** | | the same move under the OLD slice read | **173/173 pass** | | **old slice read + the set rule deleted** | **173/173 pass** | The last three are the finding, measured in both directions: a slice with a start and no end accepts a later block as the answer, and the substring accepted a declaration on the wrong element. Verified: frontend 119 suites / 1073 tests pass, tsc clean, eslint 0 errors on the changed .ts (7 pre-existing no-template-curly-in-string warnings; the .css is not linted by eslint). Two things the tests cannot see are stated on the PR rather than implied: the marquee loop (jsdom has no animation) and every pixel measurement above, which is a browser measurement by ux-lead, not something this suite can assert. --- frontend/src/i18n/locales/en.json | 4 +- frontend/src/i18n/locales/zh-CN.json | 4 +- .../v2/__tests__/V2LandingPage.stats.test.tsx | 2 +- .../v2/__tests__/v2-layout-invariants.test.ts | 288 +++++++++++++++--- frontend/src/v2/landing/V2LandingPage.tsx | 2 +- frontend/src/v2/landing/v2-landing.css | 231 ++++++++++---- 6 files changed, 428 insertions(+), 103 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9537dbc5d..17146f97c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -637,8 +637,8 @@ } }, "trusted": { - "label": "Trusted by users from", - "ariaLabel": "Trusted by users from {{affiliations}}" + "label": "Trusted by people from", + "ariaLabel": "Trusted by people from {{affiliations}}" }, "wedge": { "title": "Teammates, not subagents.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b06f143be..c9b2e53b7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -636,8 +636,8 @@ } }, "trusted": { - "label": "我们的用户来自", - "ariaLabel": "我们的用户来自 {{affiliations}}" + "label": "信任我们的人来自", + "ariaLabel": "信任我们的人来自 {{affiliations}}" }, "wedge": { "title": "是团队成员,不是用完即弃的 subagent。", diff --git a/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx b/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx index c588e9b58..5173a13e7 100644 --- a/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx +++ b/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx @@ -56,7 +56,7 @@ describe('V2LandingPage public proof', () => { const { container } = renderLanding(); - expect(screen.getByText('Trusted by users from')).toBeInTheDocument(); + expect(screen.getByText('Trusted by people from')).toBeInTheDocument(); // First set carries the accessible names (alt text), in provenance order. const sets = container.querySelectorAll('.v2-landing__trusted-set'); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index a58d9e70c..0f5148057 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -105,6 +105,54 @@ const selectorRuleBody = (css: string, selector: string): string => { return bodyStart === -1 || bodyEnd === -1 ? '' : css.slice(bodyStart, bodyEnd); }; +// Brace-matched slice of one at-rule. `indexOf` to the next `@media` is not +// enough on a sheet with nested blocks, and which block a naive slice returns +// depends on the ORDER the file happens to put them in — a guard that asserts on +// the wrong block reads as a pass. Module-level because three describes need it +// and three copies is how the fourth one drifts. +const mediaAt = (css: string, atRule: string): string => { + const at = css.indexOf(atRule); + if (at < 0) return ''; + const open = css.indexOf('{', at); + let depth = 0; + let end = open; + for (let i = open; i < css.length; i += 1) { + if (css[i] === '{') depth += 1; + if (css[i] === '}') { depth -= 1; if (depth === 0) { end = i; break; } } + } + return css.slice(at, end + 1); +}; + +// The `@media (prefers-reduced-motion: reduce)` block that carries `needle`, not +// the first such block in the sheet. There are six of them in v2-landing.css, +// and a slice from the first `prefers-reduced-motion` runs to EOF — 800 lines +// past the block it meant, widening as the file grows — so any later block could +// satisfy a read aimed at one of them. sprint-review measured the consequence on +// #2019: the fallback's own `flex-wrap: wrap` could be deleted outright and the +// guard stayed green, because `.v2-landing__footer-cols` declares the same +// property 300 lines below. A scope with a start and no end is not a scope. +const reducedMotionBlock = (css: string, needle: string): string => { + const marker = '@media (prefers-reduced-motion'; + for (let at = css.indexOf(marker); at !== -1; at = css.indexOf(marker, at + 1)) { + const block = mediaAt(css.slice(at), marker); + if (block.includes(needle)) return block; + } + return ''; +}; + +// Body of a rule that IS the whole selector — anchored at the start of a line, so +// it cannot match a rule where this selector is the TAIL of a longer one. +// `selectorRuleBody` finds the first substring match, which is wrong whenever an +// override names the class before the base rule does: the even-row rules say +// `.v2-landing__feature-row:nth-child(even) .v2-landing__feature-copy { order: 1 }` +// two rules above the base `.v2-landing__feature-copy {` — and a substring check +// reads the override's body instead of the rule's. +const topLevelRuleBody = (css: string, selector: string): string => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp('^' + escaped + ' \\{([^}]*)\\}', 'm').exec(css); + return match ? match[1] : ''; +}; + const cssVariable = (css: string, name: string): string | undefined => ( new RegExp(`${name}\\s*:\\s*([^;]+);`).exec(css)?.[1].trim() ); @@ -1370,10 +1418,42 @@ describe('v2 layout invariants (CSS rule presence)', () => { test('landing trusted-by marquee falls back to a static wrap under reduced motion', () => { // prefers-reduced-motion users get the old wrapping strip: animation off, // wrap on, duplicate set hidden (it exists only for the seamless loop). - const reduced = landing.slice(landing.indexOf('prefers-reduced-motion')); - expect(reduced).toContain('flex-wrap: wrap'); - expect(reduced).toContain("animation: none"); + // Read from the fallback's OWN block, brace-matched: the base marquee rule is + // above it, so a whole-sheet first-match reader returns that body instead, + // and a slice from the first `prefers-reduced-motion` runs to EOF and lets a + // later block answer for this one. Assert the scope is non-empty first — a + // scope that can silently be empty reads as a pass everywhere it is used. + const reduced = reducedMotionBlock(landing, '.v2-landing__trusted-track'); + expect(reduced).not.toBe(''); + expect(reduced).toContain('animation: none'); expect(reduced).toContain(".v2-landing__trusted-set[aria-hidden='true']"); + // The SET has to be the element that wraps. `flex-wrap: wrap` on the TRACK + // was the whole assertion here until row B, and it was green while 3 / 4 / + // 7 / 8 of the 11 logos were clipped at 1440 / 1200 / 760 / 390: the track's + // only visible child is one nowrap set, so the wrap has to be declared on + // the child that overflows (ux-lead's #2019 non-blocking finding). Pinned by + // the rule's OWN selector, because a substring check cannot say which + // element carries the declaration — which is exactly how this stayed green. + const wrapped = selectorRuleBody(reduced, '.v2-landing__trusted-marquee .v2-landing__trusted-set {'); + expect(wrapped).toContain('flex-wrap: wrap'); + expect(wrapped).toContain('justify-content: center'); + // ...and the track must NOT carry it. That declaration wrapped nothing (the + // track's only visible child is one nowrap set) and it is what kept the old + // substring pin green through the whole defect, so its absence is asserted + // rather than assumed: re-introducing it is a revert of this fix. Qualified on + // the DECLARATION, not the bare word: the rule's own comment says "flex-wrap" + // while explaining why it is absent, and a substring pin on the word would + // be tripped by the sentence that documents it. + const track = selectorRuleBody(reduced, '.v2-landing__trusted-track {'); + expect(track).not.toMatch(/flex-wrap\s*:/); + // The mask would eat the first logo at 390, where its fade is 31px wide. + // Both declarations are pinned LINE-ANCHORED, because `'mask-image: none'` is + // a substring of `-webkit-mask-image: none;` — the arm that deletes the + // unprefixed line came back green until this was qualified, which is the same + // defect class as the pin above, one declaration over. + const maskOff = selectorRuleBody(reduced, '.v2-landing__trusted-marquee {'); + expect(maskOff).toMatch(/^\s*mask-image:\s*none;/m); + expect(maskOff).toMatch(/^\s*-webkit-mask-image:\s*none;/m); }); test('landing adapter code scrolls inside its card instead of widening the page', () => { @@ -2437,19 +2517,6 @@ describe('the landing hero demo (TASK-147)', () => { // 760px)` is a prefix of the 641-760 query beside it, so a prefix match reads // whichever the file happens to put first and the guard quietly asserts on the // wrong block. - const mediaAt = (css: string, atRule: string): string => { - const at = css.indexOf(atRule); - if (at < 0) return ''; - const open = css.indexOf('{', at); - let depth = 0; - let end = open; - for (let i = open; i < css.length; i += 1) { - if (css[i] === '{') depth += 1; - if (css[i] === '}') { depth -= 1; if (depth === 0) { end = i; break; } } - } - return css.slice(at, end + 1); - }; - const phoneBlock = (css: string): string => mediaAt(css, '@media (max-width: 760px) {'); test('the demo is the workspace at its own canvas, not a smaller invention', () => { @@ -2557,8 +2624,10 @@ describe('the landing hero demo (TASK-147)', () => { test('even feature rows mirror their columns, and still stack on a phone', () => { // Without the mirror, :nth-child(even) only swaps order and rows 2 and 4 - // put the shot in the narrow column (496.6 px against 571.4 at 1200). - expect(ruleBody(landing, '.v2-landing__feature-row:nth-child(even)')).toContain('grid-template-columns: 1fr 1.15fr'); + // put the shot in the narrow column (423.7 px against 664.3 at 1200, and + // 486 against 762 at 1440 — the two columns are 762 + 64 gap + 486, the + // whole 1312 measure, exactly as the board draws them). + expect(ruleBody(landing, '.v2-landing__feature-row:nth-child(even)')).toContain('grid-template-columns: minmax(0, 486fr) minmax(0, 762fr)'); // :nth-child(even) (0,2,0) outranks the phone stacking rule (0,1,0), so the // phone block must name even rows too or they stay two columns at 390. const phone = landing.slice(landing.indexOf('@media (max-width: 680px)')); @@ -3435,19 +3504,6 @@ describe('TASK-167 row A — the landing hero onto Signal', () => { const landingTsx = read('../landing/V2LandingPage.tsx'); const landingEn = read('../../i18n/locales/en.json'); - const mediaAt = (css: string, atRule: string): string => { - const at = css.indexOf(atRule); - if (at < 0) return ''; - const open = css.indexOf('{', at); - let depth = 0; - let end = open; - for (let i = open; i < css.length; i += 1) { - if (css[i] === '{') depth += 1; - if (css[i] === '}') { depth -= 1; if (depth === 0) { end = i; break; } } - } - return css.slice(at, end + 1); - }; - test('one measure: 1312 on the page and on the product frame inside it', () => { // The frame padding is the page's measure, so it is asserted as a PAIR of // declarations: the left/right pair drifting apart is how a centred column @@ -3494,13 +3550,18 @@ describe('TASK-167 row A — the landing hero onto Signal', () => { expect(landingTsx).toContain('aria-describedby="v2-landing-demo-caption"'); // A frame outside the band carries the page measure itself. expect(landing).toContain('.v2-landing__hero-art,\n.v2-landing__trusted,'); - // The 94 below the frame is left where it is: the board draws that gap as - // the frame container's own height (456 − 360) on white, and dropping the - // strip's grey is row B's edit to this same rule, so the two move together - // there rather than being split across rows. + // The 94 below the frame is the board's gap between the frame container + // (456 − 360) and the strip, and row B landed it as the strip's own + // `margin-top` — outside its top hairline, where the board draws it. As top + // padding the gap fell inside the hairline, which then sat on the frame's + // bottom edge (ux-lead's #2019 finding 1). Both halves are pinned: the + // declaration that now carries the gap, and the one whose return is the + // defect. const phone = mediaAt(landing, '@media (max-width: 760px) {'); expect(phone).toContain('.v2-landing__hero-art { margin-top: -180px; }'); - expect(selectorRuleBody(landing, '.v2-landing__trusted {')).toContain('padding-top: 94px'); + const trusted = selectorRuleBody(landing, '.v2-landing__trusted {'); + expect(trusted).toContain('margin-top: 94px'); + expect(trusted).not.toContain('padding-top: 94px'); }); test('the copy column is uncapped, and the title carries the 1000', () => { @@ -3605,3 +3666,158 @@ describe('TASK-167 row A — the landing hero onto Signal', () => { } }); }); + +// TASK-167 row B (TASK-193). The three sections under the band: the trusted +// strip, the wedge, and the feature rows. The load-bearing parts are the two +// rules that only work as pairs — a mirrored grid whose halves must add up, and +// a marquee whose gap and padding-right must move together — plus the removal of +// the old ✓ mark, which is a glyph the file must not contain again. +describe('TASK-167 row B — trusted, wedge and In action onto Signal', () => { + const landing = read('../landing/v2-landing.css'); + const landingTsx = read('../landing/V2LandingPage.tsx'); + + test('the feature row is 762 + 64 + 486, and the columns cannot outgrow it', () => { + const row = selectorRuleBody(landing, '.v2-landing__feature-row {'); + // `minmax(0, …)` on both sides: bare `fr` has an automatic minimum of + // `auto`, so the 760px screenshot in the media column would push the copy + // narrower instead of letting the image shrink. + expect(row).toContain('grid-template-columns: minmax(0, 762fr) minmax(0, 486fr)'); + expect(row).toContain('gap: 64px'); + // 762 + 64 + 486 = 1312, the measure row A set — asserted as arithmetic + // rather than as three numbers that happen to sit next to each other. + expect(762 + 64 + 486).toBe(1312); + // The mirror has to reverse both tracks, or the even rows put the shot in + // the narrow column and the shots stop lining up down the page. + expect(ruleBody(landing, '.v2-landing__feature-row:nth-child(even)')).toContain('minmax(0, 486fr) minmax(0, 762fr)'); + expect(selectorRuleBody(landing, '.v2-landing__features {')).toContain('gap: 96px'); + }); + + test('the feature title is set in the display face, and the ✓ is gone', () => { + const title = selectorRuleBody(landing, '.v2-landing__feature-title {'); + expect(title).toContain('font-family: var(--v2-font-display'); + expect(title).toContain('font-size: 36px'); + expect(title).toContain('line-height: 40px'); + expect(title).toContain('text-wrap: balance'); + // The mark is a square now: a glyph that a font can render 3px wide at one + // weight and 14px at another is not a mark, and the off-palette blue was + // the only colour on the page outside the tokens. + const mark = selectorRuleBody(landing, '.v2-landing__feature-points li::before {'); + expect(mark).toContain("content: ''"); + expect(mark).toContain('width: 6px'); + expect(mark).toContain('height: 6px'); + expect(mark).not.toContain('✓'); + expect(landing).not.toContain('✓'); + expect(landing).not.toContain('#2f6feb'); + // The row holds the square against a two-line point; a square has no + // baseline, so centering is the only thing that lines them up. + expect(selectorRuleBody(landing, '.v2-landing__feature-points li {')).toContain('align-items: center'); + }); + + test('the copy column owns its spacing, so the UA margins cannot double it', () => { + // Via the anchored helper: two even-row overrides name this class before the + // base rule does, so a substring lookup returns `{ order: 1 }`. + const copy = topLevelRuleBody(landing, '.v2-landing__feature-copy'); + expect(copy).toContain('display: flex'); + expect(copy).toContain('flex-direction: column'); + expect(copy).toContain('gap: 16px'); + expect(copy).toContain('min-width: 0'); + //

,

and