diff --git a/apps/website/e2e/home-architecture.spec.ts b/apps/website/e2e/home-architecture.spec.ts new file mode 100644 index 000000000..c0e269258 --- /dev/null +++ b/apps/website/e2e/home-architecture.spec.ts @@ -0,0 +1,104 @@ +import { test, expect, type Page } from '@playwright/test'; +import { CARDS, diagramHrefs } from '../src/lib/architecture-diagram'; + +const DIAGRAM = '[data-diagram="enterprise-architecture"]'; + +/** + * Measures every rendered text run and chip in the diagram against the card + * that owns it, in viewBox units (getBBox reports user space, so the numbers + * compare directly with lib/architecture-diagram.ts). The unit spec proves + * the boxes are on the grid; this proves the type set inside them fits. + */ +async function overflowReport(page: Page) { + return page.evaluate((sel) => { + const issues: string[] = []; + for (const a of document.querySelectorAll( + `${sel} [data-card]` + )) { + const g = a.querySelector('[data-card-rect]'); + if (!g) continue; + const cx = +g.dataset['x']!; + const cy = +g.dataset['y']!; + const cw = +g.dataset['w']!; + const ch = +g.dataset['h']!; + for (const t of a.querySelectorAll('text')) { + const b = t.getBBox(); + if ( + b.x < cx + 6 || + b.x + b.width > cx + cw - 6 || + b.y < cy + 4 || + b.y + b.height > cy + ch - 4 + ) { + issues.push( + `${a.dataset['card']}: "${t.textContent?.slice( + 0, + 40 + )}" ${Math.round(b.x)}..${Math.round(b.x + b.width)} vs ${cx}..${ + cx + cw + }` + ); + } + } + for (const r of a.querySelectorAll('[data-chip] rect')) { + const b = r.getBBox(); + if (b.x + b.width > cx + cw - 6 || b.y + b.height > cy + ch - 8) { + issues.push(`${a.dataset['card']}: chip past the card edge`); + } + } + } + const images = [ + ...document.querySelectorAll(`${sel} image`), + ].map((i) => i.getBBox().width); + return { issues, images }; + }, DIAGRAM); +} + +test.describe('homepage architecture', () => { + test('replaces the scope table, links every card to its docs page, and keeps every text run inside its card', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await expect(page.locator('#architecture-heading')).toHaveText( + 'The UI layer between your users and your agents.' + ); + await expect(page.locator('#why-heading')).toHaveCount(0); + const cards = page.locator(`${DIAGRAM} [data-card]`); + await expect(cards).toHaveCount(CARDS.length); + for (const c of CARDS) { + const card = page.locator(`${DIAGRAM} [data-card="${c.id}"]`); + const link = + (await card.evaluate((el) => el.tagName.toLowerCase())) === 'a' + ? card + : card.locator('a.arch-title-link'); + await expect(link).toHaveAttribute('href', c.href); + } + const hrefs = await page + .locator(`${DIAGRAM} a[href]`) + .evaluateAll((els) => els.map((e) => e.getAttribute('href'))); + for (const href of diagramHrefs()) expect(hrefs).toContain(href); + + // Fonts must be loaded before measuring, or a fallback face lies about widths. + await page.evaluate(() => document.fonts.ready); + const report = await overflowReport(page); + expect(report.issues, report.issues.join('\n')).toEqual([]); + expect(report.images.length).toBeGreaterThanOrEqual(15); + for (const w of report.images) expect(w).toBeGreaterThan(0); + }); + + test('stacks the same cards on a phone instead of scrolling the drawing sideways', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + const stack = page.locator(`${DIAGRAM} [data-arch-stack]`); + await stack.scrollIntoViewIfNeeded(); + await expect(stack).toBeVisible(); + await expect(page.locator(`${DIAGRAM} .tp-diagram-figure`)).toBeHidden(); + await expect(stack.locator('a.arch-stack-card')).toHaveCount(CARDS.length); + const wide = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + ); + expect(wide, 'no horizontal page scroll on a phone').toBe(false); + }); +}); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 728694006..2580117c2 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -50,7 +50,7 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p const ids = [ 'hero-heading', 'proof-heading', - 'why-heading', + 'architecture-heading', 'stage-heading', 'final-cta-heading', 'pilot-heading', diff --git a/apps/website/public/logos/README.md b/apps/website/public/logos/README.md index d7febc6dc..1090d9c5d 100644 --- a/apps/website/public/logos/README.md +++ b/apps/website/public/logos/README.md @@ -28,6 +28,7 @@ These were downloaded from `https://cdn.simpleicons.org//111827` for a com - `runtimes/crewai.svg` from slug `crewai`. - `runtimes/pydantic.svg` from slug `pydantic`. +- `langchain.svg` from slug `langchain`, used for the LangSmith card on the homepage architecture diagram (Simple Icons has no LangSmith slug; LangSmith is a LangChain product in the same mark family). - `surface/angular.svg` from slug `angular`. - `surface/reactivex.svg` from slug `reactivex`. - `surface/vercel.svg` from slug `vercel`. diff --git a/apps/website/public/logos/langchain.svg b/apps/website/public/logos/langchain.svg new file mode 100644 index 000000000..df09c90d7 --- /dev/null +++ b/apps/website/public/logos/langchain.svg @@ -0,0 +1 @@ +LangChain \ No newline at end of file diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index d85b5e4c1..5d6c39df1 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 { Reliability } from '../components/landing/Reliability'; -import { ScopeTable } from '../components/landing/ScopeTable'; +import { EnterpriseArchitecture } from '../components/landing/EnterpriseArchitecture'; import { Stage } from '../components/landing/Stage'; import { TeamsBlock } from '../components/landing/TeamsBlock'; import { HomeFAQ } from '../components/landing/HomeFAQ'; @@ -34,7 +34,7 @@ export default function HomePage() { <> - + {/* The four capability beats (stream, persist, approve, render): stills by default, the pinned live act on wide, motion-tolerant viewports diff --git a/apps/website/src/components/landing/EnterpriseArchitecture.spec.tsx b/apps/website/src/components/landing/EnterpriseArchitecture.spec.tsx new file mode 100644 index 000000000..25c1b580d --- /dev/null +++ b/apps/website/src/components/landing/EnterpriseArchitecture.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + EnterpriseArchitecture, + ARCHITECTURE_HEADLINE, +} from './EnterpriseArchitecture'; +import { + CARDS, + COLUMNS, + MODEL_STRIP, + diagramHrefs, +} from '../../lib/architecture-diagram'; + +describe('EnterpriseArchitecture', () => { + it('renders the section with its heading id, every column label, card, and link', () => { + render(); + expect(document.querySelector('section#architecture')).not.toBeNull(); + expect(document.querySelector('#architecture-heading')?.textContent).toBe( + ARCHITECTURE_HEADLINE + ); + expect(document.querySelectorAll('[data-column]')).toHaveLength( + COLUMNS.length + ); + expect(document.querySelectorAll('[data-card]')).toHaveLength(CARDS.length); + const hrefs = new Set( + [...document.querySelectorAll('a[href]')].map((a) => + a.getAttribute('href') + ) + ); + for (const href of diagramHrefs()) expect(hrefs.has(href), href).toBe(true); + }); + + it('highlights Threadplane and the first-class LangGraph lane, and nests no anchors', () => { + render(); + expect( + document.querySelector('[data-card="threadplane"] .arch-card--tp') + ).not.toBeNull(); + expect( + document.querySelector('[data-card="langgraph-sdk"] .arch-card--tp') + ).not.toBeNull(); + expect( + document.querySelector('[data-card="ag-ui"] .arch-card--tp') + ).toBeNull(); + expect(document.querySelector('a a')).toBeNull(); + // The Threadplane card's title is the link; its capabilities are links of their own. + expect( + document.querySelector('[data-card="threadplane"] a.arch-title-link') + ).not.toBeNull(); + expect( + document.querySelectorAll('[data-card="threadplane"] a.arch-cap') + ).toHaveLength(5); + }); + + it('draws every card rect at the data module coordinates and the model strip chips', () => { + render(); + for (const c of CARDS) { + const rect = document.querySelector( + `[data-card="${c.id}"] rect.arch-card` + ); + expect(rect?.getAttribute('x')).toBe(String(c.x)); + expect(rect?.getAttribute('y')).toBe(String(c.y)); + expect(rect?.getAttribute('width')).toBe(String(c.width)); + expect(rect?.getAttribute('height')).toBe(String(c.height)); + } + expect( + document.querySelectorAll('[data-model-strip] [data-chip]') + ).toHaveLength(MODEL_STRIP.chips.length); + }); + + it('shows the alignment grid only when asked', () => { + render(); + expect(document.querySelector('[data-alignment-grid]')).toBeNull(); + document.body.innerHTML = ''; + render(); + expect(document.querySelector('[data-alignment-grid]')).not.toBeNull(); + }); +}); diff --git a/apps/website/src/components/landing/EnterpriseArchitecture.tsx b/apps/website/src/components/landing/EnterpriseArchitecture.tsx new file mode 100644 index 000000000..1a623d811 --- /dev/null +++ b/apps/website/src/components/landing/EnterpriseArchitecture.tsx @@ -0,0 +1,638 @@ +import type { ReactNode } from 'react'; +import { DiagramSection } from './DiagramSection'; +import { DiagramFrame } from '../docs/diagrams'; +import { + ARROWS, + CARDS, + CARD_PAD, + COLUMNS, + COLUMN_LABEL_Y, + CONTRACT_CAPTION, + GRID, + LOGOS, + MAJOR, + MODEL_STRIP, + STRIP_CHIP_H, + STRIP_GAP, + TITLE_DY, + VIEW, + stripChipWidth, + type Card, + type IconKey, + type LogoKey, +} from '../../lib/architecture-diagram'; + +export const ARCHITECTURE_EYEBROW = 'Architecture'; +export const ARCHITECTURE_HEADLINE = + 'The UI layer between your users and your agents.'; +export const ARCHITECTURE_BODY = + 'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI. Everything on the right is yours.'; +export const ARCHITECTURE_LABEL = + 'Threadplane is the UI layer between your users and your agents: it lives inside your Angular application, reaches LangGraph agents first-class through the LangGraph SDK and any AG-UI server through the AG-UI protocol, and leaves the model choice to your runtime.'; + +const SLUG = 'enterprise-architecture'; + +/** Line icons (24-unit paths) for the roles that have no mark. */ +const ICONS: Readonly> = { + users: + 'M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8a4 4 0 1 0 0 8M23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8', + chat: 'M21 12a8 8 0 0 1-8 8H8l-5 3 1-5A8 8 0 1 1 21 12z', + pause: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M10 9v6m4-6v6', + branch: + 'M6 2a2 2 0 1 0 0 4a2 2 0 1 0 0-4M6 18a2 2 0 1 0 0 4a2 2 0 1 0 0-4M18 7a2 2 0 1 0 0 4a2 2 0 1 0 0-4M6 6v12M18 11a6 6 0 0 1-6 6h-1', + sparkles: 'M12 3l2 5 5 2-5 2-2 5-2-5-5-2 5-2z', + wrench: 'M14.7 6.3a4 4 0 0 0 5 5L13 18l-2 2-4-4 2-2 6.7-7.7zM3 21l4-4', +}; + +function Icon({ + name, + x, + y, + size, + bg, + fg, +}: { + name: IconKey; + x: number; + y: number; + size: number; + bg: string; + fg: string; +}) { + const s = (size - 8) / 24; + return ( + + + + + ); +} + +function Mark({ + mark, + x, + y, + size, +}: { + mark: LogoKey; + x: number; + y: number; + size: number; +}) { + return ( + + ); +} + +/** A white rounded tile holding a mark. */ +function MarkBadge({ + mark, + x, + y, + size, +}: { + mark: LogoKey; + x: number; + y: number; + size: number; +}) { + return ( + + + + + ); +} + +function CardRows({ card }: { card: Card }) { + return ( + <> + {card.rows.map((row, i) => { + switch (row.kind) { + case 'text': + return ( + + {row.text} + + ); + case 'mono': + return ( + + {row.text} + + ); + case 'badge': + return ( + + + + {row.label} + + + ); + case 'items': + return ( + + {row.items.map((item, j) => { + const y = row.y + j * row.step; + return ( + + + + + {item} + + + ); + })} + + ); + case 'marks': + return ( + + {row.marks.map((m, j) => ( + + ))} + + ); + case 'caps': + return ( + + {row.caps.map((cap, j) => { + const y = row.y + j * 40; + return ( + + + + {cap.label} + + + ); + })} + + ); + } + })} + + ); +} + +function CardView({ card }: { card: Card }) { + const hasInnerLinks = card.rows.some((r) => r.kind === 'caps'); + const titleX = card.x + CARD_PAD + (card.mark ? 52 : 0); + const titleY = card.y + TITLE_DY; + const title = ( + + {card.highlight && !card.mark ? '\u{1F6E9}️ ' : ''} + {card.title} + + ); + const body = ( + + + {card.id === 'threadplane' ? ( + <> + + + YOUR ANGULAR APP + + + ) : null} + {card.icon ? ( + + ) : null} + {card.mark ? ( + + ) : null} + {hasInnerLinks ? ( + // A card whose rows carry their own links cannot itself be a link + // (anchors do not nest), so its title is the link instead. + + + {'\u{1F6E9}️ '} + {card.title} + + + ) : card.icon ? ( + + {card.title} + + ) : ( + title + )} + {card.title2 ? ( + + {card.title2} + + ) : null} + {card.tag ? ( + + {card.tag} + + ) : null} + + + ); + return hasInnerLinks ? ( + {body} + ) : ( + + {body} + + ); +} + +function ModelStrip() { + let x: number = MODEL_STRIP.x; + const chips: ReactNode[] = []; + for (const chip of MODEL_STRIP.chips) { + const w = stripChipWidth(chip.label); + chips.push( + + + + + {chip.label} + + + ); + x += w + STRIP_GAP; + } + return ( + + + {MODEL_STRIP.label} + + {chips} + + {MODEL_STRIP.caption} + + + ); +} + +const STACK_ORDER = [ + 'users', + 'threadplane', + 'langgraph-sdk', + 'ag-ui', + 'langsmith', + 'ag-ui-servers', +] as const; + +/** + * The phone form of the diagram: the same cards, in reading order, as an + * HTML stack. Shown under 768px by CSS; the SVG is hidden there. + */ +function ArchitectureStack() { + const byId = new Map(CARDS.map((c) => [c.id, c])); + return ( +
+ {STACK_ORDER.map((id, i) => { + const c = byId.get(id)!; + const col = COLUMNS.find((col) => col.x === c.x); + const caps = c.rows.find((r) => r.kind === 'caps'); + const marks = c.rows.find((r) => r.kind === 'marks'); + const texts = c.rows.filter((r) => r.kind === 'text'); + const items = c.rows.find((r) => r.kind === 'items'); + const mono = c.rows.find((r) => r.kind === 'mono'); + const badges = c.rows.filter((r) => r.kind === 'badge'); + return ( + + ); + })} +

{MODEL_STRIP.label}

+
+
+ {MODEL_STRIP.chips.map((chip) => ( + {chip.label} + ))} +
+
    +
  • {MODEL_STRIP.chips.map((c) => c.label).join(' · ')}
  • +
  • {MODEL_STRIP.caption}
  • +
+
+
+ ); +} + +function AlignmentGrid() { + const lines: ReactNode[] = []; + for (let x = 0; x <= VIEW.width; x += GRID) { + lines.push( + + ); + } + for (let y = 0; y <= VIEW.height; y += GRID) { + lines.push( + + ); + } + return ( + + {lines} + + ); +} + +interface Props { + /** Review aid: overlays the 8px / 40px alignment grid the geometry is authored on. */ + grid?: boolean; +} + +/** + * The homepage architecture section (spec 2026-09-07): your users, your + * Angular application with Threadplane as its UI layer, the two adapters, + * your agents, and the model strip. Geometry comes from + * `lib/architecture-diagram.ts`, which the unit spec and the e2e read too. + */ +export function EnterpriseArchitecture({ grid = false }: Props) { + return ( + +
+ + + + + + + + + + + + + + + + + + + {COLUMNS.map((c) => ( + + {c.label} + + ))} + {CARDS.map((c) => ( + + ))} + {ARROWS.map((a) => ( + + ))} + + {CONTRACT_CAPTION.lines[0]} + + + {CONTRACT_CAPTION.lines[1]} + + + {grid ? : null} + + +
+
+ ); +} diff --git a/apps/website/src/components/landing/ScopeTable.spec.tsx b/apps/website/src/components/landing/ScopeTable.spec.tsx deleted file mode 100644 index b84de63b8..000000000 --- a/apps/website/src/components/landing/ScopeTable.spec.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// apps/website/src/components/landing/ScopeTable.spec.tsx -// @vitest-environment jsdom -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { ScopeTable } from './ScopeTable'; -import { FINAL_MILE_ASIDE, FINAL_MILE_EYEBROW, FINAL_MILE_HEADING } from '../../lib/positioning'; - -describe('ScopeTable as the final-mile section', () => { - it('leads with the last-mile line and keeps the table and its anchor', () => { - const { container } = render(); - expect(screen.getByRole('heading', { name: FINAL_MILE_HEADING }).id).toBe('why-heading'); - expect(screen.getByText(FINAL_MILE_ASIDE)).toBeTruthy(); - expect(screen.getByText(FINAL_MILE_EYEBROW)).toBeTruthy(); - expect(container.querySelector('[data-ui="section"]')?.getAttribute('id')).toBe('why'); - expect(screen.getAllByRole('row')).toHaveLength(5); - }); -}); diff --git a/apps/website/src/components/landing/ScopeTable.tsx b/apps/website/src/components/landing/ScopeTable.tsx deleted file mode 100644 index dacc0780f..000000000 --- a/apps/website/src/components/landing/ScopeTable.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Container } from '../ui/Container'; -import { Section } from '../ui/Section'; -import { SectionHeader } from '../ui/SectionHeader'; -import { FINAL_MILE_ASIDE, FINAL_MILE_EYEBROW, FINAL_MILE_HEADING } from '../../lib/positioning'; - -const ROWS = [ - { - start: 'Raw SSE or stream SDK', - gives: 'Transport and events', - adds: 'Angular state model, chat UX, threads, approvals, generated UI, recovery, tests', - }, - { - start: 'Backend agent framework', - gives: 'Agent runtime and orchestration', - adds: 'The production Angular application and interaction layer', - }, - { - start: 'Generative-UI renderer', - gives: 'Structured UI rendering', - adds: 'Full agent UI, adapters, thread UX, interrupts, testing, and render support', - }, - { - start: 'React-first agent UI', - gives: 'Mature React patterns', - adds: 'Native Angular Signals, DI, templates, components, and testing', - }, -]; - -export function ScopeTable() { - return ( -
- - -
- - - - - - - - - - {ROWS.map((row) => ( - - - - - - ))} - -
Starting pointWhat it gives youWhat Threadplane adds
{row.start}{row.gives}{row.adds}
-
-
-
- ); -} diff --git a/apps/website/src/lib/architecture-diagram.spec.ts b/apps/website/src/lib/architecture-diagram.spec.ts new file mode 100644 index 000000000..2f702c6a7 --- /dev/null +++ b/apps/website/src/lib/architecture-diagram.spec.ts @@ -0,0 +1,214 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + ARROWS, + STRIP_CHIP_H, + CARDS, + CARD_GAP, + CARD_PAD, + COLUMNS, + GRID, + LOGOS, + MODEL_STRIP, + STRIP_GAP, + VIEW, + diagramHrefs, + stripChipWidth, +} from './architecture-diagram'; + +const WEBSITE = resolve(__dirname, '../..'); +const onGrid = (n: number) => n % GRID === 0; +const card = (id: string) => CARDS.find((c) => c.id === id)!; + +describe('architecture diagram geometry', () => { + it('puts every card on the 8px grid, inside the view with a 40px margin', () => { + for (const c of CARDS) { + for (const [k, v] of Object.entries({ + x: c.x, + y: c.y, + width: c.width, + height: c.height, + })) { + expect(onGrid(v), `${c.id}.${k} = ${v}`).toBe(true); + } + expect(c.x, `${c.id} left`).toBeGreaterThanOrEqual(40); + expect(c.x + c.width, `${c.id} right`).toBeLessThanOrEqual( + VIEW.width - 40 + ); + expect(c.y, `${c.id} top`).toBeGreaterThanOrEqual(40); + expect(c.y + c.height, `${c.id} bottom`).toBeLessThanOrEqual( + VIEW.height - 40 + ); + } + expect(onGrid(VIEW.width) && onGrid(VIEW.height)).toBe(true); + }); + + it('never overlaps two cards, and separates vertical neighbours by at least the card gap', () => { + const overlap = (a: (typeof CARDS)[number], b: (typeof CARDS)[number]) => + a.x < b.x + b.width && + b.x < a.x + a.width && + a.y < b.y + b.height && + b.y < a.y + a.height; + for (const a of CARDS) + for (const b of CARDS) + if (a !== b) expect(overlap(a, b), `${a.id} vs ${b.id}`).toBe(false); + for (const a of CARDS) { + const below = CARDS.filter((b) => b.x === a.x && b.y > a.y).sort( + (p, q) => p.y - q.y + )[0]; + if (below) + expect(below.y - (a.y + a.height), `${a.id} ↓ ${below.id}`).toBe( + CARD_GAP + ); + } + }); + + it('levels the columns: users and Threadplane span the adapter stack exactly', () => { + const top = card('langgraph-sdk'); + const bottom = card('ag-ui'); + for (const id of ['users', 'threadplane']) { + expect(card(id).y).toBe(top.y); + expect(card(id).y + card(id).height).toBe(bottom.y + bottom.height); + } + expect(card('langsmith').y).toBe(top.y); + expect(card('ag-ui-servers').y).toBe(bottom.y); + }); + + it('lands every arrow on the vertical centre of the card it enters, at one length', () => { + const lengths = new Set(); + for (const a of ARROWS) { + const from = CARDS.find( + (c) => c.x + c.width === a.x1 && a.y > c.y && a.y < c.y + c.height + ); + const to = CARDS.find((c) => c.x === a.x2); + expect(from, `arrow at ${a.x1} leaves a card`).toBeDefined(); + expect(to, `arrow at ${a.x2} enters a card`).toBeDefined(); + const enters = CARDS.filter( + (c) => c.x === a.x2 && a.y > c.y && a.y < c.y + c.height + )[0]; + expect(a.y, `arrow into ${enters?.id}`).toBe( + enters!.y + enters!.height / 2 + ); + lengths.add(a.x2 - a.x1); + } + expect([...lengths], 'every arrow is the same length').toHaveLength(1); + }); + + it('spaces the columns evenly and centres the drawing in the view', () => { + const xs = [...new Set(CARDS.map((c) => c.x))].sort((a, b) => a - b); + const gaps = xs.slice(1).map((x, i) => { + const right = Math.max( + ...CARDS.filter((c) => c.x === xs[i]).map((c) => c.x + c.width) + ); + return x - right; + }); + expect([...new Set(gaps)], `column gaps ${gaps}`).toHaveLength(1); + expect(onGrid(gaps[0])).toBe(true); + const left = xs[0]; + const right = VIEW.width - Math.max(...CARDS.map((c) => c.x + c.width)); + expect(right, 'left and right margins match').toBe(left); + expect( + VIEW.height - (MODEL_STRIP.chipY + STRIP_CHIP_H), + 'bottom margin matches the sides' + ).toBe(left); + expect(MODEL_STRIP.x, 'the strip starts at the first column').toBe(left); + }); + + it('aligns the rows of side-by-side cards', () => { + const firstItemY = (id: string) => { + const r = card(id).rows.find((row) => row.kind === 'items'); + return r && r.kind === 'items' ? r.y : null; + }; + expect(firstItemY('langsmith')).toBe(firstItemY('langgraph-sdk')); + expect(firstItemY('ag-ui-servers')).toBe(firstItemY('ag-ui')); + // Both rows of the stack share their tops and bottoms across the columns. + for (const [a, b] of [ + ['langgraph-sdk', 'langsmith'], + ['ag-ui', 'ag-ui-servers'], + ] as const) { + expect(card(b).y).toBe(card(a).y); + expect(card(b).height).toBe(card(a).height); + } + }); + + it("keeps the column labels at their column's left edge", () => { + for (const col of COLUMNS) { + expect( + CARDS.some((c) => c.x === col.x), + col.label + ).toBe(true); + } + }); + + it('fits the model strip inside the view', () => { + let x: number = MODEL_STRIP.x; + for (const chip of MODEL_STRIP.chips) + x += stripChipWidth(chip.label) + STRIP_GAP; + // The caption follows the last chip; leave it room. + expect(x + 8 + MODEL_STRIP.caption.length * 7).toBeLessThanOrEqual( + VIEW.width - 40 + ); + expect(MODEL_STRIP.chipY + 36).toBeLessThanOrEqual(VIEW.height - 24); + }); + + it('keeps every text row inside its card vertically', () => { + for (const c of CARDS) { + for (const r of c.rows) { + if (r.kind === 'text' || r.kind === 'mono') { + expect(r.y, `${c.id} row ${r.text}`).toBeGreaterThan(c.y + 40); + expect(r.y, `${c.id} row ${r.text}`).toBeLessThanOrEqual( + c.y + c.height - 12 + ); + } + if (r.kind === 'items') { + const last = r.y + (r.items.length - 1) * r.step; + expect(r.y - 18, `${c.id} items top`).toBeGreaterThan(c.y + 40); + expect(last + 8, `${c.id} items bottom`).toBeLessThanOrEqual( + c.y + c.height - 8 + ); + } + if (r.kind === 'caps') { + expect(r.y + r.caps.length * 40).toBeLessThanOrEqual( + c.y + c.height - 24 + ); + } + if (r.kind === 'marks') { + expect( + c.x + CARD_PAD + (r.marks.length - 1) * r.step + r.size + ).toBeLessThanOrEqual(c.x + c.width - CARD_PAD); + } + } + } + }); +}); + +describe('architecture diagram links and marks', () => { + it('links every card and capability to a docs page that exists', () => { + for (const href of diagramHrefs()) { + const mdx = resolve( + WEBSITE, + `content/docs${href.replace(/^\/docs/, '')}.mdx` + ); + const page = resolve(WEBSITE, `src/app${href}/page.tsx`); + expect(existsSync(mdx) || existsSync(page), `${href}`).toBe(true); + } + expect(diagramHrefs().length).toBeGreaterThanOrEqual(9); + }); + + it('uses only marks that exist under /logos', () => { + for (const [key, path] of Object.entries(LOGOS)) { + expect(existsSync(resolve(WEBSITE, `public${path}`)), key).toBe(true); + } + for (const c of CARDS) { + if (c.mark) expect(LOGOS[c.mark]).toBeDefined(); + for (const r of c.rows) { + if (r.kind === 'badge') expect(LOGOS[r.mark]).toBeDefined(); + if (r.kind === 'marks') + for (const m of r.marks) expect(LOGOS[m]).toBeDefined(); + } + } + for (const chip of MODEL_STRIP.chips) + expect(LOGOS[chip.mark]).toBeDefined(); + }); +}); diff --git a/apps/website/src/lib/architecture-diagram.ts b/apps/website/src/lib/architecture-diagram.ts new file mode 100644 index 000000000..846f5418a --- /dev/null +++ b/apps/website/src/lib/architecture-diagram.ts @@ -0,0 +1,346 @@ +/** + * Geometry and copy for the homepage architecture diagram + * (spec: docs/superpowers/specs/2026-09-07-enterprise-architecture-diagram-design.md). + * + * One table, three readers: the component draws it, the unit spec checks that + * every rectangle lands on the 8px grid inside the view without overlapping, + * and the e2e measures the rendered text against these same boxes. + * Coordinates are viewBox units; the SVG scales with its container. + * + * The story is four columns, left to right: your users, your Angular + * application with Threadplane as its UI layer, the two adapters, and your + * agents — with a strip of model providers beneath. Every card links to the + * docs page that backs its wording. Third-party products appear as examples + * of a role, never as integrations the library claims. + */ + +export const VIEW = { width: 1280, height: 640 } as const; +export const GRID = 8; +export const MAJOR = 40; +export const CARD_GAP = 40; +/** Left inset of card content. */ +export const CARD_PAD = 24; +/** Baseline of a card's title row, below the card's top. */ +export const TITLE_DY = 42; + +export type LogoKey = + | 'angular' + | 'vercel' + | 'google' + | 'langgraph' + | 'langchain' + | 'agui' + | 'bedrock' + | 'azure' + | 'microsoft' + | 'mastra' + | 'crewai' + | 'pydantic' + | 'openai' + | 'anthropic'; + +/** Public paths of the marks the diagram is allowed to use (README under /logos). */ +export const LOGOS: Readonly> = { + angular: '/logos/surface/angular.svg', + vercel: '/logos/surface/vercel.svg', + google: '/logos/providers/google.svg', + langgraph: '/logos/langgraph.svg', + langchain: '/logos/langchain.svg', + agui: '/logos/ag-ui.svg', + bedrock: '/logos/providers/bedrock.svg', + azure: '/logos/providers/azure.svg', + microsoft: '/logos/runtimes/microsoft.svg', + mastra: '/logos/runtimes/mastra.svg', + crewai: '/logos/runtimes/crewai.svg', + pydantic: '/logos/runtimes/pydantic.svg', + openai: '/logos/providers/openai.svg', + anthropic: '/logos/providers/anthropic.svg', +}; + +export type IconKey = + | 'users' + | 'chat' + | 'pause' + | 'branch' + | 'sparkles' + | 'wrench'; + +export interface Capability { + readonly icon: IconKey; + readonly label: string; + readonly href: string; +} + +export type Row = + | { readonly kind: 'text'; readonly y: number; readonly text: string } + | { readonly kind: 'mono'; readonly y: number; readonly text: string } + /** Capability badges stacked vertically from `y`, 40 apart, each its own link. */ + | { + readonly kind: 'caps'; + readonly y: number; + readonly caps: readonly Capability[]; + } + /** A mark badge with a label beside it, at an absolute x within the card. */ + | { + readonly kind: 'badge'; + readonly x: number; + readonly y: number; + readonly mark: LogoKey; + readonly label: string; + } + /** A banded list: one row per item, each with a left accent bar. */ + | { + readonly kind: 'items'; + readonly y: number; + readonly items: readonly string[]; + /** Vertical pitch between rows. */ + readonly step: number; + } + /** A row of mark badges with no labels. */ + | { + readonly kind: 'marks'; + readonly y: number; + readonly marks: readonly LogoKey[]; + readonly size: number; + readonly step: number; + }; + +export interface Card { + readonly id: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly title: string; + /** Second title line (the adapter cards break their names over two lines). */ + readonly title2?: string; + readonly href: string; + /** A mark badge beside the title (40px), or an icon badge. */ + readonly mark?: LogoKey; + readonly icon?: { + readonly name: IconKey; + readonly bg: string; + readonly fg: string; + }; + /** Pushes an icon card's icon and title down, to centre a short card's content. */ + readonly contentDy?: number; + /** Right-aligned tag on the card's first line. */ + readonly tag?: string; + readonly highlight?: boolean; + readonly rows: readonly Row[]; +} + +export interface ColumnLabel { + readonly x: number; + readonly label: string; +} + +export interface Arrow { + readonly x1: number; + readonly x2: number; + readonly y: number; +} + +export interface StripChip { + readonly mark: LogoKey; + readonly label: string; +} + +export const COLUMNS: readonly ColumnLabel[] = [ + { x: 48, label: 'YOUR USERS' }, + { x: 288, label: 'YOUR ANGULAR APPLICATION' }, + { x: 744, label: 'ADAPTERS' }, + { x: 1008, label: 'YOUR AGENTS' }, +]; +export const COLUMN_LABEL_Y = 72; + +export const CARDS: readonly Card[] = [ + { + id: 'users', + x: 48, + y: 104, + width: 176, + height: 392, + title: 'People', + href: '/docs/chat/getting-started/introduction', + icon: { name: 'users', bg: '#fff3e0', fg: '#c2410c' }, + // The column's single node: its block is centred on the card, where its + // outgoing arrow leaves. + contentDy: 104, + rows: [{ kind: 'text', y: 352, text: 'web · mobile' }], + }, + { + id: 'threadplane', + x: 288, + y: 104, + width: 392, + height: 392, + title: 'Threadplane', + href: '/docs/chat/getting-started/introduction', + tag: 'THE UI LAYER', + highlight: true, + rows: [ + { + kind: 'caps', + y: 224, + caps: [ + { icon: 'chat', label: 'Chat', href: '/docs/chat/components/chat' }, + { + icon: 'pause', + label: 'Approvals', + href: '/docs/langgraph/guides/interrupts', + }, + { + icon: 'branch', + label: 'Threads', + href: '/docs/langgraph/guides/persistence', + }, + { + icon: 'sparkles', + label: 'Generative UI', + href: '/docs/chat/guides/generative-ui', + }, + { + icon: 'wrench', + label: 'Client tools', + href: '/docs/chat/guides/client-tools', + }, + ], + }, + { kind: 'badge', x: 524, y: 224, mark: 'google', label: 'A2UI' }, + { kind: 'badge', x: 524, y: 272, mark: 'vercel', label: 'json-render' }, + { + kind: 'mono', + y: 470, + text: '@threadplane/chat', + }, + ], + }, + { + id: 'langgraph-sdk', + x: 744, + y: 104, + width: 200, + height: 208, + title: 'LangGraph', + title2: 'SDK', + href: '/docs/langgraph/getting-started/introduction', + mark: 'langgraph', + tag: 'FIRST-CLASS', + highlight: true, + rows: [ + { + kind: 'items', + y: 212, + step: 32, + items: [ + 'checkpoints · interrupts', + 'time travel · memory', + 'subgraphs · durable runs', + ], + }, + ], + }, + { + id: 'ag-ui', + x: 744, + y: 352, + width: 200, + height: 144, + title: 'AG-UI', + title2: 'protocol', + href: '/docs/ag-ui/getting-started/introduction', + mark: 'agui', + rows: [ + { kind: 'items', y: 468, step: 32, items: ['events · tools · state'] }, + ], + }, + { + id: 'langsmith', + x: 1008, + y: 104, + width: 224, + height: 208, + title: 'LangSmith', + href: '/docs/langgraph/guides/deployment', + mark: 'langchain', + rows: [ + { + kind: 'items', + y: 212, + step: 32, + items: ['deploy · observe', 'traces · evals', 'or self-hosted'], + }, + ], + }, + { + id: 'ag-ui-servers', + x: 1008, + y: 352, + width: 224, + height: 144, + title: 'AG-UI servers', + href: '/docs/runtimes/getting-started/introduction', + rows: [ + { + kind: 'marks', + y: 408, + marks: ['crewai', 'mastra', 'microsoft', 'bedrock', 'pydantic'], + size: 30, + step: 34, + }, + { + kind: 'items', + y: 468, + step: 32, + items: ['CrewAI · Mastra · Microsoft'], + }, + ], + }, +]; + +/** Each arrow lands on the vertical centre of the card it enters. */ +export const ARROWS: readonly Arrow[] = [ + { x1: 224, x2: 288, y: 300 }, + { x1: 680, x2: 744, y: 208 }, + { x1: 680, x2: 744, y: 424 }, + { x1: 944, x2: 1008, y: 208 }, + { x1: 944, x2: 1008, y: 424 }, +]; +/** The two-line caption between the adapter arrows. */ +export const CONTRACT_CAPTION = { + x: 712, + y: 306, + lines: ['one Agent', 'contract'], +} as const; + +export const MODEL_STRIP = { + label: 'ANY MODEL', + labelY: 524, + chipY: 556, + x: 48, + chips: [ + { mark: 'openai', label: 'OpenAI' }, + { mark: 'anthropic', label: 'Anthropic' }, + { mark: 'google', label: 'Google' }, + { mark: 'azure', label: 'Azure OpenAI' }, + { mark: 'bedrock', label: 'Amazon Bedrock' }, + ] as readonly StripChip[], + caption: 'your choice', +} as const; + +export const STRIP_CHIP_H = 36; +export const stripChipWidth = (label: string): number => + Math.round(label.length * 7.2) + 48; +export const STRIP_GAP = 12; + +/** Every docs href the diagram links to, deduplicated, for the link-resolution spec. */ +export function diagramHrefs(): readonly string[] { + const out = new Set(); + for (const c of CARDS) { + out.add(c.href); + for (const r of c.rows) + if (r.kind === 'caps') for (const cap of r.caps) out.add(cap.href); + } + return [...out]; +} diff --git a/apps/website/src/lib/positioning.spec.ts b/apps/website/src/lib/positioning.spec.ts index 8ad5cdbc1..ff6daad4b 100644 --- a/apps/website/src/lib/positioning.spec.ts +++ b/apps/website/src/lib/positioning.spec.ts @@ -155,12 +155,6 @@ describe('positioning: coding-agent prompt', () => { }); describe('homepage restructure copy (live-stage spec §3)', () => { - it('pins the final-mile eyebrow, heading and aside', async () => { - const { FINAL_MILE_EYEBROW, FINAL_MILE_HEADING, FINAL_MILE_ASIDE } = await import('./positioning'); - expect(FINAL_MILE_EYEBROW).toBe('Where Threadplane fits'); - expect(FINAL_MILE_HEADING).toBe('Angular teams are building agents. The last mile is still messy.'); - expect(FINAL_MILE_ASIDE).toBe('What you start with, and what Threadplane adds.'); - }); it('carries three reliability receipts, each linking a human-readable page', async () => { const { RELIABILITY_RECEIPTS } = await import('./positioning'); diff --git a/apps/website/src/lib/positioning.ts b/apps/website/src/lib/positioning.ts index 0e2535dae..be5b788c0 100644 --- a/apps/website/src/lib/positioning.ts +++ b/apps/website/src/lib/positioning.ts @@ -51,9 +51,6 @@ export function formatAngularRange(majors: readonly number[]): string { export const HERO_TRUST_LINE = `MIT · ${formatAngularRange(WEBSITE_SUPPORTED_ANGULAR_MAJORS)} · no account, no cloud`; // ── The final mile (live-stage spec §3, block 3) ───────────────────────────── -export const FINAL_MILE_EYEBROW = 'Where Threadplane fits'; -export const FINAL_MILE_HEADING = 'Angular teams are building agents. The last mile is still messy.'; -export const FINAL_MILE_ASIDE = 'What you start with, and what Threadplane adds.'; // ── Reliability receipts (spec §3, block 2). Each links a page a human can read; // the sourced numbers stay in Reliability.tsx beside them. ─────────────────── diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 3d730b2d8..9fed94440 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1676,6 +1676,275 @@ --diagram-scroll-bg: var(--color-surface-tinted); } +/* EnterpriseArchitecture — components/landing/EnterpriseArchitecture.tsx + * The homepage architecture diagram. Geometry lives in lib/architecture-diagram.ts; + * these rules are only the type ramp and the fills. Text sizes are viewBox + * units (the SVG is 1280 wide and scales with the figure). */ +.stack-diagram-section .arch-figure { + width: 100%; +} +.arch-figure .tp-diagram-svg { + min-width: 1024px; +} +.arch-figure text { + font-family: var(--font-inter); + font-size: 13.5px; + fill: var(--color-text-primary); +} +.arch-figure .arch-title { + font-size: 16px; + font-weight: 600; +} +.arch-figure .arch-title--lg { + font-size: 20px; +} +.arch-figure .arch-zone-label--app { + font-size: 10.5px; + fill: #2f5fa8; +} +.arch-figure .arch-caption--mid { + text-anchor: middle; + font-size: 12.5px; +} +.arch-figure .arch-body { + font-size: 13.5px; + fill: var(--color-text-secondary); +} +.arch-figure .arch-caption { + font-size: 13px; + font-style: italic; + fill: var(--color-text-secondary); +} +.arch-figure .arch-mono { + font-family: var(--font-mono); + font-size: 12px; + fill: #2a3a5c; +} +.arch-figure .arch-zone-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.14em; + fill: var(--color-text-secondary); +} +.arch-figure .arch-zone-owner { + font-size: 11px; + text-anchor: end; + fill: var(--color-text-muted, #8a8f98); +} +.arch-figure .arch-tag { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-anchor: end; + fill: #2f5fa8; +} +.arch-figure .arch-docs { + font-size: 10.5px; + text-anchor: end; + fill: #2f5fa8; + opacity: 0.75; +} +.arch-figure .arch-card-link:hover .arch-docs { + opacity: 1; +} +.arch-figure .arch-card-link:hover .arch-card { + stroke: #9db6e4; +} +.arch-figure .arch-card { + fill: url(#enterprise-architecture-card); + stroke: #dfe3ea; + stroke-width: 1; +} +.arch-figure .arch-card--tp { + fill: url(#enterprise-architecture-tp); + stroke: #9db6e4; + stroke-width: 1.2; +} +.arch-figure .arch-chip rect { + fill: #fff; + stroke: #e2e6ec; +} +.arch-figure .arch-chip[data-tone='tp'] rect { + fill: rgba(255, 255, 255, 0.7); + stroke: #c9d6ee; +} +.arch-figure .arch-chip text { + font-size: 13px; + font-weight: 500; + fill: #2a2f3a; +} +/* Adapter and agent capability rows: a banded list with a left accent, so + * the lines read as a feature list rather than loose paragraph text. */ +.arch-figure .arch-item-band { + fill: #f2f5f8; +} +.arch-figure .arch-item-bar { + fill: #c2cad6; +} +.arch-figure .arch-items[data-tone='tp'] .arch-item-band { + fill: rgba(255, 255, 255, 0.75); +} +.arch-figure .arch-items[data-tone='tp'] .arch-item-bar { + fill: #2f5fa8; +} +.arch-stack-items { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: grid; + gap: 6px; + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-text-secondary); +} +.arch-stack-items li { + background: #f2f5f8; + border-left: 3px solid #c2cad6; + border-radius: 8px; + padding: 6px 10px; +} +.arch-stack-card[data-highlight] .arch-stack-items li { + background: rgba(255, 255, 255, 0.75); + border-left-color: #2f5fa8; +} +.arch-figure .arch-badge rect { + fill: #fff; + stroke: #e2e6ec; +} +.arch-figure .arch-cap text { + font-size: 15px; + font-weight: 500; + fill: var(--color-text-primary); +} +.arch-figure .arch-cap:hover text { + fill: #2f5fa8; +} +.arch-figure .arch-arrow { + stroke: #7d8492; + stroke-width: 1.5; + fill: none; +} +.arch-figure .arch-grid-major { + stroke: #7fb0ff; + stroke-width: 1; + opacity: 0.6; +} +.arch-figure .arch-grid-minor { + stroke: #dbe7fb; + stroke-width: 0.5; + opacity: 0.6; +} +/* The dot ground stays, one step fainter than the docs kit's. */ +.arch-figure .tp-diagram-dot { + fill: #e3e7ed; +} +/* Phone form: the same cards as an HTML stack, driven by the same data. + * The SVG is hidden here instead of scrolled sideways. */ +.arch-stack { + display: none; +} +@media (max-width: 767px) { + .arch-figure .tp-diagram-figure { + display: none; + } + .arch-stack { + display: grid; + gap: 12px; + margin-top: 8px; + } +} +.arch-stack-label { + text-align: left; + font-family: var(--font-inter); + font-size: 11px; + letter-spacing: 0.14em; + font-weight: 700; + color: var(--color-text-secondary); + margin: 12px 0 0; +} +.arch-stack-card { + display: block; + text-align: left; + text-decoration: none; + color: inherit; + border: 1px solid #dfe3ea; + border-radius: 14px; + background: #fff; + padding: 16px; +} +.arch-stack-card[data-highlight] { + background: #f2f6fd; + border-color: #b7c8e6; +} +.arch-stack-head { + display: flex; + align-items: center; + gap: 12px; +} +.arch-stack-head img, +.arch-stack-marks img { + width: 20px; + height: 20px; +} +.arch-stack-title { + font-family: var(--font-inter); + font-size: 15px; + font-weight: 600; + margin: 0; +} +.arch-stack-tag { + margin-left: auto; + font-size: 10px; + letter-spacing: 0.12em; + font-weight: 700; + color: #2f5fa8; +} +.arch-stack-rows { + margin: 8px 0 0; + padding: 0; + list-style: none; + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-text-secondary); + display: grid; + gap: 4px; +} +.arch-stack-caps { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + margin: 10px 0 0; + padding: 0; + list-style: none; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 500; +} +.arch-stack-caps li { + background: #e4ecfa; + color: #2f5fa8; + border-radius: 999px; + padding: 4px 10px; +} +.arch-stack-marks { + display: flex; + gap: 8px; + margin-top: 10px; +} +.arch-stack-mono { + font-family: var(--font-mono); + font-size: 11.5px; + color: #2a3a5c; + margin: 10px 0 0; +} +.arch-stack-arrow { + text-align: center; + color: #7d8492; + font-size: 16px; + line-height: 1; +} + + /* HomeConceptGrid — components/landing/HomeConceptGrid.tsx */ .home-concept { display: flex; @@ -1902,44 +2171,3 @@ font-weight: 600; text-decoration: none; } - -/* ── Scope table (Task 13) ───────────────────────────────────────────────── */ -.scope-table-wrap { - overflow-x: auto; - margin-top: 20px; -} - -.scope-table { - width: 100%; - min-width: 640px; - border-collapse: collapse; - font-size: 14px; -} - -.scope-table th, -.scope-table td { - text-align: left; - padding: 12px 14px; - border-bottom: 1px solid var(--color-border); - vertical-align: top; -} - -.scope-table thead th { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-text-muted); -} - -/* The stills form of the beat block and the ledger — StageStills.tsx. The - * block mirrors `.stage-rail-beat` without the grid placement; the ending - * reuses `.stage-rail-close` (its `grid-area` is inert outside the rail grid) - * and only needs room after the last still. */ -.stage-still-text { - display: flex; - gap: 14px; - align-items: flex-start; -} -.stage-stills-close { - margin-top: 64px; -} diff --git a/docs/superpowers/plans/2026-09-07-enterprise-architecture-diagram.md b/docs/superpowers/plans/2026-09-07-enterprise-architecture-diagram.md new file mode 100644 index 000000000..263aa58e3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-enterprise-architecture-diagram.md @@ -0,0 +1,35 @@ +# Enterprise Architecture Diagram 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:** Replace the homepage's "Where Threadplane fits" scope table with the researched enterprise architecture diagram, pixel-aligned on an 8px grid, every card linking to its docs page, verified by a geometry unit test and a text-overflow e2e, and shown to the user before merge. + +**Architecture:** Geometry and copy live in a typed data module so the component, the unit test, and the e2e read the same numbers. The component is a server-rendered SVG inside the kit's `DiagramFrame`. The mockup generator in this session's scratchpad is the reference; the data module ports its coordinates verbatim. + +**Tech Stack:** Next.js server components, the docs diagram kit (`DiagramFrame`, `DiagramSection`), Vitest, Playwright. + +**Spec:** `docs/superpowers/specs/2026-09-07-enterprise-architecture-diagram-design.md`. + +--- + +### Task 1: The data module +**Files:** create `apps/website/src/lib/architecture-diagram.ts`, `architecture-diagram.spec.ts`. +- [ ] Types: `Zone { id, label, owner, y, height, fill, stroke, mark? }`, `Card { id, zoneId, x, y, width, height, title, icon?, tint?, href, tag?, rows: Row[] }`, `Row` = `{ kind: 'chips', y, chips: { mark?: string; label: string; href?: string }[] } | { kind: 'text', y, text } | { kind: 'mono', y, text } | { kind: 'caps', y, caps: { icon, label, href }[] }`, `Arrow { x, y1, y2, caption }`. Constants `VIEW = { width: 1280, height: 1088 }`, `GRID = 8`, `MAJOR = 40`, `ZONE_PAD = 32`, `CARD_GAP = 40`. +- [ ] Data: port every coordinate from the approved mockup (three zones at y 40/360/624, heights 280/224/424; cards per spec §3 with the mockup's x/y/width/height; arrows at x 444 between zones with their captions). +- [ ] Spec: every zone and card coordinate divisible by 8; cards inside their zone with ≥ 24px inset; no two cards in a zone overlap; gaps between horizontally adjacent cards equal `CARD_GAP`; every href starts with `/docs/` and the corresponding `apps/website/content/docs/.mdx` exists (or `/render` → `src/app/render/page.tsx`); every `mark` names a file under `apps/website/public/logos/`. +- [ ] Commit `feat(website): architecture diagram geometry and copy as data`. + +### Task 2: The component and the section +**Files:** create `apps/website/src/components/landing/EnterpriseArchitecture.tsx`, `EnterpriseArchitecture.spec.tsx`; modify `apps/website/src/app/page.tsx`, `apps/website/src/lib/positioning.ts` (+spec), `apps/website/src/styles/landing.css`; delete `ScopeTable.tsx` + spec and `FINAL_MILE_*`. +- [ ] Component: `DiagramSection id="architecture" eyebrow="Architecture" headline=… body=…` wrapping `DiagramFrame slug="enterprise-architecture" viewWidth=1280 viewHeight=1088 scale="marketing" label=…`; draws zones, cards, rows, arrows from the data module; icons as inline paths keyed by name; logos as ``; each card an `` with a "docs ↗" text at top-right (none on the Threadplane card); `data-grid` attribute on the figure toggles a hidden 8/40px grid `` (rendered only when the attribute is present, for review). +- [ ] CSS: `.arch-*` text classes mirroring the mockup's type ramp (title 15/600, body 12.5, chip 11.5/500, mono 11.5, zone label 11.5 tracked, owner 11, tag 10.5 tracked blue, docs 10.5 blue), fills via tokens where they exist (`--color-text-primary`, `--color-text-secondary`, border tokens) and literal gradient stops otherwise; `.tp-diagram-figure` minimum width 1024px inside the landing scroll frame. +- [ ] Spec: renders 3 zones, 10 cards, 12+ links with the data module's hrefs; the `data-grid` group is absent by default and present with the attribute; the heading id is `architecture-heading`. +- [ ] `page.tsx`: `` where `` was; remove `ScopeTable`, `FINAL_MILE_*`, the `.scope-table*` CSS; update `positioning.spec.ts` and `e2e/website.spec.ts` (`why-heading` → `architecture-heading`). +- [ ] Commit `feat(website): enterprise architecture diagram replaces the scope table`. + +### Task 3: e2e and visual proof +**Files:** create `apps/website/e2e/home-architecture.spec.ts`. +- [ ] At 1440×900: for every `text` inside `[data-diagram="enterprise-architecture"]`, `getBBox()` lies inside the nearest ancestor card rect (from `data-card` on the group) with ≥ 6px margin; every chip rect inside its card; every `image` has a non-zero bbox; the section heading text and the 12 card hrefs match. At 390×844: the figure's scroll width ≥ 1024 and `scrollWidth > clientWidth`. +- [ ] Render PNGs at 1440 and 390 (Playwright) for the user, plus one with the alignment grid on. +- [ ] Run `npx nx test website`, lint, `npx nx build website`, `npx nx e2e website -- --grep "homepage architecture|landing page|homepage stage"`. +- [ ] Commit `test(website): architecture diagram geometry and overflow guards`; push; open the PR WITHOUT auto-merge; show the user the frames; merge only on their word. diff --git a/docs/superpowers/specs/2026-09-07-enterprise-architecture-diagram-design.md b/docs/superpowers/specs/2026-09-07-enterprise-architecture-diagram-design.md new file mode 100644 index 000000000..cb74e7ae7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-enterprise-architecture-diagram-design.md @@ -0,0 +1,60 @@ +# Enterprise architecture diagram on the homepage + +**Date:** 2026-09-07 +**Status:** Built on `blove/architecture-diagram` (PR #1048). Iterated through eight mockups and four review passes with the user; the final direction scaled the three-zone enterprise map back to this four-column flow. Merge is gated on the user's visual sign-off. +**Surface:** `apps/website` only. +**Replaces:** the "Where Threadplane fits" section (`ScopeTable`, section id `why`) on the homepage. `ScopeTable` and the `FINAL_MILE_*` copy leave with it. + +## 1. Why + +The homepage tells a developer what Threadplane does (the stage) but not where it sits in the estate they already run. Buyers need one picture they can find themselves in: their users, their Angular application with Threadplane as its UI layer, the adapter to their runtime, their agents, and their model provider, each by its mark. The old scope table answered "what do we add" as prose; the diagram answers "where does it go" spatially, which is the final-mile argument made visible. + +## 2. Decisions + +| Decision | Choice | +|---|---| +| Framing | A left-to-right flow for a buyer scanning for their own logos: your users, your Angular application with Threadplane as its UI layer, the two adapters, your agents, and a strip of model providers beneath. The earlier three-zone enterprise map was drawn, reviewed, and scaled back at the user's direction: less text, broader architecture, more marks. | +| Source of truth for contents | The docs. Every card links to the page that backs its wording. Named third-party products are examples of a role, never integrations Threadplane claims. | +| The first-class lane | The LangGraph SDK adapter is highlighted like Threadplane and tagged FIRST-CLASS, listing what the docs reserve for a checkpoint-aware runtime beyond the AG-UI event stream: time travel and branch, memory and subgraphs, durable execution. The AG-UI card lists events, tool calls, state, interrupts. | +| Marks | Only marks held under `apps/website/public/logos/` (README documents sources). LangSmith uses the LangChain mark from Simple Icons, which has no LangSmith slug; the README records that. Generic roles get a line icon in a tinted badge. | +| Precision | Coordinates on an 8px grid with 40px majors; a hidden alignment grid is part of the component for review; a unit spec asserts every card on the grid inside the view, no overlaps, 40px stack gaps, arrows leaving and entering card edges, and every href resolving; an e2e measures every rendered text run and chip inside its card. | +| Colour | The kit's dot grid, white cards with a faint vertical gradient, a light blue tint with a blue hairline for the two highlighted cards. No zone fills. | +| Interaction | Cards are links to their docs pages; the Threadplane card's five capabilities are their own links, so its title carries the card link (anchors do not nest). No hover states beyond links, no animation. | +| Placement | Where the scope table was: between Reliability and the stage. Eyebrow "Architecture", headline "The UI layer between your users and your agents.", body "Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI. Everything on the right is yours." | +| Responsive | The SVG scales with its container down to 1024px; below that it scrolls horizontally inside the diagram frame at a 1024px minimum width. | + +## 3. Contents + +Column labels: YOUR USERS, YOUR ANGULAR APPLICATION, ADAPTERS, YOUR AGENTS. + +- **People** (users icon): "web · mobile · desktop". Links `/docs/chat/getting-started/introduction`. +- **Threadplane** (Angular mark, "YOUR ANGULAR APP", tag THE UI LAYER): five capability links, Chat, Approvals, Threads, Generative UI, Client tools (`/docs/chat/components/chat`, `/docs/langgraph/guides/interrupts`, `/docs/langgraph/guides/persistence`, `/docs/chat/guides/generative-ui`, `/docs/chat/guides/client-tools`); A2UI (Google mark) and json-render (Vercel mark); the package line `@threadplane/chat · render · langgraph · ag-ui`. Title links `/docs/chat/getting-started/introduction`. +- **LangGraph SDK** (LangGraph mark, tag FIRST-CLASS, highlighted): "threads · checkpoints", "interrupts · streaming", "time travel · branch", "memory · subgraphs", "durable execution". Links `/docs/langgraph/getting-started/introduction`. +- **AG-UI protocol** (AG-UI mark): "events · tool calls", "state · interrupts". Links `/docs/ag-ui/getting-started/introduction`. +- **LangSmith** (LangChain mark): "deploy · observe", "LangGraph agents", "traces · evals", "or self-hosted". Links `/docs/langgraph/guides/deployment`. +- **AG-UI servers**: five marks, CrewAI, Mastra, Microsoft, AWS (Strands), Pydantic AI, then "CrewAI · Mastra · Microsoft", "Strands · Pydantic AI". Links `/docs/runtimes/getting-started/introduction`. +- Arrows: users → Threadplane; Threadplane → each adapter with the caption "one Agent contract" between them; each adapter → its agents card. +- **ANY MODEL** strip: OpenAI, Anthropic, Google, Azure OpenAI, Amazon Bedrock as mark chips, caption "chosen by your runtime, never by the UI". + +## 4. Component + +`apps/website/src/components/landing/EnterpriseArchitecture.tsx`, a server component rendering one `` inside the kit's `DiagramFrame` at `scale="marketing"`, framed by `DiagramSection`. Logos are `` referencing the public files; line icons are inline paths in the component. The geometry lives in a data module, `apps/website/src/lib/architecture-diagram.ts`, as typed card/arrow/strip records, so the component, the unit spec and the e2e all read the same numbers. + +Row kinds: `text`, `mono`, `caps` (the Threadplane card's five capability links), `badge` (a mark with a label), `marks` (a row of mark tiles), and `items` — the banded list used by the adapter and agent cards, one soft band per line with a left accent bar, white-on-tint inside a highlighted card. + +Ground: a faint radial gradient under the kit's dot pattern, with the dots one step lighter than the docs default, so the figure reads as a surface. + +**Grid.** Four columns 64 apart, 48px margins on all four sides, stacked cards 40 apart, every arrow 64 long landing on the vertical centre of the card it enters, and the two paired cards in each row sharing their tops, heights and row positions. The People card's block is centred on its card, where its arrow leaves. + +**Phone form.** Under 768px the SVG is hidden and `ArchitectureStack` renders the same cards as an HTML stack in reading order, from the same data module, so the two forms cannot drift. + +## 5. Verification + +- **Unit:** every card rect is divisible by 8 and sits inside the view's margin; stacked cards are 40 apart; no two cards overlap; the users and Threadplane cards span the adapter stack exactly; every column gap is equal; the left, right and bottom margins match and the model strip starts at the first column; every arrow is one length and lands on the vertical centre of the card it enters; the paired cards share their tops, heights and first row positions; every row sits inside its card. Every href resolves to a docs page or route on disk, and every mark exists under `/logos`. Public-copy scan stays green. +- **e2e (`home-architecture.spec.ts`):** at 1440×900, with fonts loaded, every `` and every chip rect in the diagram has a `getBBox()` inside its own card with margin, every `` has a non-zero box, and the cards carry the expected hrefs. At 390px the HTML stack is visible, the SVG is hidden, it renders one card per data entry, and the page has no horizontal scroll. +- **Visual sign-off:** rendered frames at 1440 and 390 shown to the user before the PR merges; the PR is opened without auto-merge. +- **Spine test:** the homepage heading order replaces `why-heading` with `architecture-heading`. + +## 6. Out of scope + +Hover interactions beyond the links, animation, changes to the docs pages linked. LangSmith has no mark under the site's sourcing rules (Simple Icons carries no `langsmith` slug), so its card uses the LangChain mark and the logo README records why; swapping in a real LangSmith SVG is a later one-line change.