Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,4 +94,4 @@
"port": 4200
}
]
}
}
2 changes: 1 addition & 1 deletion apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
7 changes: 6 additions & 1 deletion apps/website/src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export default async function OpenGraphImage() {
</div>
))}
</div>
<div style={{ display: 'flex', marginTop: 20, fontSize: 24, lineHeight: 1.45, color: CARD.inkSecondary, maxWidth: 470 }}>
{/* 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. */}
<div style={{ display: 'flex', marginTop: 20, fontSize: 20, lineHeight: 1.45, color: CARD.inkSecondary, maxWidth: 530 }}>
{HERO_SUBHEAD}
</div>
<div style={{ display: 'flex', marginTop: 26 }}>
Expand Down
32 changes: 4 additions & 28 deletions apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -41,30 +38,9 @@ export default function HomePage() {
(live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */}
<Stage proof={STAGE_PROOF} />

<FinalCTA
variant="dark"
rows={PROVE_IT_ROWS}
headline="Prove the Angular UI before you connect the backend."
subtext="Start with a fake agent, render a real Threadplane surface, then swap in LangGraph or AG-UI when the integration is ready."
primary={{
label: 'Start the quickstart',
href: INSTALL_OPTIONS[0].quickstartHref,
ctaId: 'hero_quickstart',
}}
secondary={{
label: 'Run live examples',
href: HERO_SECONDARY_HREF,
ctaId: 'hero_live_demo',
}}
caption="MIT · no account, no cloud"
captionLink={{ label: 'Talk to an engineer', href: '/contact' }}
captionLinks={[
{
label: 'Setup prompt for coding agents',
href: '/docs/chat/getting-started/coding-agents',
},
]}
/>
{/* The open-source beat: a slim dark strip, not a second CTA. Copy lives
in OPEN_SOURCE_STRIP (positioning.ts). */}
<OpenSourceStrip />
<TeamsBlock formPolicy={formPolicy} />
<HomeFAQ />
<RecentArticles />
Expand Down
23 changes: 0 additions & 23 deletions apps/website/src/components/landing/FinalCTA.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FinalCTA
rows={[
{ claim: 'No key, no server, no network', api: 'provideFakeAgent()' },
{ claim: 'Same UI code in test and production', api: 'Agent' },
]}
/>,
);
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(<FinalCTA />);
expect(screen.queryByRole('list', { name: 'What you can prove first' })).toBeNull();
});

it('renders extra caption links after the first, separated by " · "', () => {
render(
<FinalCTA
Expand Down
16 changes: 1 addition & 15 deletions apps/website/src/components/landing/FinalCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { trackCtaClick } from '../../lib/analytics/client';
import type { CtaId } from '../../lib/analytics/events';

interface FinalCTAProps {
/** Headline. Defaults to the homepage closer. */
/** Headline. Defaults to the product closer. */
headline?: string;
/** Sub-headline. Defaults to the homepage closer. */
subtext?: string;
Expand All @@ -24,9 +24,6 @@ interface FinalCTAProps {
caption?: string | null;
/** Optional link rendered after the caption text (e.g. "Talk to an engineer"). */
captionLink?: { label: string; href: string } | null;
/** Optional claim/api rows rendered above the headline (the homepage's
* "prove it without a backend" moment). Omitted everywhere else. */
rows?: readonly { readonly claim: string; readonly api: string }[];
/** Further caption links, rendered after `captionLink`. */
captionLinks?: readonly { label: string; href: string }[];
/**
Expand All @@ -49,7 +46,6 @@ export function FinalCTA({
secondary = DEFAULT_SECONDARY,
caption = null,
captionLink = null,
rows = [],
captionLinks = [],
variant = 'default',
}: FinalCTAProps = {}) {
Expand All @@ -66,16 +62,6 @@ export function FinalCTA({
</div>
) : null}
{rows.length > 0 ? (
<ul className="final-cta-rows" role="list" aria-label="What you can prove first">
{rows.map((row) => (
<li className="final-cta-prove-row" key={row.claim}>
<span className="final-cta-prove-row-claim">{row.claim}</span>
<span className="final-cta-prove-row-api">{row.api}</span>
</li>
))}
</ul>
) : null}
<h2 id="final-cta-heading" className="final-cta-heading">
{headline}
</h2>
Expand Down
74 changes: 74 additions & 0 deletions apps/website/src/components/landing/OpenSourceStrip.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<OpenSourceStrip />);
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(<OpenSourceStrip />);
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(<OpenSourceStrip />);
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(<OpenSourceStrip />);
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(<OpenSourceStrip />);
expect(container.querySelectorAll('a').length).toBe(1);
});

it('reports the fork click as a homepage CTA', () => {
render(<OpenSourceStrip />);
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,
});
});
});
62 changes: 62 additions & 0 deletions apps/website/src/components/landing/OpenSourceStrip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Section
surface="dark"
tight
ariaLabelledBy="open-source-heading"
className="open-source-strip"
>
<Container>
<div className="open-source-strip-inner">
<h2 id="open-source-heading" className="open-source-strip-line">
{OPEN_SOURCE_STRIP.lead}{' '}
<em>{OPEN_SOURCE_STRIP.emphasis}</em>
</h2>
<div className="open-source-strip-actions">
<span className="open-source-strip-licence">
{OPEN_SOURCE_STRIP.licence}
</span>
<Button
variant="secondary"
size="md"
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
leadingIcon={<GitHubIcon />}
onClick={() =>
trackCtaClick({
cta_id: 'hero_github',
track: 'developer',
surface: 'home',
destination_url: GITHUB_REPO_URL,
})
}
>
{OPEN_SOURCE_STRIP.cta}
</Button>
</div>
</div>
</Container>
</Section>
);
}
19 changes: 7 additions & 12 deletions apps/website/src/components/shared/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
);
}

function NpmIcon() {
return (
Expand Down Expand Up @@ -146,17 +141,17 @@ export function Footer({
{showNewsletter ? <NewsletterForm formPolicy={formPolicy} /> : null}
{/* Social links */}
<div className="flex items-center gap-4">
<a href="https://github.com/cacheplane/angular-agent-framework"
<a href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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">
<GitHubIcon />
<GitHubIcon size={18} />
</a>
<a href="https://www.npmjs.com/package/@threadplane/langgraph"
target="_blank"
Expand Down Expand Up @@ -198,10 +193,10 @@ export function Footer({
onClick={() => trackFooterCta('Pricing', '/pricing')}>
Pricing
</Link>
<a href="https://github.com/cacheplane/angular-agent-framework"
<a href={GITHUB_REPO_URL}
target="_blank" rel="noopener noreferrer"
className="transition-colors footer-link"
onClick={() => trackExternalLinkClick('https://github.com/cacheplane/angular-agent-framework', {
onClick={() => trackExternalLinkClick(GITHUB_REPO_URL, {
surface: 'footer',
cta_id: 'footer_github',
cta_text: 'GitHub',
Expand Down
Loading
Loading