From a5b5a5600b874c6397a022c8ad243ea94628b0b0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 27 Aug 2026 06:59:42 -0700 Subject: [PATCH 01/18] docs(website): design spec for Basecamp-informed homepage changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes drawn from basecamp.com: a "Yes wall" replacing the Differentiator grid, a specific-proof strip, and a problem-first hero subheadline. Visual direction is Inverted x Specimen — one dark section using the dark tokens the site already ships but never consumes. All four proof-strip figures were verified against live sources and all four were wrong or misleading, so change D is marked blocked pending a rebuild. Verification results and the reasoning are recorded in the spec. Co-Authored-By: Claude Fable 5 --- .../2026-08-27-basecamp-homepage-design.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md diff --git a/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md b/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md new file mode 100644 index 000000000..f8bcf85fb --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md @@ -0,0 +1,303 @@ +# Basecamp-informed homepage changes — design + +**Date:** 2026-08-27 +**Status:** Design — awaiting review. No code written. +Changes A and F are ready to plan. **Change D is blocked**: all four of its +figures failed verification and the section needs rebuilding first. +**Branch:** `blove/basecamp-homepage-design-34a935` + +## Problem + +The homepage is clear and says true things, but it has two failures that are +easiest to see side by side with basecamp.com. + +**It repeats its pitch instead of advancing an argument.** The litany "durable +threads, interrupts, subagents, planning, memory, and generative UI" appears in +the ``, the hero subheadline, the ecosystem section, and again as the ten +rows of `Differentiator`. A reader who scrolls learns the same list four times. +Basecamp never restates its pitch; each section makes a new move. + +**It has no rhythm.** Every section is white or near-white (`canvas` and `white` +are the same value in the light theme), centred, and evenly padded. Nothing on +the page is ever an arrival. The result reads as competent and slightly airless +— clear, but with no appeal. + +There is also a concrete layout defect. `Differentiator` renders a claim, a +gloss, and an API hint per row, but the API hint is an inline element rather +than a grid column, so long rows wrap underneath it and the right edge goes +ragged. + +## What we took from Basecamp, and what we deliberately did not + +Taken: the **"all these questions have the same answer: Yes"** section, which +converts capability nouns into the questions a buyer actually asks; **proof by +specificity** rather than vague trust claims; and **section labels that state a +benefit** rather than a codename. + +Not taken: the **founder-letter hero**. Basecamp sells to non-technical managers +and trades on a twenty-year-famous founder. Threadplane sells to developers who +want the API and a running demo inside ten seconds. The product-first hero, +medium switcher, and live demos stay. + +Also not taken: **adoption numbers**. Basecamp's "84 million accounts" is the one +move Threadplane cannot borrow, because adoption is currently its weakest +signal. Specificity is the transferable principle, not size. + +## Goal + +Give the homepage one genuine arrival moment, replace the repeated capability +litany with a section that answers real questions, and add proof that is +specific and attributable — without lengthening the page. + +## Non-goals + +- **No new palette or type scale.** Everything below uses tokens that already + exist in `@threadplane/design-tokens`. +- **No change to `FeatureBlock`** or the four medium-switcher sections. Section + labels (STREAM / RENDER / SHIP / APPROVE) were considered and **cut** from + this scope. +- **No dark mode for the site.** Two sections are dark by design. This is not a + theme toggle and does not imply one. +- **No founder note, and no live-class scheduling.** Both were considered and + cut. + +## Visual direction: Inverted × Specimen + +Two devices, combined: + +**Inverted** — the section sits on the dark canvas. This is the source of the +rhythm the page lacks, and it is nearly free: the full dark palette already +ships in `libs/design-tokens/src/lib/dark.ts` and the website simply never +consumes it. + +**Specimen** — type is the graphic. A 330px italic EB Garamond "Yes" watermark +at ~8.5% accent bleeding off the right edge; group numerals at 62px in +`#3c3c3c` so they read as structure, not content; each group capped with a 2px +`#f5f5f5` rule; the answer set inline and italic so a repeating *Yes* runs down +the left margin. + +Dark-section values (from `dark.ts`): + +| Role | Value | +|---|---| +| Canvas | `rgb(17,17,17)` | +| Border | `rgb(45,45,45)` / strong `rgb(60,60,60)` | +| Text | `rgb(245,245,245)` / secondary `rgb(200,200,200)` / muted `rgb(160,160,160)` | +| Accent | `#64C3FD` | +| Accent glow | `rgba(100,195,253,.25)` | +| Row hover tint | `rgba(100,195,253,.08)` | + +**The dark treatment must appear twice.** A single inverted band reads as +arbitrary. `FinalCTA` adopts the same surface, which turns "a dark section" into +"the dark treatment" and costs little since the CTA is already short. + +## Change A — the Yes wall (replaces `Differentiator`) + +`Differentiator` is already a Yes wall wearing the wrong clothes: same three +parts, wrong markup. This replaces it rather than adding to the page. + +Sixteen questions in four numbered groups — *State that survives*, *Humans in +the loop*, *On my design system*, *Shipping it* — each row a real grid: +`1fr / 250px`, question and inline italic answer on the left, mono API on the +right. Every question is phrased as a developer would search for it ("Can a +conversation survive a page reload?"), and every answer links to the API that +delivers it. + +The questions are load-bearing content, not decoration: each must be one a real +evaluator asks, and each must be answerable **yes** without qualification. Any +question needing "yes, but" belongs in the FAQ instead. + +## Change D — proof strip (new, light) + +Basecamp's "big numbers" translated to proof that is defensible: supply-chain +posture and maintenance discipline. Four cells on the `tinted` surface +(`rgb(251,251,251)`), 74px Garamond numerals, 2px `#1c1c1c` cap rule, hairline +verticals, **and a source printed under every cell**. The source line is the +point; without it this is decoration. + +Deliberately **light**, not dark, so the Yes wall remains the page's single +arrival moment and the CTA's callback to the dark surface still lands. + +### Figures — verified, and mostly wrong + +The four figures drafted for this section were checked on 2026-08-27 and **all +four were wrong or misleading**. See "Verification results" below. Change D +**cannot ship as drafted** and needs rebuilding around three honest cells before +implementation starts. + +Two rules for whatever replaces them. A figure that moves on its own — like the +HVTrust grade, which flipped six times this month — must be a **live badge, not +a hardcoded string**. And a figure must be favourable *and* honest: the Angular +LTS cell was literally true while implying the opposite of the truth, which is +worse than omitting it. + +## Change F — hero copy + +Keep the layout, the H1, and the light surface — the hero's restraint is part of +what makes the dark section land later. + +Replace the six-noun subheadline with the problem it solves ("The streaming demo +takes an afternoon. Everything after it takes six months."), and demote the +nouns to mono chips beneath it. The keywords still scan for SEO and for +skimmers; they stop being the pitch. + +## Scope: what happens to the rest of the page + +**Decision needed at review.** The proposal is: + +- `Differentiator` is **removed**; the Yes wall takes its slot. +- `HomeFAQ` **shrinks** to genuinely non-binary questions — pricing, migration + effort from CopilotKit, licensing, SSR — since roughly half of its current + items become Yes-wall rows. +- Net page length stays approximately flat. + +The alternative is keeping both, which costs length and reintroduces the +repetition this design exists to remove. + +## Implementation notes + +`apps/website` is **Next.js 16 / React 19**, not Angular. Landing components +style via inline `style={{}}` objects reading from `@threadplane/design-tokens`; +Tailwind is present but barely used on the homepage. Match that pattern. + +**Files** + +| Change | File | +|---|---| +| A | New `apps/website/src/components/landing/YesWall.tsx`; delete `Differentiator.tsx`; update `app/page.tsx` | +| D | New `apps/website/src/components/landing/ProofStrip.tsx`; insert in `page.tsx` | +| F | `apps/website/src/components/landing/Hero.tsx` | +| Partner | `apps/website/src/components/landing/FinalCTA.tsx` | +| Scope | `apps/website/src/components/landing/HomeFAQ.tsx` | + +**Two primitives need extending — this is the main technical risk.** + +1. `components/ui/Section.tsx` accepts `surface: 'canvas' | 'tinted' | 'white'`. + A dark section needs a fourth value. Extend the union rather than bypassing + `Section` with a one-off wrapper, or the vertical rhythm drifts. + +2. The website **hardwires the light theme**: `tokens.ts`, `surfaces.ts`, and + `colors.ts` all re-export `lightOverrides`, so `tokens.colors.accent` is + always `#004090`. A dark section cannot simply read `tokens.*`. Import + `darkOverrides` explicitly for the inverted sections. Do **not** "fix" this by + making the token exports theme-aware — that is a much larger change and is + out of scope here. + +3. `components/ui/Eyebrow.tsx` has three tones (`muted`/`accent`/`angular`), all + light-theme. The dark sections need an on-dark tone resolving to `#64C3FD`. + +**Not in scope but worth recording:** there is no shared `SectionHeader` +primitive. The kicker/H2/lede block is copy-pasted across `EcosystemStrip`, +`Differentiator`, `Promises`, and `HomeFAQ` with drifting margins (28/44/56/48) +and inconsistent letter-spacing. Extracting one was the natural companion to the +section-label change that got cut; leaving a note rather than doing it here. + +**Testing.** `nx test website` does not exist and fails quasi-silently. Use: + +```bash +cd apps/website && npx vitest run --config vite.config.mts +``` + +Homepage specs cross-check content against disk; a spec asserting the ten +`Differentiator` rows will need updating with the removal. + +**Accessibility.** The dark sections must hit WCAG AA against +`rgb(17,17,17)`; `#64C3FD` body text and the `#3c3c3c` group numerals both need +checking. The numerals are decorative structure — if they fail contrast, they +must not be the only carrier of the group number. The watermark is +`aria-hidden` and `user-select: none`. + +**Responsive.** The specimen devices are the fragile part. Below ~900px the +330px watermark and the 112px numeral gutter both need explicit handling, and +the two-column row collapses to stacked question-then-API. + +## Verification results + +**Checked 2026-08-27. All four drafted figures were wrong or misleading.** +Repo resolved as `github.com/cacheplane/angular-agent-framework`. + +| # | Drafted | Actual | Verdict | +|---|---|---|---| +| 1 | Grade A, 82.8/100 | Grade A, **81.2**/100 | Grade matches, score DIFFERS | +| 2 | #7 of 75 frameworks | **#13 of 119** frameworks (**#76 of 1,316** overall) | DIFFERS badly | +| 2b | "the only Angular one" | Only entry whose *description* says Angular | UNPROVEN | +| 3 | OpenSSF Scorecard 7.7 | **8.1**/10 per official API | DIFFERS — sources disagree | +| 4 | Current + previous Angular LTS | Angular 20 + 21 — true but misleading | MISLEADING | + +Sources: `https://hvtracker.net/agents/threadplane/`, +`https://hvtracker.net/categories/agent-frameworks/`, +`https://api.securityscorecards.dev/projects/github.com/cacheplane/angular-agent-framework`, +`https://angular.dev/reference/releases`. + +**Detail that changes the design, not just the numbers:** + +1. **The HVTrust grade is volatile.** The A band starts at 80 and the project + sits at 81.2. Its own reputation timeline shows the grade flipping six times + this month (A→B Aug 5, B→A Aug 14, A→B Aug 17, B→A Aug 19, A→B Aug 22, B→A + Aug 24). **A hardcoded "Grade A" will be false on some days.** Either embed + the live badge (`https://hvtracker.net/badge/threadplane.svg`) or cut the + cell. Hardcoding is not an option. + +2. **The rank claim was wrong in an instructive way.** The detail page shows + "Rank Change ▼1 was #75" — 75 is a recent *rank*, not a category size. The + drafted claim appears to have crossed those two fields. + +3. **Two Scorecard sources genuinely disagree.** The official OpenSSF API + returns 8.1 (scanned 2026-08-18, commit `13d8a87`, Scorecard v5.0.0); + HVTracker runs its own Scorecard CLI and shows 7.7. Cite **8.1** with the + OpenSSF API as the source, because that is what a reader sees on + scorecard.dev. Do not cite HVTracker's derived number under an "OpenSSF" + label. + +4. **The Angular LTS cell must be cut.** Every Angular-facing library pins + `"@angular/core": "^20.0.0 || ^21.0.0"`. Angular 20 and 21 *are* the two LTS + lines, so the sentence is literally true — but **Angular 22 has been the + Active release since 2026-06-03 and is not in any peer range.** On a + homepage "current and previous LTS" reads as "up to date"; the honest reading + is "supports the two maintenance-mode releases, not the current one." It also + decays on its own when v20's LTS window closes 2026-11-28. This is a weakness + dressed as proof — cut it. + +### Consequences for change D + +Change D **cannot ship as drafted**. Of four cells: one must become a live badge +or be cut, one needs materially less impressive true numbers, one needs a +different source and value, and one must be cut outright. + +Options, for the review conversation: + +- **Rebuild with three honest cells** — live HVTrust badge, "#13 of 119 agent + frameworks", "OpenSSF Scorecard 8.1/10" — and find a fourth that is stable and + genuinely favourable. A three-cell grid is also fine. +- **Cut change D** and ship A and F only. The risk section already anticipated + this outcome. + +Recommendation: **rebuild with three cells.** #13 of 119 and 8.1/10 are still +good numbers honestly stated, and the per-cell source line makes them stronger +than the inflated versions would have been. + +### Out-of-scope bug this surfaced + +`README.md` (line 18 badge, line 38) and +`apps/website/content/docs/langgraph/getting-started/installation.mdx` (lines +8–9) both promise open-ended **"Angular 20+"**, but the published peer ranges +cap at `^21`. An Angular 22 user following those docs gets an npm peer failure. +Tracked separately; not part of this design. + +## Risks + +- **The dark band looks arbitrary** if `FinalCTA` does not ship with it. Treat + the two as one change, not two. +- **The questions go soft.** Sixteen rows is a lot; the temptation is to pad + with questions that are really features. A padded Yes wall is worse than the + grid it replaced. +- **Unverifiable proof figures** could hollow out change D. If two or more + cannot be verified, the section should be cut and revisited rather than + shipped thin. + +## Mockups + +Rendered mockups live in `.superpowers/brainstorm/` (gitignored). All four +candidate rounds — initial candidates, high-fidelity redraw, three visual +directions, and the final Inverted × Specimen combination — are preserved as +standalone HTML. From d3b398e83ecbd2ce2cbe81f40b530101868880e8 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 18:27:15 -0700 Subject: [PATCH 02/18] docs(website): update homepage spec for all-MIT (#881) and Angular 22 plans Licensing becomes an unqualified Yes-wall row, the CopilotKit FAQ item is gone from main and stays gone, telemetry row adopts the stronger 'installation is inert' phrasing, and the cut Angular LTS proof cell is conditionally revivable once ^22 peer support lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../2026-08-27-basecamp-homepage-design.md | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md b/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md index f8bcf85fb..4b6804eb2 100644 --- a/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md +++ b/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md @@ -146,14 +146,43 @@ skimmers; they stop being the pitch. **Decision needed at review.** The proposal is: - `Differentiator` is **removed**; the Yes wall takes its slot. -- `HomeFAQ` **shrinks** to genuinely non-binary questions — pricing, migration - effort from CopilotKit, licensing, SSR — since roughly half of its current - items become Yes-wall rows. +- `HomeFAQ` **shrinks** to genuinely non-binary questions — pricing, adapter + choice, SSR — since roughly half of its current items become Yes-wall rows. - Net page length stays approximately flat. The alternative is keeping both, which costs length and reintroduces the repetition this design exists to remove. +## Updated for main as of 2026-08-30 (post-#881, all-MIT) + +Main landed "move all packages to MIT" (#881) after this spec was first +committed. Three corrections to the design content: + +- **Licensing moves from FAQ to the Yes wall.** It is no longer a "yes, but" + ("most packages MIT, chat commercially licensed") — it is now an unqualified + yes: *"Can I use every package commercially without a license fee?" → Yes, + MIT.* The old licensing FAQ item is gone from main; do not resurrect it. +- **The CopilotKit migration question no longer exists.** #881 scrubbed + CopilotKit from the entire site (FAQ item, logo, differentiator copy). The + surviving-FAQ list above reflects that; reintroducing the comparison would + run against a deliberate decision on main. +- **The telemetry Yes-wall row strengthens.** Main's language is now "package + installation is inert" — stronger than the drafted "telemetry off by + default". Use the inert phrasing: *"Can I install it without phoning home?" + → Yes, installation is inert.* + +Main also introduced **Production Assurance** as a second commercial offering +beside Pilot-to-Prod; the FOR TEAMS section is untouched by this design, but +any Yes-wall row about support should not imply Pilot-to-Prod is the only +option. + +**Angular 22:** support is in flight per Brian (2026-08-30) but NOT yet on +main — every Angular-facing lib still pins `^20.0.0 || ^21.0.0`. Once a +`^22.0.0` range lands and releases, the cut LTS proof cell can return in +honest form ("Angular 20–22 supported" — current Active plus both LTS lines). +Until then it stays cut. Verify the published peer range, not the docs, before +reviving it. + ## Implementation notes `apps/website` is **Next.js 16 / React 19**, not Angular. Landing components From 860ed0050523ca8387268e6d4a3060f28c5ad926 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 18:33:16 -0700 Subject: [PATCH 03/18] docs(website): approve four surface treatments for homepage spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker highlight, dark-band glow/gradient atmosphere, editorial left-rail asymmetry, and paper elevation — all four approved, with exact CSS values from the rendered mockups. Treatment C moves the SectionHeader extraction from out-of-scope to in-scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../2026-08-27-basecamp-homepage-design.md | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md b/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md index 4b6804eb2..0850000f9 100644 --- a/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md +++ b/docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md @@ -183,6 +183,59 @@ honest form ("Angular 20–22 supported" — current Active plus both LTS lines) Until then it stays cut. Verify the published peer range, not the docs, before reviving it. +## Surface treatments (approved 2026-08-30) + +Four atmosphere treatments, chosen with basecamp.com as reference. Basecamp +barely uses gradients — its texture comes from highlight marks, elevation on +product shots, and asymmetric whitespace. These translate those moves; all four +ship, and they compose (A+D dress light sections, B refines the dark band, C +restructures section openings). + +**A — Marker highlight.** A skewed navy sweep behind key phrases — hero +subheadline first, testimonial pulls if/when real ones exist. Values: +`linear-gradient(100deg, rgba(0,64,144,.14), rgba(0,64,144,.08) 85%)`, +`border-radius:4px`, `transform:skewX(-6deg) rotate(-.4deg)`, drawn by a +`::before` at `z-index:-1` with `isolation:isolate` on the span (without the +isolation the sweep paints behind the section background and vanishes). +Use on at most two phrases per section; a page of highlights is a page of none. + +**B — Glow & gradient atmosphere (dark band).** The Yes wall and FinalCTA +canvas becomes `linear-gradient(180deg,#161616,#0e0e0e)` instead of flat +`#111`. A radial accent glow sits behind the watermark +(`radial-gradient(closest-side, rgba(100,195,253,.13), rgba(100,195,253,.04) +55%, transparent 75%)`, ~640px, off the top-right corner). The watermark glyph +itself is gradient-filled via `background-clip:text` +(`linear-gradient(160deg, rgba(100,195,253,.16), rgba(100,195,253,.03) 70%)`) +rather than flat low-opacity. A 1px seam marks the light→dark boundary: +`linear-gradient(90deg, transparent, rgba(100,195,253,.55) 30%, +rgba(100,195,253,.55) 70%, transparent)`. + +**C — Editorial asymmetry.** Section headers leave the centre for a left rail: +kicker over a 2px ink rule, Garamond heading below (~44px in-rail), an italic +muted aside under that; content in the right column. Grid `300px / 1fr`, gap +64px, collapsing to stacked below ~900px. This replaces the centred +kicker/H2/lede opening on the sections this design touches. **Consequence:** +the shared `SectionHeader` primitive previously noted as out-of-scope becomes +**in-scope** — restructuring every section opening by copy-paste would multiply +the existing margin/letter-spacing drift. Build a `SectionHeader` (or +`SectionRail`) primitive with centred and rail variants; sections not in this +design's scope keep the centred variant and migrate opportunistically. + +**D — Paper elevation (light sections).** Tinted bands become a whisper of +vertical gradient (`linear-gradient(180deg,#fdfdfd,#f6f6f6)`) instead of flat +`#fbfbfb`. Proof cards: layered shadows — tight contact plus long soft ambient +(`0 1px 2px rgba(0,0,0,.04), 0 12px 28px -12px rgba(28,28,28,.14)`) — a 1px +top highlight (`linear-gradient(90deg, transparent, rgba(255,255,255,.9), +transparent)`), and a faint internal vertical wash so they read as paper rather +than outlines. Product shots: slight rotation with a deep soft shadow +(`rotate(-.6deg)`, `0 2px 4px rgba(0,0,0,.05), 0 24px 48px -16px +rgba(28,28,28,.22)`) — `BrowserFrame` already supports `rotate` and +`elevation`, so this extends existing props rather than inventing. + +Note: change D's earlier drawing used a flat 4-cell hairline grid; with this +treatment the proof strip becomes three elevated cards on the gradient band. +The per-cell source lines and the live-badge rule are unchanged. + ## Implementation notes `apps/website` is **Next.js 16 / React 19**, not Angular. Landing components @@ -215,11 +268,11 @@ Tailwind is present but barely used on the homepage. Match that pattern. 3. `components/ui/Eyebrow.tsx` has three tones (`muted`/`accent`/`angular`), all light-theme. The dark sections need an on-dark tone resolving to `#64C3FD`. -**Not in scope but worth recording:** there is no shared `SectionHeader` -primitive. The kicker/H2/lede block is copy-pasted across `EcosystemStrip`, -`Differentiator`, `Promises`, and `HomeFAQ` with drifting margins (28/44/56/48) -and inconsistent letter-spacing. Extracting one was the natural companion to the -section-label change that got cut; leaving a note rather than doing it here. +**`SectionHeader` extraction is now in scope** (see treatment C above). There +is no shared primitive today — the kicker/H2/lede block is copy-pasted across +`EcosystemStrip`, `Differentiator`, `Promises`, and `HomeFAQ` with drifting +margins (28/44/56/48) and inconsistent letter-spacing. Treatment C's left-rail +opening is the reason to extract one with centred and rail variants. **Testing.** `nx test website` does not exist and fails quasi-silently. Use: From 0909509eb11cf5ed647f09cf68f0edaf505a0052 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 18:51:22 -0700 Subject: [PATCH 04/18] docs(website): implementation plan for Basecamp-informed homepage Nine tasks: dark section substrate via CSS-variable re-scoping, SectionHeader primitive, hero rewrite, YesWall replacing Differentiator, ProofStrip with live HVTrust badge, opt-in dark FinalCTA, FAQ shrink, and a verification gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../plans/2026-08-30-basecamp-homepage.md | 1388 +++++++++++++++++ 1 file changed, 1388 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-basecamp-homepage.md diff --git a/docs/superpowers/plans/2026-08-30-basecamp-homepage.md b/docs/superpowers/plans/2026-08-30-basecamp-homepage.md new file mode 100644 index 000000000..9b38b059e --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-basecamp-homepage.md @@ -0,0 +1,1388 @@ +# Basecamp-Informed Homepage 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:** Ship the approved homepage redesign — dark specimen "Yes wall" replacing `Differentiator`, a light elevated proof strip, a problem-first hero, a dark `FinalCTA` variant, and four surface treatments — per `docs/superpowers/specs/2026-08-27-basecamp-homepage-design.md`. + +**Architecture:** `apps/website` is Next.js 16 / React 19. Since the style-substrate migration, primitives render `data-ui` attributes and ALL presentation lives in **unlayered CSS** under `apps/website/src/styles/` (`ui.css` for primitives, `landing.css` for landing components) — do NOT add inline `style` props or `@layer` wrappers. Dark sections work by **re-scoping the `--color-*` custom properties** inside `[data-ui="section"][data-surface="dark"]`, so every child primitive (Eyebrow, Button) picks up dark values with zero TS changes. This supersedes the spec's older "import `darkOverrides` in TS" note — same rule honored (never make `tokens.ts` theme-aware), cleaner mechanism. + +**Tech Stack:** Next.js 16, React 19, Vitest + Testing Library (specs colocated, jsdom), plain CSS custom properties. Fonts already loaded via `next/font` as `--font-garamond` / `--font-inter` / `--font-mono`. + +**Test command (memorize it):** `cd apps/website && npx vitest run --config vite.config.mts` — `nx test website` also works but run vitest directly for single files: `npx vitest run --config vite.config.mts src/components/landing/YesWall.spec.tsx`. + +**Verification gate at the end:** production build (`npx nx build website --configuration=production`) + visual pass in the browser preview. Example-app strictness and bundle-budget failures show up only in prod builds. + +--- + +### Task 1: Dark surface substrate (`Section` + CSS variable re-scope) + +**Files:** +- Modify: `apps/website/src/components/ui/Section.tsx` (the `Surface` type, line 4) +- Modify: `apps/website/src/styles/ui.css` (section rules, ~line 141) +- Create: `apps/website/src/components/ui/Section.spec.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// apps/website/src/components/ui/Section.spec.tsx +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Section } from './Section'; + +describe('Section', () => { + it('renders data-surface="dark" when asked', () => { + const { container } = render(<Section surface="dark">x</Section>); + const el = container.querySelector('[data-ui="section"]'); + expect(el?.getAttribute('data-surface')).toBe('dark'); + }); + + it('defaults to canvas', () => { + const { container } = render(<Section>x</Section>); + expect( + container.querySelector('[data-ui="section"]')?.getAttribute('data-surface'), + ).toBe('canvas'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/ui/Section.spec.tsx` +Expected: FAIL — TS rejects `surface="dark"` (`Type '"dark"' is not assignable to type 'Surface'`). + +- [ ] **Step 3: Extend the Surface union** + +In `apps/website/src/components/ui/Section.tsx` change line 4: + +```tsx +type Surface = 'canvas' | 'tinted' | 'white' | 'dark'; +``` + +Nothing else in the component changes — the attribute already flows through. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/ui/Section.spec.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the dark-scope CSS** + +In `apps/website/src/styles/ui.css`, directly after the +`[data-ui="section"][data-surface="white"]` rule (~line 158), add: + +```css +/* Dark section scope (homepage Yes wall + FinalCTA dark variant). + * Values mirror libs/design-tokens/src/lib/dark.ts — the website hardwires + * the light theme in TS, so dark sections re-scope the CSS variables instead. + * If dark.ts changes, update here. Treatment B (spec): the canvas is a subtle + * vertical gradient, and a 1px accent seam marks the light→dark boundary. */ +[data-ui="section"][data-surface="dark"] { + --color-canvas: rgb(17, 17, 17); + --color-surface: rgb(28, 28, 28); + --color-surface-tinted: rgb(44, 44, 44); + --color-surface-dim: rgb(10, 10, 10); + --color-border: rgb(45, 45, 45); + --color-border-strong: rgb(60, 60, 60); + --color-text-primary: rgb(245, 245, 245); + --color-text-secondary: rgb(200, 200, 200); + --color-text-muted: rgb(160, 160, 160); + --color-text-inverted: rgb(17, 17, 17); + --color-accent: #64c3fd; + --color-accent-hover: #8dd4ff; + --color-accent-glow: rgba(100, 195, 253, 0.25); + --color-accent-border: rgba(100, 195, 253, 0.2); + --color-accent-surface: rgba(100, 195, 253, 0.08); + + background: linear-gradient(180deg, #161616 0%, #0e0e0e 100%); + color: var(--color-text-primary); + position: relative; +} +/* 1px accent seam at the light→dark boundary. */ +[data-ui="section"][data-surface="dark"]::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + rgba(100, 195, 253, 0.55) 30%, + rgba(100, 195, 253, 0.55) 70%, + transparent + ); + pointer-events: none; +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/components/ui/Section.tsx apps/website/src/components/ui/Section.spec.tsx apps/website/src/styles/ui.css +git commit -m "feat(website): dark section surface with scoped token re-map" +``` + +--- + +### Task 2: `SectionHeader` primitive (centered + rail variants) + +Treatment C. New sections use it; existing sections migrate opportunistically (NOT in this plan). + +**Files:** +- Create: `apps/website/src/components/ui/SectionHeader.tsx` +- Create: `apps/website/src/components/ui/SectionHeader.spec.tsx` +- Modify: `apps/website/src/styles/ui.css` (append) + +- [ ] **Step 1: Write the failing test** + +```tsx +// apps/website/src/components/ui/SectionHeader.spec.tsx +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { SectionHeader } from './SectionHeader'; + +describe('SectionHeader', () => { + it('renders eyebrow, heading with id, and aside in rail variant', () => { + const { container } = render( + <SectionHeader + variant="rail" + eyebrow="The Yes wall" + heading="Yes, it does that." + headingId="yes-wall-heading" + aside="Sixteen questions teams ask before they commit." + />, + ); + const root = container.querySelector('[data-ui="section-header"]'); + expect(root?.getAttribute('data-variant')).toBe('rail'); + expect(screen.getByText('The Yes wall')).toBeTruthy(); + const h2 = screen.getByRole('heading', { level: 2 }); + expect(h2.id).toBe('yes-wall-heading'); + expect(h2.textContent).toBe('Yes, it does that.'); + expect(screen.getByText(/Sixteen questions/)).toBeTruthy(); + }); + + it('defaults to centered variant with no aside', () => { + const { container } = render( + <SectionHeader eyebrow="Reliable" heading="Proof." />, + ); + expect( + container.querySelector('[data-ui="section-header"]')?.getAttribute('data-variant'), + ).toBe('centered'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/ui/SectionHeader.spec.tsx` +Expected: FAIL — module `./SectionHeader` not found. + +- [ ] **Step 3: Implement the component** + +```tsx +// apps/website/src/components/ui/SectionHeader.tsx +import type { ReactNode } from 'react'; +import { Eyebrow } from './Eyebrow'; + +type Variant = 'centered' | 'rail'; + +interface SectionHeaderProps { + eyebrow: string; + heading: ReactNode; + /** id for the h2, for Section's ariaLabelledBy. */ + headingId?: string; + /** Italic muted aside under the heading (rail variant). */ + aside?: ReactNode; + /** + * 'centered' — the classic kicker/H2 stack. + * 'rail' — left-rail editorial variant (treatment C): kicker over a 2px + * rule, heading below, italic aside. The PARENT owns the grid placement; + * this component only renders the header block. + */ + variant?: Variant; +} + +export function SectionHeader({ + eyebrow, + heading, + headingId, + aside, + variant = 'centered', +}: SectionHeaderProps) { + return ( + <header data-ui="section-header" data-variant={variant}> + <Eyebrow tone="accent" className="section-header-eyebrow"> + {eyebrow} + </Eyebrow> + <h2 id={headingId} className="section-header-heading"> + {heading} + </h2> + {aside ? <p className="section-header-aside">{aside}</p> : null} + </header> + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/ui/SectionHeader.spec.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the CSS** + +Append to `apps/website/src/styles/ui.css`: + +```css +/* UI primitive — SectionHeader (centered + rail variants, treatment C). */ +[data-ui="section-header"] .section-header-eyebrow { + margin-bottom: 16px; +} +[data-ui="section-header"] .section-header-heading { + font-family: var(--font-garamond); + font-size: var(--text-h2); + line-height: var(--text-h2--line-height); + font-weight: 700; + letter-spacing: -0.015em; + color: var(--color-text-primary); + margin: 0; +} +[data-ui="section-header"] .section-header-aside { + font-family: var(--font-inter); + font-size: 15px; + line-height: 1.6; + font-style: italic; + color: var(--color-text-muted); + margin: 16px 0 0; +} +[data-ui="section-header"][data-variant="centered"] { + text-align: center; +} +[data-ui="section-header"][data-variant="rail"] { + text-align: left; +} +[data-ui="section-header"][data-variant="rail"] .section-header-eyebrow { + border-top: 2px solid var(--color-text-primary); + padding-top: 8px; + margin-bottom: 14px; +} +[data-ui="section-header"][data-variant="rail"] .section-header-heading { + font-size: clamp(32px, 3.4vw, 44px); + line-height: 1.1; + letter-spacing: -0.015em; +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/components/ui/SectionHeader.tsx apps/website/src/components/ui/SectionHeader.spec.tsx apps/website/src/styles/ui.css +git commit -m "feat(website): SectionHeader primitive with centered and rail variants" +``` + +--- + +### Task 3: Hero — problem-first subhead, chips, marker highlight + +Changes F + treatment A. The H1, CTAs, proof pills, and analytics stay exactly as they are. + +**Files:** +- Modify: `apps/website/src/lib/positioning.ts` +- Modify: `apps/website/src/components/landing/Hero.tsx` +- Modify: `apps/website/src/styles/landing.css` (hero block, ~line 19) +- Modify: `apps/website/src/components/landing/Hero.spec.tsx` + +- [ ] **Step 1: Update the failing spec FIRST** + +In `apps/website/src/components/landing/Hero.spec.tsx`, replace the +`'renders the locked H1 and subhead'` test body's subhead assertion. The old +test asserts `screen.getByText(HERO_SUBHEAD)`; the new subhead contains a +marker `<span>`, so assert on the container text and the chips: + +```tsx + it('renders the locked H1, problem-first subhead, and capability chips', async () => { + render(<Hero />); + expect(screen.getByRole('heading', { level: 1 }).textContent) + .toBe('Ship production agent UIs in Angular.'); + const subhead = document.querySelector('.hero-subhead'); + expect(subhead?.textContent).toContain('Everything after it takes six months.'); + expect(subhead?.textContent).toContain('keeps your backend exactly where it is'); + for (const chip of HERO_CHIPS) { + expect(screen.getByText(chip)).toBeTruthy(); + } + for (const proofPoint of POSITIONING_PROOF_POINTS) { + expect(screen.getByText(proofPoint.label)).toBeTruthy(); + } + }); +``` + +Add `HERO_CHIPS` to the existing import from `../../lib/positioning` and +remove `HERO_SUBHEAD` from that import if it is no longer referenced anywhere +else in the spec. + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/Hero.spec.tsx` +Expected: FAIL — `HERO_CHIPS` is not exported. + +- [ ] **Step 3: Update positioning.ts** + +In `apps/website/src/lib/positioning.ts` replace the `HERO_SUBHEAD` constant +(keep the export name — `site-metadata.ts` and others may reference it; grep +first: `grep -rn 'HERO_SUBHEAD' apps/website/src`) and add chips: + +```ts +export const HERO_SUBHEAD = `The streaming demo takes an afternoon. Everything after it takes six months. Threadplane is the Angular layer that closes the gap — and it keeps your backend exactly where it is.`; + +/** Capability nouns, demoted from the subheadline to chips (spec change F). */ +export const HERO_CHIPS: readonly string[] = [ + 'durable threads', + 'interrupts', + 'subagents', + 'planning + memory', + 'generative UI', + 'LangGraph + AG-UI', +]; +``` + +If the `HERO_SUBHEAD` grep shows it used as a meta description anywhere, +leave those call sites alone — the constant's new value is the correct +description too. + +- [ ] **Step 4: Update Hero.tsx** + +In `apps/website/src/components/landing/Hero.tsx`: + +Change the positioning import to: + +```tsx +import { HERO_CHIPS, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; +``` + +Replace the `<p className="hero-subhead">{HERO_SUBHEAD}</p>` block with +(marker treatment A — at most two highlights, per spec): + +```tsx + <p className="hero-subhead"> + The streaming demo takes an afternoon.{' '} + <span className="marker-highlight"> + Everything after it takes six months. + </span>{' '} + Threadplane is the Angular layer that closes the gap — and it{' '} + <span className="marker-highlight"> + keeps your backend exactly where it is. + </span> + </p> + <ul className="hero-chip-row" aria-label="Capabilities"> + {HERO_CHIPS.map((chip) => ( + <li key={chip} className="hero-chip"> + {chip} + </li> + ))} + </ul> +``` + +Place the `hero-chip-row` list between the subhead and the existing +`hero-cta-row` div. + +- [ ] **Step 5: Add the CSS** + +In `apps/website/src/styles/landing.css`, after the `.hero-subhead` rule +(~line 40), add: + +```css +/* Treatment A — marker highlight. isolation:isolate is load-bearing: without + * it the z-index:-1 sweep paints behind the section background and vanishes. */ +.marker-highlight { + position: relative; + isolation: isolate; + color: var(--color-text-primary); + font-weight: 600; +} +.marker-highlight::before { + content: ''; + position: absolute; + inset: -1px -5px -3px; + z-index: -1; + background: linear-gradient( + 100deg, + rgba(0, 64, 144, 0.14), + rgba(0, 64, 144, 0.08) 85% + ); + border-radius: 4px; + transform: skewX(-6deg) rotate(-0.4deg); +} + +.hero-chip-row { + display: flex; + flex-wrap: wrap; + gap: 7px; + list-style: none; + padding: 0; + margin: 0 0 28px; + max-width: 54ch; +} +.hero-chip { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + padding: 5px 11px; + white-space: nowrap; +} +``` + +Also reduce `.hero-subhead`'s `margin-bottom` from `32px` to `20px` (the chip +row now carries the gap before the CTA row). + +- [ ] **Step 6: Run the hero spec** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/Hero.spec.tsx` +Expected: PASS (all tests in file — the CTA/analytics tests are untouched). + +- [ ] **Step 7: Check for other HERO_SUBHEAD-dependent specs, then run the whole suite** + +Run: `grep -rn 'HERO_SUBHEAD' apps/website/src` — update any spec asserting the +old sentence (e.g. `site-metadata.spec.ts`) to assert the new one verbatim. +Then: `cd apps/website && npx vitest run --config vite.config.mts` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add apps/website/src/lib/positioning.ts apps/website/src/components/landing/Hero.tsx apps/website/src/styles/landing.css apps/website/src/components/landing/Hero.spec.tsx apps/website/src/lib/site-metadata.spec.ts +git commit -m "feat(website): problem-first hero subhead with capability chips and marker highlight" +``` + +--- + +### Task 4: `YesWall` component (change A — data + markup + spec) + +**Files:** +- Create: `apps/website/src/components/landing/YesWall.tsx` +- Create: `apps/website/src/components/landing/YesWall.spec.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// apps/website/src/components/landing/YesWall.spec.tsx +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { YesWall, YES_WALL_GROUPS } from './YesWall'; + +vi.mock('../../lib/analytics/client', () => ({ + trackCtaClick: vi.fn(), +})); + +describe('YesWall', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 16 questions across 4 groups', () => { + render(<YesWall />); + const total = YES_WALL_GROUPS.reduce((n, g) => n + g.rows.length, 0); + expect(YES_WALL_GROUPS).toHaveLength(4); + expect(total).toBe(16); + for (const group of YES_WALL_GROUPS) { + expect(screen.getByText(group.label)).toBeTruthy(); + for (const row of group.rows) { + expect(screen.getByText(row.question)).toBeTruthy(); + expect(screen.getByText(row.api)).toBeTruthy(); + } + } + }); + + it('answers every question Yes', () => { + render(<YesWall />); + expect(screen.getAllByText('Yes')).toHaveLength(16); + }); + + it('renders the dark specimen chrome', () => { + const { container } = render(<YesWall />); + expect( + container.querySelector('[data-ui="section"]')?.getAttribute('data-surface'), + ).toBe('dark'); + const mark = container.querySelector('.yes-wall-watermark'); + expect(mark?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('links the footer to the docs', () => { + render(<YesWall />); + const link = screen.getByRole('link', { name: /Every question answered/ }); + expect(link.getAttribute('href')).toBe('/docs'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/YesWall.spec.tsx` +Expected: FAIL — module `./YesWall` not found. + +- [ ] **Step 3: Implement the component** + +Every question must be answerable **yes without qualification** (spec rule); +API labels reuse `Differentiator`'s vocabulary where the row carries over. + +```tsx +// apps/website/src/components/landing/YesWall.tsx +'use client'; + +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { SectionHeader } from '../ui/SectionHeader'; +import { trackCtaClick } from '../../lib/analytics/client'; + +interface YesRow { + question: string; + api: string; +} + +interface YesGroup { + label: string; + rows: YesRow[]; +} + +export const YES_WALL_GROUPS: readonly YesGroup[] = [ + { + label: 'State that survives', + rows: [ + { question: 'Can a conversation survive a page reload?', api: 'threadId + durable transports' }, + { question: 'Can I resume a thread days later, on another device?', api: 'checkpointed threads' }, + { question: 'Can I branch or replay a conversation from any point?', api: 'branch / replay' }, + { question: 'Can I persist threads without building a persistence layer?', api: 'durable transports' }, + ], + }, + { + label: 'Humans in the loop', + rows: [ + { question: 'Can I stop the agent before it does something irreversible?', api: 'interrupt()' }, + { question: 'Can the pause survive a refresh while someone decides?', api: 'the pause is a checkpoint' }, + { question: 'Can I show the human what the agent is about to do?', api: '<chat-interrupt-panel>' }, + { question: "Can the human's decision land in the thread record?", api: 'submit({ resume })' }, + ], + }, + { + label: 'On my design system', + rows: [ + { question: 'Can agent output render as my components, not a chat widget?', api: '@threadplane/render' }, + { question: 'Can I fall back per-component when a spec is unknown?', api: 'fallback + readiness gate' }, + { question: 'Can the browser own its own tools and render them inline?', api: 'client tools' }, + ], + }, + { + label: 'Shipping it', + rows: [ + { question: 'Can I swap LangGraph for AG-UI without rewriting the UI?', api: 'one Agent contract' }, + { question: 'Can I unit-test components that depend on an agent?', api: 'provideFakeAgent' }, + { question: 'Can I run all of it inside my own VPC?', api: 'self-host, no runtime SaaS' }, + { question: 'Can I use every package commercially without a license fee?', api: 'MIT, all packages' }, + { question: 'Can I install it without phoning home?', api: 'installation is inert' }, + ], + }, +]; + +export function YesWall() { + return ( + <Section surface="dark" id="yes-wall" ariaLabelledBy="yes-wall-heading"> + <Container> + <div className="yes-wall"> + <div className="yes-wall-watermark" aria-hidden="true"> + Yes + </div> + <div className="yes-wall-grid"> + <SectionHeader + variant="rail" + eyebrow="Every question below has the same answer" + heading="Yes, it does that." + headingId="yes-wall-heading" + aside="Sixteen questions teams ask before they commit — each linked to the API that answers it." + /> + <div className="yes-wall-body"> + {YES_WALL_GROUPS.map((group, index) => ( + <div className="yes-wall-group" key={group.label}> + <div className="yes-wall-group-numeral" aria-hidden="true"> + {String(index + 1).padStart(2, '0')} + </div> + <div className="yes-wall-group-rows"> + <p className="yes-wall-group-label">{group.label}</p> + {group.rows.map((row) => ( + <div className="yes-wall-row" key={row.question}> + <p className="yes-wall-question"> + <em className="yes-wall-yes">Yes</em> + {row.question} + </p> + <p className="yes-wall-api">{row.api}</p> + </div> + ))} + </div> + </div> + ))} + <div className="yes-wall-footer"> + <a + className="yes-wall-footer-link" + href="/docs" + onClick={() => + trackCtaClick({ + cta_id: 'home_yes_wall_docs', + track: 'developer', + surface: 'home', + }) + } + > + Every question answered, in the docs → + </a> + <p className="yes-wall-footer-count">16 questions · 16 yeses</p> + </div> + </div> + </div> + </div> + </Container> + </Section> + ); +} +``` + +Note: check `trackCtaClick`'s real signature first with +`grep -n 'export function trackCtaClick' -A6 apps/website/src/lib/analytics/client.ts` +and match `Differentiator.tsx`'s existing call shape exactly — if it takes +different keys, mirror the Differentiator footer call, keeping +`cta_id: 'home_yes_wall_docs'`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/YesWall.spec.tsx` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/components/landing/YesWall.tsx apps/website/src/components/landing/YesWall.spec.tsx +git commit -m "feat(website): YesWall component — 16 questions, 4 groups, dark specimen" +``` + +--- + +### Task 5: YesWall CSS + wire into the page, remove `Differentiator` + +**Files:** +- Modify: `apps/website/src/styles/landing.css` (append) +- Modify: `apps/website/src/app/page.tsx` +- Delete: `apps/website/src/components/landing/Differentiator.tsx` +- Delete: `apps/website/src/components/landing/Differentiator.spec.tsx` + +- [ ] **Step 1: Append the specimen CSS to landing.css** + +```css +/* ---------- Yes wall (dark specimen, spec change A + treatment B) ---------- */ +.yes-wall { + position: relative; +} +/* 330px italic Garamond watermark, gradient-filled, bleeding off the right. */ +.yes-wall-watermark { + position: absolute; + right: -40px; + top: -20px; + font-family: var(--font-garamond); + font-style: italic; + font-weight: 800; + font-size: clamp(160px, 24vw, 330px); + line-height: 0.76; + letter-spacing: -0.04em; + background: linear-gradient( + 160deg, + rgba(100, 195, 253, 0.16), + rgba(100, 195, 253, 0.03) 70% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + pointer-events: none; + user-select: none; +} +/* Radial accent glow rising behind the watermark (treatment B). */ +.yes-wall::before { + content: ''; + position: absolute; + top: -160px; + right: -120px; + width: 640px; + height: 560px; + background: radial-gradient( + closest-side, + rgba(100, 195, 253, 0.13), + rgba(100, 195, 253, 0.04) 55%, + transparent 75% + ); + pointer-events: none; +} +.yes-wall-grid { + position: relative; + display: grid; + grid-template-columns: 300px 1fr; + gap: 64px; +} +.yes-wall-group { + display: grid; + grid-template-columns: 112px 1fr; +} +.yes-wall-group + .yes-wall-group { + margin-top: 34px; +} +.yes-wall-group-numeral { + font-family: var(--font-garamond); + font-size: 62px; + font-weight: 700; + line-height: 0.85; + letter-spacing: -0.03em; + color: var(--color-border-strong); + padding-top: 14px; +} +.yes-wall-group-rows { + border-top: 2px solid var(--color-text-primary); +} +.yes-wall-group-label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent); + margin: 0; + padding: 13px 0 5px; +} +.yes-wall-row { + display: grid; + grid-template-columns: 1fr 250px; + gap: 22px; + align-items: baseline; + border-top: 1px solid var(--color-border); + padding: 12px 0; + transition: background 140ms ease; +} +.yes-wall-row:hover { + background: var(--color-accent-surface); +} +.yes-wall-question { + font-family: var(--font-inter); + font-size: 16px; + line-height: 1.45; + color: var(--color-text-primary); + margin: 0; +} +.yes-wall-yes { + font-family: var(--font-garamond); + font-style: italic; + font-weight: 700; + font-size: 20px; + color: var(--color-accent); + text-shadow: 0 0 16px var(--color-accent-glow); + margin-right: 13px; +} +.yes-wall-api { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; + color: var(--color-text-muted); + text-align: right; + margin: 0; +} +.yes-wall-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 34px; + padding-top: 20px; + border-top: 1px solid var(--color-border); +} +.yes-wall-footer-link { + font-family: var(--font-inter); + font-size: 15px; + font-weight: 500; + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 3px; +} +.yes-wall-footer-count { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-muted); + margin: 0; +} + +/* Responsive: the specimen devices are the fragile part (spec). */ +@media (max-width: 900px) { + .yes-wall-grid { + grid-template-columns: 1fr; + gap: 40px; + } + .yes-wall-group { + grid-template-columns: 1fr; + } + .yes-wall-group-numeral { + font-size: 40px; + padding: 0 0 8px; + } + .yes-wall-watermark { + font-size: 140px; + right: -16px; + } + .yes-wall-row { + grid-template-columns: 1fr; + gap: 4px; + } + .yes-wall-api { + text-align: left; + padding-left: 33px; + } +} +``` + +- [ ] **Step 2: Swap the components in page.tsx** + +In `apps/website/src/app/page.tsx`: +- Replace the import `import { Differentiator } from '../components/landing/Differentiator';` with `import { YesWall } from '../components/landing/YesWall';` +- Replace `<Differentiator />` with `<YesWall />` in the JSX. + +- [ ] **Step 3: Delete Differentiator** + +```bash +git rm apps/website/src/components/landing/Differentiator.tsx apps/website/src/components/landing/Differentiator.spec.tsx +``` + +Then verify nothing else imports it: `grep -rn 'Differentiator' apps/website/src` — expected: no matches (comments mentioning it in page.tsx may remain; update the `#approve` FeatureBlock comment that says "the Differentiator table" to say "the Yes wall"). + +- [ ] **Step 4: Run the whole suite** + +Run: `cd apps/website && npx vitest run --config vite.config.mts` +Expected: PASS — the Differentiator spec is gone, YesWall spec passes, nothing else referenced the removed rows. + +- [ ] **Step 5: Commit** + +```bash +git add -A apps/website/src +git commit -m "feat(website): replace Differentiator with the Yes wall" +``` + +--- + +### Task 6: `ProofStrip` (change D — three honest cells, paper elevation) + +**Files:** +- Create: `apps/website/src/components/landing/ProofStrip.tsx` +- Create: `apps/website/src/components/landing/ProofStrip.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` (append) +- Modify: `apps/website/src/app/page.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// apps/website/src/components/landing/ProofStrip.spec.tsx +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { ProofStrip, PROOF_CELLS } from './ProofStrip'; + +describe('ProofStrip', () => { + it('renders three cells, each with a source link', () => { + render(<ProofStrip />); + expect(PROOF_CELLS).toHaveLength(3); + for (const cell of PROOF_CELLS) { + expect(screen.getByText(cell.caption)).toBeTruthy(); + const link = screen.getByRole('link', { name: new RegExp(cell.sourceLabel, 'i') }); + expect(link.getAttribute('href')).toBe(cell.sourceHref); + } + }); + + it('renders the HVTrust grade as a live badge image, not text', () => { + render(<ProofStrip />); + const badge = screen.getByAltText(/HVTrust grade/i); + expect(badge.getAttribute('src')).toBe('https://hvtracker.net/badge/threadplane.svg'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/ProofStrip.spec.tsx` +Expected: FAIL — module `./ProofStrip` not found. + +- [ ] **Step 3: Implement** + +Figures verified 2026-08-27 (spec, "Verification results"). The rank and +score WILL drift — the source-checked comment is part of the deliverable. + +```tsx +// apps/website/src/components/landing/ProofStrip.tsx +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { SectionHeader } from '../ui/SectionHeader'; + +interface ProofCell { + /** Big Garamond numeral, or null when the cell renders a live badge. */ + value: string | null; + /** Small suffix set beside the numeral (e.g. "of 119"). */ + suffix?: string; + caption: string; + sourceLabel: string; + sourceHref: string; +} + +/** + * Verified 2026-08-27 against live sources (see the homepage design spec, + * "Verification results"). The rank and score drift over time — re-verify on + * touch, and never "round up". The HVTrust grade is deliberately a LIVE badge: + * it sits at 81.2 against an A-band floor of 80 and has flipped grade six + * times in one month; a hardcoded letter would be wrong on some days. + */ +export const PROOF_CELLS: readonly ProofCell[] = [ + { + value: '#13', + suffix: 'of 119', + caption: 'Of all agent frameworks ranked', + sourceLabel: 'hvtracker.net', + sourceHref: 'https://hvtracker.net/categories/agent-frameworks/', + }, + { + value: '8.1', + suffix: '/10', + caption: 'OpenSSF Scorecard, official API', + sourceLabel: 'securityscorecards.dev', + sourceHref: + 'https://api.securityscorecards.dev/projects/github.com/cacheplane/angular-agent-framework', + }, + { + value: null, + caption: 'HVTrust supply-chain grade, live', + sourceLabel: 'hvtracker.net/agents/threadplane', + sourceHref: 'https://hvtracker.net/agents/threadplane/', + }, +]; + +export function ProofStrip() { + return ( + <Section surface="tinted" tight id="proof" ariaLabelledBy="proof-heading"> + <Container> + <div className="proof-strip-grid"> + <SectionHeader + variant="rail" + eyebrow="Reliable to the core" + heading="Audited, scored, published." + headingId="proof-heading" + aside="Not self-reported — every number links to its source." + /> + <ul className="proof-strip-cells"> + {PROOF_CELLS.map((cell) => ( + <li className="proof-strip-cell" key={cell.caption}> + {cell.value ? ( + <p className="proof-strip-value"> + {cell.value} + {cell.suffix ? ( + <span className="proof-strip-suffix"> {cell.suffix}</span> + ) : null} + </p> + ) : ( + <img + className="proof-strip-badge" + src="https://hvtracker.net/badge/threadplane.svg" + alt="HVTrust grade for Threadplane (live badge)" + loading="lazy" + /> + )} + <p className="proof-strip-caption">{cell.caption}</p> + <a + className="proof-strip-source" + href={cell.sourceHref} + target="_blank" + rel="noopener noreferrer" + > + {cell.sourceLabel} + </a> + </li> + ))} + </ul> + </div> + </Container> + </Section> + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/ProofStrip.spec.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the paper-elevation CSS (treatment D)** + +Append to `apps/website/src/styles/landing.css`: + +```css +/* ---------- Proof strip (light, paper elevation — spec change D) ---------- */ +.proof-strip-grid { + display: grid; + grid-template-columns: 300px 1fr; + gap: 64px; + align-items: start; +} +.proof-strip-cells { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; + list-style: none; + margin: 0; + padding: 0; +} +.proof-strip-cell { + position: relative; + background: var(--color-surface); + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.9), + rgba(251, 251, 251, 0.4) + ); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 26px 24px 22px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.04), + 0 12px 28px -12px rgba(28, 28, 28, 0.14); +} +/* 1px top highlight so the card reads as paper, not an outline. */ +.proof-strip-cell::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.9), + transparent + ); +} +.proof-strip-value { + font-family: var(--font-garamond); + font-size: 58px; + font-weight: 700; + line-height: 0.9; + letter-spacing: -0.028em; + color: var(--color-text-primary); + margin: 0; +} +.proof-strip-suffix { + font-size: 18px; + font-weight: 500; + letter-spacing: 0; + color: var(--color-text-muted); +} +.proof-strip-badge { + height: 52px; + width: auto; + display: block; +} +.proof-strip-caption { + font-family: var(--font-inter); + font-size: 14px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 12px 0 0; +} +.proof-strip-source { + display: inline-block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-top: 9px; +} +@media (max-width: 900px) { + .proof-strip-grid { + grid-template-columns: 1fr; + gap: 32px; + } + .proof-strip-cells { + grid-template-columns: 1fr; + } +} +``` + +Also change the tinted section band to the treatment-D gradient. In +`apps/website/src/styles/ui.css`, the rule +`[data-ui="section"][data-surface="tinted"]` currently reads +`background: var(--color-surface-tinted);` — leave it, and add directly below: + +```css +/* Treatment D: the proof strip's tinted band is a whisper of gradient. Scoped + * to #proof so other tinted sections keep the flat token. */ +#proof[data-ui="section"][data-surface="tinted"] { + background: linear-gradient(180deg, #fdfdfd, #f6f6f6); +} +``` + +- [ ] **Step 6: Insert into page.tsx** + +In `apps/website/src/app/page.tsx` add +`import { ProofStrip } from '../components/landing/ProofStrip';` and render +`<ProofStrip />` between `<Promises />` and `<HomeFAQ />`. + +- [ ] **Step 7: Run the suite** + +Run: `cd apps/website && npx vitest run --config vite.config.mts` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add apps/website/src/components/landing/ProofStrip.tsx apps/website/src/components/landing/ProofStrip.spec.tsx apps/website/src/styles/landing.css apps/website/src/styles/ui.css apps/website/src/app/page.tsx +git commit -m "feat(website): proof strip — three verified cells with live HVTrust badge" +``` + +--- + +### Task 7: `FinalCTA` dark variant (partner section for the dark band) + +`FinalCTA` is shared by **eight pages** (`/pricing`, `/chat`, `/langgraph`, +`/ag-ui`, `/render`, `/solutions`, `/solutions/[slug]`, `/pilot-to-prod`). +The dark treatment must be **opt-in via prop**; every other page keeps the +current tinted look untouched. + +**Files:** +- Modify: `apps/website/src/components/landing/FinalCTA.tsx` +- Create: `apps/website/src/components/landing/FinalCTA.spec.tsx` +- Modify: `apps/website/src/styles/landing.css` (append) +- Modify: `apps/website/src/app/page.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// apps/website/src/components/landing/FinalCTA.spec.tsx +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { FinalCTA } from './FinalCTA'; + +describe('FinalCTA', () => { + it('defaults to the tinted surface (used by 8 non-home pages)', () => { + const { container } = render(<FinalCTA />); + expect( + container.querySelector('[data-ui="section"]')?.getAttribute('data-surface'), + ).toBe('tinted'); + }); + + it('renders the dark surface when variant="dark"', () => { + const { container } = render(<FinalCTA variant="dark" />); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('data-surface')).toBe('dark'); + expect(container.querySelector('.final-cta-mark')).toBeTruthy(); + }); +}); +``` + +If rendering `<FinalCTA />` in jsdom fails on a child (e.g. `DemoCtaPair` +touching browser APIs), mock that child module the same way other landing +specs in this repo mock theirs — look at `Hero.spec.tsx`'s mock block for the +house pattern. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/FinalCTA.spec.tsx` +Expected: FAIL — TS rejects unknown prop `variant`. + +- [ ] **Step 3: Add the variant prop** + +In `apps/website/src/components/landing/FinalCTA.tsx`: + +Add to `FinalCTAProps`: + +```tsx + /** + * 'dark' renders on the dark band — homepage only, pairing with the Yes + * wall so the inverted treatment appears twice (spec: a lone dark band + * reads as arbitrary). All other pages keep the default tinted surface. + */ + variant?: 'default' | 'dark'; +``` + +Add `variant = 'default',` to the destructured props. Change the `<Section>` +open tag to: + +```tsx + <Section + surface={variant === 'dark' ? 'dark' : 'tinted'} + ariaLabelledBy="final-cta-heading" + > +``` + +And inside the `final-cta-inner` div, first child, add the watermark arrow +(dark variant only): + +```tsx + {variant === 'dark' ? ( + <div className="final-cta-mark" aria-hidden="true"> + → + </div> + ) : null} +``` + +- [ ] **Step 4: Add the CSS** + +Append to `apps/website/src/styles/landing.css`: + +```css +/* FinalCTA dark variant — the Yes wall's partner section. */ +[data-surface="dark"] .final-cta-inner { + position: relative; +} +.final-cta-mark { + position: absolute; + right: -30px; + bottom: -70px; + font-family: var(--font-garamond); + font-style: italic; + font-weight: 800; + font-size: 210px; + line-height: 1; + letter-spacing: -0.04em; + background: linear-gradient( + 160deg, + rgba(100, 195, 253, 0.14), + rgba(100, 195, 253, 0.03) 70% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + pointer-events: none; + user-select: none; +} +``` + +Check `.final-cta-heading` / `.final-cta-subtext` rules in `landing.css`: if +they hardcode light colors (e.g. `color: rgb(28, 28, 28)`), change them to +`var(--color-text-primary)` / `var(--color-text-secondary)` so the dark scope +flows through. If they already use variables, no change. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd apps/website && npx vitest run --config vite.config.mts src/components/landing/FinalCTA.spec.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 6: Homepage opts in** + +In `apps/website/src/app/page.tsx` change `<FinalCTA />` to +`<FinalCTA variant="dark" />`. + +- [ ] **Step 7: Run the whole suite (pricing spec renders FinalCTA — must stay green)** + +Run: `cd apps/website && npx vitest run --config vite.config.mts` +Expected: PASS, including `src/app/pricing/page.spec.tsx`. + +- [ ] **Step 8: Commit** + +```bash +git add apps/website/src/components/landing/FinalCTA.tsx apps/website/src/components/landing/FinalCTA.spec.tsx apps/website/src/styles/landing.css apps/website/src/app/page.tsx +git commit -m "feat(website): dark FinalCTA variant, homepage-only" +``` + +--- + +### Task 8: Shrink `HomeFAQ` to non-binary questions + +Three current items become Yes-wall rows and leave the FAQ (spec, scope +section): "Does it work with my existing Angular app?", "Can I use this +without LangGraph?", "How do I test agent-driven components?". Seven remain. + +**Files:** +- Modify: `apps/website/src/components/landing/HomeFAQ.tsx` + +- [ ] **Step 1: Remove the three items** + +In `apps/website/src/components/landing/HomeFAQ.tsx`, delete the three full +`{ q: ..., a: ... }` entries whose `q` values are exactly: +- `'Does it work with my existing Angular app?'` +- `'Can I use this without LangGraph?'` +- `'How do I test agent-driven components?'` + +Leave the other seven untouched (AG-UI comparison, adapter choice, +Pilot-to-Prod, cost, production-ready, issues, SSR). + +- [ ] **Step 2: Verify nothing asserts the removed strings** + +Run: `grep -rn 'existing Angular app\|without LangGraph\|test agent-driven' apps/website/src apps/website/e2e` +Expected: no spec/e2e matches (only the component before your edit). If a +match appears in a spec, update that spec to drop the assertion. + +- [ ] **Step 3: Run the suite** + +Run: `cd apps/website && npx vitest run --config vite.config.mts` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/components/landing/HomeFAQ.tsx +git commit -m "refactor(website): shrink HomeFAQ to non-binary questions" +``` + +--- + +### Task 9: Full verification gate + +**Files:** none created — this task is evidence-gathering. + +- [ ] **Step 1: Full unit suite** + +Run: `cd apps/website && npx vitest run --config vite.config.mts` +Expected: PASS, 0 failures. Paste the summary line into the task report. + +- [ ] **Step 2: Production build** + +Run from repo root: `npx nx build website --configuration=production` +Expected: success. Prod-only failures (type strictness, bundle budgets) live +here — dev green does not count. + +- [ ] **Step 3: Visual pass in the browser preview** + +Start the site's dev server via the Browser pane (launch.json / preview +tools — never Bash) and check, at desktop AND ~375px width: + +1. Hero: marker sweeps visible behind exactly two phrases (if invisible, the + `isolation: isolate` rule was dropped); chips wrap cleanly. +2. Yes wall: dark band with visible top seam; watermark bleeding right but + causing NO horizontal scrollbar (if it does, the parent `Section` needs + `overflow: hidden` — add `overflow: hidden` to the + `[data-ui="section"][data-surface="dark"]` rule); italic "Yes" drumbeat + aligned; API column right-aligned at desktop, stacked at mobile. +3. Proof strip: three elevated cards; the live HVTrust badge loads (external + image — if the browser console shows it blocked, check `next.config` + image/CSP settings and report rather than inlining a stale grade). +4. FinalCTA: dark on the homepage; **open `/pricing` and `/langgraph` and + confirm their FinalCTA is still tinted.** +5. Console: no hydration warnings or errors on `/`. + +- [ ] **Step 4: Screenshot evidence** + +Capture homepage screenshots (hero, Yes wall, proof strip, dark CTA) via the +browser preview and attach to the report. + +- [ ] **Step 5: Commit any fixes, then stop** + +Any fix found in step 3 gets its own small commit. Do NOT push, do NOT open a +PR — finishing the branch is a separate decision +(superpowers:finishing-a-development-branch). + +--- + +## Deviations that require stopping + +- If `trackCtaClick`'s signature differs from what Task 4 assumes, match the + house call shape — that is expected adaptation, not a deviation. +- If the HVTrust badge URL 404s, STOP and report — do not substitute a + hardcoded grade (spec rule: volatile figures must be live or absent). +- If any non-home page's FinalCTA renders dark, STOP — the prop default is + wrong. From b3414378d499d83f7c93bab89e1b80c51558c36d Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 18:53:02 -0700 Subject: [PATCH 05/18] feat(website): dark section surface with scoped token re-map --- .../src/components/ui/Section.spec.tsx | 18 ++++++++ apps/website/src/components/ui/Section.tsx | 2 +- apps/website/src/styles/ui.css | 44 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/website/src/components/ui/Section.spec.tsx diff --git a/apps/website/src/components/ui/Section.spec.tsx b/apps/website/src/components/ui/Section.spec.tsx new file mode 100644 index 000000000..99d0a08af --- /dev/null +++ b/apps/website/src/components/ui/Section.spec.tsx @@ -0,0 +1,18 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Section } from './Section'; + +describe('Section', () => { + it('renders data-surface="dark" when asked', () => { + const { container } = render(<Section surface="dark">x</Section>); + const el = container.querySelector('[data-ui="section"]'); + expect(el?.getAttribute('data-surface')).toBe('dark'); + }); + + it('defaults to canvas', () => { + const { container } = render(<Section>x</Section>); + expect( + container.querySelector('[data-ui="section"]')?.getAttribute('data-surface'), + ).toBe('canvas'); + }); +}); diff --git a/apps/website/src/components/ui/Section.tsx b/apps/website/src/components/ui/Section.tsx index 183bb26fe..8456115ac 100644 --- a/apps/website/src/components/ui/Section.tsx +++ b/apps/website/src/components/ui/Section.tsx @@ -1,7 +1,7 @@ import type { ReactNode, HTMLAttributes } from 'react'; import { cn } from '../../lib/cn'; -type Surface = 'canvas' | 'tinted' | 'white'; +type Surface = 'canvas' | 'tinted' | 'white' | 'dark'; interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, 'id'> { children: ReactNode; diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 567589206..4a21d192a 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -157,6 +157,50 @@ background: var(--color-surface); } +/* Dark section scope (homepage Yes wall + FinalCTA dark variant). + * Values mirror libs/design-tokens/src/lib/dark.ts — the website hardwires + * the light theme in TS, so dark sections re-scope the CSS variables instead. + * If dark.ts changes, update here. Treatment B (spec): the canvas is a subtle + * vertical gradient, and a 1px accent seam marks the light→dark boundary. */ +[data-ui="section"][data-surface="dark"] { + --color-canvas: rgb(17, 17, 17); + --color-surface: rgb(28, 28, 28); + --color-surface-tinted: rgb(44, 44, 44); + --color-surface-dim: rgb(10, 10, 10); + --color-border: rgb(45, 45, 45); + --color-border-strong: rgb(60, 60, 60); + --color-text-primary: rgb(245, 245, 245); + --color-text-secondary: rgb(200, 200, 200); + --color-text-muted: rgb(160, 160, 160); + --color-text-inverted: rgb(17, 17, 17); + --color-accent: #64c3fd; + --color-accent-hover: #8dd4ff; + --color-accent-glow: rgba(100, 195, 253, 0.25); + --color-accent-border: rgba(100, 195, 253, 0.2); + --color-accent-surface: rgba(100, 195, 253, 0.08); + + background: linear-gradient(180deg, #161616 0%, #0e0e0e 100%); + color: var(--color-text-primary); + position: relative; +} +/* 1px accent seam at the light→dark boundary. */ +[data-ui="section"][data-surface="dark"]::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + rgba(100, 195, 253, 0.55) 30%, + rgba(100, 195, 253, 0.55) 70%, + transparent + ); + pointer-events: none; +} + /* UI primitive — Button */ [data-ui="button"] { display: inline-flex; From 8b4a8a16e25a715c0fd71846fed7f59313704eca Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 18:56:07 -0700 Subject: [PATCH 06/18] fix(website): re-scope accent-border-hover in dark section --- apps/website/src/styles/ui.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 4a21d192a..00409e9f4 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -177,6 +177,7 @@ --color-accent-hover: #8dd4ff; --color-accent-glow: rgba(100, 195, 253, 0.25); --color-accent-border: rgba(100, 195, 253, 0.2); + --color-accent-border-hover: rgba(100, 195, 253, 0.35); --color-accent-surface: rgba(100, 195, 253, 0.08); background: linear-gradient(180deg, #161616 0%, #0e0e0e 100%); From d6fc67f5adc3e6a1fea978686f3f64671f2df829 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 18:58:50 -0700 Subject: [PATCH 07/18] feat(website): SectionHeader primitive with centered and rail variants Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../src/components/ui/SectionHeader.spec.tsx | 33 +++++++++++++++ .../src/components/ui/SectionHeader.tsx | 40 +++++++++++++++++++ apps/website/src/styles/ui.css | 38 ++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 apps/website/src/components/ui/SectionHeader.spec.tsx create mode 100644 apps/website/src/components/ui/SectionHeader.tsx diff --git a/apps/website/src/components/ui/SectionHeader.spec.tsx b/apps/website/src/components/ui/SectionHeader.spec.tsx new file mode 100644 index 000000000..b4fa02d04 --- /dev/null +++ b/apps/website/src/components/ui/SectionHeader.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { SectionHeader } from './SectionHeader'; + +describe('SectionHeader', () => { + it('renders eyebrow, heading with id, and aside in rail variant', () => { + const { container } = render( + <SectionHeader + variant="rail" + eyebrow="The Yes wall" + heading="Yes, it does that." + headingId="yes-wall-heading" + aside="Sixteen questions teams ask before they commit." + />, + ); + const root = container.querySelector('[data-ui="section-header"]'); + expect(root?.getAttribute('data-variant')).toBe('rail'); + expect(screen.getByText('The Yes wall')).toBeTruthy(); + const h2 = screen.getByRole('heading', { level: 2 }); + expect(h2.id).toBe('yes-wall-heading'); + expect(h2.textContent).toBe('Yes, it does that.'); + expect(screen.getByText(/Sixteen questions/)).toBeTruthy(); + }); + + it('defaults to centered variant with no aside', () => { + const { container } = render( + <SectionHeader eyebrow="Reliable" heading="Proof." />, + ); + expect( + container.querySelector('[data-ui="section-header"]')?.getAttribute('data-variant'), + ).toBe('centered'); + }); +}); diff --git a/apps/website/src/components/ui/SectionHeader.tsx b/apps/website/src/components/ui/SectionHeader.tsx new file mode 100644 index 000000000..22d0dbfa4 --- /dev/null +++ b/apps/website/src/components/ui/SectionHeader.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import { Eyebrow } from './Eyebrow'; + +type Variant = 'centered' | 'rail'; + +interface SectionHeaderProps { + eyebrow: string; + heading: ReactNode; + /** id for the h2, for Section's ariaLabelledBy. */ + headingId?: string; + /** Italic muted aside under the heading (rail variant). */ + aside?: ReactNode; + /** + * 'centered' — the classic kicker/H2 stack. + * 'rail' — left-rail editorial variant (treatment C): kicker over a 2px + * rule, heading below, italic aside. The PARENT owns the grid placement; + * this component only renders the header block. + */ + variant?: Variant; +} + +export function SectionHeader({ + eyebrow, + heading, + headingId, + aside, + variant = 'centered', +}: SectionHeaderProps) { + return ( + <header data-ui="section-header" data-variant={variant}> + <Eyebrow tone="accent" className="section-header-eyebrow"> + {eyebrow} + </Eyebrow> + <h2 id={headingId} className="section-header-heading"> + {heading} + </h2> + {aside ? <p className="section-header-aside">{aside}</p> : null} + </header> + ); +} diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 00409e9f4..386b7a25c 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -438,3 +438,41 @@ background: var(--color-accent); color: var(--color-text-inverted); } + +/* UI primitive — SectionHeader (centered + rail variants, treatment C). */ +[data-ui="section-header"] .section-header-eyebrow { + margin-bottom: 16px; +} +[data-ui="section-header"] .section-header-heading { + font-family: var(--font-garamond); + font-size: var(--text-h2); + line-height: var(--text-h2--line-height); + font-weight: 700; + letter-spacing: -0.015em; + color: var(--color-text-primary); + margin: 0; +} +[data-ui="section-header"] .section-header-aside { + font-family: var(--font-inter); + font-size: 15px; + line-height: 1.6; + font-style: italic; + color: var(--color-text-muted); + margin: 16px 0 0; +} +[data-ui="section-header"][data-variant="centered"] { + text-align: center; +} +[data-ui="section-header"][data-variant="rail"] { + text-align: left; +} +[data-ui="section-header"][data-variant="rail"] .section-header-eyebrow { + border-top: 2px solid var(--color-text-primary); + padding-top: 8px; + margin-bottom: 14px; +} +[data-ui="section-header"][data-variant="rail"] .section-header-heading { + font-size: clamp(32px, 3.4vw, 44px); + line-height: 1.1; + letter-spacing: -0.015em; +} From 03c5c364350c10a5178d6871c267b4690d337b3d Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:04:31 -0700 Subject: [PATCH 08/18] feat(website): problem-first hero subhead with capability chips and marker highlight --- .../src/components/landing/Hero.spec.tsx | 18 +++++--- apps/website/src/components/landing/Hero.tsx | 18 +++++++- apps/website/src/lib/positioning.ts | 12 ++++- apps/website/src/lib/site-metadata.spec.ts | 3 +- apps/website/src/styles/landing.css | 44 ++++++++++++++++++- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/website/src/components/landing/Hero.spec.tsx b/apps/website/src/components/landing/Hero.spec.tsx index 5cbdc4bfb..048a31e8e 100644 --- a/apps/website/src/components/landing/Hero.spec.tsx +++ b/apps/website/src/components/landing/Hero.spec.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { HERO_SUBHEAD, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; +import { HERO_CHIPS, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; const trackMock = vi.hoisted(() => vi.fn()); const writeTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); @@ -46,14 +46,22 @@ beforeEach(() => { }); describe('Hero', () => { - it('renders the locked H1 and subhead', async () => { + it('renders the locked H1, problem-first subhead, and capability chips', async () => { const { Hero } = await import('./Hero'); render(<Hero />); expect(screen.getByRole('heading', { level: 1 }).textContent) - .toMatch(/Ship production agent UIs in Angular\./); - expect(screen.getByText(HERO_SUBHEAD)).toBeTruthy(); + .toBe('Ship production agent UIs in Angular.'); + const subhead = document.querySelector('.hero-subhead'); + expect(subhead?.textContent).toContain('Everything after it takes six months.'); + expect(subhead?.textContent).toContain('keeps your backend exactly where it is'); + for (const chip of HERO_CHIPS) { + // 'LangGraph + AG-UI' appears both as a chip and as a proof pill label, + // so use getAllByText rather than getByText (which throws on multiple matches). + expect(screen.getAllByText(chip).length).toBeGreaterThanOrEqual(1); + } for (const proofPoint of POSITIONING_PROOF_POINTS) { - expect(screen.getByText(proofPoint.label)).toBeTruthy(); + // Same collision as above for the shared 'LangGraph + AG-UI' label. + expect(screen.getAllByText(proofPoint.label).length).toBeGreaterThanOrEqual(1); } }); diff --git a/apps/website/src/components/landing/Hero.tsx b/apps/website/src/components/landing/Hero.tsx index da769a751..c1a4bfbab 100644 --- a/apps/website/src/components/landing/Hero.tsx +++ b/apps/website/src/components/landing/Hero.tsx @@ -9,7 +9,7 @@ import { BrowserFrame } from '../ui/BrowserFrame'; import { Pill } from '../ui/Pill'; import { track } from '../../lib/analytics/client'; import { analyticsEvents } from '../../lib/analytics/events'; -import { HERO_SUBHEAD, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; +import { HERO_CHIPS, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; const INSTALL_COMMAND = 'npm install @threadplane/chat @threadplane/langgraph'; const COPY_FEEDBACK_MS = 1500; @@ -74,8 +74,22 @@ export function Hero() { Ship production agent UIs in Angular. </h1> <p className="hero-subhead"> - {HERO_SUBHEAD} + The streaming demo takes an afternoon.{' '} + <span className="marker-highlight"> + Everything after it takes six months. + </span>{' '} + Threadplane is the Angular layer that closes the gap — and it{' '} + <span className="marker-highlight"> + keeps your backend exactly where it is. + </span> </p> + <ul className="hero-chip-row" aria-label="Capabilities"> + {HERO_CHIPS.map((chip) => ( + <li key={chip} className="hero-chip"> + {chip} + </li> + ))} + </ul> <div className="hero-cta-row"> <PrimaryInstallButton /> <SecondaryTalkButton /> diff --git a/apps/website/src/lib/positioning.ts b/apps/website/src/lib/positioning.ts index 096fdf2e3..d89200159 100644 --- a/apps/website/src/lib/positioning.ts +++ b/apps/website/src/lib/positioning.ts @@ -2,7 +2,17 @@ export const PRIMARY_TAGLINE = 'Threadplane. Durable threads, interrupts, subagents, planning, memory, and generative UI.'; export const LONG_SUBHEAD = 'The fullstack agentic Angular framework for LangGraph and AG-UI-compatible agents: durable threads, interrupts, subagents, planning, memory, and generative UI using Vercel json-render and Google A2UI.'; -export const HERO_SUBHEAD = `Build fullstack agentic apps in Angular with: durable threads, interrupts, subagents, planning, memory, and generative UI using Vercel json-render and Google A2UI.`; +export const HERO_SUBHEAD = `The streaming demo takes an afternoon. Everything after it takes six months. Threadplane is the Angular layer that closes the gap — and it keeps your backend exactly where it is.`; + +/** Capability nouns, demoted from the subheadline to chips (spec change F). */ +export const HERO_CHIPS: readonly string[] = [ + 'durable threads', + 'interrupts', + 'subagents', + 'planning + memory', + 'generative UI', + 'LangGraph + AG-UI', +]; export interface PositioningProofPoint { readonly label: string; readonly href: string; diff --git a/apps/website/src/lib/site-metadata.spec.ts b/apps/website/src/lib/site-metadata.spec.ts index b7e0b7ab9..3bf7c7b6f 100644 --- a/apps/website/src/lib/site-metadata.spec.ts +++ b/apps/website/src/lib/site-metadata.spec.ts @@ -20,7 +20,8 @@ describe('site positioning copy', () => { expect(LONG_SUBHEAD).toContain('fullstack agentic Angular framework'); expect(LONG_SUBHEAD).toContain('LangGraph and AG-UI-compatible agents'); expect(LONG_SUBHEAD).toContain('Vercel json-render and Google A2UI'); - expect(HERO_SUBHEAD).toContain('durable threads, interrupts, subagents, planning, memory, and generative UI'); + expect(HERO_SUBHEAD).toContain('Everything after it takes six months.'); + expect(HERO_SUBHEAD).toContain('keeps your backend exactly where it is'); expect(POSITIONING_PROOF_POINTS.map((p) => p.label)).toEqual([ 'LangGraph + AG-UI', 'Durable threads', diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index d01497009..f4afdd25b 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -35,9 +35,51 @@ line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); margin: 0; - margin-bottom: 32px; + margin-bottom: 20px; + max-width: 54ch; +} + +/* Treatment A — marker highlight. isolation:isolate is load-bearing: without + * it the z-index:-1 sweep paints behind the section background and vanishes. */ +.marker-highlight { + position: relative; + isolation: isolate; + color: var(--color-text-primary); + font-weight: 600; +} +.marker-highlight::before { + content: ''; + position: absolute; + inset: -1px -5px -3px; + z-index: -1; + background: linear-gradient( + 100deg, + rgba(0, 64, 144, 0.14), + rgba(0, 64, 144, 0.08) 85% + ); + border-radius: 4px; + transform: skewX(-6deg) rotate(-0.4deg); +} + +.hero-chip-row { + display: flex; + flex-wrap: wrap; + gap: 7px; + list-style: none; + padding: 0; + margin: 0 0 28px; max-width: 54ch; } +.hero-chip { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + padding: 5px 11px; + white-space: nowrap; +} + .hero-cta-row { display: flex; gap: 12px; From be755ce44b2bad27d962431ae0e38fb2a6c78e2f Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:09:16 -0700 Subject: [PATCH 09/18] test(website): pin hero copy to HERO_SUBHEAD and scope chip assertions --- apps/website/src/components/landing/Hero.spec.tsx | 15 +++++++-------- apps/website/src/components/landing/Hero.tsx | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/website/src/components/landing/Hero.spec.tsx b/apps/website/src/components/landing/Hero.spec.tsx index 048a31e8e..05ab6708a 100644 --- a/apps/website/src/components/landing/Hero.spec.tsx +++ b/apps/website/src/components/landing/Hero.spec.tsx @@ -2,8 +2,8 @@ // @vitest-environment jsdom import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { HERO_CHIPS, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { HERO_CHIPS, HERO_SUBHEAD, POSITIONING_PROOF_POINTS } from '../../lib/positioning'; const trackMock = vi.hoisted(() => vi.fn()); const writeTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); @@ -52,15 +52,14 @@ describe('Hero', () => { expect(screen.getByRole('heading', { level: 1 }).textContent) .toBe('Ship production agent UIs in Angular.'); const subhead = document.querySelector('.hero-subhead'); - expect(subhead?.textContent).toContain('Everything after it takes six months.'); - expect(subhead?.textContent).toContain('keeps your backend exactly where it is'); + expect(subhead?.textContent).toBe(HERO_SUBHEAD); + const chipRow = screen.getByRole('list', { name: 'Capabilities' }); for (const chip of HERO_CHIPS) { - // 'LangGraph + AG-UI' appears both as a chip and as a proof pill label, - // so use getAllByText rather than getByText (which throws on multiple matches). - expect(screen.getAllByText(chip).length).toBeGreaterThanOrEqual(1); + expect(within(chipRow).getByText(chip)).toBeTruthy(); } for (const proofPoint of POSITIONING_PROOF_POINTS) { - // Same collision as above for the shared 'LangGraph + AG-UI' label. + // 'LangGraph + AG-UI' appears both as a chip and as a proof pill label, + // so use getAllByText rather than getByText (which throws on multiple matches). expect(screen.getAllByText(proofPoint.label).length).toBeGreaterThanOrEqual(1); } }); diff --git a/apps/website/src/components/landing/Hero.tsx b/apps/website/src/components/landing/Hero.tsx index c1a4bfbab..497155505 100644 --- a/apps/website/src/components/landing/Hero.tsx +++ b/apps/website/src/components/landing/Hero.tsx @@ -83,7 +83,7 @@ export function Hero() { keeps your backend exactly where it is. </span> </p> - <ul className="hero-chip-row" aria-label="Capabilities"> + <ul className="hero-chip-row" role="list" aria-label="Capabilities"> {HERO_CHIPS.map((chip) => ( <li key={chip} className="hero-chip"> {chip} From 0b1c7aa007f3a3a16a153d2887aee307a4560e92 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:11:32 -0700 Subject: [PATCH 10/18] =?UTF-8?q?feat(website):=20YesWall=20component=20?= =?UTF-8?q?=E2=80=94=2016=20questions,=204=20groups,=20dark=20specimen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/landing/YesWall.spec.tsx | 47 ++++++++ .../src/components/landing/YesWall.tsx | 112 ++++++++++++++++++ apps/website/src/lib/analytics/events.ts | 2 + 3 files changed, 161 insertions(+) create mode 100644 apps/website/src/components/landing/YesWall.spec.tsx create mode 100644 apps/website/src/components/landing/YesWall.tsx diff --git a/apps/website/src/components/landing/YesWall.spec.tsx b/apps/website/src/components/landing/YesWall.spec.tsx new file mode 100644 index 000000000..2ff1603d4 --- /dev/null +++ b/apps/website/src/components/landing/YesWall.spec.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { YesWall, YES_WALL_GROUPS } from './YesWall'; + +vi.mock('../../lib/analytics/client', () => ({ + trackCtaClick: vi.fn(), +})); + +describe('YesWall', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 16 questions across 4 groups', () => { + render(<YesWall />); + const total = YES_WALL_GROUPS.reduce((n, g) => n + g.rows.length, 0); + expect(YES_WALL_GROUPS).toHaveLength(4); + expect(total).toBe(16); + for (const group of YES_WALL_GROUPS) { + expect(screen.getByText(group.label)).toBeTruthy(); + for (const row of group.rows) { + expect(screen.getByText(row.question)).toBeTruthy(); + expect(screen.getByText(row.api)).toBeTruthy(); + } + } + }); + + it('answers every question Yes', () => { + render(<YesWall />); + expect(screen.getAllByText('Yes')).toHaveLength(16); + }); + + it('renders the dark specimen chrome', () => { + const { container } = render(<YesWall />); + expect( + container.querySelector('[data-ui="section"]')?.getAttribute('data-surface'), + ).toBe('dark'); + const mark = container.querySelector('.yes-wall-watermark'); + expect(mark?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('links the footer to the docs', () => { + render(<YesWall />); + const link = screen.getByRole('link', { name: /Every question answered/ }); + expect(link.getAttribute('href')).toBe('/docs'); + }); +}); diff --git a/apps/website/src/components/landing/YesWall.tsx b/apps/website/src/components/landing/YesWall.tsx new file mode 100644 index 000000000..cb18c10a1 --- /dev/null +++ b/apps/website/src/components/landing/YesWall.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { SectionHeader } from '../ui/SectionHeader'; +import { trackCtaClick } from '../../lib/analytics/client'; + +interface YesRow { + question: string; + api: string; +} + +interface YesGroup { + label: string; + rows: YesRow[]; +} + +export const YES_WALL_GROUPS: readonly YesGroup[] = [ + { + label: 'State that survives', + rows: [ + { question: 'Can a conversation survive a page reload?', api: 'threadId + durable transports' }, + { question: 'Can I resume a thread days later, on another device?', api: 'checkpointed threads' }, + { question: 'Can I branch or replay a conversation from any point?', api: 'branch / replay' }, + { question: 'Can I persist threads without building a persistence layer?', api: 'durable transports' }, + ], + }, + { + label: 'Humans in the loop', + rows: [ + { question: 'Can I stop the agent before it does something irreversible?', api: 'interrupt()' }, + { question: 'Can the pause survive a refresh while someone decides?', api: 'the pause is a checkpoint' }, + { question: 'Can I show the human what the agent is about to do?', api: '<chat-interrupt-panel>' }, + { question: "Can the human's decision land in the thread record?", api: 'submit({ resume })' }, + ], + }, + { + label: 'On my design system', + rows: [ + { question: 'Can agent output render as my components, not a chat widget?', api: '@threadplane/render' }, + { question: 'Can I fall back per-component when a spec is unknown?', api: 'fallback + readiness gate' }, + { question: 'Can the browser own its own tools and render them inline?', api: 'client tools' }, + ], + }, + { + label: 'Shipping it', + rows: [ + { question: 'Can I swap LangGraph for AG-UI without rewriting the UI?', api: 'one Agent contract' }, + { question: 'Can I unit-test components that depend on an agent?', api: 'provideFakeAgent' }, + { question: 'Can I run all of it inside my own VPC?', api: 'self-host, no runtime SaaS' }, + { question: 'Can I use every package commercially without a license fee?', api: 'MIT, all packages' }, + { question: 'Can I install it without phoning home?', api: 'installation is inert' }, + ], + }, +]; + +export function YesWall() { + return ( + <Section surface="dark" id="yes-wall" ariaLabelledBy="yes-wall-heading"> + <Container> + <div className="yes-wall"> + <div className="yes-wall-watermark" aria-hidden="true" data-watermark-text="Yes" /> + <div className="yes-wall-grid"> + <SectionHeader + variant="rail" + eyebrow="Every question below has the same answer" + heading="Yes, it does that." + headingId="yes-wall-heading" + aside="Sixteen questions teams ask before they commit — each linked to the API that answers it." + /> + <div className="yes-wall-body"> + {YES_WALL_GROUPS.map((group, index) => ( + <div className="yes-wall-group" key={group.label}> + <div className="yes-wall-group-numeral" aria-hidden="true"> + {String(index + 1).padStart(2, '0')} + </div> + <div className="yes-wall-group-rows"> + <p className="yes-wall-group-label">{group.label}</p> + {group.rows.map((row) => ( + <div className="yes-wall-row" key={row.question}> + <span className="yes-wall-yes">Yes</span> + <p className="yes-wall-question">{row.question}</p> + <p className="yes-wall-api">{row.api}</p> + </div> + ))} + </div> + </div> + ))} + <div className="yes-wall-footer"> + <a + className="yes-wall-footer-link" + href="/docs" + onClick={() => + trackCtaClick({ + surface: 'home', + destination_url: '/docs', + cta_id: 'home_yes_wall_docs', + cta_text: 'Every question answered, in the docs', + }) + } + > + Every question answered, in the docs → + </a> + <p className="yes-wall-footer-count">16 questions · 16 yeses</p> + </div> + </div> + </div> + </div> + </Container> + </Section> + ); +} diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts index 14d95832b..fc0ae8774 100644 --- a/apps/website/src/lib/analytics/events.ts +++ b/apps/website/src/lib/analytics/events.ts @@ -63,6 +63,8 @@ export type CtaId = | 'home_whitepaper_direct_inline' // Why this exists section | 'home_why_pilot_to_prod' + // Yes Wall section + | 'home_yes_wall_docs' // Pricing tier CTAs | 'pricing_tier_community' | 'pricing_tier_production_assurance' From 01a09419037553f6c3ad97b3741dd7447fb581bf Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:16:50 -0700 Subject: [PATCH 11/18] feat(website): replace Differentiator with the Yes wall --- apps/website/src/app/page.tsx | 6 +- .../landing/Differentiator.spec.tsx | 85 ------ .../src/components/landing/Differentiator.tsx | 146 --------- .../src/components/landing/YesWall.tsx | 8 +- apps/website/src/styles/landing.css | 284 +++++++++++------- 5 files changed, 186 insertions(+), 343 deletions(-) delete mode 100644 apps/website/src/components/landing/Differentiator.spec.tsx delete mode 100644 apps/website/src/components/landing/Differentiator.tsx diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 6e8b72a94..03fda1d56 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -1,6 +1,6 @@ import { Hero } from '../components/landing/Hero'; import { EcosystemStrip } from '../components/landing/EcosystemStrip'; -import { Differentiator } from '../components/landing/Differentiator'; +import { YesWall } from '../components/landing/YesWall'; import { FeatureBlock } from '../components/landing/FeatureBlock'; import { BrowserFrame } from '../components/ui/BrowserFrame'; import { ClipPlayer } from '../components/ui/ClipPlayer'; @@ -105,7 +105,7 @@ export default async function HomePage() { <> <Hero /> <EcosystemStrip /> - <Differentiator /> + <YesWall /> {/* Interactive demo showcase */} <Section surface="canvas"> @@ -183,7 +183,7 @@ export default async function HomePage() { /> {/* - The homepage claims human-in-the-loop in the Differentiator table and + The homepage claims human-in-the-loop in the Yes wall and mentions interrupts in the Stream block, but nothing here showed it. This is the only section whose heading the approval clip actually illustrates — the same rule the solutions pages follow. diff --git a/apps/website/src/components/landing/Differentiator.spec.tsx b/apps/website/src/components/landing/Differentiator.spec.tsx deleted file mode 100644 index 1c1e9dbc9..000000000 --- a/apps/website/src/components/landing/Differentiator.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: MIT -// @vitest-environment jsdom -import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { Differentiator } from './Differentiator'; - -vi.mock('../../lib/analytics/client', () => ({ - trackCtaClick: vi.fn(), - trackExternalLinkClick: vi.fn(), -})); - -vi.mock('../ui/Container', () => ({ - Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, -})); -vi.mock('../ui/Section', () => ({ - Section: ({ children }: { children: React.ReactNode }) => <section>{children}</section>, -})); -vi.mock('../ui/Eyebrow', () => ({ - Eyebrow: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, -})); - -import { trackCtaClick } from '../../lib/analytics/client'; - -const EXPECTED_NEEDS = [ - 'Durable threads', - 'Resumable interrupts', - 'Tool calls as events', - 'Streaming state as signals', - 'Generative UI on your design system', - 'Recoverable errors', - 'Backend portability', - 'Angular-native', - 'Observability hooks', - 'Open adapters + self-hosted', -]; - -describe('Differentiator', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders the section headline', () => { - render(<Differentiator />); - expect( - screen.getByRole('heading', { - level: 2, - name: 'Everything an Angular agent needs once the demo works.', - }), - ).toBeTruthy(); - }); - - it('renders all 10 production-readiness rows', () => { - render(<Differentiator />); - for (const need of EXPECTED_NEEDS) { - expect(screen.getByText(need)).toBeTruthy(); - } - // Guards silent row loss: without this, deleting a row from the component - // AND its line here would leave the suite green on a shrunken table. - expect(EXPECTED_NEEDS).toHaveLength(10); - }); - - it('renders the @threadplane/render primitive for the generative UI row', () => { - render(<Differentiator />); - expect(screen.getByText('@threadplane/render')).toBeTruthy(); - }); - - it('links the footer CTA to /pilot-to-prod', () => { - render(<Differentiator />); - const link = screen.getByRole('link', { name: /Pilot to Prod/ }); - expect(link.getAttribute('href')).toBe('/pilot-to-prod'); - }); - - it('fires the home_why_pilot_to_prod CTA event when the footer link is clicked', () => { - render(<Differentiator />); - const link = screen.getByRole('link', { name: /Pilot to Prod/ }); - fireEvent.click(link); - expect(trackCtaClick).toHaveBeenCalledWith({ - surface: 'home', - destination_url: '/pilot-to-prod', - cta_id: 'home_why_pilot_to_prod', - cta_text: 'Pilot to Prod', - }); - }); -}); diff --git a/apps/website/src/components/landing/Differentiator.tsx b/apps/website/src/components/landing/Differentiator.tsx deleted file mode 100644 index fc5c12b13..000000000 --- a/apps/website/src/components/landing/Differentiator.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import { tokens } from '@threadplane/design-tokens'; -import { Container } from '../ui/Container'; -import { Section } from '../ui/Section'; -import { Eyebrow } from '../ui/Eyebrow'; -import { trackCtaClick } from '../../lib/analytics/client'; - -interface ProductionRow { - need: string; - description: string; - primitive: string; -} - -const PRODUCTION_ROWS: ProductionRow[] = [ - { - need: 'Durable threads', - description: 'Persist across reloads, resume, branch, replay.', - primitive: 'threadId signal + durable transports', - }, - { - need: 'Resumable interrupts', - description: 'Human-in-the-loop pause, resume token, retry, cancel.', - primitive: 'interrupt(), resume()', - }, - { - need: 'Tool calls as events', - description: 'Stream progress, structured args, surfaced errors.', - primitive: 'tool events on injectAgent()', - }, - { - need: 'Streaming state as signals', - description: 'messages(), status(), error() — not promises.', - primitive: 'signal-native injectAgent()', - }, - { - need: 'Generative UI on your design system', - description: 'Vercel json-render + Google A2UI rendered into your Angular components.', - primitive: '@threadplane/render', - }, - { - need: 'Recoverable errors', - description: 'Retry, reload, error boundaries, fallback content.', - primitive: 'error(), reload()', - }, - { - need: 'Backend portability', - description: 'LangGraph today; AG-UI / Mastra / CrewAI / your own tomorrow — same UI.', - primitive: 'runtime adapters behind one contract', - }, - { - need: 'Angular-native', - description: 'DI, signals, RxJS interop — no React rewrite.', - primitive: 'built on Angular primitives, not ported', - }, - { - need: 'Observability hooks', - description: 'Tracing seams; app telemetry off by default.', - primitive: 'event hooks, opt-in only', - }, - { - need: 'Open adapters + self-hosted', - description: 'Own the primitives long-term, no vendor lock-in.', - primitive: 'MIT adapters, no runtime SaaS dependency', - }, -]; - -function CheckIcon() { - return ( - <svg - width="16" - height="16" - viewBox="0 0 16 16" - fill="none" - stroke={tokens.colors.accent} - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - aria-hidden="true" - className="differentiator-check-icon" - > - <path d="M3 8.5l3.5 3.5L13 5" /> - </svg> - ); -} - -export function Differentiator() { - return ( - <Section - surface="canvas" - ariaLabelledBy="differentiator-heading" - className="differentiator-section" - > - <Container> - <div className="differentiator-intro"> - <Eyebrow tone="accent" className="differentiator-eyebrow">Why this exists</Eyebrow> - <h2 id="differentiator-heading" className="differentiator-heading"> - Everything an Angular agent needs once the demo works. - </h2> - <p className="differentiator-subhead"> - A streaming chat tutorial takes an hour. Shipping a real agent — durable, interruptible, observable, on your design system — takes most teams six months. Threadplane gives the Angular surface that the rest of the stack assumes you've already built. - </p> - </div> - - <ul className="differentiator-list"> - {PRODUCTION_ROWS.map((row) => ( - <li key={row.need} className="why-row"> - <CheckIcon /> - <div className="why-row__body"> - <div className="why-row__text"> - <span className="why-row__need"> - {row.need} - </span> - <span className="why-row__description"> - {row.description} - </span> - </div> - <code className="why-row__primitive"> - {row.primitive} - </code> - </div> - </li> - ))} - </ul> - - <p className="differentiator-footer"> - Want help walking these on your codebase?{' '} - <a - href="/pilot-to-prod" - onClick={() => - trackCtaClick({ - surface: 'home', - destination_url: '/pilot-to-prod', - cta_id: 'home_why_pilot_to_prod', - cta_text: 'Pilot to Prod', - }) - } - className="differentiator-footer-link" - > - Pilot to Prod → - </a> - </p> - </Container> - </Section> - ); -} diff --git a/apps/website/src/components/landing/YesWall.tsx b/apps/website/src/components/landing/YesWall.tsx index cb18c10a1..a72d12c38 100644 --- a/apps/website/src/components/landing/YesWall.tsx +++ b/apps/website/src/components/landing/YesWall.tsx @@ -54,6 +54,8 @@ export const YES_WALL_GROUPS: readonly YesGroup[] = [ }, ]; +const TOTAL_QUESTIONS = YES_WALL_GROUPS.reduce((n, g) => n + g.rows.length, 0); + export function YesWall() { return ( <Section surface="dark" id="yes-wall" ariaLabelledBy="yes-wall-heading"> @@ -75,11 +77,11 @@ export function YesWall() { {String(index + 1).padStart(2, '0')} </div> <div className="yes-wall-group-rows"> - <p className="yes-wall-group-label">{group.label}</p> + <h3 className="yes-wall-group-label">{group.label}</h3> {group.rows.map((row) => ( <div className="yes-wall-row" key={row.question}> - <span className="yes-wall-yes">Yes</span> <p className="yes-wall-question">{row.question}</p> + <em className="yes-wall-yes">Yes</em> <p className="yes-wall-api">{row.api}</p> </div> ))} @@ -101,7 +103,7 @@ export function YesWall() { > Every question answered, in the docs → </a> - <p className="yes-wall-footer-count">16 questions · 16 yeses</p> + <p className="yes-wall-footer-count">{TOTAL_QUESTIONS} questions · {TOTAL_QUESTIONS} yeses</p> </div> </div> </div> diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index f4afdd25b..de95824d8 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -273,112 +273,6 @@ } } -/* Differentiator — components/landing/Differentiator.tsx */ -.differentiator-check-icon { - flex-shrink: 0; - margin-top: 4px; -} -.differentiator-section { - padding-top: 72px; -} -.differentiator-intro { - max-width: 1020px; - margin: 0 auto 44px; - text-align: center; -} -.differentiator-eyebrow { - margin-bottom: 16px; -} -.differentiator-heading { - font-family: var(--font-garamond); - font-size: var(--text-h2); - line-height: var(--text-h2--line-height); - font-weight: 700; - color: var(--color-text-primary); - margin: 0; - margin-bottom: 20px; - letter-spacing: -0.015em; -} -.differentiator-subhead { - font-family: var(--font-inter); - font-size: var(--text-body-lg); - line-height: var(--text-body-lg--line-height); - color: var(--color-text-secondary); - margin: 0 auto; - max-width: 760px; -} -.differentiator-list { - list-style: none; - padding: 0; - margin: 0 auto; - max-width: 980px; - border-top: 1px solid var(--color-border); -} -.why-row { - display: flex; - align-items: flex-start; - gap: 16px; - padding: 18px 8px; - border-bottom: 1px solid var(--color-border); -} -.why-row__body { - flex: 1; - /* min-width:auto refused to shrink past the nowrap primitive chip and - * pushed the row 52px past a 320px viewport (findings §2). */ - min-width: 0; - display: flex; - gap: 16px; - align-items: baseline; - flex-wrap: wrap; -} -.why-row__text { - flex: 1 1 200px; - min-width: 0; -} -.why-row__need { - font-family: var(--font-inter); - font-size: var(--text-body); - line-height: var(--text-body--line-height); - font-weight: 600; - color: var(--color-text-primary); - margin-right: 8px; -} -.why-row__description { - font-family: var(--font-inter); - font-size: var(--text-body); - line-height: var(--text-body--line-height); - color: var(--color-text-secondary); -} -.why-row__primitive { - font-family: var(--font-mono); - font-size: 13px; - color: var(--color-text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} -.differentiator-footer { - margin: 24px auto 0; - max-width: 980px; - text-align: center; - font-family: var(--font-inter); - font-size: var(--text-body); - line-height: var(--text-body--line-height); - color: var(--color-text-muted); -} -.differentiator-footer-link { - color: var(--color-accent); - text-decoration: none; - font-weight: 600; -} -@media (max-width: 640px) { - .why-row__body { - flex-direction: column !important; - gap: 6px !important; - } -} - /* WhitePaperBlock — components/landing/WhitePaperBlock.tsx */ .wp-grid { display: grid; @@ -1289,3 +1183,181 @@ font-weight: 700; color: var(--color-chat-purple); } + +/* ---------- Yes wall (dark specimen, spec change A + treatment B) ---------- */ +.yes-wall { + position: relative; +} +/* 330px italic Garamond watermark, gradient-filled, bleeding off the right. + * The div holds no text (screen readers + getAllByText stay clean); the glyph + * comes from the data attribute. */ +.yes-wall-watermark::after { + content: attr(data-watermark-text); +} +.yes-wall-watermark { + position: absolute; + right: -40px; + top: -20px; + font-family: var(--font-garamond); + font-style: italic; + font-weight: 800; + font-size: clamp(160px, 24vw, 330px); + line-height: 0.76; + letter-spacing: -0.04em; + background: linear-gradient( + 160deg, + rgba(100, 195, 253, 0.16), + rgba(100, 195, 253, 0.03) 70% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + pointer-events: none; + user-select: none; +} +/* Radial accent glow rising behind the watermark (treatment B). */ +.yes-wall::before { + content: ''; + position: absolute; + top: -160px; + right: -120px; + width: 640px; + height: 560px; + background: radial-gradient( + closest-side, + rgba(100, 195, 253, 0.13), + rgba(100, 195, 253, 0.04) 55%, + transparent 75% + ); + pointer-events: none; +} +.yes-wall-grid { + position: relative; + display: grid; + grid-template-columns: 300px 1fr; + gap: 64px; +} +.yes-wall-group { + display: grid; + grid-template-columns: 112px 1fr; +} +.yes-wall-group + .yes-wall-group { + margin-top: 34px; +} +.yes-wall-group-numeral { + font-family: var(--font-garamond); + font-size: 62px; + font-weight: 700; + line-height: 0.85; + letter-spacing: -0.03em; + color: var(--color-border-strong); + padding-top: 14px; +} +.yes-wall-group-rows { + border-top: 2px solid var(--color-text-primary); +} +.yes-wall-group-label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent); + margin: 0; + padding: 13px 0 5px; +} +/* The answer is SECOND in the DOM (question first, for screen-reader order) + * but placed visually first via grid areas. */ +.yes-wall-row { + display: grid; + grid-template-columns: 33px 1fr 250px; + grid-template-areas: 'yes question api'; + gap: 0 13px; + align-items: baseline; + border-top: 1px solid var(--color-border); + padding: 12px 0; + transition: background 140ms ease; +} +.yes-wall-row:hover { + background: var(--color-accent-surface); +} +.yes-wall-question { + grid-area: question; + font-family: var(--font-inter); + font-size: 16px; + line-height: 1.45; + color: var(--color-text-primary); + margin: 0; +} +.yes-wall-yes { + grid-area: yes; + font-family: var(--font-garamond); + font-style: italic; + font-weight: 700; + font-size: 20px; + color: var(--color-accent); + text-shadow: 0 0 16px var(--color-accent-glow); +} +.yes-wall-api { + grid-area: api; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; + color: var(--color-text-muted); + text-align: right; + margin: 0; +} +.yes-wall-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 34px; + padding-top: 20px; + border-top: 1px solid var(--color-border); +} +.yes-wall-footer-link { + font-family: var(--font-inter); + font-size: 15px; + font-weight: 500; + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 3px; +} +.yes-wall-footer-count { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-muted); + margin: 0; +} + +/* Responsive: the specimen devices are the fragile part. */ +@media (max-width: 900px) { + .yes-wall-grid { + grid-template-columns: 1fr; + gap: 40px; + } + .yes-wall-group { + grid-template-columns: 1fr; + } + .yes-wall-group-numeral { + font-size: 40px; + padding: 0 0 8px; + } + .yes-wall-watermark { + font-size: 140px; + right: -16px; + } + .yes-wall-row { + grid-template-columns: 33px 1fr; + grid-template-areas: + 'yes question' + '. api'; + gap: 4px 13px; + } + .yes-wall-api { + text-align: left; + } +} From 5b374a6dff721fd3aa6719e8141bbc76b8817a47 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:22:29 -0700 Subject: [PATCH 12/18] test(website): point e2e landing assertion at the Yes wall --- apps/website/e2e/website.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index de10a1748..12b9b1cee 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -7,11 +7,9 @@ test('landing page renders hero headline', async ({ page }) => { expect(headline?.toLowerCase()).toContain('angular'); }); -test('landing page renders differentiator section', async ({ page }) => { +test('landing page renders the Yes wall', async ({ page }) => { await page.goto('/'); - // Assert on the stable id, not the heading copy — landing-page text rewrites - // happen far more often than the section structure changes. - await expect(page.locator('#differentiator-heading')).toBeVisible(); + await expect(page.locator('#yes-wall-heading')).toBeVisible(); }); test('landing page renders feature blocks (Stream/Render/Ship)', async ({ page }) => { From 6e790207b9069991be936fe90ece90a8cd3ed931 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:24:45 -0700 Subject: [PATCH 13/18] =?UTF-8?q?feat(website):=20proof=20strip=20?= =?UTF-8?q?=E2=80=94=20three=20verified=20cells=20with=20live=20HVTrust=20?= =?UTF-8?q?badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/website/src/app/page.tsx | 2 + .../components/landing/ProofStrip.spec.tsx | 21 +++++ .../src/components/landing/ProofStrip.tsx | 92 +++++++++++++++++++ apps/website/src/styles/landing.css | 90 ++++++++++++++++++ apps/website/src/styles/ui.css | 5 + 5 files changed, 210 insertions(+) create mode 100644 apps/website/src/components/landing/ProofStrip.spec.tsx create mode 100644 apps/website/src/components/landing/ProofStrip.tsx diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 03fda1d56..1656395c2 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -11,6 +11,7 @@ import { HighlightedCode } from '../components/landing/HighlightedCode'; import { SECTION_MEDIA } from '../lib/section-media'; import type { SectionMedia } from '../lib/section-media'; import { PilotBlock } from '../components/landing/PilotBlock'; +import { ProofStrip } from '../components/landing/ProofStrip'; import { WhitePaperBlock } from '../components/landing/WhitePaperBlock'; import { Promises } from '../components/landing/Promises'; import { HomeFAQ } from '../components/landing/HomeFAQ'; @@ -220,6 +221,7 @@ export default async function HomePage() { <PilotBlock /> <WhitePaperBlock /> <Promises /> + <ProofStrip /> <HomeFAQ /> <FinalCTA /> <RecentArticles /> diff --git a/apps/website/src/components/landing/ProofStrip.spec.tsx b/apps/website/src/components/landing/ProofStrip.spec.tsx new file mode 100644 index 000000000..0af66da1e --- /dev/null +++ b/apps/website/src/components/landing/ProofStrip.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { ProofStrip, PROOF_CELLS } from './ProofStrip'; + +describe('ProofStrip', () => { + it('renders three cells, each with a source link', () => { + render(<ProofStrip />); + expect(PROOF_CELLS).toHaveLength(3); + for (const cell of PROOF_CELLS) { + expect(screen.getByText(cell.caption)).toBeTruthy(); + const link = screen.getByRole('link', { name: cell.sourceLabel }); + expect(link.getAttribute('href')).toBe(cell.sourceHref); + } + }); + + it('renders the HVTrust grade as a live badge image, not text', () => { + render(<ProofStrip />); + const badge = screen.getByAltText(/HVTrust grade/i); + expect(badge.getAttribute('src')).toBe('https://hvtracker.net/badge/threadplane.svg'); + }); +}); diff --git a/apps/website/src/components/landing/ProofStrip.tsx b/apps/website/src/components/landing/ProofStrip.tsx new file mode 100644 index 000000000..8ed52dbb4 --- /dev/null +++ b/apps/website/src/components/landing/ProofStrip.tsx @@ -0,0 +1,92 @@ +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { SectionHeader } from '../ui/SectionHeader'; + +interface ProofCell { + /** Big Garamond numeral, or null when the cell renders a live badge. */ + value: string | null; + /** Small suffix set beside the numeral (e.g. "of 119"). */ + suffix?: string; + caption: string; + sourceLabel: string; + sourceHref: string; +} + +/** + * Verified 2026-08-27 against live sources (see the homepage design spec, + * "Verification results"). The rank and score drift over time — re-verify on + * touch, and never "round up". The HVTrust grade is deliberately a LIVE badge: + * it sits at 81.2 against an A-band floor of 80 and has flipped grade six + * times in one month; a hardcoded letter would be wrong on some days. + */ +export const PROOF_CELLS: readonly ProofCell[] = [ + { + value: '#13', + suffix: 'of 119', + caption: 'Of all agent frameworks ranked', + sourceLabel: 'hvtracker.net', + sourceHref: 'https://hvtracker.net/categories/agent-frameworks/', + }, + { + value: '8.1', + suffix: '/10', + caption: 'OpenSSF Scorecard, official API', + sourceLabel: 'securityscorecards.dev', + sourceHref: + 'https://api.securityscorecards.dev/projects/github.com/cacheplane/angular-agent-framework', + }, + { + value: null, + caption: 'HVTrust supply-chain grade, live', + sourceLabel: 'hvtracker.net/agents/threadplane', + sourceHref: 'https://hvtracker.net/agents/threadplane/', + }, +]; + +export function ProofStrip() { + return ( + <Section surface="tinted" tight id="proof" ariaLabelledBy="proof-heading"> + <Container> + <div className="proof-strip-grid"> + <SectionHeader + variant="rail" + eyebrow="Reliable to the core" + heading="Audited, scored, published." + headingId="proof-heading" + aside="Not self-reported — every number links to its source." + /> + <ul className="proof-strip-cells" role="list"> + {PROOF_CELLS.map((cell) => ( + <li className="proof-strip-cell" key={cell.caption}> + {cell.value ? ( + <p className="proof-strip-value"> + {cell.value} + {cell.suffix ? ( + <span className="proof-strip-suffix"> {cell.suffix}</span> + ) : null} + </p> + ) : ( + <img + className="proof-strip-badge" + src="https://hvtracker.net/badge/threadplane.svg" + alt="HVTrust grade for Threadplane (live badge)" + loading="lazy" + /> + )} + <p className="proof-strip-caption">{cell.caption}</p> + <a + className="proof-strip-source" + href={cell.sourceHref} + target="_blank" + rel="noopener noreferrer" + > + {cell.sourceLabel} + </a> + </li> + ))} + </ul> + </div> + </Container> + </Section> + ); +} diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index de95824d8..fa8ef722d 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1361,3 +1361,93 @@ text-align: left; } } + +/* ---------- Proof strip (light, paper elevation — spec change D) ---------- */ +.proof-strip-grid { + display: grid; + grid-template-columns: 300px 1fr; + gap: 64px; + align-items: start; +} +.proof-strip-cells { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; + list-style: none; + margin: 0; + padding: 0; +} +.proof-strip-cell { + position: relative; + background: var(--color-surface); + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.9), + rgba(251, 251, 251, 0.4) + ); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 26px 24px 22px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.04), + 0 12px 28px -12px rgba(28, 28, 28, 0.14); +} +/* 1px top highlight so the card reads as paper, not an outline. */ +.proof-strip-cell::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.9), + transparent + ); +} +.proof-strip-value { + font-family: var(--font-garamond); + font-size: 58px; + font-weight: 700; + line-height: 0.9; + letter-spacing: -0.028em; + color: var(--color-text-primary); + margin: 0; +} +.proof-strip-suffix { + font-size: 18px; + font-weight: 500; + letter-spacing: 0; + color: var(--color-text-muted); +} +.proof-strip-badge { + height: 52px; + width: auto; + display: block; +} +.proof-strip-caption { + font-family: var(--font-inter); + font-size: 14px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 12px 0 0; +} +.proof-strip-source { + display: inline-block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-top: 9px; +} +@media (max-width: 900px) { + .proof-strip-grid { + grid-template-columns: 1fr; + gap: 32px; + } + .proof-strip-cells { + grid-template-columns: 1fr; + } +} diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 386b7a25c..252283c94 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -153,6 +153,11 @@ [data-ui="section"][data-surface="tinted"] { background: var(--color-surface-tinted); } +/* Treatment D: the proof strip's tinted band is a whisper of gradient. Scoped + * to #proof so other tinted sections keep the flat token. */ +#proof[data-ui="section"][data-surface="tinted"] { + background: linear-gradient(180deg, #fdfdfd, #f6f6f6); +} [data-ui="section"][data-surface="white"] { background: var(--color-surface); } From f2359c76eaef1cd7ed6e782e39119790a82d327e Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:28:13 -0700 Subject: [PATCH 14/18] fix(website): reserve badge aspect ratio and trim referrer --- apps/website/src/components/landing/ProofStrip.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/website/src/components/landing/ProofStrip.tsx b/apps/website/src/components/landing/ProofStrip.tsx index 8ed52dbb4..2705c0dc7 100644 --- a/apps/website/src/components/landing/ProofStrip.tsx +++ b/apps/website/src/components/landing/ProofStrip.tsx @@ -70,7 +70,10 @@ export function ProofStrip() { className="proof-strip-badge" src="https://hvtracker.net/badge/threadplane.svg" alt="HVTrust grade for Threadplane (live badge)" + width={91} + height={20} loading="lazy" + referrerPolicy="no-referrer" /> )} <p className="proof-strip-caption">{cell.caption}</p> From 8a2b585bb5a67206be1bb14337dd76c09b05fdd1 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:29:48 -0700 Subject: [PATCH 15/18] feat(website): dark FinalCTA variant, homepage-only --- apps/website/src/app/page.tsx | 2 +- .../src/components/landing/FinalCTA.spec.tsx | 26 +++++++++++++++++++ .../src/components/landing/FinalCTA.tsx | 17 +++++++++++- apps/website/src/styles/landing.css | 26 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 apps/website/src/components/landing/FinalCTA.spec.tsx diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 1656395c2..6857d2f81 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -223,7 +223,7 @@ export default async function HomePage() { <Promises /> <ProofStrip /> <HomeFAQ /> - <FinalCTA /> + <FinalCTA variant="dark" /> <RecentArticles /> </> ); diff --git a/apps/website/src/components/landing/FinalCTA.spec.tsx b/apps/website/src/components/landing/FinalCTA.spec.tsx new file mode 100644 index 000000000..b8d0175f1 --- /dev/null +++ b/apps/website/src/components/landing/FinalCTA.spec.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import { render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { FinalCTA } from './FinalCTA'; + +vi.mock('../../lib/analytics/client', () => ({ + track: vi.fn(), + trackCtaClick: vi.fn(), +})); + +describe('FinalCTA', () => { + it('defaults to the tinted surface (used by 8 non-home pages)', () => { + const { container } = render(<FinalCTA />); + expect( + container.querySelector('[data-ui="section"]')?.getAttribute('data-surface'), + ).toBe('tinted'); + }); + + it('renders the dark surface when variant="dark"', () => { + const { container } = render(<FinalCTA variant="dark" />); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('data-surface')).toBe('dark'); + expect(container.querySelector('.final-cta-mark')).toBeTruthy(); + }); +}); diff --git a/apps/website/src/components/landing/FinalCTA.tsx b/apps/website/src/components/landing/FinalCTA.tsx index 82c1ee318..5eaac9710 100644 --- a/apps/website/src/components/landing/FinalCTA.tsx +++ b/apps/website/src/components/landing/FinalCTA.tsx @@ -14,6 +14,12 @@ interface FinalCTAProps { secondary?: { label: string; href: string; external?: boolean } | null; /** Optional trailing caption. Defaults to licensing and telemetry line. Pass null to hide. */ caption?: string | null; + /** + * 'dark' renders on the dark band — homepage only, pairing with the Yes + * wall so the inverted treatment appears twice (spec: a lone dark band + * reads as arbitrary). All other pages keep the default tinted surface. + */ + variant?: 'default' | 'dark'; } const DEFAULT_SECONDARY = { label: 'See each feature in action →', href: 'https://cockpit.threadplane.ai', external: true }; @@ -24,11 +30,20 @@ export function FinalCTA({ primary = null, secondary = DEFAULT_SECONDARY, caption = 'All packages are MIT · Production support available · App telemetry off by default', + variant = 'default', }: FinalCTAProps = {}) { return ( - <Section surface="tinted" ariaLabelledBy="final-cta-heading"> + <Section + surface={variant === 'dark' ? 'dark' : 'tinted'} + ariaLabelledBy="final-cta-heading" + > <Container> <div className="final-cta-inner"> + {variant === 'dark' ? ( + <div className="final-cta-mark" aria-hidden="true"> + → + </div> + ) : null} <h2 id="final-cta-heading" className="final-cta-heading"> {headline} </h2> diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index fa8ef722d..0532131c5 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -772,6 +772,32 @@ margin: 0; } +/* FinalCTA dark variant — the Yes wall's partner section. */ +[data-surface="dark"] .final-cta-inner { + position: relative; +} +.final-cta-mark { + position: absolute; + right: -30px; + bottom: -70px; + font-family: var(--font-garamond); + font-style: italic; + font-weight: 800; + font-size: 210px; + line-height: 1; + letter-spacing: -0.04em; + background: linear-gradient( + 160deg, + rgba(100, 195, 253, 0.14), + rgba(100, 195, 253, 0.03) 70% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + pointer-events: none; + user-select: none; +} + /* HomeFAQ — components/landing/HomeFAQ.tsx */ .home-faq-intro { text-align: center; From 66513ab64bd62c7eb838a94b2e1b19fb68fae240 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:34:46 -0700 Subject: [PATCH 16/18] fix(website): clip dark FinalCTA bleed, correct spec mock, size mark for mobile --- apps/website/src/components/landing/FinalCTA.spec.tsx | 2 ++ apps/website/src/components/landing/FinalCTA.tsx | 1 + apps/website/src/styles/landing.css | 8 ++++++++ 3 files changed, 11 insertions(+) diff --git a/apps/website/src/components/landing/FinalCTA.spec.tsx b/apps/website/src/components/landing/FinalCTA.spec.tsx index b8d0175f1..03443a5c7 100644 --- a/apps/website/src/components/landing/FinalCTA.spec.tsx +++ b/apps/website/src/components/landing/FinalCTA.spec.tsx @@ -7,6 +7,7 @@ import { FinalCTA } from './FinalCTA'; vi.mock('../../lib/analytics/client', () => ({ track: vi.fn(), trackCtaClick: vi.fn(), + trackExternalLinkClick: vi.fn(), })); describe('FinalCTA', () => { @@ -22,5 +23,6 @@ describe('FinalCTA', () => { const section = container.querySelector('[data-ui="section"]'); expect(section?.getAttribute('data-surface')).toBe('dark'); expect(container.querySelector('.final-cta-mark')).toBeTruthy(); + expect(container.querySelector('.final-cta-mark')?.getAttribute('aria-hidden')).toBe('true'); }); }); diff --git a/apps/website/src/components/landing/FinalCTA.tsx b/apps/website/src/components/landing/FinalCTA.tsx index 5eaac9710..8fb1f1d09 100644 --- a/apps/website/src/components/landing/FinalCTA.tsx +++ b/apps/website/src/components/landing/FinalCTA.tsx @@ -35,6 +35,7 @@ export function FinalCTA({ return ( <Section surface={variant === 'dark' ? 'dark' : 'tinted'} + className={variant === 'dark' ? 'final-cta-dark' : undefined} ariaLabelledBy="final-cta-heading" > <Container> diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 0532131c5..b9fadff5c 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -797,6 +797,14 @@ pointer-events: none; user-select: none; } +.final-cta-dark { + overflow: clip; +} +@media (max-width: 640px) { + .final-cta-mark { + font-size: 140px; + } +} /* HomeFAQ — components/landing/HomeFAQ.tsx */ .home-faq-intro { From ca1f59dbb9ea73be848cb00d90517c15e08d70fd Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:37:12 -0700 Subject: [PATCH 17/18] refactor(website): shrink HomeFAQ to non-binary questions --- apps/website/src/components/landing/HomeFAQ.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/website/src/components/landing/HomeFAQ.tsx b/apps/website/src/components/landing/HomeFAQ.tsx index 85d9a1942..44611d7a1 100644 --- a/apps/website/src/components/landing/HomeFAQ.tsx +++ b/apps/website/src/components/landing/HomeFAQ.tsx @@ -8,14 +8,6 @@ const ITEMS: FAQItem[] = [ q: 'How is this different from using AG-UI directly?', a: 'AG-UI is a protocol rather than a complete Angular UI layer. Threadplane gives Angular teams the production surface around compatible runtimes: headless chat, durable threads, interrupts, subagents, planning, memory, generative UI, and runtime adapters.', }, - { - q: 'Does it work with my existing Angular app?', - a: 'Yes. Drop provideAgent (from @threadplane/langgraph or @threadplane/ag-ui) into your app.config.ts. The headless primitives don’t impose any UI; the chat compositions are opt-in.', - }, - { - q: 'Can I use this without LangGraph?', - a: 'Yes. Use the @threadplane/ag-ui adapter for any AG-UI compliant backend, or implement the agent contract yourself. Threadplane keeps the Angular UI layer stable while the backend agent runtime can change.', - }, { q: 'Which adapter should I use — @threadplane/langgraph or @threadplane/ag-ui?', a: 'If your backend is LangGraph Platform, use @threadplane/langgraph. If your backend speaks the AG-UI protocol (CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, or AWS Strands), use @threadplane/ag-ui. Both expose the same provideAgent/injectAgent API — see /docs/choosing-an-adapter for a side-by-side comparison.', @@ -40,10 +32,6 @@ const ITEMS: FAQItem[] = [ q: 'Does it work with Angular Universal / SSR?', a: 'Streaming is client-side by design — agents are stateful and signal-based. If your shell is SSR’d, the agent-talking parts stay client-only; render fallbacks during hydration via standard Angular SSR patterns.', }, - { - q: 'How do I test agent-driven components?', - a: 'The agent is provided through Angular DI, so test doubles work the way you’re used to — supply a stub agent in your test module, drive it with signals, assert on the rendered output. See /docs/langgraph/guides/testing.', - }, ]; export function HomeFAQ() { From 3c9d6bca56cc139aff3807a4078e5d37179e16bf Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Sun, 30 Aug 2026 19:48:18 -0700 Subject: [PATCH 18/18] fix(website): true up Yes wall aside, analytics track, and glow containment --- apps/website/src/components/landing/YesWall.tsx | 3 ++- apps/website/src/styles/landing.css | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/website/src/components/landing/YesWall.tsx b/apps/website/src/components/landing/YesWall.tsx index a72d12c38..a1a6a6e1e 100644 --- a/apps/website/src/components/landing/YesWall.tsx +++ b/apps/website/src/components/landing/YesWall.tsx @@ -68,7 +68,7 @@ export function YesWall() { eyebrow="Every question below has the same answer" heading="Yes, it does that." headingId="yes-wall-heading" - aside="Sixteen questions teams ask before they commit — each linked to the API that answers it." + aside="Sixteen questions teams ask before they commit — each paired with the API that answers it." /> <div className="yes-wall-body"> {YES_WALL_GROUPS.map((group, index) => ( @@ -98,6 +98,7 @@ export function YesWall() { destination_url: '/docs', cta_id: 'home_yes_wall_docs', cta_text: 'Every question answered, in the docs', + track: 'developer', }) } > diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index b9fadff5c..b2fcbb74c 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1219,6 +1219,10 @@ } /* ---------- Yes wall (dark specimen, spec change A + treatment B) ---------- */ +/* Match FinalCTA's containment: the glow and watermark stay inside the band. */ +#yes-wall { + overflow: clip; +} .yes-wall { position: relative; }