diff --git a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx
index 094ff645a..a073289c0 100644
--- a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx
+++ b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx
@@ -16,10 +16,10 @@ Clone the repo, run `nx serve cockpit-ag-ui-interrupts-angular`, and follow alon
-
+
The refund agent running in the docs workspace. Walk the approve / edit / cancel flow yourself.
-
+
The exact graph.py, server.py, and Angular component from this post.
diff --git a/apps/website/content/docs/ag-ui/guides/testing.mdx b/apps/website/content/docs/ag-ui/guides/testing.mdx
index fffbdf951..264144b34 100644
--- a/apps/website/content/docs/ag-ui/guides/testing.mdx
+++ b/apps/website/content/docs/ag-ui/guides/testing.mdx
@@ -1,5 +1,5 @@
---
-description: Test AG-UI components with provideFakeAgent() and its event script, the neutral mockAgent(), or a hand-written AbstractAgent, and know which double covers which surface.
+description: Test AG-UI components with provideFakeAgent() and its event script, the neutral mockAgent(), or a hand-written AbstractAgent, and which double fits where.
---
# Testing
diff --git a/apps/website/content/docs/chat/guides/error-handling.mdx b/apps/website/content/docs/chat/guides/error-handling.mdx
index 682b51191..0f9cf0820 100644
--- a/apps/website/content/docs/chat/guides/error-handling.mdx
+++ b/apps/website/content/docs/chat/guides/error-handling.mdx
@@ -1,5 +1,5 @@
---
-description: The structured AgentError on the agent error signal — its failure kinds, the retryable flag, the built-in chat-error UI, and classifying failures in a custom adapter.
+description: The structured AgentError on the agent error signal — its failure kinds, the retryable flag, the built-in chat-error UI, and classifying failures.
---
# Error Handling
diff --git a/apps/website/content/docs/chat/guides/lifecycle.mdx b/apps/website/content/docs/chat/guides/lifecycle.mdx
index dd6305d5d..9e7c5fd70 100644
--- a/apps/website/content/docs/chat/guides/lifecycle.mdx
+++ b/apps/website/content/docs/chat/guides/lifecycle.mdx
@@ -1,5 +1,5 @@
---
-description: The CHAT_LIFECYCLE token exposes per-instance signals for readiness, first submit, submit count and last submit time — and where in the injector tree they are reachable.
+description: The CHAT_LIFECYCLE token exposes per-instance signals for readiness, first submit, submit count and last submit time, and where they are reachable.
---
# Chat Lifecycle Signals
diff --git a/apps/website/content/docs/chat/guides/theming.mdx b/apps/website/content/docs/chat/guides/theming.mdx
index 4458578c8..c61eda29a 100644
--- a/apps/website/content/docs/chat/guides/theming.mdx
+++ b/apps/website/content/docs/chat/guides/theming.mdx
@@ -1,5 +1,5 @@
---
-description: How the theming example swaps --tplane-chat-* custom properties at runtime, where the token defaults come from, and how to override them in your own application.
+description: How the theming example swaps --tplane-chat-* custom properties at runtime, where the token defaults come from, and how to override them in your app.
---
# Theming
diff --git a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx
index 06ff35e71..ffab2c707 100644
--- a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx
+++ b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx
@@ -1,5 +1,5 @@
---
-description: Implement the runtime-neutral Agent contract for a custom backend — the required fields, a working in-process example, the conformance suite, and publishing notes.
+description: Implement the runtime-neutral Agent contract for a custom backend — the required fields, a working in-process example, and the conformance suite.
---
# Writing an Adapter
diff --git a/apps/website/content/docs/langgraph/concepts/agent-contract.mdx b/apps/website/content/docs/langgraph/concepts/agent-contract.mdx
index 106645cb8..244c4bd8e 100644
--- a/apps/website/content/docs/langgraph/concepts/agent-contract.mdx
+++ b/apps/website/content/docs/langgraph/concepts/agent-contract.mdx
@@ -1,5 +1,5 @@
---
-description: The runtime-neutral Agent contract from @threadplane/chat — its state signals, actions, optional capabilities, and how the LangGraph and AG-UI adapters satisfy it
+description: The runtime-neutral Agent contract from @threadplane/chat — its state signals, actions, optional capabilities, and how the adapters satisfy it.
---
# Agent Contract
diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx
index 80cde3eae..2592dd655 100644
--- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx
+++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx
@@ -1,5 +1,5 @@
---
-description: How the subgraphs example routes a turn into a compiled child graph, keeps the child's state and tokens out of the transcript, and surfaces the child as a stream
+description: How the subgraphs example routes a turn into a compiled child graph, keeps the child's state and tokens out of the transcript, and streams the child.
---
# Subgraphs
diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx
index 433c1ccb4..c73b7ccf4 100644
--- a/apps/website/content/docs/langgraph/guides/time-travel.mdx
+++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx
@@ -1,5 +1,5 @@
---
-description: How the time travel example checkpoints every turn, lists the checkpoints in a timeline sidebar, and selects or forks from one, plus the history signals and the branch tree
+description: How the time travel example checkpoints every turn, lists them in a timeline sidebar, and forks from one, plus the history signals and the branch tree.
---
# Time Travel
diff --git a/apps/website/content/docs/render/api/define-angular-registry.mdx b/apps/website/content/docs/render/api/define-angular-registry.mdx
index 071439013..d9d4ccdb5 100644
--- a/apps/website/content/docs/render/api/define-angular-registry.mdx
+++ b/apps/website/content/docs/render/api/define-angular-registry.mdx
@@ -1,6 +1,6 @@
---
title: defineAngularRegistry()
-description: API reference for defineAngularRegistry() -- the entry shapes it accepts, the normalized registry it returns, per-component fallbacks, and schema-gated mounting.
+description: API reference for defineAngularRegistry() -- the entry shapes it accepts, the normalized registry it returns, per-component fallbacks, and schema gating.
---
# defineAngularRegistry()
diff --git a/apps/website/src/components/docs/mdx/Card.tsx b/apps/website/src/components/docs/mdx/Card.tsx
index fdbf3ab46..23daeb321 100644
--- a/apps/website/src/components/docs/mdx/Card.tsx
+++ b/apps/website/src/components/docs/mdx/Card.tsx
@@ -15,13 +15,11 @@ export function CardGroup({ cols = 2, children }: { cols?: number; children: Rea
export function Card({
title,
href,
- icon,
external = false,
children,
}: {
title: string;
href: string;
- icon?: string;
/** When true, open in a new tab (for off-site links: demos, GitHub, etc.). */
external?: boolean;
children: React.ReactNode;
@@ -34,7 +32,6 @@ export function Card({
- {icon ?
{icon}
: null}
{title}
→
diff --git a/apps/website/src/lib/docs-content-rules.spec.ts b/apps/website/src/lib/docs-content-rules.spec.ts
new file mode 100644
index 000000000..08c2af158
--- /dev/null
+++ b/apps/website/src/lib/docs-content-rules.spec.ts
@@ -0,0 +1,307 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { readFrontmatterDescription } from './docs';
+import { META_DESCRIPTION_MAX, clampMetaDescription } from './site-metadata';
+import { resolveWebsiteDir } from './website-dir';
+
+/**
+ * Mechanical authoring rules for `content/**` MDX.
+ *
+ * Every rule here stands for a defect the accuracy audit found by hand and
+ * that no build step catches: the page still renders, it just renders wrong.
+ * Each one reports the offending `path:line`, so a failure names the file to
+ * open rather than the rule that fired.
+ */
+const WEBSITE_ROOT = resolveWebsiteDir();
+const CONTENT_ROOT = join(WEBSITE_ROOT, 'content');
+const DOCS_ROOT = join(CONTENT_ROOT, 'docs');
+const CALLOUT_COMPONENT = 'src/components/docs/mdx/Callout.tsx';
+const CARD_COMPONENT = 'src/components/docs/mdx/Card.tsx';
+
+interface MdxFile {
+ /** Relative to `apps/website`, so a failure reads `content/docs/...`. */
+ readonly relativePath: string;
+ readonly content: string;
+}
+
+function mdxFiles(directory: string): MdxFile[] {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) return mdxFiles(path);
+ if (!entry.isFile() || !entry.name.endsWith('.mdx')) return [];
+ return [
+ {
+ relativePath: relative(WEBSITE_ROOT, path),
+ content: readFileSync(path, 'utf8'),
+ },
+ ];
+ });
+}
+
+function lineNumberAt(content: string, index: number): number {
+ return content.slice(0, index).split('\n').length;
+}
+
+/**
+ * Opening tags for one MDX component. `[^>]` matches newlines, so a tag whose
+ * props wrap across lines is found the same as a one-liner.
+ */
+function openingTags(
+ file: MdxFile,
+ component: string
+): { readonly tag: string; readonly location: string }[] {
+ const pattern = new RegExp(`<${component}\\b[^>]*>`, 'g');
+ return [...file.content.matchAll(pattern)].map((match) => ({
+ tag: match[0],
+ location: `${file.relativePath}:${lineNumberAt(file.content, match.index)}`,
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Rule 1 — `
` prints the prop verbatim.
+// ---------------------------------------------------------------------------
+
+/**
+ * `Card` has no icon lookup and, since the dead prop was deleted, no `icon`
+ * prop at all. MDX props are not type-checked, so an `icon` an author adds is
+ * accepted by the compiler and then silently dropped — and while the prop
+ * existed it rendered its own value as text (`icon="rocket"` printed
+ * "rocket"). Either way the page never shows what the author meant.
+ */
+function findCardIconProps(files: readonly MdxFile[]): string[] {
+ return files.flatMap((file) =>
+ openingTags(file, 'Card')
+ .filter(({ tag }) => /\sicon\s*=/.test(tag))
+ .map(({ location }) => location)
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Rule 2 — every docs page describes itself.
+// ---------------------------------------------------------------------------
+
+/**
+ * With no frontmatter `description`, `getDocDescription()` falls back to the
+ * page's first paragraph and then to the library blurb, so unrelated pages
+ * ship identical meta descriptions. A description longer than
+ * {@link META_DESCRIPTION_MAX} is silently clamped mid-sentence instead.
+ */
+function findDescriptionDefects(files: readonly MdxFile[]): string[] {
+ return files.flatMap((file) => {
+ const description = readFrontmatterDescription(file.content)?.trim();
+ if (!description) return [`${file.relativePath}: no frontmatter description`];
+ if (clampMetaDescription(description) !== description) {
+ return [
+ `${file.relativePath}: description is ${description.length} characters, clamped at ${META_DESCRIPTION_MAX}`,
+ ];
+ }
+ return [];
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Rule 3 — `` outside the union renders unstyled.
+// ---------------------------------------------------------------------------
+
+/**
+ * The allowed set is read out of the component so the guard cannot drift from
+ * it. `Callout` indexes `ICON_PATHS[type]` with no fallback, so an unknown
+ * type (`type="note"` was the one in the wild) renders an empty icon and an
+ * unstyled band.
+ */
+function calloutTypesFrom(source: string): string[] {
+ const union = source.match(/type\s+CalloutType\s*=\s*([^;]+);/)?.[1];
+ if (!union) {
+ throw new Error(`CalloutType union not found in ${CALLOUT_COMPONENT}`);
+ }
+ return [...union.matchAll(/'([^']+)'/g)].map((match) => match[1]);
+}
+
+function findCalloutTypeDefects(
+ files: readonly MdxFile[],
+ allowed: readonly string[]
+): string[] {
+ return files.flatMap((file) =>
+ openingTags(file, 'Callout').flatMap(({ tag, location }) => {
+ const attribute = tag.match(/\stype\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/);
+ if (!attribute) return []; // No type at all is fine; the component defaults.
+ const literal = attribute[1] ?? attribute[2];
+ if (literal === undefined) return [`${location}: type={${attribute[3]}}`];
+ return allowed.includes(literal) ? [] : [`${location}: type="${literal}"`];
+ })
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Rule 4 — docs prose uses no contractions.
+// ---------------------------------------------------------------------------
+
+/**
+ * Possessives are not contractions, so the patterns never match a bare `X's`:
+ * the `'s` pattern is a closed list of pronouns and determiners that cannot
+ * take a possessive in this prose, and the other patterns end in suffixes no
+ * possessive uses.
+ */
+const CONTRACTION_PATTERNS: readonly RegExp[] = [
+ /\b[A-Za-z]+n['’]t\b/g, // does not, cannot, is not
+ /\b[A-Za-z]+['’](?:re|ve|ll|m|d)\b/g, // you are, we have, it will, I am, we would
+ /\b(?:everything|he|here|how|it|let|nothing|one|she|something|that|there|this|what|when|where|which|who|why)['’]s\b/gi,
+];
+
+/** `## What's Next` is the site's section convention and stays as written. */
+const WHATS_HEADING = /^#{1,6}\s+What['’]s\b/;
+
+/** Blank out code so a contraction inside a sample is not prose. Line numbers survive. */
+function withoutCode(content: string): string {
+ const blank = (block: string): string => block.replace(/[^\n]/g, ' ');
+ return content
+ .replace(/```[\s\S]*?```/g, blank)
+ .replace(/`[^`\n]*`/g, blank)
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, blank);
+}
+
+function findContractions(files: readonly MdxFile[]): string[] {
+ return files.flatMap((file) =>
+ withoutCode(file.content)
+ .split('\n')
+ .flatMap((line, index) => {
+ if (WHATS_HEADING.test(line.trim())) return [];
+ const found = CONTRACTION_PATTERNS.flatMap((pattern) => [
+ ...line.matchAll(pattern),
+ ]).map((match) => match[0]);
+ if (found.length === 0) return [];
+ return [`${file.relativePath}:${index + 1}: ${found.join(', ')}`];
+ })
+ );
+}
+
+// ---------------------------------------------------------------------------
+
+const CONTENT_FILES = mdxFiles(CONTENT_ROOT);
+const DOCS_FILES = mdxFiles(DOCS_ROOT);
+
+describe('docs content rules', () => {
+ it('scans the whole authored MDX tree', () => {
+ const paths = CONTENT_FILES.map((file) => file.relativePath);
+ expect(paths.length).toBeGreaterThan(100);
+ expect(paths).toContain('content/docs/chat/getting-started/introduction.mdx');
+ expect(paths).toContain(
+ 'content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx'
+ );
+ expect(DOCS_FILES.length).toBeGreaterThan(100);
+ expect(
+ DOCS_FILES.every((file) => file.relativePath.startsWith('content/docs/'))
+ ).toBe(true);
+ });
+
+ it('passes no icon prop to Card, which does not accept one', () => {
+ expect(
+ readFileSync(join(WEBSITE_ROOT, CARD_COMPONENT), 'utf8'),
+ `${CARD_COMPONENT} must not reintroduce an icon prop without an icon lookup`
+ ).not.toMatch(/\bicon\b/i);
+ expect(findCardIconProps(CONTENT_FILES)).toEqual([]);
+ });
+
+ it('detects an icon prop wherever it sits in the tag', () => {
+ const content = [
+ 'body',
+ 'body',
+ 'body',
+ '',
+ ].join('\n');
+
+ expect(findCardIconProps([{ relativePath: 'p.mdx', content }])).toEqual([
+ 'p.mdx:2',
+ 'p.mdx:3',
+ ]);
+ });
+
+ it('gives every docs page its own frontmatter description', () => {
+ expect(findDescriptionDefects(DOCS_FILES)).toEqual([]);
+ });
+
+ it('reports a missing description and one long enough to be clamped', () => {
+ const long = `A${'b'.repeat(META_DESCRIPTION_MAX)} c`;
+ const files: MdxFile[] = [
+ { relativePath: 'ok.mdx', content: '---\ndescription: A short one.\n---\n# T\n' },
+ { relativePath: 'none.mdx', content: '---\ntitle: T\n---\n# T\n' },
+ { relativePath: 'empty.mdx', content: '---\ndescription: \n---\n# T\n' },
+ { relativePath: 'bare.mdx', content: '# T\n' },
+ { relativePath: 'long.mdx', content: `---\ndescription: ${long}\n---\n# T\n` },
+ ];
+
+ expect(findDescriptionDefects(files).map((entry) => entry.split(':')[0])).toEqual([
+ 'none.mdx',
+ 'empty.mdx',
+ 'bare.mdx',
+ 'long.mdx',
+ ]);
+ });
+
+ it('reads the Callout union out of the component', () => {
+ const source = readFileSync(join(WEBSITE_ROOT, CALLOUT_COMPONENT), 'utf8');
+ // Update this list, the docs style rule, and any affected pages together.
+ expect([...calloutTypesFrom(source)].sort()).toEqual([
+ 'danger',
+ 'info',
+ 'tip',
+ 'warning',
+ ]);
+ expect(() => calloutTypesFrom('type Other = 1;')).toThrow(/CalloutType union/);
+ });
+
+ it('uses only Callout types the component styles', () => {
+ const source = readFileSync(join(WEBSITE_ROOT, CALLOUT_COMPONENT), 'utf8');
+ expect(findCalloutTypeDefects(CONTENT_FILES, calloutTypesFrom(source))).toEqual([]);
+ });
+
+ it('flags an unknown Callout type and leaves a typeless Callout alone', () => {
+ const content = [
+ 'plain',
+ 'fine',
+ 'wrong',
+ 'wrong',
+ ].join('\n');
+
+ expect(
+ findCalloutTypeDefects([{ relativePath: 'p.mdx', content }], [
+ 'tip',
+ 'warning',
+ 'info',
+ 'danger',
+ ])
+ ).toEqual(['p.mdx:3: type="note"', 'p.mdx:4: type={kind}']);
+ });
+
+ it('writes docs prose without contractions', () => {
+ expect(findContractions(DOCS_FILES)).toEqual([]);
+ });
+
+ it('flags contractions without flagging possessives or the What’s Next heading', () => {
+ const content = [
+ "The agent's state and the component's inputs stay intact.", // 1 possessive
+ "## What's Next", // 2 site convention
+ "It doesn't stream.", // 3
+ "You're holding a signal.", // 4
+ "That's the whole contract.", // 5
+ "The graph would’ve resumed.", // 6
+ 'Run `it doesn\'t matter` inline.', // 7 code span
+ '```ts', // 8
+ "// you're inside a fence", // 9
+ '```', // 10
+ "The user's cannot-be-empty note.", // 11 possessive
+ ].join('\n');
+
+ expect(findContractions([{ relativePath: 'p.mdx', content }])).toEqual([
+ "p.mdx:3: doesn't",
+ "p.mdx:4: You're",
+ "p.mdx:5: That's",
+ 'p.mdx:6: would’ve',
+ ]);
+ });
+});
diff --git a/apps/website/src/lib/docs.spec.ts b/apps/website/src/lib/docs.spec.ts
index 8d812ae30..216672b21 100644
--- a/apps/website/src/lib/docs.spec.ts
+++ b/apps/website/src/lib/docs.spec.ts
@@ -10,6 +10,7 @@ import {
getAllDocSlugs,
getDocBySlug,
getDocMetadata,
+ readFrontmatterDescription,
stripFrontmatter,
} from './docs';
import {
@@ -235,6 +236,32 @@ describe('website docs bindings', () => {
);
});
+ it('reads a frontmatter description that contains an apostrophe', () => {
+ // The value pattern excluded every quote character, so a description with
+ // a possessive never matched and the page silently fell back to its first
+ // paragraph while declaring a description of its own.
+ const metadata = getDocMetadata('chat', 'components', 'chat-reasoning');
+
+ expect(metadata?.description).toBe(
+ "The ChatReasoningComponent pill that expands to reveal an assistant's reasoning text, its five inputs, and the auto-collapse behavior."
+ );
+ });
+
+ it('strips only a matched pair of surrounding quotes', () => {
+ expect(readFrontmatterDescription("---\ndescription: 'Quoted.'\n---\n")).toBe(
+ 'Quoted.'
+ );
+ expect(readFrontmatterDescription('---\ndescription: "Quoted."\n---\n')).toBe(
+ 'Quoted.'
+ );
+ expect(
+ readFrontmatterDescription("---\ndescription: The child's state.\n---\n")
+ ).toBe("The child's state.");
+ expect(readFrontmatterDescription('---\ntitle: T\n---\n')).toBeNull();
+ expect(readFrontmatterDescription('---\ndescription: \n---\n')).toBeNull();
+ expect(readFrontmatterDescription('# No frontmatter\n')).toBeNull();
+ });
+
it('never leaks frontmatter keys into a derived description', () => {
for (const { library, section, slug } of getAllDocSlugs()) {
const description =
diff --git a/apps/website/src/lib/docs.ts b/apps/website/src/lib/docs.ts
index bb25c4227..b3af65d8f 100644
--- a/apps/website/src/lib/docs.ts
+++ b/apps/website/src/lib/docs.ts
@@ -32,7 +32,16 @@ export type ResolvedDocMetadata = Metadata;
*/
const FRONTMATTER_BLOCK_PATTERN = /^---\s*\n(?[\s\S]*?)\n---\s*(?:\n|$)/;
-const FRONTMATTER_DESCRIPTION_PATTERN = /^description:\s*['"]?(?[^'"\n]+?)['"]?\s*$/m;
+/**
+ * The value is captured whole and unquoted afterwards. Excluding quote
+ * characters from the capture instead made every description containing a
+ * possessive unreadable, so those pages fell back to their first paragraph.
+ */
+const FRONTMATTER_DESCRIPTION_PATTERN = /^description:[^\S\n]*(?\S.*?)[^\S\n]*$/m;
+
+function unquote(value: string): string {
+ return value.match(/^(?['"])(?.*)\k$/)?.groups?.inner ?? value;
+}
/**
* Remove a frontmatter block so the rest can be handed to the MDX pipeline.
@@ -48,10 +57,16 @@ export function stripFrontmatter(source: string): string {
return source.replace(FRONTMATTER_BLOCK_PATTERN, '');
}
-function readFrontmatterDescription(content: string): string | null {
+/**
+ * The `description` a page declares for itself, or `null` when it declares
+ * none. Exported so the content guard can assert on the same parse the page
+ * metadata uses rather than a second copy of these patterns.
+ */
+export function readFrontmatterDescription(content: string): string | null {
const body = content.match(FRONTMATTER_BLOCK_PATTERN)?.groups?.body;
if (!body) return null;
- return body.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description ?? null;
+ const description = body.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description;
+ return description ? unquote(description) : null;
}
function normalizeDescription(description: string): string {
diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css
index c07e319cb..e1ad7b65f 100644
--- a/apps/website/src/styles/docs.css
+++ b/apps/website/src/styles/docs.css
@@ -565,10 +565,6 @@ body:has([data-website-workspace-host]) {
justify-content: space-between;
align-items: flex-start;
}
-.mdx-card-icon {
- font-size: 1.15rem;
- margin-bottom: 6px;
-}
.mdx-card-title {
font-family: var(--font-display);
font-size: 0.95rem;