From 9cf73c6143d5fbf17a8bdd010a66e652d5b4cd13 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:03:51 -0700 Subject: [PATCH] feat(v2): the closing CTA and the footer onto Signal (TASK-167 row E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close comes off its navy band and onto the page: type on white at 64/64, one sentence in two blocks so the break belongs to the copy and the accessible name stays whole, and a cobalt Get started beside a bordered Star on GitHub. The subtitle is gone with its string. The footer becomes the board's grid — the wordmark in a 1fr track, the four link columns as auto tracks 64 apart, mono headings, and the legal line spanning both. The legal rule also moves up beside the rest of the footer: after the phone block it was an equal-specificity base rule beating the phone padding it had to give up. The glyph mark goes entirely, from the component and the sheet, and the close's buttons join row A's 56/17 rules instead of restating sizes. --- frontend/src/i18n/locales/en.json | 4 +- frontend/src/i18n/locales/zh-CN.json | 4 +- .../v2/__tests__/v2-layout-invariants.test.ts | 189 +++++++++++++++++- frontend/src/v2/landing/V2LandingPage.tsx | 24 +-- frontend/src/v2/landing/v2-landing.css | 152 +++++++++----- 5 files changed, 297 insertions(+), 76 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4ac0e8b65..9ccfb1f20 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -866,8 +866,8 @@ "foot": "BYO agents free on every tier · Never per agent · Flat $12 per human · Self-host free forever" }, "finalCta": { - "title": "Give every agent its own place on your team.", - "sub": "Open the hosted app, or clone the repo and self-host in one command. It's all open." + "titleLead": "Your team already has a chat.", + "titleTail": "Give it an agent." }, "footer": { "product": "Product", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b36e1f001..907b49ef2 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -865,8 +865,8 @@ "foot": "自带 agent 所有档位免费 · 绝不按 agent 收费 · 每位成员固定 $12 · 自托管永久免费" }, "finalCta": { - "title": "给每个 agent 在团队里一个专属的位置。", - "sub": "打开托管应用,或克隆仓库、一条命令自托管。全部开源。" + "titleLead": "团队早就有群聊了。", + "titleTail": "给它配个 agent。" }, "footer": { "product": "产品", diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index cb30c23cc..1ee0a8eca 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -3581,9 +3581,15 @@ describe('TASK-167 row A — the landing hero onto Signal', () => { test('the hero CTA is white on cobalt; the bar\'s stays primary on white', () => { // --primary measured cobalt on cobalt: only the label showed (finding 2). expect(landingTsx).toContain('v2-landing__btn v2-landing__btn--onaccent" to={appHref}>{primaryLabel}'); - expect(landing).toContain('.v2-landing__hero-actions .v2-landing__btn--onaccent { padding: 0 26px; font-weight: 700; }'); + // Row E joins these two rules with the CTA band's own buttons, so the + // selector list's tail is optional rather than one literal line. + expect(landing).toMatch( + /\.v2-landing__hero-actions \.v2-landing__btn--onaccent(?:,\n\.v2-landing__cta \.v2-landing__btn--primary)? \{ padding: 0 26px; font-weight: 700; \}/, + ); const small = mediaAt(landing, '@media (max-width: 680px) {'); - expect(small).toContain('.v2-landing__hero-actions .v2-landing__btn--onaccent { padding: 0 20px; font-size: 16px; }'); + expect(small).toMatch( + /\.v2-landing__hero-actions \.v2-landing__btn--onaccent(?:,\n \.v2-landing__cta \.v2-landing__btn--primary)? \{ padding: 0 20px; font-size: 16px; \}/, + ); expect(small).toContain('.v2-landing__hero-actions .v2-landing__btn--onaccent-ghost { padding: 0 18px; font-size: 16px; }'); // The bar CTA sits on the page's white ground and keeps the fill. expect(landingTsx).toContain('v2-landing__btn v2-landing__btn--primary v2-landing__btn--sm" to={appHref}'); @@ -3614,7 +3620,9 @@ describe('TASK-167 row A — the landing hero onto Signal', () => { // on the band: the switch is a browser check. (The stub's existence is why // the reason is that and not "jsdom has no IntersectionObserver".) expect(landingTsx).toContain("typeof IntersectionObserver === 'undefined'"); - // Wordmark only in the bar; the mark is a footer element now. + // Wordmark only in the bar. (Row E removed the glyph mark entirely, so the + // bar assertion below is now a claim about a page that has no mark at all — + // the row E pins assert that.) const bar = landingTsx.slice(landingTsx.indexOf('v2-landing__bar'), landingTsx.indexOf('')); expect(bar).not.toContain('; the override has to carry the @@ -4210,3 +4218,178 @@ describe('TASK-167 row D — Use cases, Architecture, the proof panel and Pricin expect(resetBlock).not.toContain('price-tag'); }); }); + +// TASK-167 row E (TASK-196). The last row: the close comes off its navy band +// and onto the page, and the footer becomes the board's wordmark-beside-four- +// columns grid. Three removals ride along — the navy fill, the subtitle and the +// glyph mark — and each is asserted absent rather than merely unmentioned. +describe('TASK-167 row E — the closing CTA and the footer onto Signal', () => { + const landing = read('../landing/v2-landing.css'); + const landingTsx = read('../landing/V2LandingPage.tsx'); + + // `.v2-landing__cta` and `.v2-landing__footer` are ALSO the last lines of the + // shared 1312 padding-frame selector list at the top of the sheet, so a + // first-match reader returns that list's body — the padding — instead of the + // rule this row writes. Take the last match at line start. + const lastRuleBody = (selector: string): string => { + const idx = landing.lastIndexOf(`\n${selector} {`); + expect(idx).toBeGreaterThan(-1); + const open = landing.indexOf('{', idx); + return landing.slice(open + 1, landing.indexOf('}', open)); + }; + + test('the glyph mark is gone: no { + // Rows A and E both touch it; E lands second, so E deletes it. A future + // edit that wants the glyph back has to write it, not un-comment it. + expect(landingTsx).not.toContain(' { + const cta = lastRuleBody('.v2-landing__cta'); + expect(cta).toContain('display: flex'); + expect(cta).toContain('align-items: flex-start'); + expect(cta).toContain('gap: 24px'); + expect(cta).toContain('padding-top: 80px'); + expect(cta).toContain('padding-bottom: 80px'); + expect(cta).toContain('border-top: 1px solid var(--v2-border-soft)'); + // The band's fill, centring and its own padding are gone, not overridden. + expect(cta).not.toContain('background'); + expect(cta).not.toContain('text-align'); + expect(landing).not.toContain('.v2-landing__cta-sub'); + expect(landingTsx).not.toContain('cta-sub'); + // The frame above still gives the close its 1312 measure and side padding. + // (The frame's selector list is at column 0, one selector per line.) + expect(landing).toContain('.v2-landing__cta,\n.v2-landing__footer {'); + }); + + test('the close title is 64/64 on the display face, and not balanced', () => { + const title = lastRuleBody('.v2-landing__cta-title'); + expect(title).toContain('var(--v2-font-display, var(--v2-font))'); + expect(title).toContain('font-size: 64px'); + expect(title).toContain('line-height: 64px'); + expect(title).toContain('font-weight: 800'); + expect(title).toContain('letter-spacing: -0.03em'); + expect(title).toContain('color: var(--v2-text-primary)'); + expect(title).toContain('margin: 0'); + expect(title).not.toContain('max-width'); + // The board's note: balance split the sentence ("…has / a chat."). + expect(title).not.toContain('text-wrap'); + }); + + test('one sentence in two blocks: the break is the copy\'s, the name is whole', () => { + // Two spans joined by {' '} — each starts a line, and the space between two + // blocks makes no line box, so the heading's accessible name is the + // sentence and not "…chat. Give…" read as two. + expect(landingTsx).toContain( + "{t('landing.finalCta.titleLead')}{' '}", + ); + expect(landingTsx).toContain( + "{t('landing.finalCta.titleTail')}", + ); + expect(landing).toContain('.v2-landing__cta-line { display: block; }'); + + const en = JSON.parse(read('../../i18n/locales/en.json')); + expect(`${en.landing.finalCta.titleLead} ${en.landing.finalCta.titleTail}`) + .toBe('Your team already has a chat. Give it an agent.'); + const zh = JSON.parse(read('../../i18n/locales/zh-CN.json')); + expect(Object.keys(zh.landing.finalCta)).toEqual(['titleLead', 'titleTail']); + }); + + test('the old close copy is out of both locales', () => { + for (const locale of ['en.json', 'zh-CN.json']) { + const json = JSON.parse(read(`../../i18n/locales/${locale}`)); + expect(Object.keys(json.landing.finalCta)).toEqual(['titleLead', 'titleTail']); + expect(Object.keys(json.landing.finalCta)).not.toContain('title'); + expect(Object.keys(json.landing.finalCta)).not.toContain('sub'); + } + }); + + test('the footer is two tracks, and the four columns are auto tracks', () => { + const footer = lastRuleBody('.v2-landing__footer'); + expect(footer).toContain('grid-template-columns: minmax(0, 1fr) auto'); + expect(footer).toContain('column-gap: 64px'); + expect(footer).toContain('padding-top: 48px'); + expect(footer).toContain('padding-bottom: 0'); + expect(footer).toContain('border-top: 1px solid var(--v2-border-soft)'); + expect(footer).toContain('font-size: 13px'); + expect(footer).toContain('line-height: 20px'); + expect(footer).toContain('color: var(--v2-text-tertiary)'); + // The columns are NOT a nested count of the container's tracks: 591.1 + 64 + // + (182.1 + 87.1 + 71.3 + 124.3) with 64s between them is the board's 1312. + expect(footer).not.toContain('repeat(4'); + + const cols = lastRuleBody('.v2-landing__footer-cols'); + expect(cols).toContain('grid-template-columns: repeat(4, auto)'); + expect(cols).toContain('gap: 64px'); + expect(cols).toContain('padding-bottom: 48px'); + expect(lastRuleBody('.v2-landing__footer-col')).toContain('min-width: 0'); + }); + + test('the footer wordmark is the 16px one, and its headings are mono', () => { + // Row A sets 24 in the bar; the footer is 16/20, and (0,2,0) also beats row + // A's phone 22px. + expect(landing).toContain( + '.v2-landing__footer .v2-landing__brand-name { font-size: 16px; line-height: 20px; }', + ); + const title = lastRuleBody('.v2-landing__footer-title'); + expect(title).toContain('font-family: var(--v2-font-mono)'); + expect(title).toContain('font-size: 12px'); + expect(title).toContain('line-height: 20px'); + expect(title).toContain('font-weight: 500'); + expect(title).toContain('letter-spacing: 0.06em'); + expect(title).toContain('text-transform: uppercase'); + expect(title).toContain('color: var(--v2-text-primary)'); + expect(title).toContain('margin-bottom: 4px'); + }); + + test('the legal line spans the footer and is declared before the phone block', () => { + // Declared after the ≤680 block it would be an equal-specificity base rule + // beating the phone padding — which is where it used to sit. + // Base rules only: the phone block's own legal rule is indented, and that + // indentation is what distinguishes the two declarations of the class. + expect(landing.match(/^\.v2-landing__footer-legal \{/gm) || []).toHaveLength(1); + const legalAt = landing.lastIndexOf('\n.v2-landing__footer-legal {'); + expect(legalAt).toBeLessThan(landing.indexOf('@media (max-width: 680px)')); + const legal = lastRuleBody('.v2-landing__footer-legal'); + expect(legal).toContain('grid-column: 1 / -1'); + expect(legal).toContain('margin: 0'); + expect(legal).toContain('padding: 18px 0 22px'); + expect(legal).toContain('border-top: 1px solid var(--v2-border-soft)'); + expect(legal).toContain('font-size: 12px'); + expect(legal).toContain('line-height: 18px'); + }); + + test('the close joins row A\'s button rules instead of restating them', () => { + expect(landing).toContain('.v2-landing__cta .v2-landing__btn { height: 56px; font-size: 17px; }'); + expect(landing).toContain('.v2-landing__cta .v2-landing__btn--primary { padding: 0 26px; font-weight: 700; }'); + expect(landing).toContain('.v2-landing__cta .v2-landing__btn--ghost { padding: 0 22px; }'); + // The section's one cobalt button, on the page's white: base colours. + expect(landingTsx).toContain('v2-landing__btn v2-landing__btn--primary" to={appHref}>{primaryLabel}'); + expect(landingTsx).toContain('v2-landing__btn v2-landing__btn--ghost" href={REPO}'); + }); + + test('the tablet and phone values, and the two rules the tablet values replace', () => { + const tablet = mediaAt(landing, '@media (max-width: 900px)'); + expect(tablet).toContain('.v2-landing__cta-title { font-size: 48px; line-height: 50px; }'); + expect(tablet).toContain('.v2-landing__footer { grid-template-columns: minmax(0, 1fr); }'); + expect(tablet).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));'); + expect(tablet).toContain('gap: 28px 24px;'); + expect(tablet).toContain('padding: 24px 0 32px;'); + + const phone = mediaAt(landing, '@media (max-width: 680px)'); + expect(phone).toContain('.v2-landing__cta { padding-top: 48px; padding-bottom: 48px; gap: 20px; }'); + expect(phone).toContain('.v2-landing__cta-title { font-size: 36px; line-height: 38px; }'); + expect(phone).toContain('.v2-landing__cta .v2-landing__btn { height: 48px; }'); + expect(phone).toContain('.v2-landing__cta .v2-landing__btn--ghost { padding: 0 18px; font-size: 16px; }'); + expect(phone).toContain('.v2-landing__footer { padding-top: 32px; }'); + expect(phone).toContain('.v2-landing__footer-legal { padding: 16px 0 20px; }'); + // The old phone gaps are gone: the 900 block carries the column gaps now, + // and a phone rule left behind would beat it on specificity. + expect(phone).not.toContain('.v2-landing__footer-cols { gap: 28px; }'); + expect(phone).not.toContain('.v2-landing__footer { gap: 24px; }'); + }); +}); diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index e89680c90..8070160c7 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -78,15 +78,6 @@ const TRUSTED_AFFILIATIONS = [ { name: 'Ajaib', logo: '/logos/ajaib.svg' }, ] as const; -const Mark: React.FC<{ size?: number }> = ({ size = 26 }) => ( - -); - interface Stats { activePods?: number; activeAgents?: number; @@ -814,19 +805,24 @@ const V2LandingPage: React.FC = () => { {/* ---- Final CTA ---- */}
-

{t('landing.finalCta.title')}

-

{t('landing.finalCta.sub')}

+ {/* Two blocks, one sentence: the break is the copy's, not the + viewport's, and the space between blocks makes no line box — so + the heading's accessible name still reads as one sentence. */} +

+ {t('landing.finalCta.titleLead')}{' '} + {t('landing.finalCta.titleTail')} +

- {primaryLabel} - {t('landing.actions.starGithub')} + {primaryLabel} + {t('landing.actions.starGithub')}
{/* ---- Footer ---- */}