diff --git a/.claude/launch.json b/.claude/launch.json index 47fdbce32..0668f92c5 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -24,7 +24,7 @@ "runtimeExecutable": "/bin/bash", "runtimeArgs": [ "-c", - "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve website --port $PORT" + "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && export GROWTH_FORM_POLICY=growth_v1 && npx nx serve website --port $PORT" ], "port": 3000, "autoPort": true @@ -94,4 +94,4 @@ "port": 4200 } ] -} \ No newline at end of file +} diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 2580117c2..22ee6c235 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -52,7 +52,7 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p 'proof-heading', 'architecture-heading', 'stage-heading', - 'final-cta-heading', + 'open-source-heading', 'pilot-heading', 'faq-heading', ]; diff --git a/apps/website/src/app/opengraph-image.tsx b/apps/website/src/app/opengraph-image.tsx index cc01b80a0..613489e01 100644 --- a/apps/website/src/app/opengraph-image.tsx +++ b/apps/website/src/app/opengraph-image.tsx @@ -70,7 +70,12 @@ export default async function OpenGraphImage() { ))} -
+ {/* Three lines. The subhead names every capability, and at 24px/470 + it ran to four and overlapped the pills — the column is centred in + a fixed 630px card, so overflow collides rather than pushing. 530 + is the widest the 600px column's 64px left padding allows, and 20px + is what holds three lines without orphaning the last two words. */} +
{HERO_SUBHEAD}
diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 5d6c39df1..b78bc52ec 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -4,19 +4,16 @@ import { EnterpriseArchitecture } from '../components/landing/EnterpriseArchitec import { Stage } from '../components/landing/Stage'; import { TeamsBlock } from '../components/landing/TeamsBlock'; import { HomeFAQ } from '../components/landing/HomeFAQ'; -import { FinalCTA } from '../components/landing/FinalCTA'; +import { OpenSourceStrip } from '../components/landing/OpenSourceStrip'; import { RecentArticles } from '../components/landing/RecentArticles'; -import { PROVE_IT_ROWS } from '../lib/positioning'; // The homepage must stay statically rendered (no cookies()/headers()/dynamic): // the proof lines are read from the demo recording at build time, and the file // is traced into the deployment only as a safety net. import { STAGE_PROOF } from '../lib/stage-proof'; import { createPageMetadata, - HERO_SECONDARY_HREF, HOME_DESCRIPTION, HOME_TITLE, - INSTALL_OPTIONS, } from '../lib/site-metadata'; import { getFormPolicy } from '../lib/growth/form-policy'; @@ -41,30 +38,9 @@ export default function HomePage() { (live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */} - + {/* The open-source beat: a slim dark strip, not a second CTA. Copy lives + in OPEN_SOURCE_STRIP (positioning.ts). */} + diff --git a/apps/website/src/components/landing/FinalCTA.spec.tsx b/apps/website/src/components/landing/FinalCTA.spec.tsx index f44ec944c..791d475a8 100644 --- a/apps/website/src/components/landing/FinalCTA.spec.tsx +++ b/apps/website/src/components/landing/FinalCTA.spec.tsx @@ -91,29 +91,6 @@ describe('FinalCTA', () => { expect(screen.getByRole('link', { name: 'Talk to an engineer' }).getAttribute('href')).toBe('/contact'); }); - it('renders optional rows above the headline in the claim/api grammar', () => { - render( - , - ); - const list = screen.getByRole('list', { name: 'What you can prove first' }); - expect(list.querySelectorAll('li')).toHaveLength(2); - expect(screen.getByText('provideFakeAgent()')).toBeTruthy(); - const heading = screen.getByRole('heading', { level: 2 }); - expect(list.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(list.querySelector('.final-cta-prove-row-claim')?.textContent).toBe('No key, no server, no network'); - expect(list.querySelector('.final-cta-prove-row-api')?.textContent).toBe('provideFakeAgent()'); - }); - - it('renders no rows list when rows are omitted', () => { - render(); - expect(screen.queryByRole('list', { name: 'What you can prove first' })).toBeNull(); - }); - it('renders extra caption links after the first, separated by " · "', () => { render( ) : null} - {rows.length > 0 ? ( -
    - {rows.map((row) => ( -
  • - {row.claim} - {row.api} -
  • - ))} -
- ) : null}

{headline}

diff --git a/apps/website/src/components/landing/OpenSourceStrip.spec.tsx b/apps/website/src/components/landing/OpenSourceStrip.spec.tsx new file mode 100644 index 000000000..0c8857aba --- /dev/null +++ b/apps/website/src/components/landing/OpenSourceStrip.spec.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OpenSourceStrip } from './OpenSourceStrip'; +import { GITHUB_REPO_URL, OPEN_SOURCE_STRIP } from '../../lib/positioning'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/analytics/client', () => ({ + track: vi.fn(), + trackCtaClick: trackCtaClickMock, + trackExternalLinkClick: vi.fn(), +})); + +beforeEach(() => trackCtaClickMock.mockClear()); + +describe('OpenSourceStrip', () => { + it('renders the whole sentence as the section heading, emphasis included', () => { + render(); + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading.textContent).toBe( + `${OPEN_SOURCE_STRIP.lead} ${OPEN_SOURCE_STRIP.emphasis}`, + ); + expect(heading.querySelector('em')?.textContent).toBe(OPEN_SOURCE_STRIP.emphasis); + }); + + it('names the section by that heading', () => { + const { container } = render(); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('aria-labelledby')).toBe('open-source-heading'); + expect(container.querySelector('#open-source-heading')).toBeTruthy(); + }); + + it('sits on the dark surface at the tight rhythm', () => { + const { container } = render(); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('data-surface')).toBe('dark'); + // The strip's own padding override keys off [data-tight]; dropping the + // prop silently restores the full 48-80px band this replaced. + expect(section?.getAttribute('data-tight')).toBe('true'); + expect(section?.classList.contains('open-source-strip')).toBe(true); + }); + + it('links the repo in a new tab, with the mark beside the label', () => { + const { container } = render(); + const link = screen.getByRole('link', { name: OPEN_SOURCE_STRIP.cta }); + expect(link.getAttribute('href')).toBe(GITHUB_REPO_URL); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + // The mark is decorative: the accessible name above must stay the label + // alone, so the svg has to be hidden rather than merely unlabelled. + const svg = link.querySelector('svg'); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute('aria-hidden')).toBe('true'); + expect(container.querySelector('.open-source-strip-licence')?.textContent).toBe( + OPEN_SOURCE_STRIP.licence, + ); + }); + + it('is the page’s quiet beat: one action, no second CTA', () => { + const { container } = render(); + expect(container.querySelectorAll('a').length).toBe(1); + }); + + it('reports the fork click as a homepage CTA', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: OPEN_SOURCE_STRIP.cta })); + expect(trackCtaClickMock).toHaveBeenCalledWith({ + cta_id: 'hero_github', + track: 'developer', + surface: 'home', + destination_url: GITHUB_REPO_URL, + }); + }); +}); diff --git a/apps/website/src/components/landing/OpenSourceStrip.tsx b/apps/website/src/components/landing/OpenSourceStrip.tsx new file mode 100644 index 000000000..085195606 --- /dev/null +++ b/apps/website/src/components/landing/OpenSourceStrip.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { Button } from '../ui/Button'; +import { GitHubIcon } from '../ui/GitHubIcon'; +import { trackCtaClick } from '../../lib/analytics/client'; +import { GITHUB_REPO_URL, OPEN_SOURCE_STRIP } from '../../lib/positioning'; + +/** + * The homepage's open-source beat: one dark strip between the stage and the + * teams block. + * + * It replaced a full dark `FinalCTA` (the "prove the Angular UI" closer). The + * point of the section is that there is no catch and no upsell, so it is + * deliberately the quietest band on the page: one sentence at the left edge of + * the page container, the licence and the repo at the right, and no second + * pitch. The four library pages still close on `FinalCTA variant="dark"` — + * that component is untouched. + */ +export function OpenSourceStrip() { + return ( +
+ +
+

+ {OPEN_SOURCE_STRIP.lead}{' '} + {OPEN_SOURCE_STRIP.emphasis} +

+
+ + {OPEN_SOURCE_STRIP.licence} + + +
+
+
+
+ ); +} diff --git a/apps/website/src/components/shared/Footer.tsx b/apps/website/src/components/shared/Footer.tsx index 83e752fdc..718c0407c 100644 --- a/apps/website/src/components/shared/Footer.tsx +++ b/apps/website/src/components/shared/Footer.tsx @@ -8,16 +8,11 @@ import { trackCtaClick, trackExternalLinkClick } from '../../lib/analytics/clien import { DEMOS, demoCtaSuffix } from '../../lib/demos'; import { LogoMark } from '../ui/LogoMark'; import { Button } from '../ui/Button'; +import { GitHubIcon } from '../ui/GitHubIcon'; +import { GITHUB_REPO_URL } from '../../lib/positioning'; import { Eyebrow } from '../ui/Eyebrow'; import { Field, FormStatus, SubmitButton, TextInput, emailError, useGrowthForm } from '../form'; -function GitHubIcon() { - return ( - - ); -} function NpmIcon() { return ( @@ -146,17 +141,17 @@ export function Footer({ {showNewsletter ? : null} {/* Social links */}
- trackExternalLinkClick('https://github.com/cacheplane/angular-agent-framework', { + onClick={() => trackExternalLinkClick(GITHUB_REPO_URL, { surface: 'footer', cta_id: 'footer_github_icon', cta_text: 'GitHub', })} className="transition-colors footer-social-link" aria-label="GitHub"> - + trackFooterCta('Pricing', '/pricing')}> Pricing - trackExternalLinkClick('https://github.com/cacheplane/angular-agent-framework', { + onClick={() => trackExternalLinkClick(GITHUB_REPO_URL, { surface: 'footer', cta_id: 'footer_github', cta_text: 'GitHub', diff --git a/apps/website/src/components/shared/Nav.tsx b/apps/website/src/components/shared/Nav.tsx index 70d432416..4ad4bd7ee 100644 --- a/apps/website/src/components/shared/Nav.tsx +++ b/apps/website/src/components/shared/Nav.tsx @@ -10,6 +10,8 @@ import { import type { AnalyticsLibrary } from '../../lib/analytics/events'; import { LogoMark } from '../ui/LogoMark'; import { Button } from '../ui/Button'; +import { GitHubIcon } from '../ui/GitHubIcon'; +import { GITHUB_REPO_URL } from '../../lib/positioning'; import { DEMOS, demoCtaSuffix } from '../../lib/demos'; import { DocsContextContent } from '../docs/DocsControlPlane'; @@ -31,19 +33,6 @@ const toAnalyticsLibrary = (library: LibraryId | null): AnalyticsLibrary => { } }; -function GitHubIcon() { - return ( - - ); -} function MenuIcon() { return ( @@ -319,12 +308,12 @@ export function Nav() { )} trackExternalLinkClick( - 'https://github.com/cacheplane/angular-agent-framework', + GITHUB_REPO_URL, { surface: 'nav', cta_id: 'nav_github', @@ -481,12 +470,12 @@ export function Nav() { ))} { trackExternalLinkClick( - 'https://github.com/cacheplane/angular-agent-framework', + GITHUB_REPO_URL, { surface: 'mobile_nav', cta_id: 'mobile_nav_github', diff --git a/apps/website/src/components/ui/Button.tsx b/apps/website/src/components/ui/Button.tsx index c887cb168..32c0cfaa6 100644 --- a/apps/website/src/components/ui/Button.tsx +++ b/apps/website/src/components/ui/Button.tsx @@ -14,6 +14,8 @@ interface CommonProps { size?: Size; /** Optional right-side icon — typically an arrow for ghost links. */ trailingIcon?: ReactNode; + /** Optional left-side icon — typically a brand mark (e.g. the GitHub logo). */ + leadingIcon?: ReactNode; } type AnchorButtonProps = CommonProps & @@ -34,12 +36,14 @@ export function Button(props: ButtonProps) { variant = 'primary', size = 'md', trailingIcon, + leadingIcon, className, style, } = props; const content = ( <> + {leadingIcon ? : null} {children} {trailingIcon ? : null} @@ -52,6 +56,7 @@ export function Button(props: ButtonProps) { variant: _v, size: _s, trailingIcon: _t, + leadingIcon: _li, className: _cn, style: _st, ...anchorAttrs @@ -76,6 +81,7 @@ export function Button(props: ButtonProps) { variant: _v2, size: _s2, trailingIcon: _t2, + leadingIcon: _li2, className: _cn2, style: _st2, href: _h, diff --git a/apps/website/src/components/ui/GitHubIcon.tsx b/apps/website/src/components/ui/GitHubIcon.tsx new file mode 100644 index 000000000..33ddd87f3 --- /dev/null +++ b/apps/website/src/components/ui/GitHubIcon.tsx @@ -0,0 +1,22 @@ +/** + * The GitHub mark. + * + * One copy, three callers: the nav, the footer and the homepage's open-source + * strip. It was duplicated inline in the first two before the strip needed a + * third. `fill: currentColor` lets each caller colour it from its own scope — + * the dark section re-points `--color-text-primary`, so the strip's mark + * follows the button label without any extra rule. + */ +export function GitHubIcon({ size = 16 }: { size?: number }) { + return ( + + ); +} diff --git a/apps/website/src/lib/positioning.spec.ts b/apps/website/src/lib/positioning.spec.ts index ff6daad4b..b4d49b317 100644 --- a/apps/website/src/lib/positioning.spec.ts +++ b/apps/website/src/lib/positioning.spec.ts @@ -49,7 +49,9 @@ describe('positioning: hero copy', () => { it('names the exact category in eyebrow, H1, title and description', () => { expect(HERO_EYEBROW).toBe('Angular · LangGraph & AG-UI'); expect(HERO_H1).toBe('The open-source thread-plane for agents.'); - expect(HERO_SUBHEAD).toBe('Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.'); + expect(HERO_SUBHEAD).toBe( + 'Chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI. Your backend stays where it is.', + ); expect(HOME_TITLE).toBe('Threadplane — The open-source thread-plane for agents'); expect(HOME_DESCRIPTION).toBe( 'The open-source thread-plane for agents: chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI.', @@ -185,13 +187,16 @@ describe('homepage restructure copy (live-stage spec §3)', () => { } }); - it('carries the three prove-it rows the final CTA absorbs from the Test section', async () => { - const { PROVE_IT_ROWS } = await import('./positioning'); - expect(PROVE_IT_ROWS).toEqual([ - { claim: 'No key, no server, no network', api: 'provideFakeAgent()' }, - { claim: 'Script tool calls and interrupts', api: 'mockLangGraphAgent()' }, - { claim: 'Same UI code in test and production', api: 'Agent' }, - ]); + it('closes on the open-source offer: one sentence, the licence, the fork CTA', async () => { + const { OPEN_SOURCE_STRIP } = await import('./positioning'); + expect(`${OPEN_SOURCE_STRIP.lead} ${OPEN_SOURCE_STRIP.emphasis}`).toBe( + 'Yes, this is all free. Don’t like something? Fork us.', + ); + // The strip is one line on a wide viewport; long copy would wrap into the + // action group and undo the whole point of the shape. + expect(`${OPEN_SOURCE_STRIP.lead} ${OPEN_SOURCE_STRIP.emphasis}`.length).toBeLessThanOrEqual(60); + expect(OPEN_SOURCE_STRIP.licence).toBe('MIT'); + expect(OPEN_SOURCE_STRIP.cta).toBe('Fork on GitHub'); }); }); diff --git a/apps/website/src/lib/positioning.ts b/apps/website/src/lib/positioning.ts index be5b788c0..3260d64d7 100644 --- a/apps/website/src/lib/positioning.ts +++ b/apps/website/src/lib/positioning.ts @@ -12,7 +12,7 @@ export const HERO_H1 = 'The open-source thread-plane for agents.'; */ export const HERO_H1_LINES: readonly string[] = ['The open-source', 'thread-plane', 'for agents.']; export const HERO_SUBHEAD = - 'Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.'; + 'Chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI. Your backend stays where it is.'; /** * The subhead, split so Hero.tsx can marker-highlight the boundary claim. @@ -26,7 +26,7 @@ export interface HeroSubheadSegment { highlight?: boolean; } export const HERO_SUBHEAD_SEGMENTS: readonly HeroSubheadSegment[] = [ - { text: 'Chat, threads, approvals, and generative UI on Signals and DI. ' }, + { text: 'Chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI. ' }, { text: 'Your backend stays where it is.', highlight: true }, ]; @@ -81,13 +81,29 @@ export const RELIABILITY_RECEIPTS: readonly ReliabilityReceipt[] = [ }, ]; -// ── Prove it without a backend (spec §3, block 5): the Test rows the final CTA -// absorbs. ──────────────────────────────────────────────────────────────────── -export const PROVE_IT_ROWS = [ - { claim: 'No key, no server, no network', api: 'provideFakeAgent()' }, - { claim: 'Script tool calls and interrupts', api: 'mockLangGraphAgent()' }, - { claim: 'Same UI code in test and production', api: 'Agent' }, -] as const; +/** + * The public source repository. + * + * It lives here, not in site-metadata.ts: that module reaches node:fs through + * blog.ts, so importing it from a client component drags the filesystem into + * the browser bundle and the page 500s. positioning.ts is client-safe — the + * hero already imports it. + */ +export const GITHUB_REPO_URL = 'https://github.com/cacheplane/angular-agent-framework'; + +// ── The open-source strip (the slim dark band after the stage). One sentence, +// the licence, the repo. Bold in what it says, quiet in how it looks: the +// point is that there is no catch, and the remedy for disagreeing with us is +// a fork — so it must not read as another sales pitch. ────────────────────── +export const OPEN_SOURCE_STRIP = { + /** The sentence, up to the part that carries the emphasis. */ + lead: 'Yes, this is all free. Don’t like something?', + /** Rendered italic at the end of the same line. */ + emphasis: 'Fork us.', + /** The licence tag beside the action. */ + licence: 'MIT', + cta: 'Fork on GitHub', +} as const; // ── The stage rail (stage-rail spec §3–5): the four-claim ledger beside the // pinned demo. One label, one claim and one docs link per beat; the still diff --git a/apps/website/src/lib/site-metadata.spec.ts b/apps/website/src/lib/site-metadata.spec.ts index 9e5e36bd0..33b897638 100644 --- a/apps/website/src/lib/site-metadata.spec.ts +++ b/apps/website/src/lib/site-metadata.spec.ts @@ -22,7 +22,7 @@ describe('site positioning copy', () => { expect(LONG_SUBHEAD).toContain('open-source thread-plane for agents'); expect(LONG_SUBHEAD).toContain('LangGraph and AG-UI'); expect(HERO_SUBHEAD).toBe( - 'Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.', + 'Chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI. Your backend stays where it is.', ); expect(POSITIONING_PROOF_POINTS.map((p) => p.label)).toEqual([ 'LangGraph + AG-UI', diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 9fed94440..c10ab8bea 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -585,33 +585,6 @@ margin: 0; } -/* Prove-it rows above the closing headline. FinalCTA.tsx `rows`. */ -.final-cta-rows { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: 12px 24px; - list-style: none; - margin: 0 0 28px; - padding: 0; - text-align: left; -} -.final-cta-prove-row { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} -.final-cta-prove-row-claim { - font-family: var(--font-inter); - font-size: 14px; - color: var(--color-text-secondary); -} -.final-cta-prove-row-api { - font-family: var(--font-mono); - font-size: 12px; - color: var(--color-accent); - overflow-wrap: anywhere; -} /* FinalCTA dark variant — the Yes wall's partner section. */ [data-surface="dark"] .final-cta-inner { position: relative; @@ -646,6 +619,59 @@ } } +/* OpenSourceStrip — components/landing/OpenSourceStrip.tsx + * + * A strip, not a section: the sentence sits at the left edge of the page + * container and the action at the right, so it reads as an aside between the + * stage and the teams block. It deliberately undercuts the tight section + * rhythm (48–80px) — at that height the band still reads as a full closing + * beat, which is the volume this replaced. The [data-tight] selector is two + * attributes, so the override has to carry both to win. */ +[data-ui="section"][data-tight].open-source-strip { + padding-top: clamp(28px, 3.2vw, 44px); + padding-bottom: clamp(28px, 3.2vw, 44px); +} +.open-source-strip-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px 32px; + flex-wrap: wrap; +} +.open-source-strip-line { + font-family: var(--font-garamond); + font-size: 23px; + line-height: 1.35; + font-weight: 400; + letter-spacing: -0.005em; + color: var(--color-text-primary); + margin: 0; +} +.open-source-strip-line em { + font-style: italic; +} +.open-source-strip-actions { + display: flex; + align-items: center; + gap: 18px; + flex: none; +} +.open-source-strip-licence { + font-family: var(--font-inter); + font-size: 12px; + letter-spacing: 0.02em; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill, 999px); + padding: 4px 10px; + white-space: nowrap; +} +@media (max-width: 640px) { + .open-source-strip-line { + font-size: 21px; + } +} + /* HomeFAQ — components/landing/HomeFAQ.tsx */ .home-faq-intro { margin-bottom: 48px;