Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/website/src/components/landing/Hero.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ describe('Hero', () => {
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HERO_H1);
expect(screen.getByText(HERO_EYEBROW)).toBeTruthy();
expect(document.querySelector('.hero-subhead')?.textContent).toBe(HERO_SUBHEAD);
expect(document.querySelector('.hero-subhead .marker-highlight')?.textContent).toBe(
'Your backend stays where it is.',
);
expect(document.querySelectorAll('.hero-subhead .marker-highlight')).toHaveLength(1);
expect(document.querySelector('.hero-trust')?.textContent).toBe(HERO_TRUST_LINE);
expect(document.querySelector('.hero-chip-row')).toBeNull();
expect(screen.queryByText(/six months/)).toBeNull();
Expand Down
12 changes: 10 additions & 2 deletions apps/website/src/components/landing/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
HERO_PRIMARY_LABEL,
HERO_SECONDARY_HREF,
HERO_SECONDARY_LABEL,
HERO_SUBHEAD,
HERO_SUBHEAD_SEGMENTS,
HERO_TRUST_LINE,
} from '../../lib/positioning';
import { HeroDemo } from './HeroDemo';
Expand All @@ -32,7 +32,15 @@ export function Hero() {
<div className="hero-stack">
<Eyebrow tone="accent" className="hero-eyebrow">{HERO_EYEBROW}</Eyebrow>
<h1 id="hero-heading" className="hero-heading">{HERO_H1}</h1>
<p className="hero-subhead">{HERO_SUBHEAD}</p>
<p className="hero-subhead">
{HERO_SUBHEAD_SEGMENTS.map((segment) =>
segment.highlight ? (
<span className="marker-highlight" key={segment.text}>{segment.text}</span>
) : (
<React.Fragment key={segment.text}>{segment.text}</React.Fragment>
),
)}
</p>
<div className="hero-cta-row">
<Button variant="primary" size="lg" onClick={openInstall}>{HERO_PRIMARY_LABEL}</Button>
<a
Expand Down
37 changes: 18 additions & 19 deletions apps/website/src/components/landing/LogoRibbon.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,34 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { LogoRibbon, COMPAT_GROUPS } from './LogoRibbon';
import { LogoRibbon, RIBBON_ITEMS, RIBBON_MORE_COUNT } from './LogoRibbon';

describe('LogoRibbon (compatibility boundary)', () => {
it('renders three labelled groups in order', () => {
describe('LogoRibbon', () => {
it('renders eight named items and the more-count', () => {
render(<LogoRibbon />);
expect(COMPAT_GROUPS.map((g) => g.label)).toEqual([
'Direct Threadplane adapters',
'Backends reachable through AG-UI',
'Model providers, behind your backend',
]);
for (const group of COMPAT_GROUPS) {
expect(screen.getByText(group.label)).toBeTruthy();
for (const item of group.items) expect(screen.getByText(item.name)).toBeTruthy();
expect(RIBBON_ITEMS).toHaveLength(8);
for (const item of RIBBON_ITEMS) {
expect(screen.getByText(item.name)).toBeTruthy();
}
expect(screen.getByText(`+ ${RIBBON_MORE_COUNT} more`)).toBeTruthy();
});

it('direct adapters are exactly LangGraph and AG-UI', () => {
expect(COMPAT_GROUPS[0].items.map((i) => i.name)).toEqual(['LangGraph', 'AG-UI']);
it('is a labelled landmark with no links', () => {
const { container } = render(<LogoRibbon />);
const section = container.querySelector('section');
expect(section?.getAttribute('aria-label')).toBe('Works with your agent stack');
expect(container.querySelectorAll('a')).toHaveLength(0);
});

it('is a labelled landmark, logos hidden from assistive tech, no customer wording', () => {
it('reads as compatibility, not customers: hidden logos, visible names, no endorsement wording', () => {
const { container } = render(<LogoRibbon />);
expect(container.querySelector('section')?.getAttribute('aria-label')).toBe(
'Keep your agent stack. Standardize the Angular surface.',
);
for (const img of Array.from(container.querySelectorAll('img'))) {
expect(screen.getByText('Works with')).toBeTruthy();
const imgs = Array.from(container.querySelectorAll('img'));
expect(imgs).toHaveLength(RIBBON_ITEMS.length);
for (const img of imgs) {
expect(img.getAttribute('aria-hidden')).toBe('true');
expect(img.getAttribute('alt')).toBe('');
}
expect(container.textContent).not.toMatch(/trusted by|customers/i);
expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i);
});
});
125 changes: 39 additions & 86 deletions apps/website/src/components/landing/LogoRibbon.tsx
Original file line number Diff line number Diff line change
@@ -1,102 +1,55 @@
import { Container } from '../ui/Container';

interface CompatItem {
interface RibbonItem {
name: string;
logoSrc?: string;
logoSrc: string;
}

interface CompatGroup {
label: string;
note: string;
items: readonly CompatItem[];
}

/**
* Compatibility boundary (spec §5). Three rows so a provider logo is never
* read as a direct adapter. The AG-UI row lists only runtimes the site already
* presents as reachable — the three with docs runtime sections in
* `docs-config.ts` (AWS Strands, Microsoft Agent Framework, Mastra) plus the
* two that ship on the /ag-ui product page's BackendsGrid. Re-verify against
* both before editing.
*/
export const COMPAT_GROUPS: readonly CompatGroup[] = [
{
label: 'Direct Threadplane adapters',
note: '@threadplane/langgraph · @threadplane/ag-ui',
items: [
{ name: 'LangGraph', logoSrc: '/logos/langgraph.svg' },
{ name: 'AG-UI', logoSrc: '/logos/ag-ui.svg' },
],
},
{
label: 'Backends reachable through AG-UI',
note: 'any AG-UI-compatible endpoint',
items: [
{ name: 'Mastra', logoSrc: '/logos/runtimes/mastra.svg' },
{ name: 'Microsoft Agent Framework', logoSrc: '/logos/runtimes/microsoft.svg' },
{ name: 'AWS Strands' },
{ name: 'Pydantic AI', logoSrc: '/logos/runtimes/pydantic.svg' },
{ name: 'CrewAI', logoSrc: '/logos/runtimes/crewai.svg' },
],
},
{
label: 'Model providers, behind your backend',
note: 'model choice stays in the backend you operate',
items: [
{ name: 'OpenAI', logoSrc: '/logos/providers/openai.svg' },
{ name: 'Anthropic', logoSrc: '/logos/providers/anthropic.svg' },
{ name: 'Gemini', logoSrc: '/logos/providers/google.svg' },
{ name: 'Bedrock', logoSrc: '/logos/providers/bedrock.svg' },
{ name: 'Azure OpenAI', logoSrc: '/logos/providers/azure.svg' },
],
},
export const RIBBON_ITEMS: readonly RibbonItem[] = [
{ name: 'OpenAI', logoSrc: '/logos/providers/openai.svg' },
{ name: 'Anthropic', logoSrc: '/logos/providers/anthropic.svg' },
{ name: 'Gemini', logoSrc: '/logos/providers/google.svg' },
{ name: 'Bedrock', logoSrc: '/logos/providers/bedrock.svg' },
{ name: 'LangGraph', logoSrc: '/logos/langgraph.svg' },
{ name: 'AG-UI', logoSrc: '/logos/ag-ui.svg' },
{ name: 'CrewAI', logoSrc: '/logos/runtimes/crewai.svg' },
{ name: 'Mastra', logoSrc: '/logos/runtimes/mastra.svg' },
];

/** Azure OpenAI, Pydantic AI, Microsoft Agent Framework, AWS Strands. */
export const RIBBON_MORE_COUNT = 4;

/**
* The compatibility boundary. Deliberately not a Section: it reads as a
* boundary statement between the hero and the argument below it, and the three
* labelled rows carry their own hierarchy. No links, no hover states.
* The "works with" recognition line. Deliberately not a Section: no heading,
* no subhead — the portability argument lives in the proof band below; this
* line carries recognition only. No links, no hover states.
*
* "Works with" is a compatibility claim, never a customer claim: the logos are
* `alt="" aria-hidden` decoration beside the visible names, and no wording here
* may imply these companies are Threadplane users. LogoRibbon.spec.tsx guards
* that. The labelled three-group variant was tried and rolled back — see
* `git show aaf8dea1:apps/website/src/components/landing/LogoRibbon.tsx`.
*/
export function LogoRibbon() {
return (
<section
aria-label="Keep your agent stack. Standardize the Angular surface."
className="logo-ribbon"
>
<section aria-label="Works with your agent stack" className="logo-ribbon">
<Container>
<p className="logo-ribbon-heading">
Keep your agent stack. Standardize the Angular surface.
</p>
<p className="logo-ribbon-lede">
Threadplane adapts LangGraph and AG-UI into one signal-shaped Agent contract. Your model
provider stays behind the backend you already operate.
</p>
<div className="logo-ribbon-groups">
{COMPAT_GROUPS.map((group) => (
<div className="logo-ribbon-group" key={group.label}>
<div className="logo-ribbon-group-head">
<span className="logo-ribbon-label">{group.label}</span>
<span className="logo-ribbon-note">{group.note}</span>
</div>
<div className="logo-ribbon-line">
{group.items.map((item) => (
<span className="logo-ribbon-item" key={item.name}>
{item.logoSrc ? (
<img
src={item.logoSrc}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="logo-ribbon-logo"
/>
) : null}
<span className="logo-ribbon-name">{item.name}</span>
</span>
))}
</div>
</div>
<div className="logo-ribbon-line">
<span className="logo-ribbon-label">Works with</span>
{RIBBON_ITEMS.map((item) => (
<span className="logo-ribbon-item" key={item.name}>
<img
src={item.logoSrc}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="logo-ribbon-logo"
/>
<span className="logo-ribbon-name">{item.name}</span>
</span>
))}
<span className="logo-ribbon-more">+ {RIBBON_MORE_COUNT} more</span>
</div>
</Container>
</section>
Expand Down
12 changes: 12 additions & 0 deletions apps/website/src/lib/positioning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
HERO_SECONDARY_HREF,
HERO_SECONDARY_LABEL,
HERO_SUBHEAD,
HERO_SUBHEAD_SEGMENTS,
HERO_TRUST_LINE,
HOME_DESCRIPTION,
HOME_TITLE,
Expand Down Expand Up @@ -53,6 +54,17 @@ describe('positioning: hero copy', () => {
expect(HOME_DESCRIPTION.length).toBeLessThanOrEqual(160);
});

it('subhead segments join back to HERO_SUBHEAD, with exactly one highlight', () => {
// The segments exist only so Hero.tsx can marker-highlight one phrase.
// If they ever stop reassembling the source-of-truth string, the rendered
// subhead silently diverges from the copy every other surface quotes.
expect(HERO_SUBHEAD_SEGMENTS.map((s) => s.text).join('')).toBe(HERO_SUBHEAD);
expect(HERO_SUBHEAD_SEGMENTS.filter((s) => s.highlight)).toHaveLength(1);
expect(HERO_SUBHEAD_SEGMENTS.find((s) => s.highlight)?.text).toBe(
'Your backend stays where it is.',
);
});

it('pins the hero action labels and the secondary destination', () => {
expect(HERO_PRIMARY_LABEL).toBe('Install Threadplane');
expect(HERO_SECONDARY_LABEL).toBe('See it running in the docs →');
Expand Down
17 changes: 17 additions & 0 deletions apps/website/src/lib/positioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ export const HERO_EYEBROW = 'Open-source · Angular · LangGraph & AG-UI';
export const HERO_H1 = 'The AI agent UI framework for Angular.';
export const HERO_SUBHEAD =
'Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.';

/**
* The subhead, split so Hero.tsx can marker-highlight the boundary claim.
* HERO_SUBHEAD stays the single source of truth: positioning.spec.ts asserts
* these segments join back to it character for character, so the two cannot
* drift. Exactly one segment is highlighted — a second one in a sentence this
* short reads as decoration and cancels the emphasis.
*/
export interface HeroSubheadSegment {
text: string;
highlight?: boolean;
}
export const HERO_SUBHEAD_SEGMENTS: readonly HeroSubheadSegment[] = [
{ text: 'Chat, threads, approvals, and generative UI on Signals and DI. ' },
{ text: 'Your backend stays where it is.', highlight: true },
];

export const HERO_PRIMARY_LABEL = 'Install Threadplane';
export const HERO_SECONDARY_LABEL = 'See it running in the docs →';
export const HERO_SECONDARY_HREF = '/docs/chat/guides/generative-ui?mode=run';
Expand Down
52 changes: 17 additions & 35 deletions apps/website/src/styles/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
.hero-stack .hero-subhead {
max-width: 40em;
}
/* The centered hero wraps the highlighted boundary claim mid-phrase — measured
* at 768px it orphaned a lone "Your" in its own pill at the end of line 1, and
* at 1440px it split into two staggered boxes. inline-block makes the phrase
* wrap as one unit, so it drops whole onto its own line instead. max-width is
* the safety valve: below ~360px the phrase is wider than the measure, and
* without it a shrink-to-fit box would overflow the page rather than wrap. */
.hero-stack .hero-subhead .marker-highlight {
display: inline-block;
max-width: 100%;
}
.hero-stack .hero-cta-row {
justify-content: center;
}
Expand Down Expand Up @@ -1228,46 +1238,16 @@
/* ---------- LogoRibbon — components/landing/LogoRibbon.tsx ---------- */
.logo-ribbon {
background: var(--color-canvas);
padding: 32px 0;
border-top: 1px solid var(--color-border);
/* No border-bottom: the dark proof band directly below draws its own accent
* seam at the boundary; two hairlines would stack. */
}
.logo-ribbon-heading {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
.logo-ribbon-lede {
margin: 0 0 20px;
max-width: 60em;
color: var(--color-text-muted);
font-size: 14px;
}
.logo-ribbon-groups {
display: grid;
gap: 14px;
}
.logo-ribbon-group {
display: grid;
grid-template-columns: minmax(0, 260px) minmax(0, 1fr);
gap: 12px 24px;
align-items: center;
}
.logo-ribbon-group-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.logo-ribbon-note {
font-size: 12px;
color: var(--color-text-muted);
}
.logo-ribbon-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 26px;
padding: 16px 0;
}
.logo-ribbon-label {
font-family: var(--font-mono);
Expand All @@ -1294,10 +1274,12 @@
font-weight: 500;
color: var(--color-text-secondary);
}
@media (max-width: 767px) {
.logo-ribbon-group {
grid-template-columns: 1fr;
}
.logo-ribbon-more {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-muted);
}

/* Shared rail for the three CodeShowcase intros — the last centered kicker
Expand Down
9 changes: 9 additions & 0 deletions apps/website/src/styles/style-contracts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ const CONTRACTS: StyleContract[] = [
'min-height': /min-height:\s*44px/,
},
},
{
file: 'landing.css',
selector: '.marker-highlight',
why: 'The hero subhead is centered and the highlighted boundary claim wraps at every width below ~1200px (it wraps mid-phrase at 390px). Without box-decoration-break: clone the browser paints ONE background box spanning the union of the wrapped lines, so the marker bleeds across the full paragraph width and past the text it is meant to emphasise — it still renders, just wrongly. The negative margins cancel the padding so the highlight does not shift the line box.',
requires: {
'box-decoration-break': /[^-]box-decoration-break:\s*clone/,
'-webkit-box-decoration-break': /-webkit-box-decoration-break:\s*clone/,
},
},
];

function baseDeclarationsFor(css: string, selector: string): string {
Expand Down
Loading
Loading