From 2cbd506f4ebdb3bb46f8a5295700d5694597adc0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 31 Aug 2026 22:36:27 -0700 Subject: [PATCH] refactor(website): finish the json-render rename; generalise style contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from #923. json-render on marketing surfaces --------------------------------- #923 renamed the docs picker label but deliberately left the marketing surfaces. They now agree: the homepage FeatureBlock eyebrow, the footer link, and the three solutions architecture layers all read json-render. Two hazards this surfaced: - The footer derives cta_id from the visible label, so renaming the link would have silently split PostHog's footer_render into a new footer_json_render series. trackFooterCta now takes an optional explicit CtaId and the render link pins the original. - The solutions page maps library name to href through a Record. Renaming the data without the key returns undefined and renders the card unlinked — no error, no type failure. solutions-links.spec.ts asserts every layer resolves; mutation-tested by renaming one side only. Style contracts --------------- docs-sidebar-styles.spec.ts guarded two CSS declarations whose loss is invisible to jsdom, but it was a one-off with an inline parser. It is replaced by style-contract.ts plus a registry in style-contracts.spec.ts, so adding a guard is one entry rather than a new file. Seeded with four live rules, each mutation-tested by deleting the declaration and by renaming the selector wholesale: - .docs-sidebar-lib-item-text flex column (the #892 collision) - .docs-sidebar-lib-menu max-height + overflow-y - .docs-control-plane position sticky + align-self - [data-control-plane-pane] overflow-y Writing the registry found a bug in the extracted parser: a CSS comment above a rule lands inside the selector capture, so the exact match never fires and a guarded rule reports as missing. Comments are stripped first. Also removes the DocsSidebar wrapper and its .docs-sidebar rule, dead since #892 moved the docs nav into the control plane — only DocsNavigation is imported, and the sole class usage was inside the dead component. Its comment documented the same align-self hazard now held by the .docs-control-plane contract. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/page.tsx | 2 +- .../website/src/app/solutions/[slug]/page.tsx | 10 ++- .../src/components/docs/DocsSidebar.tsx | 8 -- .../components/landing/FeatureBlock.spec.tsx | 2 +- apps/website/src/components/shared/Footer.tsx | 17 ++-- apps/website/src/lib/solutions-data.ts | 6 +- apps/website/src/lib/solutions-links.spec.ts | 28 +++++++ .../src/styles/docs-sidebar-styles.spec.ts | 42 ---------- apps/website/src/styles/docs.css | 11 --- apps/website/src/styles/style-contract.ts | 37 +++++++++ .../src/styles/style-contracts.spec.ts | 81 +++++++++++++++++++ 11 files changed, 171 insertions(+), 73 deletions(-) create mode 100644 apps/website/src/lib/solutions-links.spec.ts delete mode 100644 apps/website/src/styles/docs-sidebar-styles.spec.ts create mode 100644 apps/website/src/styles/style-contract.ts create mode 100644 apps/website/src/styles/style-contracts.spec.ts diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 07e428d76..548c61f88 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -67,7 +67,7 @@ export default async function HomePage() { {/* Render */} ; } -const LIBRARY_HREF: Record = { +/** + * Keyed by the display name in solutions-data. A miss renders the card + * unlinked rather than failing, and `Record` will not catch a + * rename on either side — so `solutions-links.spec.ts` asserts every layer + * resolves. + */ +export const LIBRARY_HREF: Record = { Agent: '/langgraph', - Render: '/render', + 'json-render': '/render', Chat: '/chat', }; diff --git a/apps/website/src/components/docs/DocsSidebar.tsx b/apps/website/src/components/docs/DocsSidebar.tsx index 3e7fa2b14..059d0b66a 100644 --- a/apps/website/src/components/docs/DocsSidebar.tsx +++ b/apps/website/src/components/docs/DocsSidebar.tsx @@ -397,11 +397,3 @@ export function DocsNavigation({ ); } - -export function DocsSidebar(props: DocsNavigationProps) { - return ( - - ); -} diff --git a/apps/website/src/components/landing/FeatureBlock.spec.tsx b/apps/website/src/components/landing/FeatureBlock.spec.tsx index aa27ae72e..bb082df5a 100644 --- a/apps/website/src/components/landing/FeatureBlock.spec.tsx +++ b/apps/website/src/components/landing/FeatureBlock.spec.tsx @@ -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' }, diff --git a/apps/website/src/components/shared/Footer.tsx b/apps/website/src/components/shared/Footer.tsx index 9e166cc0d..5262e2d09 100644 --- a/apps/website/src/components/shared/Footer.tsx +++ b/apps/website/src/components/shared/Footer.tsx @@ -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'; @@ -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, }); }; @@ -200,8 +207,8 @@ export function Footer() { AG-UI trackFooterCta('Render', '/render')}> - Render + onClick={() => trackFooterCta('json-render', '/render', 'footer_render')}> + json-render trackFooterCta('Chat', '/chat')}> diff --git a/apps/website/src/lib/solutions-data.ts b/apps/website/src/lib/solutions-data.ts index ef5035cbd..3041dbd28 100644 --- a/apps/website/src/lib/solutions-data.ts +++ b/apps/website/src/lib/solutions-data.ts @@ -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.', }, @@ -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.', }, @@ -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.', }, diff --git a/apps/website/src/lib/solutions-links.spec.ts b/apps/website/src/lib/solutions-links.spec.ts new file mode 100644 index 000000000..1cb5910cb --- /dev/null +++ b/apps/website/src/lib/solutions-links.spec.ts @@ -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`. 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); + }); +}); diff --git a/apps/website/src/styles/docs-sidebar-styles.spec.ts b/apps/website/src/styles/docs-sidebar-styles.spec.ts deleted file mode 100644 index 37f819f63..000000000 --- a/apps/website/src/styles/docs-sidebar-styles.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -/** - * The picker's title and tagline are two sibling spans. They only stack because - * `.docs-sidebar-lib-item-text` is a column flex container — there is no other - * rule keeping them apart. - * - * PR #892 moved the JSX off Tailwind onto semantic class names and dropped the - * `flex flex-col` utilities without porting them here. Both spans fell back to - * `display: inline`, every menu row rendered as one run-on line - * ("LangGraphLangChain/LangGraph adapter for Angular UI"), and it shipped to - * production unnoticed. - * - * jsdom does not apply this stylesheet, so the component tests cannot see it. - * This is the only guard for that failure mode. - */ -const css = readFileSync(join(__dirname, 'docs.css'), 'utf8'); - -function ruleFor(selector: string): string { - const blocks = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)] - .filter((m) => m[1].split(',').some((s) => s.trim() === selector)) - .map((m) => m[2]); - return blocks.join(';'); -} - -describe('docs sidebar library picker styles', () => { - it('stacks the menu item title above its tagline', () => { - const rule = ruleFor('.docs-sidebar-lib-item-text'); - - expect(rule).toMatch(/display:\s*flex/); - expect(rule).toMatch(/flex-direction:\s*column/); - }); - - it('caps the menu height so it cannot run past the fold', () => { - const rule = ruleFor('.docs-sidebar-lib-menu'); - - expect(rule).toMatch(/max-height:/); - expect(rule).toMatch(/overflow-y:\s*auto/); - }); -}); diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index 122778ab6..7da71bf0b 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -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); diff --git a/apps/website/src/styles/style-contract.ts b/apps/website/src/styles/style-contract.ts new file mode 100644 index 000000000..77162e0d0 --- /dev/null +++ b/apps/website/src/styles/style-contract.ts @@ -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(';'); +} diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts new file mode 100644 index 000000000..955d8beaa --- /dev/null +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -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; +} + +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); + }); + } + }); + } +});