diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index 2e80e15a..8cbed031 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -6,9 +6,9 @@ test.describe('Docs landing page', () => { // Hero await expect(page.locator('#docs-heading')).toBeVisible(); - await expect(page.locator('#docs-heading')).toContainText('Build AI agent UIs in Angular'); + await expect(page.locator('#docs-heading')).toContainText('Start building with Threadplane'); - // Step headings (match on the plain substring to avoid the middle-dot char) + // Step headings (match the label text; the numbered badge is a separate aria-hidden span) await expect(page.getByText('Pick your backend').first()).toBeVisible(); await expect(page.getByText('Generative UI').first()).toBeVisible(); await expect(page.getByText('Chat UI').first()).toBeVisible(); @@ -17,6 +17,15 @@ test.describe('Docs landing page', () => { await expect(page.locator('main a[href="/docs/langgraph/getting-started/quickstart"]').first()).toBeVisible(); await expect(page.locator('main a[href="/docs/ag-ui/getting-started/quickstart"]').first()).toBeVisible(); + // Vendor logo marks on the fork cards + await expect(page.locator('main img[src="/logos/langgraph.svg"]').first()).toBeVisible(); + await expect(page.locator('main img[src="/logos/runtimes/copilotkit.svg"]').first()).toBeVisible(); + await expect(page.locator('main img[src="/logos/providers/google.svg"]').first()).toBeVisible(); + await expect(page.locator('main img[src="/logos/surface/vercel.svg"]').first()).toBeVisible(); + + // Install snippet copy buttons + await expect(page.locator('main button[aria-label="Copy install command"]').first()).toBeVisible(); + // Step 2 — generative UI links await expect(page.locator('main a[href="/docs/a2ui/getting-started/introduction"]').first()).toBeVisible(); await expect(page.locator('main a[href="/docs/render/getting-started/introduction"]').first()).toBeVisible(); diff --git a/apps/website/src/app/docs/page.tsx b/apps/website/src/app/docs/page.tsx index def32ede..60447c6e 100644 --- a/apps/website/src/app/docs/page.tsx +++ b/apps/website/src/app/docs/page.tsx @@ -6,6 +6,7 @@ import { Section } from '../../components/ui/Section'; import { Eyebrow } from '../../components/ui/Eyebrow'; import { Card } from '../../components/ui/Card'; import { Pill } from '../../components/ui/Pill'; +import { CopyButton } from '../../components/docs/CopyButton'; import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ @@ -21,44 +22,53 @@ interface Backend { blurb: string; install: string; href: string; + logoSrc: string; + attribution: string; } const BACKENDS: Backend[] = [ { title: 'LangGraph', - blurb: 'For LangChain / LangGraph backends', + blurb: 'For LangChain & LangGraph backends.', install: 'npm i @threadplane/langgraph', href: '/docs/langgraph/getting-started/quickstart', + logoSrc: '/logos/langgraph.svg', + attribution: 'LangChain', }, { title: 'AG-UI', - blurb: 'CrewAI, Mastra, Pydantic AI, Strands…', + blurb: 'For CrewAI, Mastra, Pydantic AI, Strands, and more.', install: 'npm i @threadplane/ag-ui', href: '/docs/ag-ui/getting-started/quickstart', + logoSrc: '/logos/runtimes/copilotkit.svg', + attribution: 'AG-UI · CopilotKit', }, ]; interface GenerativeUi { - vendor: string; title: string; blurb: string; href: string; + logoSrc: string; + attribution: string; } const GENERATIVE_UI: GenerativeUi[] = [ { - vendor: 'Google', title: 'A2UI', blurb: 'Agent-to-UI protocol — the agent streams and updates surfaces over the conversation.', href: '/docs/a2ui/getting-started/introduction', + logoSrc: '/logos/providers/google.svg', + attribution: 'Google', }, { - vendor: 'Vercel', title: 'json-render', blurb: 'Render a fixed JSON spec into your own Angular components. You own the schema.', href: '/docs/render/getting-started/introduction', + logoSrc: '/logos/surface/vercel.svg', + attribution: 'Vercel', }, ]; @@ -66,6 +76,7 @@ interface SupportingLib { title: string; blurb: string; href: string; + glyph: 'key' | 'pulse'; } const SUPPORTING: SupportingLib[] = [ @@ -73,39 +84,130 @@ const SUPPORTING: SupportingLib[] = [ title: 'Licensing', blurb: 'Token verification', href: '/docs/licensing/getting-started/introduction', + glyph: 'key', }, { title: 'Telemetry', blurb: 'Browser & Node events', href: '/docs/telemetry/getting-started/introduction', + glyph: 'pulse', }, ]; -function StepLabel({ id, children }: { id: string; children: ReactNode }) { +function ChatGlyph() { return ( -

+ + ); +} + +function KeyGlyph() { + return ( + + ); +} + +function PulseGlyph() { + return ( + + ); +} + +const GLYPHS = { key: KeyGlyph, pulse: PulseGlyph } as const; + +const stepLabelStyle = { + fontFamily: tokens.typography.eyebrow.family, + fontSize: tokens.typography.eyebrow.size, + fontWeight: tokens.typography.eyebrow.weight, + letterSpacing: tokens.typography.eyebrow.letterSpacing, + textTransform: tokens.typography.eyebrow.transform, + lineHeight: tokens.typography.eyebrow.line, + color: tokens.colors.textMuted, + margin: 0, + marginBottom: 16, + display: 'flex', + alignItems: 'center', + gap: 10, +} as const; + +const stepBadgeStyle = { + width: 20, + height: 20, + borderRadius: tokens.radius.full, + background: tokens.colors.accent, + color: '#fff', + fontSize: 12, + fontWeight: 700, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flex: '0 0 auto', +} as const; + +function StepLabel({ id, step, children }: { id: string; step?: number; children: ReactNode }) { + return ( +

+ {step != null ? ( + + ) : null} {children}

); } -const gridStyle = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', - gap: 16, +const logoChipStyle = { + width: 30, + height: 30, + borderRadius: tokens.radius.md, + background: tokens.surfaces.surface, + border: `1px solid ${tokens.surfaces.border}`, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flex: '0 0 auto', +} as const; + +const glyphChipStyle = { + borderRadius: tokens.radius.md, + background: tokens.colors.accentSurface, + border: `1px solid ${tokens.colors.accentBorder}`, + color: tokens.colors.accent, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flex: '0 0 auto', +} as const; + +function LogoChip({ src }: { src: string }) { + return ( + + + + ); +} + +function GlyphChip({ size, children }: { size: number; children: ReactNode }) { + return {children}; +} + +const cardHeaderStyle = { + display: 'flex', + alignItems: 'center', + gap: 12, + marginBottom: 12, } as const; const cardTitleStyle = { @@ -115,7 +217,16 @@ const cardTitleStyle = { fontWeight: 600, color: tokens.colors.textPrimary, margin: 0, - marginBottom: 8, +} as const; + +const attributionStyle = { + fontFamily: tokens.typography.fontMono, + fontSize: 10, + lineHeight: 1.4, + textTransform: 'uppercase', + letterSpacing: '0.08em', + color: tokens.colors.textMuted, + marginTop: 2, } as const; const cardBlurbStyle = { @@ -133,6 +244,33 @@ const ctaStyle = { color: tokens.colors.accent, } as const; +const snippetRowStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + background: tokens.surfaces.surface, + border: `1px solid ${tokens.surfaces.border}`, + borderRadius: tokens.radius.md, + padding: '5px 6px 5px 12px', + margin: '14px 0 16px', +} as const; + +const snippetCodeStyle = { + fontFamily: tokens.typography.fontMono, + fontSize: 13, + color: tokens.colors.textSecondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const; + +const gridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: 16, +} as const; + const helperStyle = { fontFamily: tokens.typography.body.family, fontSize: 14, @@ -153,15 +291,26 @@ const accentCardStyle = { border: `1px solid ${tokens.colors.accentBorder}`, } as const; -const supportingTitleStyle = { - ...cardTitleStyle, - fontSize: 16, - marginBottom: 4, +const plainCardStyle = { + height: '100%', +} as const; + +const dividerStyle = { + height: 1, + background: tokens.surfaces.border, + border: 'none', + margin: '0 0 40px', } as const; export default function DocsLandingPage() { return ( <> + + {/* Hero */}
@@ -182,7 +331,7 @@ export default function DocsLandingPage() { letterSpacing: '-0.02em', }} > - Build AI agent UIs in Angular + Start building with Threadplane

- Step 1 · Pick your backend + Pick your backend

{BACKENDS.map((b) => ( - -

{b.title}

-

{b.blurb}

- - {b.install} - + +
+ +
+

{b.title}

+
{b.attribution}
+
+
+

{b.blurb}

+
+ {b.install} + +
Quickstart →
@@ -243,27 +387,28 @@ export default function DocsLandingPage() { {/* Step 2 — generative UI */}
- Step 2 · Generative UI +
+ Generative UI
{GENERATIVE_UI.map((g) => ( - - - {g.vendor} - -

{g.title}

-

{g.blurb}

- Get started → + +
+ +
+

{g.title}

+
{g.attribution}
+
+
+

{g.blurb}

+ Get started →
))}

Which fits my use case?{' '} - + json-render vs A2UI →

@@ -273,17 +418,21 @@ export default function DocsLandingPage() { {/* Step 3 — chat */}
- Step 3 · Chat UI - - -

Chat

+
+ Chat UI + + +
+ +
+

Chat

+
Threadplane
+
+

Drop-in chat components — message list, input, streaming, tool - calls, interrupts, subagents. Renders A2UI & json-render - surfaces inline. + calls, interrupts, subagents. Renders A2UI & json-render surfaces + inline.

@@ -293,16 +442,25 @@ export default function DocsLandingPage() { {/* Supporting libraries */}
+
Supporting libraries
- {SUPPORTING.map((s) => ( - - -

{s.title}

-

{s.blurb}

-
- - ))} + {SUPPORTING.map((s) => { + const Glyph = GLYPHS[s.glyph]; + return ( + + +
+ +
+

{s.title}

+

{s.blurb}

+
+
+
+ + ); + })}
diff --git a/apps/website/src/components/docs/CopyButton.spec.tsx b/apps/website/src/components/docs/CopyButton.spec.tsx new file mode 100644 index 00000000..b256fc0d --- /dev/null +++ b/apps/website/src/components/docs/CopyButton.spec.tsx @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +const trackMock = vi.hoisted(() => vi.fn()); +const writeTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock('../../lib/analytics/client', () => ({ + track: trackMock, +})); + +beforeEach(() => { + trackMock.mockClear(); + writeTextMock.mockClear(); + Object.assign(navigator, { clipboard: { writeText: writeTextMock } }); +}); + +describe('CopyButton', () => { + it('renders an accessible copy button by default', async () => { + const { CopyButton } = await import('./CopyButton'); + render(); + expect(screen.getByRole('button', { name: /copy install command/i })).toBeTruthy(); + }); + + it('copies the text, shows copied state, and fires docs:copy_code_click with cta_id=copy_install', async () => { + const { CopyButton } = await import('./CopyButton'); + render(); + fireEvent.click(screen.getByRole('button', { name: /copy install command/i })); + expect(writeTextMock).toHaveBeenCalledWith('npm i @threadplane/langgraph'); + await screen.findByRole('button', { name: /copied/i }); + expect(trackMock).toHaveBeenCalledWith( + 'docs:copy_code_click', + expect.objectContaining({ surface: 'docs', cta_id: 'copy_install' }), + ); + }); +}); diff --git a/apps/website/src/components/docs/CopyButton.tsx b/apps/website/src/components/docs/CopyButton.tsx new file mode 100644 index 00000000..0c7124b1 --- /dev/null +++ b/apps/website/src/components/docs/CopyButton.tsx @@ -0,0 +1,70 @@ +'use client'; +import { useState } from 'react'; +import { tokens } from '@threadplane/design-tokens'; +import { analyticsEvents } from '../../lib/analytics/events'; +import { track } from '../../lib/analytics/client'; + +function CopyIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +interface Props { + /** The exact string copied to the clipboard. */ + text: string; +} + +export function CopyButton({ text }: Props) { + const [copied, setCopied] = useState(false); + + const handleClick = async () => { + try { + await navigator.clipboard.writeText(text); + track(analyticsEvents.docsCopyCodeClick, { + surface: 'docs', + cta_id: 'copy_install', + }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard access denied — silently ignore + } + }; + + return ( + + ); +} diff --git a/docs/superpowers/plans/2026-06-03-docs-landing-polish.md b/docs/superpowers/plans/2026-06-03-docs-landing-polish.md new file mode 100644 index 00000000..0350b537 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-docs-landing-polish.md @@ -0,0 +1,852 @@ +# Docs Landing Page Polish 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:** Polish the `/docs` landing into direction B (branded & iconic): vendor logo chips on the four fork cards, in-house glyphs for our own libraries, numbered step badges, a copy-able install snippet, hover lift, and dividers — plus the headline and blurb copy updates. + +**Architecture:** The page (`apps/website/src/app/docs/page.tsx`) stays a Next.js **server component**. One new **client component** (`CopyButton`) handles clipboard interaction. Marks reuse the existing `/logos/*.svg` assets and the `EcosystemStrip` image treatment; the copy button reuses `CodeBlock`'s icons + the existing `docs:copy_code_click` analytics event. No new dependencies or asset files. + +**Tech Stack:** Next.js (App Router), React server + client components, TypeScript, `@threadplane/design-tokens`, Vitest + Testing Library, Playwright. + +**Reference spec:** `docs/superpowers/specs/2026-06-03-docs-landing-polish-design.md` + +--- + +## File Structure + +- **Create:** `apps/website/src/components/docs/CopyButton.tsx` — client component: an icon copy button that writes a passed string to the clipboard, shows a 2s "copied" state, and fires `docs:copy_code_click`. One responsibility. +- **Create:** `apps/website/src/components/docs/CopyButton.spec.tsx` — unit test (vitest + jsdom). +- **Modify:** `apps/website/src/app/docs/page.tsx` — full rewrite: new headline/blurbs, logo+glyph chips, numbered badges, snippet row with `CopyButton`, dividers, scoped hover ` + + {/* Hero */} +
+ +
+ + Documentation + +

+ Start building with Threadplane +

+

+ A suite of MIT-licensed libraries for streaming agent interfaces. + Pick your backend to get started. +

+
+
+
+ + {/* Step 1 — backend */} +
+ + Pick your backend +
+ {BACKENDS.map((b) => ( + + +
+ +
+

{b.title}

+
{b.attribution}
+
+
+

{b.blurb}

+
+ {b.install} + +
+ Quickstart → +
+ + ))} +
+

+ Not sure which to use?{' '} + + Choosing an adapter → + +

+
+
+ + {/* Step 2 — generative UI */} +
+ +
+ Generative UI +
+ {GENERATIVE_UI.map((g) => ( + + +
+ +
+

{g.title}

+
{g.attribution}
+
+
+

{g.blurb}

+ Get started → +
+ + ))} +
+

+ Which fits my use case?{' '} + + json-render vs A2UI → + +

+ +
+ + {/* Step 3 — chat */} +
+ +
+ Chat UI + + +
+ +
+

Chat

+
Threadplane
+
+
+

+ Drop-in chat components — message list, input, streaming, tool + calls, interrupts, subagents. Renders A2UI & json-render surfaces + inline. +

+
+ + +
+ + {/* Supporting libraries */} +
+ +
+ Supporting libraries +
+ {SUPPORTING.map((s) => { + const Glyph = GLYPHS[s.glyph]; + return ( + + +
+ +
+

{s.title}

+

{s.blurb}

+
+
+
+ + ); + })} +
+ +
+ + {/* Search prompt */} +
+ +
+

+ Looking for something specific? +

+

+ Press ⌘K to search the docs. +

+
+
+
+ + ); +} +``` + +- [ ] **Step 2: Lint the changed files** + +Run: +```bash +cd /Users/blove/repos/angular-agent-framework && npx eslint apps/website/src/app/docs/page.tsx +``` +Expected: exit 0, no output. (Direct-file lint avoids the unrelated git-ignored `public/demo/main.js` failure.) + +- [ ] **Step 3: Typecheck the website** + +Run: +```bash +cd /Users/blove/repos/angular-agent-framework && npx tsc --noEmit -p apps/website/tsconfig.json 2>&1 | grep -E "docs/page.tsx|components/docs/CopyButton" || echo "no new type errors in changed files" +``` +Expected: `no new type errors in changed files`. (The project has pre-existing `TS6305` stale-build-info baseline errors unrelated to this change; this command filters to only the files we touched.) + +--- + +## Task 4: Verify end-to-end and commit + +**Files:** none (verification + commit) + +- [ ] **Step 1: Run the landing-page e2e test** + +Run: +```bash +cd apps/website && npx playwright test e2e/docs.spec.ts -g "start-here funnel" +``` +Expected: PASS. The dev server auto-starts via `apps/website/playwright.config.ts`. + +- [ ] **Step 2: Run the full docs e2e file to confirm no regressions** + +Run: +```bash +cd apps/website && npx playwright test e2e/docs.spec.ts +``` +Expected: PASS for all blocks (landing page, slug page, search). + +- [ ] **Step 3: Commit** + +```bash +cd /Users/blove/repos/angular-agent-framework +git add apps/website/src/app/docs/page.tsx apps/website/e2e/docs.spec.ts +git commit -m "$(cat <<'EOF' +feat(website): polish docs landing (direction B — branded & iconic) + +Vendor logo chips on the four fork cards, in-house glyphs for our own +libraries, numbered step badges, copy-able install snippet, hover lift, +and dividers. Headline -> "Start building with Threadplane"; tightened +backend blurbs. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +## Manual verification (browser) + +After the e2e passes, confirm visually: + +- [ ] Open `/docs` on the dev server (port 3000). +- [ ] Hero reads "Start building with Threadplane". +- [ ] Step labels show numbered badges 1 / 2 / 3; "Supporting libraries" has no badge. +- [ ] Each fork card shows its vendor mark in a white chip + uppercase attribution (LangChain, AG-UI · CopilotKit, Google, Vercel). +- [ ] Chat / Licensing / Telemetry show accent-tinted glyph chips. +- [ ] The backend install snippets have a working copy button (click → checkmark, command on clipboard). +- [ ] Hairline dividers separate steps 1/2/3/supporting; hover lifts each card. +- [ ] Resize to mobile — grids collapse to one column; no console errors. + +--- + +## Self-Review (completed during planning) + +- **Spec coverage:** Numbered badges ✓ (Task 3 `StepBadge`/`StepLabel`). Vendor logo chips with the four exact `src`s + attributions ✓. In-house glyphs for Chat/Licensing/Telemetry ✓. Hover lift via `data-ui="docs-card"` + scoped style + reduced-motion guard ✓. h1 "Start building with Threadplane" ✓. Tightened backend blurbs ✓. A2UI link unchanged (`/docs/a2ui/getting-started/introduction`) ✓. CopyButton client component reusing CodeBlock icons + `docs:copy_code_click` + `cta_id: copy_install` ✓ (Task 1). Snippet row ✓. Dividers ✓. e2e + unit tests ✓ (Tasks 1, 2). No spec requirement left unimplemented. +- **Placeholder scan:** No TBD/TODO; every code step shows complete code; no hand-waved error handling. +- **Type consistency:** Interface names (`Backend`, `GenerativeUi`, `SupportingLib`) and array names (`BACKENDS`, `GENERATIVE_UI`, `SUPPORTING`) consistent between definition and use. `CopyButton` takes `{ text }` in both the component and every call site. `GLYPHS` keys (`key`, `pulse`) match the `glyph` union in `SupportingLib`. `LogoChip`/`GlyphChip`/`StepLabel` signatures match their call sites. e2e `src`/`aria-label` assertions match the values rendered by `page.tsx`. +``` diff --git a/docs/superpowers/specs/2026-06-03-docs-landing-polish-design.md b/docs/superpowers/specs/2026-06-03-docs-landing-polish-design.md new file mode 100644 index 00000000..89683cce --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-docs-landing-polish-design.md @@ -0,0 +1,165 @@ +# Docs Landing Page Polish — Design + +**Date:** 2026-06-03 +**Status:** Draft for review +**Scope:** Visual + copy polish of `apps/website/src/app/docs/page.tsx` (the `/docs` index), building on the backend-first funnel shipped in #564 and the nit cleanups in #566. + +## Goal + +Elevate the docs landing from a clean-but-spartan wireframe into a designed, +branded page — **direction B (branded & iconic)** — without changing the funnel +structure. Add recognition (vendor marks on the fork cards), structure (numbered +step badges), and craft (snippet copy, hover, dividers), and resolve the two +open copy questions. + +This is a polish pass, not a re-architecture. The five-section funnel (hero → +Step 1 backend → Step 2 generative UI → Step 3 chat → supporting → search) stays +exactly as-is in order and routing. + +## Decisions (locked during brainstorming) + +- **Direction:** B — branded & iconic. +- **Icon strategy:** official vendor marks on the four fork cards; in-house line + glyphs for our own libraries (Chat, Licensing, Telemetry). +- **Headline:** change hero h1 to **"Start building with Threadplane"** + (docs-flavored; the eyebrow already reads "Documentation"). +- **A2UI card link:** unchanged — `/docs/a2ui/getting-started/introduction`. +- **Install snippet:** add a copy-to-clipboard control via a small new client + component; the page stays a server component. + +## Section 1 — Card craft + +### Numbered step badges + +Replace the plain uppercase step labels with a numbered badge + label. Each badge +is a filled accent circle (20px, `tokens.colors.accent` background, white digit, +600 weight) preceding the existing uppercase `StepLabel` text. Steps 1–3 are +numbered; "Supporting libraries" keeps a plain (un-numbered) label. + +### Fork cards — vendor logo chips + +Each of the four fork cards gets a logo chip left of the title: a 30px white +rounded square (`tokens.surfaces.surface` bg, `tokens.surfaces.border`, +`tokens.radius.md`) containing an 18px ``. Below the title, a small +uppercase mono attribution line (matching the ecosystem strip's `note` style: +`fontMono`, 10px, `textMuted`, `letterSpacing: 0.08em`). + +Marks reuse the existing, shipped, trademark-cleared assets from +`apps/website/public/logos/` (already used by `EcosystemStrip`): + +| Card | Logo `src` | Attribution | +|------|-----------|-------------| +| LangGraph | `/logos/langgraph.svg` | LangChain | +| AG-UI | `/logos/runtimes/copilotkit.svg` | AG-UI · CopilotKit | +| A2UI | `/logos/providers/google.svg` | Google | +| json-render | `/logos/surface/vercel.svg` | Vercel | + +Images render exactly like `EcosystemStrip`: `alt=""`, `aria-hidden="true"`, +`loading="lazy"`, `decoding="async"`, `objectFit: 'contain'`. + +### Own libraries — in-house line glyphs + +Chat (Step 3) and the two supporting libraries get a monochrome accent-tinted +square (`tokens.colors.accentSurface` bg, `tokens.colors.accentBorder`, +`tokens.radius.md`, icon stroked in `tokens.colors.accent`) holding a simple +inline-SVG line icon: + +- **Chat** — speech-bubble glyph (30px chip; attribution "Threadplane"). +- **Licensing** — key glyph (26px chip). +- **Telemetry** — signal-pulse glyph (26px chip). + +These are small inline SVGs defined in the page file (no new asset files). + +### Hover lift + +Cards reuse the ecosystem-tile hover treatment via a `data-ui` attribute + a +scoped `