diff --git a/apps/website/src/app/pricing/page.spec.tsx b/apps/website/src/app/pricing/page.spec.tsx index 321d7e651..fc3df48fc 100644 --- a/apps/website/src/app/pricing/page.spec.tsx +++ b/apps/website/src/app/pricing/page.spec.tsx @@ -16,6 +16,7 @@ describe('PricingPage', () => { expect(screen.getByText(/Every package is MIT/i)).toBeTruthy(); expect(screen.getByText(/commercial products, internal tools, and client work/i)).toBeTruthy(); expect(screen.getByText(/No Threadplane cloud/i)).toBeTruthy(); + expect(screen.getByText(/Angular 20, 21, 22, CI-tested/i)).toBeTruthy(); expect(screen.getByRole('heading', { level: 2, name: /Open software/i })).toBeTruthy(); expect(screen.getByText(/does not host your agents or conversations/i)).toBeTruthy(); expect(screen.getByText(/durable persistence.*connected backend/i)).toBeTruthy(); @@ -27,7 +28,9 @@ describe('PricingPage', () => { expect(screen.getByRole('table', { name: /Full plan comparison/i })).toBeTruthy(); expect(screen.getByRole('columnheader', { name: 'Community' })).toBeTruthy(); expect(screen.getByRole('columnheader', { name: 'Production Assurance' })).toBeTruthy(); - expect(screen.getByRole('rowheader', { name: 'MIT-licensed software' })).toBeTruthy(); + expect(screen.queryByRole('rowheader', { name: 'MIT-licensed software' })).toBeNull(); + expect(screen.getByRole('rowheader', { name: 'Private support channel' })).toBeTruthy(); + expect(screen.getByText(/identical on every path/i)).toBeTruthy(); expect(container.querySelectorAll('table[aria-label="Full plan comparison"]')).toHaveLength(1); }); diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx index 7058bf94b..e1fa3131d 100644 --- a/apps/website/src/app/pricing/page.tsx +++ b/apps/website/src/app/pricing/page.tsx @@ -3,11 +3,11 @@ 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'; import { FinalCTA } from '../../components/landing/FinalCTA'; import { createPageMetadata } from '../../lib/site-metadata'; +import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs'; export const metadata = createPageMetadata({ title: 'Pricing — Threadplane', @@ -37,6 +37,8 @@ export default function PricingPage() { Commercial use without registration or runtime checks + {WEBSITE_SUPPORTED_ANGULAR_VERSIONS}, CI-tested + No Threadplane cloud

@@ -57,19 +59,6 @@ export default function PricingPage() { -
- - Compatibility -

- Angular version support -

-

- We ship against the versions our CI tests. Other versions may work but aren't guaranteed. -

- -
-
- diff --git a/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx b/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx deleted file mode 100644 index b958696a3..000000000 --- a/apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: MIT -// @vitest-environment jsdom -import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { - WEBSITE_ANGULAR_SUPPORT_ROWS, - WEBSITE_SUPPORTED_ANGULAR_MAJORS, -} from './angular-support.mjs'; -import { CompatibilityMatrix } from './CompatibilityMatrix'; - -describe('CompatibilityMatrix', () => { - it('renders the structured Angular support buckets', () => { - render(); - expect(WEBSITE_SUPPORTED_ANGULAR_MAJORS).toEqual([20, 21, 22]); - - expect(screen.getByRole('rowheader', { name: 'Supported' })).toBeTruthy(); - expect(screen.getByText('Angular 20, 21, 22')).toBeTruthy(); - expect(screen.getByText(/Experimental/)).toBeTruthy(); - expect(screen.getByRole('rowheader', { name: 'Planned' })).toBeTruthy(); - expect(screen.getAllByText('—')).toHaveLength(2); - expect( - WEBSITE_ANGULAR_SUPPORT_ROWS.some( - (row) => row.label === 'Planned' && /Angular 22/.test(row.versions) - ) - ).toBe(false); - 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 deleted file mode 100644 index 6181487ae..000000000 --- a/apps/website/src/components/pricing/CompatibilityMatrix.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: MIT -import React from 'react'; -import { WEBSITE_ANGULAR_SUPPORT_ROWS } from './angular-support.mjs'; - -interface Row { - readonly label: string; - readonly versions: string; - readonly tone: 'success' | 'warn' | 'info' | 'muted'; -} - -const ROWS: readonly Row[] = WEBSITE_ANGULAR_SUPPORT_ROWS; - -export function CompatibilityMatrix() { - return ( -
- - - - - - - - - {ROWS.map((row) => ( - - - - - ))} - -
- Status - - Angular versions -
- {row.label} - {row.versions}
-
- ); -} diff --git a/apps/website/src/components/pricing/LeadForm.spec.tsx b/apps/website/src/components/pricing/LeadForm.spec.tsx new file mode 100644 index 000000000..55a6168c1 --- /dev/null +++ b/apps/website/src/components/pricing/LeadForm.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LeadForm } from './LeadForm'; + +const trackMock = vi.fn(); +vi.mock('../../lib/analytics/client', async (importOriginal) => { + const mod = await importOriginal>(); + return { ...mod, track: (...args: unknown[]) => trackMock(...args) }; +}); + +describe('LeadForm', () => { + beforeEach(() => { + trackMock.mockClear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + }); + + it('submits and fires the lead analytics with surface/source_section, then shows the sent state', async () => { + render(); + fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Dev' } }); + fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } }); + fireEvent.change(screen.getByLabelText(/^company$/i), { target: { value: 'Acme' } }); + fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!); + + await waitFor(() => { + expect(screen.getByText(/we'll be in touch within one business day/i)).toBeTruthy(); + }); + + expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_submit', { + surface: 'pricing', + source_section: 'lead-form', + }); + expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_success', { + surface: 'pricing', + source_section: 'lead-form', + }); + expect(trackMock).not.toHaveBeenCalledWith('marketing:lead_form_fail', expect.anything()); + }); + + it('fires the fail event with error_reason api_error when the request fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + render(); + fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Dev' } }); + fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } }); + fireEvent.change(screen.getByLabelText(/^company$/i), { target: { value: 'Acme' } }); + fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!); + + await waitFor(() => { + expect(screen.getByText(/something went wrong/i)).toBeTruthy(); + }); + + expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_fail', { + surface: 'pricing', + source_section: 'lead-form', + error_reason: 'api_error', + }); + }); + + it('renders the See how Pilot-to-Prod works link pointing at /pilot-to-prod', () => { + render(); + const link = screen.getByRole('link', { name: /see how pilot-to-prod works/i }); + expect(link.getAttribute('href')).toBe('/pilot-to-prod'); + }); +}); diff --git a/apps/website/src/components/pricing/LeadForm.tsx b/apps/website/src/components/pricing/LeadForm.tsx index 791dc259a..95de540a9 100644 --- a/apps/website/src/components/pricing/LeadForm.tsx +++ b/apps/website/src/components/pricing/LeadForm.tsx @@ -8,27 +8,6 @@ import { Eyebrow } from '../ui/Eyebrow'; import { Button } from '../ui/Button'; import { Card } from '../ui/Card'; -const VALUE_PROPS = [ - { - title: 'Production assurance', - body: 'Architecture guidance, private support, and response commitments scoped to your production needs.', - }, - { - title: 'SLA + security review', - body: 'Response SLAs, security questionnaires, and a private support channel.', - }, - { - title: 'Optional Pilot-to-Prod engagement', - body: 'A separately scoped delivery engagement for teams that want guided implementation from prototype through production.', - highlight: true, - link: { href: '/pilot-to-prod', label: 'See how Pilot-to-Prod works →' }, - }, - { - title: 'Procurement support', - body: 'Master services agreement where applicable, security review, and custom indemnification where contractually agreed.', - }, -]; - export function LeadForm() { const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [pilotInterest, setPilotInterest] = useState<'yes' | 'maybe' | 'no'>('maybe'); @@ -77,45 +56,22 @@ export function LeadForm() {
- Enterprise +
+ Enterprise +

Choose the support.
Add delivery if you need it.

Production Assurance and Pilot-to-Prod are separate choices. Request ongoing support or ask us to scope a hands-on delivery engagement.

+ + See how Pilot-to-Prod works → +
- {/* Value props column */} -
    - {VALUE_PROPS.map((vp) => ( -
  • - -
    -
    - {vp.title} -
    -

    - {vp.body} -

    - {vp.link && ( - - {vp.link.label} - - )} -
    -
  • - ))} -
- - {/* Form column */} {status === 'sent' ? (

diff --git a/apps/website/src/components/pricing/PricingDetails.tsx b/apps/website/src/components/pricing/PricingDetails.tsx index 325726e24..ff9d3f6c0 100644 --- a/apps/website/src/components/pricing/PricingDetails.tsx +++ b/apps/website/src/components/pricing/PricingDetails.tsx @@ -1,5 +1,4 @@ import { TIERS, type TierSlug } from '../../../../../pricing/tiers.config'; -import { WEBSITE_PRICING_SUPPORT_SUMMARY } from './angular-support.mjs'; type ComparisonCells = Record; @@ -14,74 +13,55 @@ const ALL_INCLUDED: ComparisonCells = { enterprise: 'Included', }; -const COMPARISON_GROUPS: readonly { - title: string; - rows: readonly ComparisonRow[]; -}[] = [ +const COMPARISON_ROWS: readonly ComparisonRow[] = [ + { label: 'Public documentation and examples', cells: ALL_INCLUDED }, { - title: 'Software', - rows: [ - { label: 'MIT-licensed software', cells: ALL_INCLUDED }, - { label: 'Commercial use', cells: ALL_INCLUDED }, - { label: 'All framework and UI capabilities', cells: ALL_INCLUDED }, - { label: WEBSITE_PRICING_SUPPORT_SUMMARY, cells: ALL_INCLUDED }, - { label: 'Source modification and redistribution', cells: ALL_INCLUDED }, - { label: 'Customer-operated deployment', cells: ALL_INCLUDED }, - ], + label: 'Private support channel', + cells: { + community: 'Not included', + production_assurance: 'Included', + enterprise: 'Included', + }, }, { - title: 'Support and delivery', - rows: [ - { label: 'Public documentation and examples', cells: ALL_INCLUDED }, - { - label: 'Private support channel', - cells: { - community: 'Not included', - production_assurance: 'Included', - enterprise: 'Included', - }, - }, - { - label: 'Response commitments', - cells: { - community: 'Not included', - production_assurance: 'Defined scope', - enterprise: 'Custom', - }, - }, - { - label: 'Architecture and implementation reviews', - cells: { - community: 'Not included', - production_assurance: 'Included', - enterprise: 'Included', - }, - }, - { - label: 'Security and procurement assistance', - cells: { - community: 'Not included', - production_assurance: 'Included', - enterprise: 'Included', - }, - }, - { - label: 'Pilot-to-Prod delivery', - cells: { - community: 'Not included', - production_assurance: 'Optional', - enterprise: 'Available', - }, - }, - { - label: 'Custom enablement and training', - cells: { - community: 'Not included', - production_assurance: 'Optional', - enterprise: 'Available', - }, - }, - ], + label: 'Response commitments', + cells: { + community: 'Not included', + production_assurance: 'Defined scope', + enterprise: 'Custom', + }, + }, + { + label: 'Architecture and implementation reviews', + cells: { + community: 'Not included', + production_assurance: 'Included', + enterprise: 'Included', + }, + }, + { + label: 'Security and procurement assistance', + cells: { + community: 'Not included', + production_assurance: 'Included', + enterprise: 'Included', + }, + }, + { + label: 'Pilot-to-Prod delivery', + cells: { + community: 'Not included', + production_assurance: 'Optional', + enterprise: 'Available', + }, + }, + { + label: 'Custom enablement and training', + cells: { + community: 'Not included', + production_assurance: 'Optional', + enterprise: 'Available', + }, }, ]; @@ -89,7 +69,10 @@ export function ArchitectureBoundary() { return (

-

What you are buying

+
+

What you are buying

+

Open software. Commercial support when you want it.

@@ -137,12 +120,16 @@ export function PricingComparison() { return (
-

Full comparison

+
+

Full comparison

+

What changes between paths.

- The software stays open. The level of support and delivery changes. + The software is identical on every path — MIT, all of it. What + changes is support.

@@ -167,30 +154,23 @@ export function PricingComparison() { ))} - {COMPARISON_GROUPS.map((group) => ( - - - - {group.title} + + {COMPARISON_ROWS.map((row) => ( + + + {row.label} + {TIERS.map((tier) => ( + + {row.cells[tier.slug]} + + ))} - {group.rows.map((row) => ( - - - {row.label} - - {TIERS.map((tier) => ( - - {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 f48bc2e39..f59f0ccae 100644 --- a/apps/website/src/components/pricing/PricingFAQ.spec.tsx +++ b/apps/website/src/components/pricing/PricingFAQ.spec.tsx @@ -15,14 +15,10 @@ vi.mock('../ui/Eyebrow', () => ({ })); const EXPECTED_QUESTIONS = [ - 'Is Threadplane free?', - 'Can I use every package commercially?', '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?', - 'Does a paid plan unlock different software?', - 'Can I modify or redistribute the source?', 'What is Production Assurance?', 'What is Pilot-to-Prod?', ]; @@ -48,8 +44,8 @@ describe('PricingFAQ', () => { expect(container.querySelector('#faq')).toBeTruthy(); }); - it('states that all packages use MIT', () => { + it('describes Production Assurance as a scoped support relationship', () => { render(); - expect(screen.getByText(/every published Threadplane package is MIT-licensed/i)).toBeTruthy(); + expect(screen.getByText(/Production Assurance is a scoped support relationship/i)).toBeTruthy(); }); }); diff --git a/apps/website/src/components/pricing/PricingFAQ.tsx b/apps/website/src/components/pricing/PricingFAQ.tsx index 9d7c4d9e8..a50951449 100644 --- a/apps/website/src/components/pricing/PricingFAQ.tsx +++ b/apps/website/src/components/pricing/PricingFAQ.tsx @@ -4,14 +4,6 @@ import { Eyebrow } from '../ui/Eyebrow'; import { FAQ, type FAQItem } from '../ui/FAQ'; const ITEMS: FAQItem[] = [ - { - q: 'Is Threadplane free?', - a: 'Yes. Every published Threadplane package is MIT-licensed and free to use, modify, and redistribute under the MIT terms.', - }, - { - q: 'Can I use every package commercially?', - a: 'Yes. The MIT license permits commercial products, internal tools, client work, and hosted services. There are no paid-use activation steps or runtime checks.', - }, { 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.', @@ -28,14 +20,6 @@ const ITEMS: FAQItem[] = [ q: 'What am I paying for?', a: 'Paid engagements provide expert support, architecture guidance, response commitments, security and procurement assistance, or hands-on delivery. The software remains MIT-licensed.', }, - { - q: 'Does a paid plan unlock different software?', - a: 'No. Community users and paid customers run the same packages with the same core capabilities. Paid relationships add services and accountability.', - }, - { - q: 'Can I modify or redistribute the source?', - a: 'Yes. You may modify and redistribute the packages under the MIT license, including as part of commercial software. Preserve the copyright and permission notice as required by MIT.', - }, { q: 'What is Production Assurance?', a: 'Production Assurance is a scoped support relationship for teams running Threadplane in production. It can include private support, response commitments, architecture reviews, migration guidance, and security assistance.', @@ -51,7 +35,10 @@ export function PricingFAQ() {
- Questions +
+ Questions +

Pricing FAQ.

diff --git a/apps/website/src/components/pricing/angular-support.mjs b/apps/website/src/components/pricing/angular-support.mjs index df57a83a3..f13cb3ff2 100644 --- a/apps/website/src/components/pricing/angular-support.mjs +++ b/apps/website/src/components/pricing/angular-support.mjs @@ -4,25 +4,3 @@ export const WEBSITE_SUPPORTED_ANGULAR_MAJORS = Object.freeze([20, 21, 22]); export const WEBSITE_SUPPORTED_ANGULAR_VERSIONS = `Angular ${WEBSITE_SUPPORTED_ANGULAR_MAJORS.join( ', ' )}`; - -const WEBSITE_SUPPORTED_ANGULAR_SUMMARY = `Angular ${WEBSITE_SUPPORTED_ANGULAR_MAJORS.slice( - 0, - -1 -).join(', ')}, and ${WEBSITE_SUPPORTED_ANGULAR_MAJORS.at(-1)}`; - -export const WEBSITE_PRICING_SUPPORT_SUMMARY = `${WEBSITE_SUPPORTED_ANGULAR_SUMMARY} support`; - -export const WEBSITE_ANGULAR_SUPPORT_ROWS = Object.freeze([ - Object.freeze({ - label: 'Supported', - versions: WEBSITE_SUPPORTED_ANGULAR_VERSIONS, - tone: 'success', - }), - Object.freeze({ label: 'Experimental', versions: '—', tone: 'warn' }), - Object.freeze({ label: 'Planned', versions: '—', tone: 'info' }), - Object.freeze({ - label: 'Unsupported', - versions: 'Angular ≤19', - tone: 'muted', - }), -]); diff --git a/apps/website/src/styles/marketing.css b/apps/website/src/styles/marketing.css index c62f8c07f..34fc92feb 100644 --- a/apps/website/src/styles/marketing.css +++ b/apps/website/src/styles/marketing.css @@ -52,9 +52,11 @@ } .pricing-plan-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; align-items: stretch; + max-width: 1040px; + margin-inline: auto; } .pricing-plan-card { position: relative; @@ -203,6 +205,16 @@ max-width: 760px; margin-bottom: 36px; } +.pricing-section-rail { + display: flex; + align-items: baseline; + gap: 14px; +} +.pricing-section-rail-line { + flex: 1; + height: 1px; + background: var(--color-border); +} .pricing-section-kicker { margin: 0 0 10px; color: var(--color-accent); @@ -230,7 +242,6 @@ .pricing-boundary, .pricing-comparison { max-width: 1200px; - margin: 0 auto; } .pricing-boundary-flow { display: grid; @@ -329,16 +340,6 @@ letter-spacing: 0.08em; text-transform: uppercase; } -.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-comparison-row-heading { position: sticky; left: 0; @@ -364,6 +365,8 @@ .pricing-plan-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 16px; + max-width: none; + margin-inline: 0; } .pricing-plan-description { min-height: 0; @@ -405,15 +408,23 @@ margin: 0 auto; } .lead-form-header { - text-align: center; margin-bottom: 48px; max-width: 720px; - margin-left: auto; - margin-right: auto; } -.lead-form-eyebrow { +.lead-form-rail { + display: flex; + align-items: baseline; + gap: 14px; margin-bottom: 12px; } +.lead-form-rail-line { + flex: 1; + height: 1px; + background: var(--color-border); +} +.lead-form-eyebrow { + margin-bottom: 0; +} .lead-form-heading { font-family: var(--font-garamond); font-weight: 700; @@ -431,61 +442,14 @@ color: var(--color-text-secondary); margin: 0; } -.lead-form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 24px; - align-items: start; -} -.lead-form-value-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 16px; -} -.lead-form-value-item { - padding: 16px 18px; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-md); - display: flex; - gap: 12px; -} -.lead-form-value-item[data-highlight] { - background: var(--color-surface-tinted); - border-color: var(--color-accent); -} -.lead-form-value-check { - color: var(--color-accent); - font-size: 18px; - font-weight: 700; - line-height: 1.4; - flex-shrink: 0; -} -.lead-form-value-title { - font-family: var(--font-inter); - font-size: 15px; - font-weight: 600; - color: var(--color-text-primary); - margin-bottom: 4px; -} -.lead-form-value-body { - font-family: var(--font-inter); - font-size: 14px; - line-height: 1.5; - color: var(--color-text-secondary); - margin: 0; -} -.lead-form-value-link { +.lead-form-p2p-link { display: inline-block; - margin-top: 6px; - font-family: var(--font-inter); - font-size: 13px; - color: var(--color-accent); - text-decoration: none; + margin-top: 10px; font-weight: 600; + color: var(--color-accent); +} +.lead-form-grid { + max-width: 640px; } .lead-form-sent-message { text-align: center; @@ -573,49 +537,8 @@ margin: 0; } -/* CompatibilityMatrix — components/pricing/CompatibilityMatrix.tsx */ -.compat-matrix { - border: 1px solid var(--color-border); - border-radius: 8px; - overflow: hidden; -} -.compat-matrix-table { - width: 100%; - border-collapse: collapse; -} -.compat-matrix-head-row { - background: var(--color-surface); -} -.compat-matrix-th { - text-align: left; - padding: 12px 16px; - font-weight: 600; - color: var(--color-text-primary); - font-size: var(--text-body); - 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; - border-bottom: 1px solid var(--color-border); -} -.compat-matrix-td-label[data-tone="success"] { color: #16a34a; } -.compat-matrix-td-label[data-tone="warn"] { color: #d97706; } -.compat-matrix-td-label[data-tone="info"] { color: var(--color-accent); } -.compat-matrix-td-label[data-tone="muted"] { color: var(--color-text-muted); } -.compat-matrix-td-versions { - padding: 12px 16px; - font-size: var(--text-body); - color: var(--color-text-secondary); - font-family: var(--font-mono); - border-bottom: 1px solid var(--color-border); -} - /* PricingFAQ — components/pricing/PricingFAQ.tsx */ .pricing-faq-header { - text-align: center; margin-bottom: 48px; } .pricing-faq-eyebrow { diff --git a/apps/website/src/styles/pages.css b/apps/website/src/styles/pages.css index 77e7fc8ec..87b2781c3 100644 --- a/apps/website/src/styles/pages.css +++ b/apps/website/src/styles/pages.css @@ -1163,23 +1163,6 @@ .pricing-page-license-line strong { color: var(--color-accent); } -.pricing-page-eyebrow-tight { - margin-bottom: 12px; -} -.pricing-page-h2 { - font-family: var(--font-garamond); - font-size: var(--text-h2); - margin: 0; - margin-bottom: 16px; - color: var(--color-text-primary); -} -.pricing-page-compat-body { - margin: 0; - margin-bottom: 24px; - color: var(--color-text-secondary); - max-width: 60ch; -} - @media (max-width: 640px) { .pricing-page-license-line { align-items: flex-start; diff --git a/docs/superpowers/plans/2026-08-31-pricing-tighten.md b/docs/superpowers/plans/2026-08-31-pricing-tighten.md new file mode 100644 index 000000000..762e8a5e6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-pricing-tighten.md @@ -0,0 +1,344 @@ +# Pricing Page Tightening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cut the pricing page's repetition per `docs/superpowers/specs/2026-08-31-pricing-tighten-design.md` — one MIT statement, no all-"Included" table block, no 700px compat table, no third tier restatement beside the lead form — while pinning the LeadForm's behavior untouched. + +**Architecture:** Next.js 16 / React 19. Pricing components in `apps/website/src/components/pricing/`; pricing CSS in `apps/website/src/styles/marketing.css` (pricing-*, lead-form-* classes). `angular-support.mjs` is the single source of truth for supported majors — the hero's new compat clause must consume it, not hardcode. UNLAYERED CSS, no inline styles. + +**Test command:** `cd apps/website && npx vitest run --config vite.config.mts` — baseline is ALL GREEN (48 files / 389+ tests). Any failure is yours. +**Branch:** `blove/pricing-tighten`, expected starting HEAD `66c67e51` or descendant. +**Visual verification surface:** Chrome MCP (the in-app pane returns blank frames after scrolls) — but subagents don't drive Chrome; the controller runs the visual gate. Subagents verify via tests + DOM-free reasoning. + +--- + +### Task 1: Pin the LeadForm, then strip it to header + form + +**Files:** +- Create: `apps/website/src/components/pricing/LeadForm.spec.tsx` +- Modify: `apps/website/src/components/pricing/LeadForm.tsx` +- Modify: `apps/website/src/styles/marketing.css` + +- [ ] **Step 1: Write the pinning spec against the UNMODIFIED component** + +Read `LeadForm.tsx` first: its submit handler's analytics events (grep `track(` — events around lines 43–68 use `source_section: 'lead-form'`), the field labels/placeholders, the success-state text. Write a spec in the WhitePaperBlock.spec pattern: + +```tsx +// apps/website/src/components/pricing/LeadForm.spec.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LeadForm } from './LeadForm'; + +const trackMock = vi.fn(); +vi.mock('../../lib/analytics/client', async (importOriginal) => { + const mod = await importOriginal>(); + return { ...mod, track: (...args: unknown[]) => trackMock(...args) }; +}); + +describe('LeadForm', () => { + beforeEach(() => { + trackMock.mockClear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + }); + + it('submits and fires the lead analytics with the form payload', async () => { + render(); + // Fill required fields — use the REAL labels from the component + // (Name / Work email / Company, the team-size + timeline selects, + // the Pilot-to-Prod radio). Adapt these queries to what exists: + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Dev' } }); + fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } }); + fireEvent.change(screen.getByLabelText(/company/i), { target: { value: 'Acme' } }); + fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!); + await waitFor(() => { + const events = trackMock.mock.calls.map((c) => c[0]); + expect(events.some((e) => String(e).includes('lead'))).toBe(true); + }); + }); +}); +``` + +Adapt every literal to the real component (mocked module path, event names — use the EXACT event constants you find, not the `.includes('lead')` sketch; assert the success render if one exists). The spec must PASS unmodified. If required-field validation blocks a jsdom submit in a way that needs component changes, STOP (NEEDS_CONTEXT). Paste the passing run line. + +- [ ] **Step 2: Strip the section chrome** + +In `LeadForm.tsx`: +- The header becomes the rail pattern: +```tsx +
+ Enterprise +
+``` + (heading + subhead keep their elements/ids below it, left-aligned). +- DELETE the `
    ` block (the four + checkmark cards) entirely — but FIRST find the "See how Pilot-to-Prod + works →" link inside it and MOVE that link to directly under the + `lead-form-subhead` paragraph, preserving its href and any onClick + analytics, with `className="lead-form-p2p-link"`. +- The `lead-form-grid` two-column wrapper collapses: the form becomes the + single child; rename/keep the wrapper as needed so the form gets + `max-width: 640px` (left-aligned under the header, not centered). +- The form element and EVERYTHING inside it: byte-untouched. + +- [ ] **Step 3: CSS (marketing.css)** + +Delete the `lead-form-value-list` / value-item rules (grep `lead-form-value`). +Delete or neutralize `lead-form-grid` two-column rules (grep `lead-form-grid`) +— replace with `max-width: 640px;` on the form container. Remove centering +from `lead-form-header` (`text-align: center`, auto margins). Add: + +```css +.lead-form-rail { + display: flex; + align-items: baseline; + gap: 14px; +} +.lead-form-rail-line { + flex: 1; + height: 1px; + background: var(--color-border); +} +.lead-form-p2p-link { + display: inline-block; + margin-top: 10px; + font-weight: 600; + color: var(--color-accent); +} +``` + +- [ ] **Step 4: Re-run LeadForm.spec (MUST still pass) + full suite + commit** + +```bash +git add apps/website/src +git commit -m "feat(website): lead form takes center stage — tier restatement cards deleted" +``` + +--- + +### Task 2: Comparison table — delete the SOFTWARE block + +**Files:** +- Modify: `apps/website/src/components/pricing/PricingDetails.tsx` +- Modify: `apps/website/src/app/pricing/page.spec.tsx` + +- [ ] **Step 1: Update page.spec FIRST (failing)** + +`page.spec.tsx:30` asserts `getByRole('rowheader', { name: 'MIT-licensed software' })`. Replace with: + +```tsx + expect(screen.queryByRole('rowheader', { name: 'MIT-licensed software' })).toBeNull(); + expect(screen.getByRole('rowheader', { name: 'Private support channel' })).toBeTruthy(); + expect(screen.getByText(/identical on every path/i)).toBeTruthy(); +``` + +Run the pricing page spec — expect FAIL (the null assertion fails against the current page). Paste it. + +- [ ] **Step 2: PricingDetails.tsx** + +- Delete the entire `Software` group from `COMPARISON_GROUPS` (six rows) and + the now-unused `ALL_INCLUDED` rows within it. `Public documentation and + examples` (in Support) still uses `ALL_INCLUDED` — keep the constant. +- Remove the now-unused `WEBSITE_PRICING_SUPPORT_SUMMARY` import (it only fed + the deleted Angular row) — verify with grep before removing. +- With one group left, drop the group-header row: in the render, replace the + `COMPARISON_GROUPS.map` + group-row `` with a single `` over the + remaining rows (keep the `COMPARISON_GROUPS` shape with one group if that + is smaller — implementer's choice — but the rendered `pricing-comparison-group-row` + header must NOT render when only one group exists; simplest is to flatten + to a `COMPARISON_ROWS` array and delete the group machinery). +- The lede changes: `"The software stays open. The level of support and + delivery changes."` → `"The software is identical on every path — MIT, all + of it. What changes is support."` + +- [ ] **Step 3: Run pricing page spec (PASS) + full suite + commit** + +```bash +git add apps/website/src +git commit -m "feat(website): comparison table keeps only rows that differ" +``` + +--- + +### Task 3: Compatibility section → one hero clause + +**Files:** +- Modify: `apps/website/src/app/pricing/page.tsx` +- Delete: `apps/website/src/components/pricing/CompatibilityMatrix.tsx` +- Delete: `apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx` +- Modify: `apps/website/src/components/pricing/angular-support.mjs` +- Modify: `apps/website/src/app/pricing/page.spec.tsx` +- Modify: `apps/website/src/styles/marketing.css` + +- [ ] **Step 1: Hero license strip gains the compat clause** + +In `page.tsx`, the license line becomes (importing from angular-support.mjs): + +```tsx +import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs'; +``` +```tsx +

    + Every package is MIT + + Commercial use without registration or runtime checks + + {WEBSITE_SUPPORTED_ANGULAR_VERSIONS}, CI-tested + + No Threadplane cloud +

    +``` + +(If the `.mjs` import trips the TS build, check how `PricingDetails.tsx` +imports it today and mirror that exactly.) + +- [ ] **Step 2: Delete the section + component** + +Remove the whole `
    ` wrapping the Compatibility block from +`page.tsx` (Eyebrow "Compatibility", h2, body, ``) +plus the `CompatibilityMatrix` import. Then: + +```bash +git rm apps/website/src/components/pricing/CompatibilityMatrix.tsx apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx +``` + +In `angular-support.mjs`: grep consumers of each export; delete +`WEBSITE_ANGULAR_SUPPORT_ROWS` (and `WEBSITE_PRICING_SUPPORT_SUMMARY` if +Task 2 left it orphaned); keep `WEBSITE_SUPPORTED_ANGULAR_MAJORS` and +`WEBSITE_SUPPORTED_ANGULAR_VERSIONS`. Zero orphaned exports remain. + +Delete compat CSS: grep `pricing-page-compat\|compat-matrix\|compatibility` +in marketing.css and remove pricing-scoped hits; also delete the +`pricing-page-eyebrow-tight` rule if the deleted section was its only user +(grep first). + +- [ ] **Step 3: page.spec updates** + +Add: `expect(screen.getByText(/Angular 20, 21, 22, CI-tested/i)).toBeTruthy();` +Remove/adjust any assertion referencing the compat section (grep +'Compatibility\|Angular version support' in the spec). If the spec's +prohibited-strings test (line ~45) is unrelated, leave it. + +- [ ] **Step 4: Full suite + commit** + +```bash +git add -A apps/website/src +git commit -m "feat(website): compatibility collapses into the hero license strip" +``` + +--- + +### Task 4: FAQ 10 → 6 + +**Files:** +- Modify: `apps/website/src/components/pricing/PricingFAQ.tsx` +- Modify: `apps/website/src/components/pricing/PricingFAQ.spec.tsx` + +- [ ] **Step 1: Spec first** + +`PricingFAQ.spec.tsx` holds an `EXPECTED_QUESTIONS` array — trim it to the +six survivors IN ORDER: '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?', 'What is Production Assurance?', +'What is Pilot-to-Prod?'. The spec also asserts an answer string from the +deleted "Is Threadplane free?" item (line ~53, /every published Threadplane +package is MIT-licensed/i) — replace that assertion with one from a +SURVIVING answer (read the surviving answers and pick a distinctive phrase). +Run → FAIL. Paste it. + +- [ ] **Step 2: Delete the four items** + +Remove the full `{ q, a }` entries for: 'Is Threadplane free?', 'Can I use +every package commercially?', 'Does a paid plan unlock different software?', +'Can I modify or redistribute the source?'. + +- [ ] **Step 3: Spec PASS + full suite + commit** + +```bash +git add apps/website/src +git commit -m "copy(website): pricing FAQ keeps only genuinely open questions" +``` + +--- + +### Task 5: Rail headers + tier-grid centering + +**Files:** +- Modify: `apps/website/src/components/pricing/PricingDetails.tsx` +- Modify: `apps/website/src/components/pricing/PricingFAQ.tsx` +- Modify: `apps/website/src/components/pricing/CompareTable.tsx` (and/or marketing.css) +- Modify: `apps/website/src/styles/marketing.css` + +- [ ] **Step 1: Rail conversion** + +`PricingDetails.tsx` has two `pricing-section-heading-wrap` headers (boundary ++ comparison), each: kicker `

    ` + h2 + +body. Wrap each kicker in the rail: + +```tsx +

    +

    +
    +``` + +Same for `PricingFAQ.tsx`'s header (read it — it has kicker "Questions" + +"Pricing FAQ." heading; if it uses Eyebrow, wrap that). CSS: + +```css +.pricing-section-rail { + display: flex; + align-items: baseline; + gap: 14px; +} +.pricing-section-rail-line { + flex: 1; + height: 1px; + background: var(--color-border); +} +``` + +Remove centering from `pricing-section-heading-wrap` and the FAQ header +(grep `text-align: center` near those rules in marketing.css; also `margin: +… auto` max-width centering — keep max-widths, left-align). The page HERO +stays centered — do not touch `pricing-page-hero-inner`. + +- [ ] **Step 2: Center the tier-card grid** + +Find the container of the three `pricing-plan-card`s in `CompareTable.tsx` +(the section/grid wrapper class) and its rule in marketing.css. Diagnose the +left-cluster: likely a `grid-template-columns: repeat(3, )` or +max-width without `margin-inline: auto`. Fix so the three cards center as a +group in the container (e.g. `justify-content: center` on the grid or +`margin-inline: auto` on a max-width wrapper). Do not resize the cards. + +- [ ] **Step 3: Run CompareTable.spec + pricing page spec + full suite; commit** + +```bash +git add apps/website/src +git commit -m "refactor(website): pricing rail headers and centered tier grid" +``` + +--- + +### Task 6: Verification gate (controller-run) + +- [ ] Full suite green; `npx nx build website --configuration=production` exit 0. +- [ ] Chrome MCP visual pass at 1440px + ~375px (device toolbar or narrow + window): hero strip has 4 clauses on one/two lines; tier cards centered; + boundary + comparison + FAQ + lead-form all rail-headed and left-aligned; + table has NO all-Included rows and NO group header; no compat section; + form full-width-ish alone with the P2P link present under the subhead. +- [ ] Measure total page height (expect roughly 4,300–4,800px, down from + 7,049px) — report the number. +- [ ] Count "MIT" occurrences in rendered page text — expect ≤ 3 (hero + clause, boundary lede, FinalCTA caption). Report the count. +- [ ] Commit any fixes; stop. No push/PR — separate decision. + +## Deviations that require stopping + +- LeadForm spec unattainable without component changes (Task 1 Step 1). +- Any consumer of CompatibilityMatrix outside pricing (grep says none: + pricing/page.tsx + marketing.css only — verify). +- The `.mjs` hero import breaking the prod build with no house pattern to + mirror. diff --git a/docs/superpowers/specs/2026-08-31-pricing-tighten-design.md b/docs/superpowers/specs/2026-08-31-pricing-tighten-design.md new file mode 100644 index 000000000..0786b227e --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-pricing-tighten-design.md @@ -0,0 +1,72 @@ +# Pricing page tightening + +**Date:** 2026-08-31 +**Status:** Approved (both cuts confirmed; LeadForm FORM explicitly retained). +**Branch:** `blove/pricing-tighten` + +## Problem + +The pricing page is ~7,050px and says "everything is MIT" six times: hero +license line, Community card, boundary section, the comparison table's +six-row all-"Included" SOFTWARE block, four of ten FAQ items, and the CTA +caption. The compatibility section spends ~700px on one sentence (two of its +four rows are "—"). The LeadForm section restates the tiers a third time in +four checkmark cards. Headers are centered while the site now speaks rails. +The three tier cards cluster left with dead space right (grid not centered). + +## Changes + +1. **Comparison table** (`PricingDetails.tsx` / `PricingComparison`): delete + the SOFTWARE section (all six rows + its group header). Above the table, + one line replaces it: "The software is identical on every path — MIT, all + of it. What changes is support." (rendered as the section's lede, + replacing "The software stays open. The level of support and delivery + changes."). The SUPPORT AND DELIVERY group header becomes unnecessary + with one group — remove it; the seven remaining rows stand alone. +2. **Compatibility section**: the `
    ` in `pricing/page.tsx` and + `CompatibilityMatrix` usage are deleted. One compatibility line joins the + hero license strip: "Every package is MIT · Commercial use without + registration or runtime checks · Angular 20–22, CI-tested · No + Threadplane cloud". `CompatibilityMatrix.tsx` + its spec are deleted IF + the homepage/docs don't consume it (grep first; if consumed elsewhere, + only the pricing usage is removed). + Note: `angular-support.mjs` in the pricing dir may generate the versions — + if so, the hero line should consume the same source (single source of + truth for "20–22"); investigate at plan time. +3. **FAQ 10 → 6** (`PricingFAQ.tsx`): delete "Is Threadplane free?", + "Can I use every package commercially?", "Does a paid plan unlock + different software?", "Can I modify or redistribute the source?". Keep: + cloud service, conversations/data storage, model/hosting costs, what am I + paying for, Production Assurance, Pilot-to-Prod. Update `PricingFAQ.spec` + accordingly (it exists). +4. **LeadForm** (`LeadForm.tsx`): the FORM and all its fields, states, and + analytics are UNTOUCHED. Delete the four checkmark cards; the section + becomes rail header + the framing sentence + the form, with the form + allowed the visual center (single-column layout, form max-width ~640px). + The "See how Pilot-to-Prod works →" link from the deleted cards moves + under the framing sentence (do not lose the link). +5. **Rail headers** on: boundary section ("What you are buying"), comparison + ("Full comparison"), FAQ ("Questions"), LeadForm ("Enterprise") — the + local railkick pattern (kicker + hairline), left-aligned. Hero stays + centered (it is the page's one centered moment, like the homepage hero). +6. **Tier card grid centered** (`CompareTable`): the three cards center in + the container (fix the left-cluster + dead right column). + +## Testing + +- `pricing/page.spec.tsx` exists — update assertions minimally where they + reference removed content (grep for compat/FAQ strings first). +- `PricingFAQ.spec` updated to the six survivors. +- `CompareTable.spec` untouched unless the centering fix moves DOM. +- New `LeadForm.spec` pinning: submit fires its analytics events and + renders its success state (mock fetch; same pattern as + WhitePaperBlock.spec) — written BEFORE the layout change, passing before + and after. +- Full suite green (48+ files, all green baseline); prod build; Chrome MCP + visual pass at 1440px + 375px (the pane's hidden-viewport artifacts make + Chrome the verification surface for this arc). + +## Out of scope + +Tier copy/structure inside the cards; the boundary diagram's three bands; +FinalCTA (default variant is correct here); the /contact page.