From 5ea65bf8ca0d0375ccd1c4314e6157cfc6bd7a66 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 12:58:26 -0700 Subject: [PATCH] feat(website): redesign pricing and licensing journey --- apps/minting-service/src/lib/email.spec.ts | 24 +- apps/minting-service/src/lib/email.ts | 23 +- apps/website/content/AGENTS.md.template | 10 +- apps/website/content/CLAUDE.md.template | 10 +- .../chat/getting-started/installation.mdx | 4 +- apps/website/e2e/website.spec.ts | 33 +- apps/website/emails/drip-angular-followup.ts | 8 +- apps/website/emails/drip-chat-followup.ts | 8 +- apps/website/emails/drip-render-followup.ts | 8 +- .../emails/drip-whitepaper-followup.ts | 8 +- apps/website/public/AGENTS.md | 12 +- apps/website/public/CLAUDE.md | 12 +- apps/website/src/app/about/page.tsx | 2 +- .../app/api/checkout/session/route.spec.ts | 7 + .../src/app/api/checkout/session/route.ts | 4 +- apps/website/src/app/docs/licensing/page.tsx | 28 +- apps/website/src/app/pilot-to-prod/page.tsx | 2 +- apps/website/src/app/pricing/page.spec.tsx | 56 +++ apps/website/src/app/pricing/page.tsx | 29 +- .../components/pricing/CompareTable.spec.tsx | 79 ++++ .../src/components/pricing/CompareTable.tsx | 391 +++++----------- .../pricing/CompatibilityMatrix.spec.tsx | 7 + .../pricing/CompatibilityMatrix.tsx | 8 +- .../src/components/pricing/LeadForm.tsx | 14 +- .../src/components/pricing/PricingDetails.tsx | 290 ++++++++++++ .../components/pricing/PricingFAQ.spec.tsx | 22 +- .../src/components/pricing/PricingFAQ.tsx | 48 +- .../components/pricing/TiersConfig.spec.ts | 69 +++ apps/website/src/styles/marketing.css | 439 +++++++++++++----- apps/website/src/styles/pages.css | 40 +- libs/chat/README.md | 9 +- pricing/tiers.config.ts | 123 +++-- scripts/stripe/sync-products.spec.ts | 134 ++++-- scripts/stripe/sync-products.ts | 8 +- 34 files changed, 1403 insertions(+), 566 deletions(-) create mode 100644 apps/website/src/app/pricing/page.spec.tsx create mode 100644 apps/website/src/components/pricing/CompareTable.spec.tsx create mode 100644 apps/website/src/components/pricing/PricingDetails.tsx create mode 100644 apps/website/src/components/pricing/TiersConfig.spec.ts diff --git a/apps/minting-service/src/lib/email.spec.ts b/apps/minting-service/src/lib/email.spec.ts index 9214981bb..32c68d572 100644 --- a/apps/minting-service/src/lib/email.spec.ts +++ b/apps/minting-service/src/lib/email.spec.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 -import { renderLicenseEmail } from './email.js'; +import { renderLicenseEmail, renderRevocationEmail } from './email.js'; describe('renderLicenseEmail', () => { it('includes the token wrapped in BEGIN/END delimiters in the text body', () => { @@ -16,7 +16,7 @@ describe('renderLicenseEmail', () => { expect(out.text).toContain('-----END THREADPLANE LICENSE-----'); }); - it('subject includes tier and seat count with plural s for seats > 1', () => { + it('uses the public Pro plan name and plural seats in customer-facing copy', () => { const out = renderLicenseEmail({ tier: 'developer_seat', seats: 3, @@ -24,7 +24,11 @@ describe('renderLicenseEmail', () => { expiresAt: new Date('2027-04-20T00:00:00Z'), stripeCustomerId: 'cus_test', }); - expect(out.subject).toBe('Your Threadplane license — developer_seat (3 seats)'); + expect(out.subject).toBe('Your Threadplane Pro license — 3 seats'); + expect(out.text).toContain('Plan: Pro'); + expect(out.html).toContain('Plan: Pro'); + expect(out.subject).not.toContain('developer_seat'); + expect(out.text).not.toContain('developer_seat'); }); it('subject uses singular seat for seats === 1', () => { @@ -35,7 +39,7 @@ describe('renderLicenseEmail', () => { expiresAt: new Date('2027-04-20T00:00:00Z'), stripeCustomerId: 'cus_test', }); - expect(out.subject).toBe('Your Threadplane license — team (1 seat)'); + expect(out.subject).toBe('Your Threadplane Team license — 1 seat'); }); it('includes ISO 8601 UTC expiry in text body', () => { @@ -62,3 +66,15 @@ describe('renderLicenseEmail', () => { expect(out.html).toContain('BEGIN THREADPLANE LICENSE'); }); }); + +describe('renderRevocationEmail', () => { + it('describes the record change without claiming offline signature checks perform revocation lookup', () => { + const out = renderRevocationEmail({ tier: 'developer_seat' }); + + expect(out.text).toContain('Threadplane Pro license'); + expect(out.text).toContain('marked revoked in Threadplane records'); + expect(out.text).toContain('Runtime verification remains offline'); + expect(out.text).not.toContain('fail signature checks'); + expect(out.html).not.toContain('developer_seat'); + }); +}); diff --git a/apps/minting-service/src/lib/email.ts b/apps/minting-service/src/lib/email.ts index 2f8698a7d..1e08f4b8a 100644 --- a/apps/minting-service/src/lib/email.ts +++ b/apps/minting-service/src/lib/email.ts @@ -28,12 +28,17 @@ export interface RenderedEmail { html: string; } +function publicTierName(tier: MintableTier): string { + return tier === 'developer_seat' ? 'Pro' : 'Team'; +} + /** * Pure: render the subject / text / html for a license delivery email. */ export function renderLicenseEmail(vars: LicenseEmailVars): RenderedEmail { const seatWord = vars.seats === 1 ? 'seat' : 'seats'; - const subject = `Your Threadplane license — ${vars.tier} (${vars.seats} ${seatWord})`; + const planName = publicTierName(vars.tier); + const subject = `Your Threadplane ${planName} license — ${vars.seats} ${seatWord}`; const expiresIso = vars.expiresAt.toISOString(); const portal = portalUrl(vars.stripeCustomerId); @@ -47,7 +52,7 @@ the link at the bottom of this email. ${vars.token} -----END THREADPLANE LICENSE----- -Tier: ${vars.tier} +Plan: ${planName} Seats: ${vars.seats} Expires: ${expiresIso} @@ -72,7 +77,7 @@ Questions: reply to this email.
-----BEGIN THREADPLANE LICENSE-----
 ${escapeHtml(vars.token)}
 -----END THREADPLANE LICENSE-----
-

Tier: ${escapeHtml(vars.tier)}
+

Plan: ${escapeHtml(planName)}
Seats: ${vars.seats}
Expires: ${escapeHtml(expiresIso)}

Installation:

@@ -131,20 +136,20 @@ export interface RevocationEmailVars { export function renderRevocationEmail(vars: RevocationEmailVars): RenderedEmail { const subject = `Your Threadplane license has been revoked`; + const planName = publicTierName(vars.tier); - const text = `Your Threadplane ${vars.tier} license has been revoked because the -underlying payment was refunded. + const text = `Your Threadplane ${planName} license has been marked revoked in Threadplane records because the underlying payment was refunded. -The token previously delivered will fail signature checks at boot and -@threadplane/chat will fall back to a noncommercial-use warning. +Runtime verification remains offline and does not make a revocation lookup. +Continued use must remain within the applicable license terms. If you believe this is in error, reply to this email. -- The Threadplane team `; - const html = `

Your Threadplane ${escapeHtml(vars.tier)} license has been revoked because the underlying payment was refunded.

-

The token previously delivered will fail signature checks at boot and @threadplane/chat will fall back to a noncommercial-use warning.

+ const html = `

Your Threadplane ${escapeHtml(planName)} license has been marked revoked in Threadplane records because the underlying payment was refunded.

+

Runtime verification remains offline and does not make a revocation lookup. Continued use must remain within the applicable license terms.

If you believe this is in error, reply to this email.

-- The Threadplane team

`; diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index 9df38fcab..c2ac0785c 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -1,6 +1,12 @@ # Threadplane v@VERSION@ -Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. +Production-ready chat, thread/history/branch UI, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. + +## Licensing and deployment boundary +- Most Threadplane packages are MIT-licensed and free for any use. +- `@threadplane/chat` is source-available under PolyForm Noncommercial 1.0.0, includes a good-faith 30-day commercial evaluation, and requires a Threadplane Commercial license for commercial production. +- Threadplane runs inside the customer's Angular application. Agent runtime, models, storage, checkpointing, retention, authorization, and hosting remain customer-operated. +- License-token verification is offline and advisory; it makes no runtime call to Threadplane and never blocks rendering. ## Install npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked @@ -34,7 +40,7 @@ export class ChatComponent { ``` ## Key patterns -- Thread persistence: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })` +- Thread selection: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })`; actual durability and cross-device persistence depend on the connected runtime and persistence layer - Global config: `provideAgent({ apiUrl, assistantId })` in app.config.ts - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself diff --git a/apps/website/content/CLAUDE.md.template b/apps/website/content/CLAUDE.md.template index 9df38fcab..c2ac0785c 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -1,6 +1,12 @@ # Threadplane v@VERSION@ -Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. +Production-ready chat, thread/history/branch UI, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. + +## Licensing and deployment boundary +- Most Threadplane packages are MIT-licensed and free for any use. +- `@threadplane/chat` is source-available under PolyForm Noncommercial 1.0.0, includes a good-faith 30-day commercial evaluation, and requires a Threadplane Commercial license for commercial production. +- Threadplane runs inside the customer's Angular application. Agent runtime, models, storage, checkpointing, retention, authorization, and hosting remain customer-operated. +- License-token verification is offline and advisory; it makes no runtime call to Threadplane and never blocks rendering. ## Install npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked @@ -34,7 +40,7 @@ export class ChatComponent { ``` ## Key patterns -- Thread persistence: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })` +- Thread selection: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })`; actual durability and cross-device persistence depend on the connected runtime and persistence layer - Global config: `provideAgent({ apiUrl, assistantId })` in app.config.ts - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself diff --git a/apps/website/content/docs/chat/getting-started/installation.mdx b/apps/website/content/docs/chat/getting-started/installation.mdx index 5c368ad07..5d2465190 100644 --- a/apps/website/content/docs/chat/getting-started/installation.mdx +++ b/apps/website/content/docs/chat/getting-started/installation.mdx @@ -5,7 +5,7 @@ A complete walkthrough for installing `@threadplane/chat` in an Angular 20+ appl This guide goes deeper than the Quick Start: license activation, peer dependencies, and the warnings you'll see in the console. -This guide is written for customers who purchased a Developer Seat, Team, or Enterprise plan and received a `THREADPLANE_LICENSE` token by email. If you're evaluating `@threadplane/chat` for noncommercial use, you can skip the license steps — the library runs without a token (with a one-time advisory warning). +This guide is written for customers who purchased a Pro, Team, or Enterprise plan and received a `THREADPLANE_LICENSE` token by email. If you're using `@threadplane/chat` within permitted noncommercial scope, you can skip the license steps. Commercial evaluation is permitted for 30 calendar days from first commercial use without registration; the library's token check remains advisory. ## Prerequisites @@ -21,7 +21,7 @@ Required for the Angular build toolchain. `node --version` should report `v18` o The chat UI needs something to talk to. Two officially supported adapters cover virtually every backend: - **`@threadplane/langgraph`** — pick this if your backend is LangGraph or LangGraph Platform. -- **`@threadplane/ag-ui`** — pick this for any AG-UI compatible backend (CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, AWS Strands, CopilotKit runtime). +- **`@threadplane/ag-ui`** — pick this for an AG-UI-compatible backend such as CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, or AWS Strands. Both adapters expose the same `Agent` contract to `@threadplane/chat`, so swapping later is a one-line change. If you don't have a backend yet, use `mockAgent()` from `@threadplane/chat` to wire up the UI first. diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 038aa67a4..6ec88d8a4 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -32,12 +32,35 @@ test('landing page license copy distinguishes MIT packages from commercially lic await expect(main).not.toContainText('MIT · No signup required · App telemetry off by default'); }); -test('pricing page shows plan cards', async ({ page }) => { +test('pricing page presents the four-stage journey and licensing boundary', async ({ page }) => { await page.goto('/pricing'); - await expect(page.getByText('Community').first()).toBeVisible(); - await expect(page.getByText('Developer Seat').first()).toBeVisible(); - await expect(page.getByText('Team').first()).toBeVisible(); - await expect(page.getByText('Enterprise').first()).toBeVisible(); + const plans = page.locator('.pricing-plan-card'); + await expect(plans).toHaveCount(4); + await expect(plans.nth(0).getByRole('heading', { level: 3 })).toHaveText('Developer'); + await expect(plans.nth(1).getByRole('heading', { level: 3 })).toHaveText('Pro'); + await expect(plans.nth(2).getByRole('heading', { level: 3 })).toHaveText('Team'); + await expect(plans.nth(3).getByRole('heading', { level: 3 })).toHaveText('Enterprise'); + await expect(plans.nth(0)).toContainText('For permitted noncommercial use'); + await expect(plans.nth(0)).toContainText('30-day commercial evaluation'); + await expect(page.getByText('No Threadplane cloud').first()).toBeVisible(); +}); + +test('pricing page preserves public-to-internal checkout mappings', async ({ page }) => { + await page.goto('/pricing'); + + await expect(page.getByRole('button', { name: 'Get Pro' }).locator('xpath=ancestor::form/input[@name="tier"]')).toHaveValue('developer_seat'); + await expect(page.getByRole('button', { name: 'Get Team' }).locator('xpath=ancestor::form/input[@name="tier"]')).toHaveValue('team'); + await expect(page.getByRole('link', { name: 'Start free' })).toHaveAttribute('href', /npmjs\.com\/package\/@threadplane\/chat/); + await expect(page.getByRole('link', { name: 'Talk to Sales' })).toHaveAttribute('href', '/contact?source=pricing_tier_enterprise'); +}); + +test('pricing page is responsive without page-level horizontal overflow', async ({ page }) => { + for (const width of [375, 768, 1280]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto('/pricing'); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow, `pricing at ${width}px`).toBeLessThanOrEqual(1); + } }); test('pricing page lead form validates required fields', async ({ page }) => { diff --git a/apps/website/emails/drip-angular-followup.ts b/apps/website/emails/drip-angular-followup.ts index a2b47e05d..353472b56 100644 --- a/apps/website/emails/drip-angular-followup.ts +++ b/apps/website/emails/drip-angular-followup.ts @@ -33,16 +33,16 @@ export function dripAngularFollowupHtml(day: number): { subject: string; html: s if (day === 10) { return { - subject: 'The pilot program includes hands-on integration', + subject: 'An optional eight-week path from pilot to production', html: wrapEmail({ body: `

Pilot Program

-

The pilot program includes hands-on integration

-

Every app deployment license includes a 3-month co-pilot engagement — we work alongside your Angular team to ship your first agent to production.

+

An optional eight-week path from pilot to production

+

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

Week 1 · Integration & first stream

Month 1 · First agent in staging

-

Month 3 · Production deployment

+

Week 8 · Production readiness

Learn About the Pilot → `, diff --git a/apps/website/emails/drip-chat-followup.ts b/apps/website/emails/drip-chat-followup.ts index e1bfcfbfc..78f30674a 100644 --- a/apps/website/emails/drip-chat-followup.ts +++ b/apps/website/emails/drip-chat-followup.ts @@ -33,16 +33,16 @@ export function dripChatFollowupHtml(day: number): { subject: string; html: stri if (day === 10) { return { - subject: 'The pilot program includes hands-on integration', + subject: 'An optional eight-week path from pilot to production', html: wrapEmail({ body: `

Pilot Program

-

The pilot program includes hands-on integration

-

Every app deployment license includes a 3-month co-pilot engagement — we work alongside your Angular team to ship your first agent to production.

+

An optional eight-week path from pilot to production

+

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

Week 1 · Integration & first stream

Month 1 · First agent in staging

-

Month 3 · Production deployment

+

Week 8 · Production readiness

Learn About the Pilot → `, diff --git a/apps/website/emails/drip-render-followup.ts b/apps/website/emails/drip-render-followup.ts index 5581c0edf..8d4b8a575 100644 --- a/apps/website/emails/drip-render-followup.ts +++ b/apps/website/emails/drip-render-followup.ts @@ -33,16 +33,16 @@ export function dripRenderFollowupHtml(day: number): { subject: string; html: st if (day === 10) { return { - subject: 'The pilot program includes hands-on integration', + subject: 'An optional eight-week path from pilot to production', html: wrapEmail({ body: `

Pilot Program

-

The pilot program includes hands-on integration

-

Every app deployment license includes a 3-month co-pilot engagement — we work alongside your Angular team to ship your first agent to production.

+

An optional eight-week path from pilot to production

+

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

Week 1 · Integration & first stream

Month 1 · First agent in staging

-

Month 3 · Production deployment

+

Week 8 · Production readiness

Learn About the Pilot → `, diff --git a/apps/website/emails/drip-whitepaper-followup.ts b/apps/website/emails/drip-whitepaper-followup.ts index ca8b71356..2ecad2f3b 100644 --- a/apps/website/emails/drip-whitepaper-followup.ts +++ b/apps/website/emails/drip-whitepaper-followup.ts @@ -33,16 +33,16 @@ export function dripWhitepaperFollowupHtml(day: number): { subject: string; html if (day === 10) { return { - subject: 'The pilot program is included with every app license', + subject: 'An optional eight-week path from pilot to production', html: wrapEmail({ body: `

Pilot Program

-

The pilot program is included with every app license

-

Every app deployment license includes a 3-month co-pilot engagement — we work alongside your Angular team to ship your first agent to production.

+

An optional eight-week path from pilot to production

+

Pilot-to-Prod is a separately scoped eight-week engineering engagement for teams that want hands-on help shipping their first agent to production.

Week 1 · Integration & first stream

Month 1 · First agent in staging

-

Month 3 · Production deployment

+

Week 8 · Production readiness

Learn About the Pilot → `, diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index ffad0b76d..9803f3a98 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -1,6 +1,12 @@ -# Threadplane v0.0.56 +# Threadplane v0.0.61 -Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. +Production-ready chat, thread/history/branch UI, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. + +## Licensing and deployment boundary +- Most Threadplane packages are MIT-licensed and free for any use. +- `@threadplane/chat` is source-available under PolyForm Noncommercial 1.0.0, includes a good-faith 30-day commercial evaluation, and requires a Threadplane Commercial license for commercial production. +- Threadplane runs inside the customer's Angular application. Agent runtime, models, storage, checkpointing, retention, authorization, and hosting remain customer-operated. +- License-token verification is offline and advisory; it makes no runtime call to Threadplane and never blocks rendering. ## Install npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked @@ -34,7 +40,7 @@ export class ChatComponent { ``` ## Key patterns -- Thread persistence: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })` +- Thread selection: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })`; actual durability and cross-device persistence depend on the connected runtime and persistence layer - Global config: `provideAgent({ apiUrl, assistantId })` in app.config.ts - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index ffad0b76d..9803f3a98 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -1,6 +1,12 @@ -# Threadplane v0.0.56 +# Threadplane v0.0.61 -Production-ready chat, durable threads, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. +Production-ready chat, thread/history/branch UI, interrupts, subagents, planning, memory, and generative UI for Angular agent apps. + +## Licensing and deployment boundary +- Most Threadplane packages are MIT-licensed and free for any use. +- `@threadplane/chat` is source-available under PolyForm Noncommercial 1.0.0, includes a good-faith 30-day commercial evaluation, and requires a Threadplane Commercial license for commercial production. +- Threadplane runs inside the customer's Angular application. Agent runtime, models, storage, checkpointing, retention, authorization, and hosting remain customer-operated. +- License-token verification is offline and advisory; it makes no runtime call to Threadplane and never blocks rendering. ## Install npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked @@ -34,7 +40,7 @@ export class ChatComponent { ``` ## Key patterns -- Thread persistence: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })` +- Thread selection: configure `provideAgent({ assistantId, threadId: signal(localStorage.getItem('t')), onThreadId })`; actual durability and cross-device persistence depend on the connected runtime and persistence layer - Global config: `provideAgent({ apiUrl, assistantId })` in app.config.ts - Scoped config: re-provide `provideAgent({ apiUrl, assistantId })` in a component `providers` array for a subtree - Testing: use `MockAgentTransport` — never mock `injectAgent()` itself diff --git a/apps/website/src/app/about/page.tsx b/apps/website/src/app/about/page.tsx index aaee94e05..5bdf02b87 100644 --- a/apps/website/src/app/about/page.tsx +++ b/apps/website/src/app/about/page.tsx @@ -91,7 +91,7 @@ export default function AboutPage() {

@threadplane/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0; commercial production use requires a Threadplane Commercial - license. The other libraries are MIT. The{' '} + license. Most other published Threadplane packages are MIT-licensed. The{' '} licensing docs {' '} diff --git a/apps/website/src/app/api/checkout/session/route.spec.ts b/apps/website/src/app/api/checkout/session/route.spec.ts index 78eb1b838..81d483178 100644 --- a/apps/website/src/app/api/checkout/session/route.spec.ts +++ b/apps/website/src/app/api/checkout/session/route.spec.ts @@ -80,6 +80,13 @@ describe('POST /api/checkout/session', () => { expect(args.line_items[0].adjustable_quantity).toEqual({ enabled: true, minimum: 1, maximum: 100 }); }); + it('keeps Team checkout to one fixed five-developer bundle', async () => { + await POST(makeReq({ tier: 'team', quantity: 7 })); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.line_items[0].quantity).toBe(1); + expect(args.line_items[0].adjustable_quantity).toBeUndefined(); + }); + it('clamps quantity to [1, 100]', async () => { await POST(makeReq({ tier: 'developer_seat', quantity: 9999 })); expect(stripeCreate.mock.calls[0]?.[0].line_items[0].quantity).toBe(100); diff --git a/apps/website/src/app/api/checkout/session/route.ts b/apps/website/src/app/api/checkout/session/route.ts index 5cdca5a24..a84cf1984 100644 --- a/apps/website/src/app/api/checkout/session/route.ts +++ b/apps/website/src/app/api/checkout/session/route.ts @@ -68,7 +68,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Tier missing from config' }, { status: 500 }); } - const rawQuantity = body.quantity ?? tierConfig.defaultQuantity ?? 1; + const rawQuantity = tierConfig.adjustableQuantity + ? (body.quantity ?? tierConfig.defaultQuantity ?? 1) + : (tierConfig.defaultQuantity ?? 1); const quantity = Math.max(1, Math.min(100, Math.floor(rawQuantity))); const origin = getOrigin(req); diff --git a/apps/website/src/app/docs/licensing/page.tsx b/apps/website/src/app/docs/licensing/page.tsx index b37a52fee..bfcf9cde8 100644 --- a/apps/website/src/app/docs/licensing/page.tsx +++ b/apps/website/src/app/docs/licensing/page.tsx @@ -47,6 +47,12 @@ export default function LicensingPage() { for production use inside a for-profit context. The same source ships under both — you don't get a different build.

+

+ Threadplane does not provide a hosted cloud runtime, conversation database, model inference, or + usage bundle. Your application connects to the agent runtime, models, storage, and infrastructure + you operate. Thread, history, branching, and reload UI depend on the capabilities and persistence + supplied by that connected backend. +

Do you need a paid license?

@@ -111,7 +117,8 @@ export const appConfig: ApplicationConfig = {

Tier scoping

Pick the tier that matches how you'll deploy. All paid tiers grant the same{' '} - Threadplane Commercial license; the difference is the scope of use and the number of seats. + Threadplane Commercial license; they differ in scope, included developer seats, support, and + contracting options.

@@ -124,9 +131,9 @@ export const appConfig: ApplicationConfig = { - + - + @@ -136,14 +143,16 @@ export const appConfig: ApplicationConfig = { - +
Developer Seat — $29/dev/mo or $299/dev/yrPro — $29/developer/month or $299/developer/year Per seatSolo devs, growing teamsSolo developers and teams purchasing seats individually
Team — $149/mo or $1,495/yr
Enterprise — from $4,000/mo CustomSLA, security review, Pilot-to-Prod engagement, Slack ConnectCustom terms, SLA, security review, procurement, and private support

- Paid tiers are recurring subscriptions. Annual saves ~15% vs monthly. Cancel anytime — the license - stays valid through the end of the current paid period. + Paid tiers are recurring subscriptions. Pro saves $49 per developer per year (about 14%) and Team + saves $293 per year (about 16%) when billed annually. Cancel anytime — the license stays valid + through the end of the current paid period. Pilot-to-Prod is available as a separately scoped + eight-week engagement; it is not included automatically with a license.

@@ -163,9 +172,10 @@ export const appConfig: ApplicationConfig = {

Refunds

- If you refund a license through Stripe, the token is revoked automatically and we email a confirmation. - The verification check warns on boot. There's no clawback of the source code you already have — - everything is source-available under PolyForm Noncommercial by default. + If you refund a license through Stripe, the license is marked revoked in Threadplane records and we + email a confirmation. Runtime token verification remains offline and does not make a revocation + lookup. There is no clawback of source code already received; continued use must remain within the + applicable license terms.

Questions

diff --git a/apps/website/src/app/pilot-to-prod/page.tsx b/apps/website/src/app/pilot-to-prod/page.tsx index 1c01c2dd2..86bc9bb57 100644 --- a/apps/website/src/app/pilot-to-prod/page.tsx +++ b/apps/website/src/app/pilot-to-prod/page.tsx @@ -13,7 +13,7 @@ import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ title: 'Pilot to Production — Threadplane', - description: 'Close the last-mile gap. The 3-month pilot engagement is included with every app deployment license. We work alongside your Angular team to ship your first agent to production.', + description: 'An optionally scoped eight-week concierge engagement for Angular teams shipping an agent into production with their own runtime, data, and infrastructure.', pathname: '/pilot-to-prod', type: 'website', }); diff --git a/apps/website/src/app/pricing/page.spec.tsx b/apps/website/src/app/pricing/page.spec.tsx new file mode 100644 index 000000000..ac339648c --- /dev/null +++ b/apps/website/src/app/pricing/page.spec.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import PricingPage, { metadata } from './page'; + +vi.mock('../../components/pricing/LeadForm', () => ({ LeadForm: () => null })); +vi.mock('../../components/landing/FinalCTA', () => ({ FinalCTA: () => null })); +vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: vi.fn() })); + +describe('PricingPage', () => { + it('answers the licensing and hosting questions above the comparison', () => { + render(); + + expect(screen.getByRole('heading', { level: 1, name: 'From prototype to production.' })).toBeTruthy(); + expect(screen.getByText(/Most packages are MIT/i)).toBeTruthy(); + expect(screen.getByText(/requires a license for commercial production/i)).toBeTruthy(); + expect(screen.getByText(/No Threadplane cloud/i)).toBeTruthy(); + expect(screen.getByRole('heading', { level: 2, name: /Same software/i })).toBeTruthy(); + expect(screen.getByText(/does not host your agents or conversations/i)).toBeTruthy(); + expect(screen.getByText(/durable persistence.*connected backend/i)).toBeTruthy(); + }); + + it('renders one grouped comparison with semantic row and column headers', () => { + const { container } = render(); + + expect(screen.getByRole('table', { name: /Full plan comparison/i })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: 'Developer' })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: 'Pro' })).toBeTruthy(); + expect(screen.getByRole('rowheader', { name: 'Commercial production rights for @threadplane/chat' })).toBeTruthy(); + expect(container.querySelectorAll('table[aria-label="Full plan comparison"]')).toHaveLength(1); + }); + + it('does not present hosted-product quotas or bundled infrastructure', () => { + const { container } = render(); + const text = container.textContent?.toLowerCase() ?? ''; + + for (const prohibited of [ + 'max threads', + 'hosted storage', + 'cloud hosting included', + 'channels credits', + 'model credits', + ]) { + expect(text).not.toContain(prohibited); + } + }); + + it('uses licensing-accurate search metadata', () => { + const description = String(metadata.description); + expect(description).toContain('Most Threadplane packages are MIT-licensed'); + expect(description).toContain('@threadplane/chat'); + expect(description).toContain('$29 per developer per month'); + expect(description).toContain('your own stack'); + }); +}); diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx index 42c5403e8..33e743724 100644 --- a/apps/website/src/app/pricing/page.tsx +++ b/apps/website/src/app/pricing/page.tsx @@ -2,6 +2,7 @@ import { Container } from '../../components/ui/Container'; import { Section } from '../../components/ui/Section'; import { Eyebrow } from '../../components/ui/Eyebrow'; import { CompareTable } from '../../components/pricing/CompareTable'; +import { ArchitectureBoundary, PricingComparison } from '../../components/pricing/PricingDetails'; import { CompatibilityMatrix } from '../../components/pricing/CompatibilityMatrix'; import { PricingFAQ } from '../../components/pricing/PricingFAQ'; import { LeadForm } from '../../components/pricing/LeadForm'; @@ -11,7 +12,7 @@ import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ title: 'Pricing — Threadplane', description: - '@threadplane/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a Threadplane Commercial license. Other libraries remain MIT.', + 'Most Threadplane packages are MIT-licensed. @threadplane/chat is free for permitted noncommercial use and evaluation; commercial production plans start at $29 per developer per month. Threadplane runs in your own stack.', pathname: '/pricing', type: 'website', }); @@ -24,14 +25,38 @@ export default function PricingPage() {
Pricing

- Simple, transparent pricing + From prototype to production.

+

+ Start free, then purchase a commercial license when you ship{' '} + @threadplane/chat in a for-profit context. Threadplane runs inside your + Angular application and connects to the agent infrastructure you already operate. +

+

+ Most packages are MIT + + @threadplane/chat requires a license for commercial production + + No Threadplane cloud +

+
+ + + +
+ +
+ + + +
+
Compatibility diff --git a/apps/website/src/components/pricing/CompareTable.spec.tsx b/apps/website/src/components/pricing/CompareTable.spec.tsx new file mode 100644 index 000000000..e3784da8b --- /dev/null +++ b/apps/website/src/components/pricing/CompareTable.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { CompareTable } from './CompareTable'; + +vi.mock('../../lib/analytics/client', () => ({ + trackCtaClick: vi.fn(), +})); + +describe('CompareTable', () => { + it('presents the four public stages in journey order', () => { + render(); + + const plans = screen.getAllByRole('article'); + expect(plans).toHaveLength(4); + expect( + plans.map((plan) => within(plan).getByRole('heading', { level: 3 }).textContent), + ).toEqual(['Developer', 'Pro', 'Team', 'Enterprise']); + }); + + it('shows the required free-use restrictions before the FAQ', () => { + render(); + + const developer = screen.getByRole('article', { name: /Developer/i }); + expect(within(developer).getByText(/Free forever/i)).toBeTruthy(); + expect(within(developer).getByText(/For permitted noncommercial use/i)).toBeTruthy(); + expect(within(developer).getByText(/30-day commercial evaluation/i)).toBeTruthy(); + }); + + it('keeps annual selected initially and makes the savings claim non-universal', () => { + render(); + + const annual = screen.getByRole('radio', { name: /Annual/i }); + const monthly = screen.getByRole('radio', { name: /Monthly/i }); + expect(annual.getAttribute('aria-checked')).toBe('true'); + expect(monthly.getAttribute('aria-checked')).toBe('false'); + expect(annual.getAttribute('aria-label')).toMatch(/save up to 16%/i); + expect(annual.getAttribute('aria-label')).not.toMatch(/Annual — save 16%$/i); + }); + + it('maps public Pro and Team CTAs to stable checkout slugs', () => { + render(); + + const proButton = screen.getByRole('button', { name: 'Get Pro' }); + const proForm = proButton.closest('form'); + expect(proForm?.querySelector('input[name="tier"]')?.value).toBe( + 'developer_seat', + ); + + const teamButton = screen.getByRole('button', { name: 'Get Team' }); + const teamForm = teamButton.closest('form'); + expect(teamForm?.querySelector('input[name="tier"]')?.value).toBe('team'); + }); + + it('keeps free and enterprise actions out of paid checkout', () => { + render(); + + expect(screen.getByRole('link', { name: 'Start free' }).getAttribute('href')).toContain( + '@threadplane/chat', + ); + expect(screen.getByRole('link', { name: 'Talk to Sales' }).getAttribute('href')).toBe( + '/contact?source=pricing_tier_enterprise', + ); + }); + + it('switches exact paid prices without changing tier structure', () => { + render(); + + expect(screen.getByRole('article', { name: /Pro/i }).textContent).toContain('$299'); + expect(screen.getByRole('article', { name: /Team/i }).textContent).toContain('$1,495'); + expect(screen.getAllByText(/billed annually/i).length).toBeGreaterThanOrEqual(2); + + fireEvent.click(screen.getByRole('radio', { name: /Monthly/i })); + + expect(screen.getByRole('article', { name: /Pro/i }).textContent).toContain('$29'); + expect(screen.getByRole('article', { name: /Team/i }).textContent).toContain('$149'); + }); +}); diff --git a/apps/website/src/components/pricing/CompareTable.tsx b/apps/website/src/components/pricing/CompareTable.tsx index 5dabf2262..d493b175e 100644 --- a/apps/website/src/components/pricing/CompareTable.tsx +++ b/apps/website/src/components/pricing/CompareTable.tsx @@ -9,338 +9,205 @@ import { type TierConfig, type BillingCycle, annualDiscountPercent, + annualSavingsDollars, } from '../../../../../pricing/tiers.config'; interface PlanCta { - readonly cta: string; + readonly label: string; readonly ctaId: CtaId; readonly stripeBuyable?: boolean; - readonly ctaHref?: string; - readonly ctaExternal?: boolean; + readonly href?: string; + readonly external?: boolean; } const CTAS: Record = { community: { - cta: 'Start free', + label: 'Start free', ctaId: 'pricing_tier_community', - ctaHref: 'https://www.npmjs.com/package/@threadplane/chat', - ctaExternal: true, + href: 'https://www.npmjs.com/package/@threadplane/chat', + external: true, }, developer_seat: { - cta: 'Get Developer Seat', + label: 'Get Pro', ctaId: 'pricing_tier_developer_seat', stripeBuyable: true, }, team: { - cta: 'Get Team', + label: 'Get Team', ctaId: 'pricing_tier_team', stripeBuyable: true, }, enterprise: { - cta: 'Talk to Sales', + label: 'Talk to Sales', ctaId: 'pricing_tier_enterprise', - ctaHref: '/contact?source=pricing_tier_enterprise', + href: '/contact?source=pricing_tier_enterprise', }, }; -type CellValue = boolean | string; -interface FeatureRow { - feature: string; - cells: Record; -} - -const LICENSING_ROWS: FeatureRow[] = [ - { - feature: 'Commercial', - cells: { community: false, developer_seat: true, team: true, enterprise: true }, - }, - { - feature: 'Developers', - cells: { - community: 'Unlimited (noncommercial)', - developer_seat: 'Per seat', - team: '5 included', - enterprise: 'Unlimited', - }, - }, - { - feature: '30-day commercial eval', - cells: { community: true, developer_seat: false, team: false, enterprise: false }, - }, - { - feature: 'Support', - cells: { community: 'GitHub', developer_seat: 'GitHub', team: 'Email', enterprise: 'Slack Connect' }, - }, - { - feature: 'SLA', - cells: { community: false, developer_seat: false, team: false, enterprise: true }, - }, - { - feature: 'Pilot-to-Prod', - cells: { community: false, developer_seat: false, team: false, enterprise: 'Weekly 30-min check-in' }, - }, -]; - -const FEATURE_ROWS: FeatureRow[] = [ - { feature: 'Headless chat primitives', cells: allInclusive() }, - { feature: 'Durable threads', cells: allInclusive() }, - { feature: 'Interrupts (human-in-the-loop)', cells: allInclusive() }, - { feature: 'Subagents + delegation', cells: allInclusive() }, - { feature: 'Planning + memory', cells: allInclusive() }, - { feature: 'Generative UI (json-render + A2UI)', cells: allInclusive() }, - { feature: 'Signal-based streaming', cells: allInclusive() }, - { feature: 'Citations + sources panel', cells: allInclusive() }, - { feature: 'LangGraph + AG-UI adapters', cells: allInclusive() }, - { feature: 'Theme presets (light/dark, Material 3)', cells: allInclusive() }, -]; - -function allInclusive(): Record { - return { community: true, developer_seat: true, team: true, enterprise: true }; -} - -const Check = () => ( - -); -const Dash = () => ( - -); - -function renderCell(value: CellValue): React.ReactNode { - if (typeof value === 'boolean') return value ? : ; - if (value === '—') return ; - return {value}; -} - function PlanButton({ tier, cycle }: { tier: TierConfig; cycle: BillingCycle }) { const cta = CTAS[tier.slug]; - const variant: 'primary' | 'secondary' = tier.highlight ? 'primary' : 'secondary'; - const common = { - variant, - size: 'md' as const, - className: 'pricing-plan-btn', - }; + const variant = tier.highlight ? 'primary' : 'secondary'; + const trackClick = (destinationUrl: string) => + trackCtaClick({ + surface: 'pricing', + destination_url: destinationUrl, + cta_id: cta.ctaId, + cta_text: cta.label, + }); + if (cta.stripeBuyable) { return ( -
+
); } + + const href = cta.href; + if (!href) return null; + return ( ); } -const LABEL_COL_WIDTH = '22%'; - function BillingToggle({ cycle, setCycle, - discountPct, }: { cycle: BillingCycle; - setCycle: (c: BillingCycle) => void; - discountPct: number; + setCycle: (cycle: BillingCycle) => void; }) { + const discountPct = annualDiscountPercent(); + return ( -
-
- - +
+ Billing cycle +
+ {(['monthly', 'annual'] as const).map((value) => { + const selected = cycle === value; + const label = value === 'monthly' + ? 'Monthly' + : `Annual — save up to ${discountPct}%`; + return ( + + ); + })}
-
+ ); } -function SectionTable({ - title, - rows, - cycle, - showPrice, -}: { - title: string; - rows: FeatureRow[]; - cycle: BillingCycle; - showPrice: boolean; -}) { +function Price({ tier, cycle }: { tier: TierConfig; cycle: BillingCycle }) { + const price = tier.prices[cycle]; + const savings = annualSavingsDollars(tier); + const isAnnualPaid = cycle === 'annual' && price.cents != null; + return ( -
-
- - - - - {TIERS.map((tier) => ( - - ))} - - {showPrice ? ( - - - {TIERS.map((tier) => { - const p = tier.prices[cycle]; - return ( - - ); - })} - - ) : ( - - - )} - - - {rows.map((row, i) => ( - - - {TIERS.map((tier) => ( - - ))} - - ))} - -
- {title} - - {tier.highlight && ( -
- MOST POPULAR -
- )} -
- {tier.name} -
-
- Price - -
- - {p.display} - - {p.period && ( - - {p.period} - - )} -
-
- {TIERS.map((tier) => ( - - ))} -
- {row.feature} - - {renderCell(row.cells[tier.slug])} -
+
+
+ {tier.slug === 'community' ? ( + Free forever + ) : ( + <> + {price.display} + {price.period ? {price.period} : null} + + )}
+

{tier.priceQualifier}

+ {isAnnualPaid ? ( +

+ Billed annually{savings > 0 ? ` · save $${savings} per year` : ''} +

+ ) : tier.slug === 'enterprise' ? ( +

Sales-led pricing

+ ) : null} + {tier.additionalQualifier ? ( +

{tier.additionalQualifier}

+ ) : null}
); } -function CtaStrip({ cycle }: { cycle: BillingCycle }) { +function PlanCard({ tier, cycle }: { tier: TierConfig; cycle: BillingCycle }) { + const headingId = `pricing-plan-${tier.slug}`; + return ( -
-
- {TIERS.map((tier) => ( -
-
- -
-
- ))} -
+ {tier.highlight ?
Most popular
: null} +
+ {tier.stageLabel} + {tier.journeyLabel} +
+

{tier.displayName}

+ +

{tier.description}

+
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ +
+ ); } export function CompareTable() { const [cycle, setCycle] = useState('annual'); - const discountPct = annualDiscountPercent(); return ( -
- - - -
- -
- -
- - -
- +
+

Plans for every shipping stage

+ +
+ {TIERS.map((tier) => ( + + ))}
); diff --git a/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx b/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx index 63018acfb..cba2c7b88 100644 --- a/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx +++ b/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx @@ -16,4 +16,11 @@ describe('CompatibilityMatrix', () => { expect(screen.getByText(/Unsupported/)).toBeTruthy(); expect(screen.getByText(/≤19/)).toBeTruthy(); }); + + it('uses semantic column and row headers', () => { + render(); + expect(screen.getByRole('columnheader', { name: 'Status' }).getAttribute('scope')).toBe('col'); + expect(screen.getByRole('columnheader', { name: 'Angular versions' }).getAttribute('scope')).toBe('col'); + expect(screen.getByRole('rowheader', { name: 'Supported' }).getAttribute('scope')).toBe('row'); + }); }); diff --git a/apps/website/src/components/pricing/CompatibilityMatrix.tsx b/apps/website/src/components/pricing/CompatibilityMatrix.tsx index 43dd46874..629c67d35 100644 --- a/apps/website/src/components/pricing/CompatibilityMatrix.tsx +++ b/apps/website/src/components/pricing/CompatibilityMatrix.tsx @@ -20,10 +20,10 @@ export function CompatibilityMatrix() { - - @@ -31,9 +31,9 @@ export function CompatibilityMatrix() { {ROWS.map((row) => ( - diff --git a/apps/website/src/components/pricing/LeadForm.tsx b/apps/website/src/components/pricing/LeadForm.tsx index 287793845..c55d24af1 100644 --- a/apps/website/src/components/pricing/LeadForm.tsx +++ b/apps/website/src/components/pricing/LeadForm.tsx @@ -10,22 +10,22 @@ import { Card } from '../ui/Card'; const VALUE_PROPS = [ { - title: 'Threadplane Commercial license', - body: 'Multi-app coverage, unlimited developers, custom contract — built for procurement.', + title: 'Enterprise commercial license', + body: 'Custom or organization-wide developer coverage, multi-application scope, and contract terms built for procurement.', }, { title: 'SLA + security review', body: 'Response SLAs, security questionnaires, and a private support channel.', }, { - title: 'Pilot-to-Prod engagement', - body: '8-week concierge delivery. We ship your first Angular agent on your real data, in your real app — and your engineers own it at the end.', + title: 'Optional Pilot-to-Prod engagement', + body: 'Separately scoped eight-week concierge delivery for teams that want guided implementation alongside their license.', highlight: true, link: { href: '/pilot-to-prod', label: 'See how Pilot-to-Prod works →' }, }, { title: 'Procurement support', - body: 'Master services agreement, security review, custom indemnification — handled by humans, not portals.', + body: 'Master services agreement where applicable, security review, and custom indemnification where contractually agreed.', }, ]; @@ -79,10 +79,10 @@ export function LeadForm() {
Enterprise

- Built for procurement.
Backed by delivery. + Choose the license.
Add delivery if you need it.

- Volume licensing, custom contract, and optional concierge delivery — so your first Angular agent ships, not just compiles. + Enterprise licensing and Pilot-to-Prod are separate choices. Request license-only terms or ask us to scope the optional eight-week engagement.

diff --git a/apps/website/src/components/pricing/PricingDetails.tsx b/apps/website/src/components/pricing/PricingDetails.tsx new file mode 100644 index 000000000..975c4a558 --- /dev/null +++ b/apps/website/src/components/pricing/PricingDetails.tsx @@ -0,0 +1,290 @@ +import { TIERS, type TierSlug } from '../../../../../pricing/tiers.config'; + +type ComparisonCells = Record; + +interface ComparisonRow { + readonly label: string; + readonly cells: ComparisonCells; + readonly note?: string; +} + +interface ComparisonGroup { + readonly title: string; + readonly rows: readonly ComparisonRow[]; +} + +const PAID_CAPABILITY: ComparisonCells = { + community: 'For permitted free use', + developer_seat: 'Included under commercial license', + team: 'Included under commercial license', + enterprise: 'Included under commercial license', +}; + +const COMPARISON_GROUPS: readonly ComparisonGroup[] = [ + { + title: 'License and deployment', + rows: [ + { + label: 'Permitted free use', + cells: { + community: 'Yes, within PolyForm Noncommercial terms', + developer_seat: 'Yes', + team: 'Yes', + enterprise: 'Yes', + }, + }, + { + label: '30-day commercial evaluation', + cells: { + community: '30 calendar days', + developer_seat: 'Not needed after purchase', + team: 'Not needed after purchase', + enterprise: 'Handled during sales process', + }, + }, + { + label: 'Commercial production rights for @threadplane/chat', + cells: { + community: 'Not included', + developer_seat: 'Included', + team: 'Included', + enterprise: 'Included by contract', + }, + }, + { + label: 'Developer coverage', + cells: { + community: 'Unlimited only for permitted free use', + developer_seat: 'Per purchased seat', + team: '5 developers included', + enterprise: 'Custom or organization-wide by contract', + }, + }, + { + label: 'Licensed applications', + cells: { + community: 'Permitted free-use applications', + developer_seat: 'Unlimited', + team: 'Unlimited', + enterprise: 'Multi-application or custom scope', + }, + }, + { + label: 'End-user seats required', + cells: { + community: 'No', + developer_seat: 'No', + team: 'No', + enterprise: 'No', + }, + }, + { + label: 'Development, staging, CI/CD, and production use', + cells: { + community: 'Commercial evaluation only; no commercial production', + developer_seat: 'Included', + team: 'Included', + enterprise: 'Included by contract', + }, + }, + { + label: 'Customer-deployed', + cells: { + community: 'Yes', + developer_seat: 'Yes', + team: 'Yes', + enterprise: 'Yes', + }, + }, + { + label: 'Offline license verification', + cells: { + community: 'No paid token required for permitted free use', + developer_seat: 'Signed token included', + team: 'Signed token included', + enterprise: 'Signed token included', + }, + }, + { + label: 'Recurring subscription', + cells: { + community: 'No', + developer_seat: 'Monthly or annual', + team: 'Monthly or annual', + enterprise: 'Annual contract', + }, + }, + { + label: 'Custom contract', + cells: { + community: 'PolyForm terms', + developer_seat: 'Standard commercial terms', + team: 'Standard commercial terms', + enterprise: 'Included', + }, + }, + ], + }, + { + title: 'Framework and UI capabilities', + rows: [ + 'Headless chat primitives', + 'Composed chat UI', + 'Signal-based streaming state', + 'Tool-call progress and errors', + 'Human-in-the-loop interrupts', + 'Subagents and delegation surfaces', + 'Planning and memory surfaces', + 'Thread, history, and branch UI primitives', + 'Citations and sources', + 'json-render integration', + 'A2UI integration', + 'LangGraph adapter', + 'AG-UI adapter', + 'Light, dark, and Material-related theme presets', + 'Angular 20 and 21 support', + ].map((label) => ({ + label, + cells: PAID_CAPABILITY, + ...(label === 'Thread, history, and branch UI primitives' + ? { + note: + 'Availability and durability depend on capabilities provided by the connected agent runtime and persistence layer.', + } + : {}), + })), + }, + { + title: 'Support and procurement', + rows: [ + { + label: 'Documentation and examples', + cells: { community: 'Included', developer_seat: 'Included', team: 'Included', enterprise: 'Included' }, + }, + { + label: 'GitHub support', + cells: { community: 'GitHub community support', developer_seat: 'Included', team: 'Included', enterprise: 'Included' }, + }, + { + label: 'Email support', + cells: { community: 'Not included', developer_seat: 'Not included', team: 'Included', enterprise: 'Included' }, + }, + { + label: 'Private support channel or Slack Connect', + cells: { community: 'Not included', developer_seat: 'Not included', team: 'Not included', enterprise: 'Included' }, + }, + { + label: 'Response SLA', + cells: { community: 'Not included', developer_seat: 'Not included', team: 'Not included', enterprise: 'Included' }, + }, + { + label: 'Security review assistance', + cells: { community: 'Not included', developer_seat: 'Not included', team: 'Not included', enterprise: 'Included' }, + }, + { + label: 'Procurement support', + cells: { community: 'Not applicable', developer_seat: 'Self-service checkout', team: 'Single team subscription', enterprise: 'Included' }, + }, + { + label: 'Custom terms', + cells: { community: 'Not included', developer_seat: 'Not included', team: 'Not included', enterprise: 'Available by contract' }, + }, + { + label: 'Pilot-to-Prod availability', + cells: { community: 'Contact sales', developer_seat: 'Contact sales', team: 'Contact sales', enterprise: 'Available as an optional engagement' }, + }, + ], + }, +]; + +export function ArchitectureBoundary() { + return ( +
+
+

What you are buying

+

Same software. Different license scope and support.

+

+ Threadplane does not gate core Angular agent UI capabilities by plan. Paid plans license{' '} + @threadplane/chat for commercial production and expand developer coverage, + support, and contract terms. +

+
+ +
+
+ Your product + Your Angular application +
+ +
+ Runs in your app + Threadplane UI packages +
+ +
+ Customer-operated + Your agent runtime, models, storage, and infrastructure +
+
+ +

+ Threadplane does not host your agents or conversations. Runtime behavior, durable persistence, + retention, and infrastructure costs are determined by the connected backend and providers. +

+
+ ); +} + +export function PricingComparison() { + return ( +
+
+

Full comparison

+

What changes between plans.

+

+ Capabilities stay consistent. License scope, developer coverage, support, and procurement terms change. +

+
+
+
+ Status + Angular versions
+ {row.label} - + {row.versions}
+ + + + {TIERS.map((tier) => ( + + ))} + + + {COMPARISON_GROUPS.map((group) => ( + + + + + {group.rows.map((row) => ( + + + {TIERS.map((tier) => ( + + ))} + + ))} + + ))} +
Plan detail + {tier.displayName} + {tier.highlight ? : null} +
{group.title}
+ {row.label} + {row.note ? {row.note} : null} + + {row.cells[tier.slug]} +
+
+
+ ); +} diff --git a/apps/website/src/components/pricing/PricingFAQ.spec.tsx b/apps/website/src/components/pricing/PricingFAQ.spec.tsx index 0ce145623..09cf6bc87 100644 --- a/apps/website/src/components/pricing/PricingFAQ.spec.tsx +++ b/apps/website/src/components/pricing/PricingFAQ.spec.tsx @@ -15,12 +15,19 @@ vi.mock('../ui/Eyebrow', () => ({ })); const EXPECTED_QUESTIONS = [ + 'Is Threadplane free?', 'Is @threadplane/chat open source?', - 'Can I use it for free?', - 'Can I use it at work?', + 'What counts as commercial use?', + 'Does Threadplane have a cloud service?', + 'Does Threadplane store my conversations or agent data?', + 'Are model or hosting costs included?', + 'What am I paying for?', 'Do my end users need licenses?', - 'Can I modify the source?', - 'Can I redistribute it?', + 'What is a developer seat?', + 'Does a paid plan unlock different software?', + 'How does the license token work?', + 'Can I modify or redistribute the source?', + 'What happens after cancellation or refund?', ]; describe('PricingFAQ', () => { @@ -50,4 +57,11 @@ describe('PricingFAQ', () => { screen.getByText(/source-available under the PolyForm Noncommercial License 1\.0\.0/i), ).toBeTruthy(); }); + + it('explains offline advisory token verification without a licensing API call', () => { + render(); + expect(screen.getByText(/Ed25519/i)).toBeTruthy(); + expect(screen.getByText(/does not call a Threadplane licensing API/i)).toBeTruthy(); + expect(screen.getByText(/does not block rendering/i)).toBeTruthy(); + }); }); diff --git a/apps/website/src/components/pricing/PricingFAQ.tsx b/apps/website/src/components/pricing/PricingFAQ.tsx index 129bf4cf8..e79deaf2b 100644 --- a/apps/website/src/components/pricing/PricingFAQ.tsx +++ b/apps/website/src/components/pricing/PricingFAQ.tsx @@ -4,29 +4,57 @@ import { Eyebrow } from '../ui/Eyebrow'; import { FAQ, type FAQItem } from '../ui/FAQ'; const ITEMS: FAQItem[] = [ + { + q: 'Is Threadplane free?', + a: 'Most Threadplane packages are MIT-licensed and free for commercial or noncommercial use. @threadplane/chat is free for uses permitted by PolyForm Noncommercial 1.0.0 and for a 30-calendar-day commercial evaluation. Commercial production use of @threadplane/chat requires a Threadplane Commercial license.', + }, { q: 'Is @threadplane/chat open source?', - a: '@threadplane/chat is source-available under the PolyForm Noncommercial License 1.0.0. Because commercial use requires a license, it is not OSI open source.', + a: 'Most Threadplane packages are MIT open source. @threadplane/chat is source-available under the PolyForm Noncommercial License 1.0.0 and is not OSI open source.', + }, + { + q: 'What counts as commercial use?', + a: 'Commercial use means using @threadplane/chat in an application, product, service, internal tool, client deliverable, hosted experience, or workflow that is operated by or for a for-profit entity, generates revenue, supports paid services, supports business operations, or is delivered to a paying client.', + }, + { + q: 'Does Threadplane have a cloud service?', + a: 'No. Threadplane runs inside your Angular application. You operate your application, agent runtime, data stores, hosting, and model-provider accounts.', + }, + { + q: 'Does Threadplane store my conversations or agent data?', + a: 'No hosted Threadplane persistence service is included. Threadplane provides Angular UI packages and adapter contracts for thread state, history, branching, reload, and interrupts. The connected runtime and persistence layer determine storage, checkpointing, retention, authorization, and cross-device behavior.', }, { - q: 'Can I use it for free?', - a: 'Yes. Personal, educational, nonprofit, academic, demo, open-source, and evaluation use are free under the noncommercial license.', + q: 'Are model or hosting costs included?', + a: 'No. Model inference, agent runtime, database, observability, hosting, and other infrastructure charges remain with the providers you select.', }, { - q: 'Can I use it at work?', - a: 'You can evaluate it at work for 30 calendar days from your first commercial use. After that, production use in a commercial product, internal tool, SaaS app, or client deliverable requires a Threadplane Commercial license. The eval window is good-faith — no telemetry, no registration.', + q: 'What am I paying for?', + a: 'Paid plans provide commercial production rights for @threadplane/chat, developer-seat or organization scope, support, and contract or procurement services. They do not provide hosted usage, bundled infrastructure, or a different package build.', }, { q: 'Do my end users need licenses?', - a: 'No. Commercial licenses are for the developers, organization, or production application using @threadplane/chat, depending on the plan.', + a: 'No. End users of a licensed application do not need seats. Commercial coverage applies to the developers, organization, or application scope defined by the plan or contract.', + }, + { + q: 'What is a developer seat?', + a: 'A developer seat covers one developer working on or maintaining the licensed application. Contact sales for contractor, subsidiary, or other organizational edge cases not addressed by the standard commercial summary.', + }, + { + q: 'Does a paid plan unlock different software?', + a: 'No. Paid plans use the same source and package build. The plan changes permitted use, developer coverage, support, and contractual scope—not core product capabilities.', + }, + { + q: 'How does the license token work?', + a: 'The package verifies the signed token locally with Ed25519. Verification does not call a Threadplane licensing API at runtime. A missing, expired, or invalid token produces an advisory console warning and does not block rendering.', }, { - q: 'Can I modify the source?', - a: 'Yes, for permitted noncommercial use under the PolyForm Noncommercial license, or for commercial production use under a paid Threadplane Commercial license.', + q: 'Can I modify or redistribute the source?', + a: 'You may modify @threadplane/chat within your permitted use and embed it in a larger licensed application. You may not redistribute it as a standalone package or as part of a competing component library, SDK, template kit, app builder, or design system.', }, { - q: 'Can I redistribute it?', - a: 'You may bundle it inside a larger licensed application. You may not redistribute it as a standalone package or as part of a competing component library, SDK, template kit, app builder, or design system.', + q: 'What happens after cancellation or refund?', + a: 'After cancellation, the license remains valid through the end of the current paid period. A refund marks the license revoked in Threadplane records and triggers a confirmation email. Runtime token verification remains offline and does not make a revocation lookup.', }, ]; diff --git a/apps/website/src/components/pricing/TiersConfig.spec.ts b/apps/website/src/components/pricing/TiersConfig.spec.ts new file mode 100644 index 000000000..461192fab --- /dev/null +++ b/apps/website/src/components/pricing/TiersConfig.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { TIERS } from '../../../../../pricing/tiers.config'; + +describe('pricing tier configuration', () => { + it('separates public names from compatibility-sensitive identifiers', () => { + expect( + TIERS.map((tier) => ({ + slug: tier.slug, + displayName: (tier as { displayName?: string }).displayName, + })), + ).toEqual([ + { slug: 'community', displayName: 'Developer' }, + { slug: 'developer_seat', displayName: 'Pro' }, + { slug: 'team', displayName: 'Team' }, + { slug: 'enterprise', displayName: 'Enterprise' }, + ]); + }); + + it('preserves every public price and billing interval exactly', () => { + expect( + TIERS.map((tier) => ({ slug: tier.slug, prices: tier.prices })), + ).toEqual([ + { + slug: 'community', + prices: { + monthly: { cents: null, display: 'Free', period: '' }, + annual: { cents: null, display: 'Free', period: '' }, + }, + }, + { + slug: 'developer_seat', + prices: { + monthly: { cents: 2900, display: '$29', period: '/developer/month' }, + annual: { cents: 29900, display: '$299', period: '/developer/year' }, + }, + }, + { + slug: 'team', + prices: { + monthly: { cents: 14900, display: '$149', period: '/month' }, + annual: { cents: 149500, display: '$1,495', period: '/year' }, + }, + }, + { + slug: 'enterprise', + prices: { + monthly: { cents: null, display: 'From $4,000', period: '/month' }, + annual: { cents: null, display: 'From $4,000', period: '/month' }, + }, + }, + ]); + }); + + it('preserves buyability and individual-seat quantity behavior', () => { + expect( + TIERS.map(({ slug, stripeBuyable, adjustableQuantity, defaultQuantity }) => ({ + slug, + stripeBuyable, + adjustableQuantity: adjustableQuantity ?? false, + defaultQuantity: defaultQuantity ?? null, + })), + ).toEqual([ + { slug: 'community', stripeBuyable: false, adjustableQuantity: false, defaultQuantity: null }, + { slug: 'developer_seat', stripeBuyable: true, adjustableQuantity: true, defaultQuantity: 1 }, + { slug: 'team', stripeBuyable: true, adjustableQuantity: false, defaultQuantity: null }, + { slug: 'enterprise', stripeBuyable: false, adjustableQuantity: false, defaultQuantity: null }, + ]); + }); +}); diff --git a/apps/website/src/styles/marketing.css b/apps/website/src/styles/marketing.css index 0c0f90ccf..eeff24d48 100644 --- a/apps/website/src/styles/marketing.css +++ b/apps/website/src/styles/marketing.css @@ -9,11 +9,18 @@ * Migration: docs/superpowers/plans/2026-08-29-inline-style-substrate-migration.md */ -/* CompareTable — components/pricing/CompareTable.tsx */ -.pricing-billing-toggle-wrap { +/* Pricing plans and comparison */ +.pricing-plans-section { + max-width: 1440px; + margin: 0 auto; + padding: 32px 24px 88px; +} +.pricing-billing-fieldset { + border: 0; + padding: 0; + margin: 0 0 32px; display: flex; justify-content: center; - margin-bottom: 32px; } .pricing-billing-toggle { display: inline-flex; @@ -24,11 +31,11 @@ gap: 4px; } .pricing-billing-tab { + position: relative; font-family: Inter, system-ui, sans-serif; font-size: 13px; font-weight: 600; padding: 8px 16px; - border: none; cursor: pointer; transition: background 150ms ease, color 150ms ease; background: transparent; @@ -39,172 +46,357 @@ background: var(--color-accent); color: #fff; } -.pricing-compare-scroll { - overflow-x: auto; +.pricing-billing-tab:has(input:focus-visible) { + outline: 2px solid var(--color-accent); + outline-offset: 2px; } -.pricing-compare-box { - background: var(--color-surface); +.pricing-plan-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} +.pricing-plan-card { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + padding: 26px 22px 22px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); - overflow: hidden; - min-width: 960px; -} -.pricing-compare-table { - width: 100%; - border-collapse: collapse; - font-family: Inter, system-ui, sans-serif; - font-size: 14px; -} -.pricing-compare-th-label { - text-align: left; - padding: 20px 18px 14px; - color: var(--color-text-muted); - font-family: "JetBrains Mono", monospace; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - font-weight: 600; - width: 22%; background: var(--color-surface); } -.pricing-compare-th-tier { - text-align: center; - padding: 20px 14px 14px; - background: var(--color-surface); - position: relative; -} -.pricing-compare-th-tier[data-highlight] { +.pricing-plan-card[data-highlight] { + border-color: var(--color-accent); background: var(--color-surface-tinted); + box-shadow: 0 0 0 1px var(--color-accent); } -.pricing-compare-badge { +.pricing-plan-popular { position: absolute; - top: 6px; - left: 50%; - transform: translateX(-50%); + top: 0; + right: 20px; + transform: translateY(-50%); background: var(--color-accent); color: #fff; - font-family: Inter, system-ui, sans-serif; - font-size: 9px; + font-family: var(--font-inter); + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; - padding: 2px 8px; + text-transform: uppercase; + padding: 4px 10px; border-radius: 999px; - white-space: nowrap; } -.pricing-compare-tier-name { - font-family: Inter, system-ui, sans-serif; - color: var(--color-accent); - font-size: 11px; +.pricing-plan-stage-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 24px; + margin-bottom: 14px; +} +.pricing-plan-stage { + font-family: var(--font-mono); + font-size: 10px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.1em; text-transform: uppercase; - margin-top: 0; + color: var(--color-accent); } -.pricing-compare-tier-name[data-highlight] { - margin-top: 12px; +.pricing-plan-journey { + font-family: var(--font-inter); + font-size: 12px; + font-weight: 700; + color: var(--color-text-muted); + text-align: right; } -.pricing-compare-price-label-th { - text-align: left; - padding: 0 18px 20px; +.pricing-plan-name { + margin: 0 0 18px; + font-family: var(--font-garamond); + font-size: 34px; + line-height: 1; color: var(--color-text-primary); - font-family: Inter, system-ui, sans-serif; - font-size: 13px; - font-weight: 600; - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); -} -.pricing-compare-price-th { - text-align: center; - padding: 0 14px 20px; - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); } -.pricing-compare-price-th[data-highlight] { - background: var(--color-surface-tinted); +.pricing-plan-price-block { + min-height: 126px; } -.pricing-compare-price-row { +.pricing-plan-price { display: flex; align-items: baseline; - justify-content: center; - gap: 4px; + gap: 6px; + flex-wrap: wrap; } -.pricing-compare-price-amount { - font-family: "EB Garamond", Georgia, serif; +.pricing-plan-price-amount { + font-family: var(--font-garamond); font-weight: 700; - font-size: 28px; + font-size: 30px; color: var(--color-text-primary); line-height: 1; } -.pricing-compare-price-period { - font-family: Inter, system-ui, sans-serif; +.pricing-plan-price-period { + font-family: var(--font-inter); + font-size: 11px; + color: var(--color-text-muted); +} +.pricing-plan-price-qualifier, +.pricing-plan-billing-note, +.pricing-plan-additional-qualifier { + font-family: var(--font-inter); + line-height: 1.45; +} +.pricing-plan-price-qualifier { + margin: 10px 0 0; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 700; +} +.pricing-plan-billing-note { + margin: 5px 0 0; + color: var(--color-text-muted); + font-size: 11px; +} +.pricing-plan-additional-qualifier { + margin: 7px 0 0; + color: var(--color-accent); + font-size: 12px; + font-weight: 700; +} +.pricing-plan-description { + margin: 6px 0 20px; + min-height: 88px; + color: var(--color-text-secondary); + font-family: var(--font-inter); + font-size: 13px; + line-height: 1.55; +} +.pricing-plan-feature-list { + list-style: none; + padding: 18px 0 0; + margin: 0; + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 11px; +} +.pricing-plan-feature { + display: flex; + align-items: flex-start; + gap: 9px; + font-family: var(--font-inter); font-size: 12px; + line-height: 1.45; + color: var(--color-text-secondary); +} +.pricing-plan-feature-mark { + flex: 0 0 auto; + color: var(--color-accent); + font-weight: 800; +} +.pricing-plan-action { + margin-top: auto; + padding-top: 24px; +} +.pricing-plan-form, +.pricing-plan-btn { + width: 100%; +} +.pricing-section-heading-wrap { + max-width: 760px; + margin-bottom: 36px; +} +.pricing-section-kicker { + margin: 0 0 10px; + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.pricing-section-heading { + margin: 0 0 14px; + color: var(--color-text-primary); + font-family: var(--font-garamond); + font-size: clamp(30px, 4vw, 44px); + line-height: 1.08; + letter-spacing: -0.02em; +} +.pricing-section-body { + margin: 0; + color: var(--color-text-secondary); + font-family: var(--font-inter); + font-size: 16px; + line-height: 1.65; +} +.pricing-boundary, +.pricing-comparison { + max-width: 1200px; + margin: 0 auto; +} +.pricing-boundary-flow { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1.35fr; + gap: 14px; + align-items: center; +} +.pricing-boundary-block { + min-height: 132px; + padding: 20px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + color: var(--color-text-primary); + font-family: var(--font-inter); + line-height: 1.4; +} +.pricing-boundary-block[data-accent] { + border-color: var(--color-accent); + box-shadow: 0 0 0 1px var(--color-accent); +} +.pricing-boundary-label { color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; } -.pricing-compare-spacer-label-th { - padding: 0 18px 12px; +.pricing-boundary-arrow { + color: var(--color-accent); + font-size: 22px; + font-weight: 700; +} +.pricing-boundary-note { + margin: 24px 0 0; + padding: 18px 20px; + border-left: 3px solid var(--color-accent); background: var(--color-surface); - border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-family: var(--font-inter); + font-size: 14px; + line-height: 1.6; } -.pricing-compare-spacer-th { - padding: 0 14px 12px; +.pricing-comparison-scroll { + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); background: var(--color-surface); - border-bottom: 1px solid var(--color-border); } -.pricing-compare-spacer-th[data-highlight] { - background: var(--color-surface-tinted); +.pricing-comparison-table { + width: 100%; + min-width: 1060px; + border-collapse: separate; + border-spacing: 0; + color: var(--color-text-secondary); + font-family: var(--font-inter); + font-size: 12px; + line-height: 1.45; } -.pricing-compare-row { +.pricing-comparison-table th, +.pricing-comparison-table td { + padding: 13px 14px; border-bottom: 1px solid var(--color-border); + vertical-align: top; } -.pricing-compare-row[data-last] { - border-bottom: none; -} -.pricing-compare-td-label { - padding: 14px 18px; +.pricing-comparison-table thead th { + position: sticky; + top: 0; + z-index: 2; color: var(--color-text-primary); - font-family: Inter, system-ui, sans-serif; + background: var(--color-surface); font-size: 13px; - font-weight: 500; + text-align: left; } -.pricing-compare-td { - padding: 14px 14px; - text-align: center; - font-family: Inter, system-ui, sans-serif; - font-size: 13px; - background: transparent; +.pricing-comparison-feature-col { + width: 27%; + left: 0; + z-index: 3 !important; +} +.pricing-comparison-plan-col { + width: 18.25%; } -.pricing-compare-td[data-highlight] { +.pricing-comparison-plan-col[data-highlight], +.pricing-comparison-table td[data-highlight] { background: var(--color-surface-tinted); } -.pricing-compare-check { +.pricing-comparison-popular { + display: block; + margin-top: 3px; color: var(--color-accent); - font-weight: 700; + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; } -.pricing-compare-dash { - color: var(--color-text-muted); +.pricing-comparison-group-row th { + padding-top: 20px; + color: var(--color-accent); + background: var(--color-surface-tinted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-align: left; + text-transform: uppercase; } -.pricing-compare-cell-text { - color: var(--color-text-secondary); +.pricing-comparison-row-heading { + position: sticky; + left: 0; + z-index: 1; + color: var(--color-text-primary); + background: var(--color-surface); + font-weight: 600; + text-align: left; } -.pricing-plan-btn { - width: 100%; +.pricing-comparison-row-note { + display: block; + margin-top: 5px; + color: var(--color-text-muted); + font-size: 10px; + font-weight: 400; } -.pricing-compare-cta-cell { - padding: 0 14px; - display: flex; - justify-content: center; +.pricing-comparison-table tbody:last-child tr:last-child th, +.pricing-comparison-table tbody:last-child tr:last-child td { + border-bottom: 0; } -.pricing-compare-cta-btn-wrap { - width: 100%; - max-width: 220px; + +@media (max-width: 1100px) { + .pricing-plan-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px 16px; + } + .pricing-plan-description { + min-height: 0; + } } -.pricing-compare-section { - max-width: 1280px; - margin: 0 auto; - padding: 32px 24px; + +@media (max-width: 720px) { + .pricing-plans-section { + padding: 24px 16px 64px; + } + .pricing-plan-grid { + grid-template-columns: minmax(0, 1fr); + } + .pricing-plan-price-block { + min-height: 0; + } + .pricing-boundary-flow { + grid-template-columns: minmax(0, 1fr); + } + .pricing-boundary-arrow { + transform: rotate(90deg); + justify-self: center; + } + .pricing-billing-tab { + padding: 8px 12px; + font-size: 12px; + } } -.pricing-compare-spacer { - height: 56px; + +@media (prefers-reduced-motion: reduce) { + .pricing-billing-tab { + transition: none; + } } /* LeadForm — components/pricing/LeadForm.tsx */ @@ -403,6 +595,7 @@ border-bottom: 1px solid var(--color-border); } .compat-matrix-td-label { + text-align: left; padding: 12px 16px; font-size: var(--text-body); font-weight: 500; @@ -743,16 +936,6 @@ font-size: 14px; } -/* CompareTable CTA strip (lint-guard follow-up): static box moved here; - * --cta-cols is set inline from the tier config. */ -.pricing-cta-strip { - min-width: 960px; - margin: 24px 0 0; - display: grid; - grid-template-columns: var(--cta-cols); - gap: 0; - align-items: start; -} .sol-code-eyebrow { margin-bottom: 12px; } diff --git a/apps/website/src/styles/pages.css b/apps/website/src/styles/pages.css index 12de85355..cd3a33238 100644 --- a/apps/website/src/styles/pages.css +++ b/apps/website/src/styles/pages.css @@ -1289,7 +1289,7 @@ /* Pricing page — app/pricing/page.tsx */ .pricing-page-hero-inner { text-align: center; - max-width: 760px; + max-width: 960px; margin: 0 auto; } .pricing-page-eyebrow-spaced { @@ -1304,6 +1304,33 @@ margin: 0; letter-spacing: -0.02em; } +.pricing-page-subtitle { + max-width: 760px; + margin: 20px auto 0; + color: var(--color-text-secondary); + font-family: var(--font-inter); + font-size: var(--text-body-lg); + line-height: 1.65; +} +.pricing-page-license-line { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + margin: 24px auto 0; + padding: 13px 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface-tinted); + color: var(--color-text-secondary); + font-family: var(--font-inter); + font-size: 13px; + line-height: 1.45; +} +.pricing-page-license-line strong { + color: var(--color-accent); +} .pricing-page-eyebrow-tight { margin-bottom: 12px; } @@ -1321,6 +1348,17 @@ max-width: 60ch; } +@media (max-width: 640px) { + .pricing-page-license-line { + align-items: flex-start; + flex-direction: column; + text-align: left; + } + .pricing-page-license-line > span[aria-hidden] { + display: none; + } +} + /* Home page — app/page.tsx */ .home-code-frame { border-radius: 14px; diff --git a/libs/chat/README.md b/libs/chat/README.md index b53f4b5b3..0d16c2024 100644 --- a/libs/chat/README.md +++ b/libs/chat/README.md @@ -300,10 +300,14 @@ The full token vocabulary (`--a2ui-primary`, `--a2ui-spacing-1..7`, `--a2ui-typo Chat compositions consume the runtime-neutral `Agent` contract. Two adapters ship today: - **`@threadplane/langgraph`** — for LangGraph / LangGraph Platform backends. -- **`@threadplane/ag-ui`** — for AG-UI-compatible backends (LangGraph, CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, AWS Strands, CopilotKit runtime). +- **`@threadplane/ag-ui`** — for AG-UI-compatible backends such as LangGraph, CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, and AWS Strands. Custom backends implement the `Agent` (or `AgentWithHistory`) interface directly with no library dependency. +Threadplane does not host agents, conversations, models, or storage. Thread, history, branching, and reload +surfaces use the connected adapter contracts; durable storage, checkpointing, retention, authorization, and +cross-device persistence depend on the backend and infrastructure you operate. + --- ## Commercial use @@ -320,7 +324,8 @@ See [COMMERCIAL-USE.md](./COMMERCIAL-USE.md) for the definition of commercial us ## Using a commercial license -After purchase, Threadplane emails a signed license token to the address on your receipt. The license is valid for 12 months. Pass the token to `provideChat()`: +After purchase, Threadplane emails a signed license token to the address on your receipt. The token is valid +through the current paid subscription period and is reissued after renewal. Pass the token to `provideChat()`: ```typescript // app.config.ts diff --git a/pricing/tiers.config.ts b/pricing/tiers.config.ts index bf63535c2..5367edc12 100644 --- a/pricing/tiers.config.ts +++ b/pricing/tiers.config.ts @@ -25,16 +25,22 @@ export interface TierPrice { readonly cents: number | null; /** Display value, e.g. "$29" or "$299". */ readonly display: string; - /** Period suffix shown inline after the price, e.g. "/dev/mo" or "/dev/yr". */ + /** Period suffix shown inline after the price, e.g. "/developer/month". */ readonly period: string; } export interface TierConfig { readonly slug: TierSlug; - readonly name: string; + /** Existing Stripe product name. Kept separate from public marketing names. */ + readonly stripeProductName: string; + readonly displayName: string; + readonly stageLabel: string; + readonly journeyLabel: string; + readonly description: string; readonly prices: Record; - /** Subtitle under the price; replaces the standalone period gray subline. */ - readonly subtitle: string; + /** Essential qualification displayed directly with the price. */ + readonly priceQualifier: string; + readonly additionalQualifier?: string; readonly features: readonly string[]; /** Short one-liner shown in its own row below the features. */ readonly bestFor: string; @@ -42,7 +48,7 @@ export interface TierConfig { readonly stripeBuyable: boolean; /** Highlighted card / column in the pricing table. */ readonly highlight: boolean; - /** Checkout `adjustable_quantity` enabled. Only Developer Seat today. */ + /** Checkout `adjustable_quantity` enabled. Only the individual-seat product today. */ readonly adjustableQuantity?: boolean; /** Default quantity passed to Stripe Checkout when the buyer doesn't override. */ readonly defaultQuantity?: number; @@ -53,32 +59,49 @@ const FREE: TierPrice = { cents: null, display: 'Free', period: '' }; export const TIERS: readonly TierConfig[] = [ { slug: 'community', - name: 'Community', + stripeProductName: 'Community', + displayName: 'Developer', + stageLabel: 'Stage 01', + journeyLabel: 'First prototype', + description: + 'For individual learning, personal projects, student and academic work, nonprofit use, public demos, qualifying open-source applications, and commercial evaluation.', prices: { monthly: FREE, annual: FREE }, - subtitle: 'forever', + priceQualifier: 'For permitted noncommercial use', + additionalQualifier: 'Includes a 30-day commercial evaluation', features: [ - 'Personal, OSS, demos', + 'All MIT-licensed Threadplane packages', + '@threadplane/chat within permitted free-use scope', 'Source access', - '30-day commercial eval', + 'Public documentation, examples, and GitHub community support', + 'No registration required for the good-faith evaluation', + 'Unlimited contributors within permitted free-use scope', ], - bestFor: 'Tinkering, OSS projects, students', + bestFor: 'Learning, personal projects, qualifying free use, and evaluation', stripeBuyable: false, highlight: false, }, { slug: 'developer_seat', - name: 'Developer Seat', + stripeProductName: 'Developer Seat', + displayName: 'Pro', + stageLabel: 'Stage 02', + journeyLabel: 'Shipping commercially', + description: + 'For solo developers and teams purchasing commercial developer seats individually.', prices: { - monthly: { cents: 2900, display: '$29', period: '/dev/mo' }, - annual: { cents: 29900, display: '$299', period: '/dev/yr' }, + monthly: { cents: 2900, display: '$29', period: '/developer/month' }, + annual: { cents: 29900, display: '$299', period: '/developer/year' }, }, - subtitle: 'per developer', + priceQualifier: 'One developer seat per purchased quantity', features: [ - 'Per developer seat', - 'Unlimited apps', + 'Commercial production rights for @threadplane/chat', + 'Unlimited licensed applications and end users', + 'Development, staging, CI/CD, and production use', + 'Same package and core capabilities as every paid plan', + 'Offline signed license token', 'GitHub support', ], - bestFor: 'Solo devs, growing teams', + bestFor: 'Solo developers and teams buying seats individually', stripeBuyable: true, highlight: false, adjustableQuantity: true, @@ -86,36 +109,52 @@ export const TIERS: readonly TierConfig[] = [ }, { slug: 'team', - name: 'Team', + stripeProductName: 'Team', + displayName: 'Team', + stageLabel: 'Stage 03', + journeyLabel: 'Whole team shipping', + description: + 'For small teams that want one subscription, one renewal, five seats, and direct email support.', prices: { - monthly: { cents: 14900, display: '$149', period: '/mo' }, - annual: { cents: 149500, display: '$1,495', period: '/yr' }, + monthly: { cents: 14900, display: '$149', period: '/month' }, + annual: { cents: 149500, display: '$1,495', period: '/year' }, }, - subtitle: '5 developer seats', + priceQualifier: '5 developer seats included', features: [ - '5 developer seats included', - 'Unlimited apps', + 'Commercial production rights for @threadplane/chat', + 'Unlimited licensed applications and end users', + 'Same package and core capabilities as Pro', + 'Offline signed license token', 'Email support', + 'One procurement-friendly team subscription', ], - bestFor: 'Procurement-friendly small teams', + bestFor: 'Small teams that want a single subscription', stripeBuyable: true, highlight: true, }, { slug: 'enterprise', - name: 'Enterprise', + stripeProductName: 'Enterprise', + displayName: 'Enterprise', + stageLabel: 'Destination', + journeyLabel: 'Production at scale', + description: + 'For organizations requiring broader license scope, enterprise support, security review, contractual terms, and guided delivery.', // Enterprise is sales-led — same "From $4,000/mo" label regardless of cycle. prices: { - monthly: { cents: null, display: 'From $4,000', period: '/mo' }, - annual: { cents: null, display: 'From $4,000', period: '/mo' }, + monthly: { cents: null, display: 'From $4,000', period: '/month' }, + annual: { cents: null, display: 'From $4,000', period: '/month' }, }, - subtitle: 'annual contract', + priceQualifier: 'Annual contract', features: [ - 'Pilot-to-Prod engagement', - 'Slack Connect support', - 'SLA + private channel', + 'Custom or organization-wide developer coverage', + 'Multi-application commercial scope', + 'Custom contract and procurement support', + 'Private support channel and response SLA', + 'Security review assistance', + 'Pilot-to-Prod available as an optional engagement', ], - bestFor: 'Procurement-led orgs', + bestFor: 'Organizations with custom support and contract requirements', stripeBuyable: false, highlight: false, }, @@ -143,15 +182,17 @@ export function annualSavingsDollars(tier: TierConfig): number { } /** - * Compute the global "save N%" badge shown on the Annual toggle. We use the - * Team tier as the canonical example since it's the highlighted plan. + * Compute the largest annual discount shown by a public paid tier. The UI + * labels this as "save up to" so it does not imply one universal discount. */ export function annualDiscountPercent(): number { - const team = TIERS.find((t) => t.slug === 'team'); - if (!team) return 0; - const m = team.prices.monthly.cents; - const a = team.prices.annual.cents; - if (m == null || a == null) return 0; - const annualizedMonthly = m * 12; - return Math.round((1 - a / annualizedMonthly) * 100); + return Math.max( + 0, + ...TIERS.map((tier) => { + const monthly = tier.prices.monthly.cents; + const annual = tier.prices.annual.cents; + if (monthly == null || annual == null) return 0; + return Math.round((1 - annual / (monthly * 12)) * 100); + }), + ); } diff --git a/scripts/stripe/sync-products.spec.ts b/scripts/stripe/sync-products.spec.ts index 09a6a5dfb..9e867ff36 100644 --- a/scripts/stripe/sync-products.spec.ts +++ b/scripts/stripe/sync-products.spec.ts @@ -4,76 +4,124 @@ import type Stripe from 'stripe'; import { syncProducts } from './sync-products'; function stubStripe(opts: { - productSearch?: Stripe.Product[]; - priceList?: Stripe.Price[]; + productsBySlug?: Record; + pricesByProduct?: Record; } = {}): Stripe { const products = { - search: vi.fn().mockResolvedValue({ data: opts.productSearch ?? [] }), - create: vi.fn().mockImplementation(({ name }: { name: string }) => - Promise.resolve({ id: `prod_new_${name.replace(/\W+/g, '_')}`, name, active: true })), + search: vi.fn().mockImplementation(({ query }: { query: string }) => { + const slug = query.match(/:'([^']+)'/)?.[1] ?? ''; + const product = opts.productsBySlug?.[slug]; + return Promise.resolve({ data: product ? [product] : [] }); + }), + create: vi.fn().mockImplementation(({ name, metadata }: Stripe.ProductCreateParams) => + Promise.resolve({ + id: `prod_${metadata?.['tplane_tier_slug']}`, + name, + metadata, + active: true, + })), update: vi.fn().mockImplementation((id: string, body: Stripe.ProductUpdateParams) => Promise.resolve({ id, ...body, active: true })), }; const prices = { - list: vi.fn().mockResolvedValue({ data: opts.priceList ?? [] }), + list: vi.fn().mockImplementation(({ product }: { product: string }) => + Promise.resolve({ data: opts.pricesByProduct?.[product] ?? [] })), create: vi.fn().mockImplementation((body: Stripe.PriceCreateParams) => - Promise.resolve({ id: `price_new_${body.unit_amount}`, ...body })), + Promise.resolve({ + id: `price_${String(body.product)}_${body.recurring?.interval}_${body.unit_amount}`, + ...body, + })), update: vi.fn().mockImplementation((id: string) => Promise.resolve({ id, active: false })), }; return { products, prices } as unknown as Stripe; } describe('syncProducts', () => { - it('creates a new product and price when none exist', async () => { + it('creates the two buyable products with unchanged prices and billing intervals', async () => { const stripe = stubStripe(); const ids = await syncProducts(stripe); - expect(Object.keys(ids).sort()).toEqual(['app_deployment', 'developer_seat', 'indie']); - expect(ids.indie.startsWith('price_new_14900')).toBe(true); + + expect(ids).toEqual({ + developer_seat: { + monthly: 'price_prod_developer_seat_month_2900', + annual: 'price_prod_developer_seat_year_29900', + }, + team: { + monthly: 'price_prod_team_month_14900', + annual: 'price_prod_team_year_149500', + }, + }); }); - it('reuses an existing product and matching active price', async () => { - const existingIndieProduct = { - id: 'prod_existing_indie', - name: 'Indie Commercial', + it('preserves existing Stripe product names when public display names differ', async () => { + const developerProduct = { + id: 'prod_existing_developer', + name: 'Developer Seat', active: true, - } as Stripe.Product; - const existingIndiePrice = { - id: 'price_existing_indie', - product: 'prod_existing_indie', - unit_amount: 14900, - currency: 'usd', - type: 'one_time', + metadata: { tplane_tier_slug: 'developer_seat' }, + } as unknown as Stripe.Product; + const teamProduct = { + id: 'prod_existing_team', + name: 'Team', active: true, - } as Stripe.Price; + metadata: { tplane_tier_slug: 'team' }, + } as unknown as Stripe.Product; const stripe = stubStripe({ - productSearch: [existingIndieProduct], - priceList: [existingIndiePrice], + productsBySlug: { developer_seat: developerProduct, team: teamProduct }, }); - const ids = await syncProducts(stripe); - expect(ids.indie).toBe('price_existing_indie'); + + await syncProducts(stripe); + + expect(stripe.products.update).not.toHaveBeenCalled(); + expect(stripe.products.create).not.toHaveBeenCalled(); }); - it('archives a stale price when unit_amount no longer matches and creates a new one', async () => { - const staleIndiePrice = { - id: 'price_stale_indie', - product: 'prod_existing_indie', - unit_amount: 9900, - currency: 'usd', - type: 'one_time', + it('reuses matching recurring prices without generating new IDs', async () => { + const developerProduct = { + id: 'prod_existing_developer', + name: 'Developer Seat', + active: true, + metadata: { tplane_tier_slug: 'developer_seat' }, + } as unknown as Stripe.Product; + const teamProduct = { + id: 'prod_existing_team', + name: 'Team', active: true, - } as Stripe.Price; - const existingIndieProduct = { - id: 'prod_existing_indie', - name: 'Indie Commercial', + metadata: { tplane_tier_slug: 'team' }, + } as unknown as Stripe.Product; + const recurringPrice = ( + id: string, + product: string, + unitAmount: number, + interval: 'month' | 'year', + ) => ({ + id, + product, + unit_amount: unitAmount, + currency: 'usd', + type: 'recurring', + recurring: { interval }, active: true, - } as Stripe.Product; + }) as Stripe.Price; const stripe = stubStripe({ - productSearch: [existingIndieProduct], - priceList: [staleIndiePrice], + productsBySlug: { developer_seat: developerProduct, team: teamProduct }, + pricesByProduct: { + prod_existing_developer: [ + recurringPrice('price_dev_month', 'prod_existing_developer', 2900, 'month'), + recurringPrice('price_dev_year', 'prod_existing_developer', 29900, 'year'), + ], + prod_existing_team: [ + recurringPrice('price_team_month', 'prod_existing_team', 14900, 'month'), + recurringPrice('price_team_year', 'prod_existing_team', 149500, 'year'), + ], + }, }); + const ids = await syncProducts(stripe); - expect(ids.indie.startsWith('price_new_14900')).toBe(true); - // @ts-expect-error vitest mock typing - expect(stripe.prices.update).toHaveBeenCalledWith('price_stale_indie', { active: false }); + + expect(ids.developer_seat).toEqual({ monthly: 'price_dev_month', annual: 'price_dev_year' }); + expect(ids.team).toEqual({ monthly: 'price_team_month', annual: 'price_team_year' }); + expect(stripe.prices.create).not.toHaveBeenCalled(); + expect(stripe.prices.update).not.toHaveBeenCalled(); }); }); diff --git a/scripts/stripe/sync-products.ts b/scripts/stripe/sync-products.ts index c17372990..19eb59c03 100644 --- a/scripts/stripe/sync-products.ts +++ b/scripts/stripe/sync-products.ts @@ -8,7 +8,7 @@ * IDs to pricing/tiers.generated.ts. * * Usage: - * STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe/sync-products.ts + * STRIPE_SECRET_KEY=sk_test_... npx tsx scripts/stripe/sync-products.ts * * Re-running is safe: products are matched by metadata, prices are reused if * the unit_amount and interval match, otherwise stale prices are archived @@ -38,13 +38,13 @@ async function findOrCreateProduct(stripe: Stripe, tier: TierConfig): Promise