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
2 changes: 1 addition & 1 deletion apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default async function HomePage() {
{/* Render */}
<FeatureBlock
id="render"
eyebrow="Render"
eyebrow="json-render"
headline="Agent output, rendered as your components."
body="The server emits a JSON spec. Angular renders it with components you own — json-render and A2UI both speak it."
rows={[
Expand Down
10 changes: 8 additions & 2 deletions apps/website/src/app/solutions/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ interface PageProps {
params: Promise<{ slug: string }>;
}

const LIBRARY_HREF: Record<string, string> = {
/**
* Keyed by the display name in solutions-data. A miss renders the card
* unlinked rather than failing, and `Record<string, string>` will not catch a
* rename on either side — so `solutions-links.spec.ts` asserts every layer
* resolves.
*/
export const LIBRARY_HREF: Record<string, string> = {
Agent: '/langgraph',
Render: '/render',
'json-render': '/render',
Chat: '/chat',
};

Expand Down
8 changes: 0 additions & 8 deletions apps/website/src/components/docs/DocsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,3 @@ export function DocsNavigation({
</div>
);
}

export function DocsSidebar(props: DocsNavigationProps) {
return (
<aside className="docs-sidebar">
<DocsNavigation {...props} />
</aside>
);
}
2 changes: 1 addition & 1 deletion apps/website/src/components/landing/FeatureBlock.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest';
import { FeatureBlock } from './FeatureBlock';

const base = {
eyebrow: 'Render',
eyebrow: 'json-render',
headline: 'Agent output, rendered as your components.',
body: 'Two sentences.',
cta: { label: 'See it', href: '/render' },
Expand Down
17 changes: 12 additions & 5 deletions apps/website/src/components/shared/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { analyticsEvents } from '../../lib/analytics/events';
import { analyticsEvents, type CtaId } from '../../lib/analytics/events';
import { track, trackCtaClick, trackExternalLinkClick } from '../../lib/analytics/client';
import { DEMOS, demoCtaSuffix } from '../../lib/demos';
import { LogoMark } from '../ui/LogoMark';
Expand Down Expand Up @@ -90,11 +90,18 @@ function NewsletterForm() {
}

export function Footer() {
const trackFooterCta = (label: string, href: string) => {
/**
* `ctaId` defaults to a slug of the label. Pass it explicitly when the visible
* text changes but the analytics series should stay continuous — renaming
* "Render" to "json-render" would otherwise silently split footer_render into
* a new footer_json_render series.
*/
const trackFooterCta = (label: string, href: string, ctaId?: CtaId) => {
trackCtaClick({
surface: 'footer',
destination_url: href,
cta_id: `footer_${label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
cta_id:
ctaId ?? `footer_${label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
cta_text: label,
});
};
Expand Down Expand Up @@ -200,8 +207,8 @@ export function Footer() {
AG-UI
</Link>
<Link href="/render" className="transition-colors footer-link"
onClick={() => trackFooterCta('Render', '/render')}>
Render
onClick={() => trackFooterCta('json-render', '/render', 'footer_render')}>
json-render
</Link>
<Link href="/chat" className="transition-colors footer-link"
onClick={() => trackFooterCta('Chat', '/chat')}>
Expand Down
6 changes: 3 additions & 3 deletions apps/website/src/lib/solutions-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const SOLUTIONS: SolutionConfig[] = [
role: 'Production agent state with first-class interrupt support. Every agent action can require human approval before execution. Durable thread persistence preserves the full record of every tool call and state transition.',
},
{
library: 'Render',
library: 'json-render',
pkg: '@threadplane/render',
role: 'Approval workflows rendered as structured UI — not chat messages. The agent proposes an action, renders a confirmation card, and waits for the human gate before proceeding.',
},
Expand Down Expand Up @@ -217,7 +217,7 @@ export const SOLUTIONS: SolutionConfig[] = [
role: 'Streams query results token-by-token as the LangGraph agent reasons over your data. Thread persistence means users can refine questions without re-running expensive queries.',
},
{
library: 'Render',
library: 'json-render',
pkg: '@threadplane/render',
role: 'The agent emits chart specs, data tables, and KPI cards as structured render specs. Your Angular components render them with streaming JSON patches — live-updating visualizations as data arrives.',
},
Expand Down Expand Up @@ -295,7 +295,7 @@ export class DashboardComponent {
role: 'LangGraph interrupts let the agent pause before sensitive actions — refunds, account changes, escalations. Thread persistence preserves the full conversation across bot-to-human handoffs.',
},
{
library: 'Render',
library: 'json-render',
pkg: '@threadplane/render',
role: 'The agent renders structured UI — order summaries, refund confirmations, knowledge base cards — instead of dumping text. Customers see clean, actionable information.',
},
Expand Down
28 changes: 28 additions & 0 deletions apps/website/src/lib/solutions-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { LIBRARY_HREF } from '../app/solutions/[slug]/page';
import { SOLUTIONS } from './solutions-data';

/**
* Every architecture layer names a library, and the solutions page turns that
* name into an href through a plain `Record<string, string>`. A miss is silent
* — the card just renders without a link — and the types cannot catch a rename
* on one side only.
*/
describe('solutions architecture layers', () => {
it('every named library resolves to a href', () => {
const unresolved = SOLUTIONS
.flatMap((s) => s.architectureLayers.map((l) => l.library))
.filter((library) => !LIBRARY_HREF[library]);

expect(unresolved).toEqual([]);
});

it('names the render library the way the docs do', () => {
const names = new Set(
SOLUTIONS.flatMap((s) => s.architectureLayers.map((l) => l.library)),
);

expect(names.has('json-render')).toBe(true);
expect(names.has('Render')).toBe(false);
});
});
42 changes: 0 additions & 42 deletions apps/website/src/styles/docs-sidebar-styles.spec.ts

This file was deleted.

11 changes: 0 additions & 11 deletions apps/website/src/styles/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -729,17 +729,6 @@
color: var(--color-text-primary);
}

.docs-sidebar {
border-right: 1px solid var(--color-border);
background: var(--color-surface);
position: sticky;
top: var(--nav-h);
/* Without align-self the flex row stretches the aside to the article's full
* height (measured 10,030px), so its overflow-y:auto never engaged. */
align-self: flex-start;
min-height: calc(100vh - var(--nav-h));
max-height: calc(100vh - var(--nav-h));
}
.docs-sidebar-lib-trigger {
background: var(--color-surface);
border: 1px solid var(--color-border);
Expand Down
37 changes: 37 additions & 0 deletions apps/website/src/styles/style-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

/**
* Reading declarations out of a stylesheet, for rules whose loss is *silent*.
*
* jsdom does not apply stylesheets, so a component test renders the same DOM
* whether or not a load-bearing declaration exists. That gap is how PR #892
* shipped a docs picker whose title and description collided into one run-on
* line: the JSX moved off Tailwind onto semantic class names, the
* `flex flex-col` was never ported, and every test stayed green.
*
* Use this only for declarations where the failure mode is plausible-but-wrong
* rendering. Ordinary styling belongs in review, not in a test.
*
* Limitation: this is a flat scan, not a CSS parser. Rules nested in
* `@media` blocks are merged into the same selector's declarations, and
* cascade order is not modelled. That is fine for asserting "this declaration
* exists somewhere for this selector" and wrong for anything subtler.
*/
export function loadStylesheet(file: string): string {
return readFileSync(join(__dirname, file), 'utf8');
}

/** Merged declaration text for every rule whose selector list contains `selector`. */
export function declarationsFor(css: string, selector: string): string {
// Comments must go first: a `/* ... */` above a rule lands inside the
// selector capture below, and the exact match then never fires. That reads
// as "the rule is missing" — which is how a contract would report a false
// failure the moment someone documented the rule it guards.
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');

return [...withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
.filter((match) => match[1].split(',').some((part) => part.trim() === selector))
.map((match) => match[2])
.join(';');
}
81 changes: 81 additions & 0 deletions apps/website/src/styles/style-contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { declarationsFor, loadStylesheet } from './style-contract';

/**
* The registry of CSS declarations that are load-bearing and whose loss is
* silent — the page still renders, just wrongly.
*
* Add an entry when you find yourself writing a comment in a stylesheet that
* explains why a declaration must not be removed. That comment is the tell:
* the next person cannot see the consequence from the code, and neither can
* jsdom.
*
* Do not add ordinary styling here. A contract that fires on every design
* tweak teaches people to delete contracts.
*/
interface StyleContract {
file: string;
selector: string;
/** Why losing this is invisible. Read by whoever the failure wakes up. */
why: string;
requires: Record<string, RegExp>;
}

const CONTRACTS: StyleContract[] = [
{
file: 'docs.css',
selector: '.docs-sidebar-lib-item-text',
why: 'Title and tagline are sibling spans; this column is the only thing stacking them. Losing it renders every picker row as one run-on line — shipped to production in #892.',
requires: {
display: /display:\s*flex/,
'flex-direction': /flex-direction:\s*column/,
},
},
{
file: 'docs.css',
selector: '.docs-sidebar-lib-menu',
why: 'The picker menu opens ~340px down a scrolling pane. Without a cap it runs past the fold and the last libraries are unreachable.',
requires: {
'max-height': /max-height:/,
'overflow-y': /overflow-y:\s*auto/,
},
},
{
file: 'docs.css',
selector: '.docs-control-plane',
why: 'In a flex row an un-aligned sticky child stretches to the article\'s full height, so its own height cap never applies and internal scrolling silently stops working.',
requires: {
position: /position:\s*sticky/,
'align-self': /align-self:\s*flex-start/,
},
},
{
file: 'docs.css',
selector: '.docs-control-plane [data-control-plane-pane]',
why: 'The pane holds the whole docs nav in a fixed-height column. Without its own scrolling the lower sections are unreachable on short viewports.',
requires: {
'overflow-y': /overflow-y:\s*auto/,
},
},
];

describe('style contracts', () => {
for (const contract of CONTRACTS) {
describe(`${contract.file} ${contract.selector}`, () => {
const declarations = declarationsFor(loadStylesheet(contract.file), contract.selector);

it('has a rule at all', () => {
// A selector that stops matching is the loudest way this drifts: the
// rule was renamed or deleted and every property assertion below would
// otherwise fail with the same unhelpful message.
expect(declarations, contract.why).not.toBe('');
});

for (const [property, pattern] of Object.entries(contract.requires)) {
it(`declares ${property}`, () => {
expect(declarations, contract.why).toMatch(pattern);
});
}
});
}
});
Loading