Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<p align="center">
<img
src="https://threadplane.ai/assets/hero.svg"
alt="Threadplane — the open-source thread-plane for agents"
alt="Threadplane — the open-source thread-plane for Angular agents"
width="100%"
/>
</p>

<p align="center">
<em>The open-source thread-plane for agents.</em>
<em>The open-source thread-plane for Angular agents.</em>
</p>

<p align="center">
Expand All @@ -33,7 +33,7 @@

---

**Threadplane is the open-source thread-plane for agents.** Chat, durable
**Threadplane is the open-source thread-plane for Angular agents.** Chat, durable
threads, persistence, human approvals, tool progress, subagents, and generative UI — built on
Angular Signals and dependency injection, for Angular 20–22. Your backend stays
where it is: Threadplane adapts a LangGraph or AG-UI agent into a runtime-neutral
Expand Down
56 changes: 48 additions & 8 deletions apps/website/e2e/home-hero.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,60 @@ test.describe('homepage hero', () => {
await page.goto('/');
const demo = page.locator('[data-hero-demo]');
await expect(demo.locator('img')).toHaveAttribute('src', '/screenshots/hero-walkthrough-poster.webp');
// The frame mounts on a 25%-visibility threshold, and the three-line H1
// leaves the stage short of that at the default viewport. Scroll it in, as
// the mobile sibling below already does, so this asserts the mount and not
// the fold position.
// The frame mounts on a 25%-visibility threshold and the hero sits above
// it, so scroll it in — this asserts the mount, not the fold position.
await demo.scrollIntoViewIfNeeded();
await expect(demo.locator('iframe')).toHaveAttribute('src', 'https://demo.threadplane.ai/hero');
});

test('mobile shows Play walkthrough instead of the frame', async ({ page }) => {
test('the frame mounts on a phone without scrolling', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
// No scrollIntoViewIfNeeded, deliberately. Shortening the H1 lifted the demo
// to y=469 of this 844px fold, leaving ~44% of it visible on load — the 25%
// IntersectionObserver threshold is met without any scroll, and asserting
// the mount from a standing start is what proves it.
//
// This test still does NOT defend the fold; the budget below does. The 25%
// threshold is a proxy that only bites once the demo is pushed a long way
// down, and it moves whenever the stage's aspect ratio changes, so it is
// not a stable thing to reason about a fold with. Keep the two separate:
// this asserts that a phone autoplays, the budget asserts where the demo
// sits.
const demo = page.locator('[data-hero-demo]');
await demo.scrollIntoViewIfNeeded();
await expect(demo.getByRole('button', { name: 'Play walkthrough' })).toBeVisible();
await expect(demo.locator('iframe')).toHaveCount(0);
await expect(demo.locator('iframe')).toHaveAttribute('src', 'https://demo.threadplane.ai/hero');
});

test('the hero fits the fold at 375x812', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
// Absolute budgets, not a comparison against whatever currently renders: a
// relative guard passes vacuously once the thing it guards drifts, which is
// how the four-line H1 and the sliced poster both shipped unnoticed.
//
// Three lines at 36px/1.08 measure 116.63px. line-height is pinned as a
// RATIO of font-size, so this height is quantized to 38.88px steps — 3
// lines is 116.64, 4 lines is 155.52, and nothing lands in between. A
// webfont falling back changes where the text WRAPS, not the line box
// height, so any budget inside that gap behaves identically and there is no
// metric-variance flake surface. 130 sits in the gap.
const heading = page.locator('.hero-heading');
await expect(heading).toBeVisible();
const headingHeight = (await heading.boundingBox())!.height;
expect(headingHeight).toBeLessThanOrEqual(130);

// Measure [data-hero-demo], the same element the 501.42px figure came from.
// .hero-demo-stage sits ~48px lower inside the BrowserFrame chrome, so
// budgeting the stage at this number would be a far tighter guard.
//
// 530, not 560: one extra H1 line adds 38.88px and lands at 540.3, which a
// 560 budget would wave through — leaving the heading budget as the only
// thing catching a taller hero. 530 trips on a single extra line by itself,
// and 28px of slack still absorbs sub-line drift in the eyebrow, subhead or
// CTA row. Measured: 501.42.
const demo = page.locator('[data-hero-demo]');
await expect(demo).toBeVisible();
const demoTop = (await demo.boundingBox())!.y;
expect(demoTop).toBeLessThanOrEqual(530);
});
});
10 changes: 6 additions & 4 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ async function expectNoHorizontalOverflow(
test('landing page renders hero headline', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#hero-heading')).toBeVisible();
await expect(page.locator('#hero-heading')).toHaveText('The open-source thread-plane for agents.');
// The tagline stopped naming Angular on 2026-09-06, so the guard that the
// hero says which framework this is moved to the eyebrow directly above it.
await expect(page.locator('.hero-eyebrow')).toContainText('Angular');
// This also carries the guard that the hero names the framework. That guard
// used to live on .hero-eyebrow, because the H1 was framework-neutral; the H1
// names Angular itself now, so the eyebrow no longer repeats it. Asserting
// "Angular" separately here would be green by construction — the literal
// above already contains it — so the full-text pin is the whole check.
await expect(page.locator('#hero-heading')).toHaveText('The open-source thread-plane for Angular agents.');
});

test('the default social card renders as a PNG', async ({ request }) => {
Expand Down
6 changes: 3 additions & 3 deletions apps/website/public/assets/hero.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/website/src/app/github-card/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const runtime = 'nodejs';
export const GITHUB_CARD_SIZE = { width: 1280, height: 640 } as const;

const RUNTIMES = POSITIONING_PROOF_POINTS[0].label;
const EYEBROW = 'OPEN SOURCE · ANGULAR';
const EYEBROW = 'OPEN SOURCE';

export async function GET() {
const fonts = await loadCardFonts({ mono: true });
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const contentType = 'image/png';

/** "LangGraph + AG-UI" — the first proof point is the runtime claim. */
const RUNTIMES = POSITIONING_PROOF_POINTS[0].label;
const EYEBROW = 'OPEN SOURCE · ANGULAR';
const EYEBROW = 'OPEN SOURCE';

/** Describes what the card actually shows, not just the page it links to. */
export const alt = `${PRIMARY_TAGLINE}. ${HERO_SUBHEAD} Beside the copy, a browser frame shows the product pausing for a human: an agent proposes deleting three backups, with Approve and Decline. Works with ${RUNTIMES}.`;
Expand Down
50 changes: 41 additions & 9 deletions apps/website/src/components/landing/HeroDemo.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ describe('HeroDemo', () => {
* markup, the <source> has to precede the <img> (a <picture> takes the FIRST
* matching source, and an <img> that came first would win every time), and
* the media query has to stay on the same 768px boundary as the stage's
* portrait ratio in landing.css and as MIN_AUTOPLAY_WIDTH.
* portrait ratio in landing.css. Those two are the whole coupling, and both
* are tied to the phone poster's 390x906 geometry: the poster is served
* exactly where the stage is portrait, so `object-fit: cover` crops nothing.
* MIN_AUTOPLAY_WIDTH used to be a third leg and is not one any more —
* autoplay is width-independent and this boundary says nothing about it.
*/
it('offers a phone-width poster source ahead of the desktop img', async () => {
installEnv();
Expand All @@ -97,9 +101,12 @@ describe('HeroDemo', () => {
expect(HERO_POSTER_MOBILE).not.toBe(HERO_POSTER);
expect(source.getAttribute('media')).toBe('(max-width: 767px)');
expect(HERO_POSTER_MOBILE_MEDIA).toBe('(max-width: 767px)');
// 585x975 — the 3:5 phone capture, so `object-fit: cover` crops nothing.
// 585x1359 — the 65:151 phone capture (390x906 at the 1.5x ship scale), so
// `object-fit: cover` crops nothing. Moves with the recorder's viewport and
// the `.hero-demo-stage` ratio in landing.css; see that recorder's header
// for how 906 is measured from the replay's own block boundaries.
expect(source.getAttribute('width')).toBe('585');
expect(source.getAttribute('height')).toBe('975');
expect(source.getAttribute('height')).toBe('1359');
expect([...picture.children].map((el) => el.tagName)).toEqual(['SOURCE', 'IMG']);
});

Expand All @@ -114,10 +121,10 @@ describe('HeroDemo', () => {
* files are re-recorded together, so re-recording the walkthrough shrank the
* desktop capture 38.1KB -> 33.0KB and failed the phone poster for content it
* does not contain. The phone poster is not justified on bytes anyway. Below
* 768px `.hero-demo-stage` is `aspect-ratio: 3 / 5` with `object-fit: cover`,
* so the 1200x720 desktop capture covering that portrait box shows about 36%
* of its own width — the phone poster exists because that crop is unusable,
* and it would still be worth shipping if it cost slightly more.
* 768px `.hero-demo-stage` is `aspect-ratio: 65 / 151` with `object-fit:
* cover`, so the 1200x720 desktop capture covering that portrait box shows
* about 26% of its own width — the phone poster exists because that crop is
* unusable, and it would still be worth shipping if it cost slightly more.
*
* The ceiling is what the recorder actually budgeted against when it chose to
* ship 1.5x rather than 2x ("2x would cost ~51KB"): the mid-30s KB. Raise it
Expand Down Expand Up @@ -286,8 +293,15 @@ describe('HeroDemo', () => {
expect(post).toHaveBeenCalledWith({ type: 'tplane-hero', visible: true }, 'https://demo.threadplane.ai');
});

it('shows Play walkthrough instead of mounting on narrow viewports, and mounts on click', async () => {
installEnv({ width: 390 });
/**
* Reduced motion is the ONLY thing that still holds the iframe back, and a
* phone is where it matters most — the stage is portrait and the replay
* fills it.
* The suite's other reduced-motion test runs at the default 1280, so without
* this one nothing covers reduce at phone width.
*/
it('shows Play walkthrough under reduced motion at phone width, and mounts on click', async () => {
installEnv({ width: 390, reduced: true });
const { HeroDemo } = await import('./HeroDemo');
const { container } = render(<HeroDemo />);
act(() => {
Expand All @@ -301,6 +315,24 @@ describe('HeroDemo', () => {
expect(trackCtaClickMock).toHaveBeenCalledWith(expect.objectContaining({ cta_id: 'hero_demo_play' }));
});

/**
* The change this guards: MIN_AUTOPLAY_WIDTH is 0, so a phone autoplays. The
* poster is a capture displayed at ~0.86 scale while a live iframe lays out
* at the stage's real width, so mobile autoplay is a legibility win, not just
* motion. Restoring any width floor would fail here.
*/
it('autoplays the iframe at phone width when motion is allowed', async () => {
installEnv({ width: 390 });
const { HeroDemo } = await import('./HeroDemo');
const { container } = render(<HeroDemo />);
expect(container.querySelector('iframe')).toBeNull();
act(() => {
ioCallback?.([{ isIntersecting: true }]);
});
expect(container.querySelector('iframe')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Play walkthrough' })).toBeNull();
});

it('shows Play walkthrough under reduced motion', async () => {
installEnv({ reduced: true });
const { HeroDemo } = await import('./HeroDemo');
Expand Down
29 changes: 22 additions & 7 deletions apps/website/src/components/landing/HeroDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const HERO_DEMO_ORIGIN = 'https://demo.threadplane.ai';
export const HERO_DEMO_URL = `${HERO_DEMO_ORIGIN}/hero`;
export const HERO_POSTER = '/screenshots/hero-walkthrough-poster.webp';
/**
* The phone-width capture of the same walkthrough beat (585x975, 3:5). It is
* The phone-width capture of the same walkthrough beat (585x1359, 65:151). It is
* a separate render, not a crop: the desktop poster shrunk to a ~348px phone
* stage is an unreadable smudge, and cropping it slices the right edge off
* every line of prose. Recorded by
Expand All @@ -17,14 +17,29 @@ export const HERO_POSTER_MOBILE = '/screenshots/hero-walkthrough-poster-mobile.w
const POSTER_W = 1200;
const POSTER_H = 720;
const POSTER_MOBILE_W = 585;
const POSTER_MOBILE_H = 975;
const POSTER_MOBILE_H = 1359;
const READY_TIMEOUT_MS = 8000;
const MIN_AUTOPLAY_WIDTH = 768;
/**
* 0, not 768: phones autoplay too. The poster is a capture displayed at ~0.86
* scale, but a live iframe lays out at the stage's real width, so its type
* renders at its designed size — mobile autoplay improves legibility by
* construction, not just motion. Kept as a named constant so the floor can be
* restored if the iframe ever costs too much on a phone.
*/
const MIN_AUTOPLAY_WIDTH = 0;
/**
* Kept in lockstep with the `@media (max-width: 767px)` block in landing.css
* that gives `.hero-demo-stage` its 3:5 portrait ratio, and with
* MIN_AUTOPLAY_WIDTH above: the phone poster is served exactly where the stage
* is portrait and exactly where the iframe does not autoplay.
* that gives `.hero-demo-stage` its 65:151 portrait ratio. This pair is coupled
* to the PHONE POSTER'S GEOMETRY (390x906, shipped 585x1359) — the poster is
* served exactly where the stage is portrait, so `object-fit: cover` crops
* nothing. The height is MEASURED from the replay's own block boundaries, not
* chosen: 906 frames the whole streamed backup table, and because this ratio
* also sizes the LIVE iframe below 768px it gives the phone demo more viewport
* than the earlier 3:5 and 195:263 budgets did. See that recorder's header.
*
* It used to be a triple including MIN_AUTOPLAY_WIDTH, which is no longer part
* of it: autoplay is now width-independent and this breakpoint no longer has
* anything to do with whether the iframe mounts.
*/
export const HERO_POSTER_MOBILE_MEDIA = '(max-width: 767px)';
const MESSAGE_TYPE = 'tplane-hero';
Expand All @@ -39,7 +54,7 @@ function autoplayAllowed(): boolean {

/**
* Hero demo: server-rendered poster (the LCP), iframe mounted after hydration
* when the hero is visible on a wide, motion-tolerant viewport, crossfaded in
* when the hero is visible on a motion-tolerant viewport, crossfaded in
* when the frame reports ready.
*/
export function HeroDemo() {
Expand Down
23 changes: 19 additions & 4 deletions apps/website/src/lib/positioning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,36 @@ function parses(code: string): boolean {

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_EYEBROW).toBe('LangGraph & AG-UI');
expect(HERO_H1).toBe('The open-source thread-plane for Angular agents.');
expect(HERO_SUBHEAD).toBe(
'Make agent work persistent, durable, visible, reviewable, and resumable.',
);
expect(HOME_TITLE).toBe('Threadplane — The open-source thread-plane for agents');
expect(HOME_TITLE).toBe('Threadplane — The open-source thread-plane for Angular 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.',
);
expect(HOME_DESCRIPTION.length).toBeLessThanOrEqual(160);
});

it('H1 lines join back to HERO_H1 on three lines', () => {
it('H1 lines join back to HERO_H1 on the three lines the cards are laid out for', () => {
expect(HERO_H1_LINES).toHaveLength(3);
expect(HERO_H1_LINES.join(' ')).toBe(HERO_H1);
// Pinned exactly, because this array is also the line-breaking for
// opengraph-image.tsx and github-card/route.tsx, whose H1 columns are
// ~536px and ~588px at 60px and 62px Archivo Black. Canvas-measured at 60px
// with -0.02em tracking: 538 / 513 / 507px. The cards are centred at fixed
// height, so a line that overflows collides with the pills below rather
// than pushing them down.
//
// Pinned rather than computed: character count is NOT a proxy for width
// here — 'thread-plane for' is one character longer than 'The open-source'
// and 25px narrower. Re-measure in Archivo Black before changing a line.
expect(HERO_H1_LINES).toEqual([
'The open-source',
'thread-plane for',
'Angular agents.',
]);
});

it('subhead segments preserve the copy and link each capability to its docs', () => {
Expand Down
22 changes: 18 additions & 4 deletions apps/website/src/lib/positioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@
import { WEBSITE_SUPPORTED_ANGULAR_MAJORS } from '../components/pricing/angular-support.mjs';
import type { StageBeat } from './stage-beats';

export const HERO_EYEBROW = 'Angular · LangGraph & AG-UI';
export const HERO_H1 = 'The open-source thread-plane for agents.';
export const HERO_EYEBROW = 'LangGraph & AG-UI';
export const HERO_H1 = 'The open-source thread-plane for Angular agents.';
/**
* The H1 broken where it is meant to break: three lines, one thought each.
* HERO_H1 stays the single source of truth — positioning.spec.ts asserts the
* lines join back to it with single spaces, so the rendered heading, the
* <title> and the social card cannot drift apart.
*
* The split is load-bearing for two generated images: opengraph-image.tsx and
* github-card/route.tsx stack these as fixed lines at 60px and 62px. Measured
* in Archivo Black at 60px with -0.02em tracking: 538 / 513 / 507px against a
* ~536px column. "The open-source" is the widest and already shipped before
* "Angular" was added, so the worst case did not move. Below 767px landing.css
* flows the spans inline and drops the H1 to 36px, so the browser breaks the
* sentence to the column instead — three lines, 116.63px. Both halves are
* required: left as blocks at the shared token's 48px clamp floor, each span
* takes a line and then wraps again.
*/
export const HERO_H1_LINES: readonly string[] = ['The open-source', 'thread-plane', 'for agents.'];
export const HERO_H1_LINES: readonly string[] = [
'The open-source',
'thread-plane for',
'Angular agents.',
];
export const HERO_SUBHEAD =
'Make agent work persistent, durable, visible, reviewable, and resumable.';

Expand Down Expand Up @@ -43,7 +57,7 @@ export const HERO_SECONDARY_LABEL = 'See it running in the docs →';
export const HERO_SECONDARY_HREF = '/docs/chat/guides/generative-ui?mode=run';

/** Kept for layout.tsx default title and the OG image alt. */
export const PRIMARY_TAGLINE = 'Threadplane — The open-source thread-plane for agents';
export const PRIMARY_TAGLINE = 'Threadplane — The open-source thread-plane for Angular agents';
export const HOME_TITLE = PRIMARY_TAGLINE;
export const HOME_DESCRIPTION =
'The open-source thread-plane for agents: chat, durable threads, persistence, human approvals, and generative UI for Angular, on LangGraph and AG-UI.';
Expand Down
Loading
Loading