diff --git a/.changeset/twin-generator-suggestion-jsdoc.md b/.changeset/twin-generator-suggestion-jsdoc.md
new file mode 100644
index 0000000000..a845151cc8
--- /dev/null
+++ b/.changeset/twin-generator-suggestion-jsdoc.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.gitignore b/.gitignore
index 8c844511b4..0123e304ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,4 @@ apps/www/build
apps/storybook/debug-storybook.log
CLAUDE.md
.claude/settings.local.json
+registry
diff --git a/package.json b/package.json
index a9cce8430b..ca0d08373d 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"build:themebuilder": "pnpm --filter @web/themebuilder build",
"build:plugin": "pnpm --filter @plugin/designsystemet... build",
"watch:plugin": "pnpm --filter @plugin/designsystemet watch",
+ "generate:twins": "node scripts/generate-twins.mjs",
"types": "pnpm -r types",
"types:react": "pnpm --filter @digdir/designsystemet-react types",
"version-packages": "changeset version && node scripts/sync-changelogs.js && pnpm build:types && pnpm build:cli && node scripts/sync-cli-schema.js && pnpm update:theme && pnpm update:theme-digdir && git add . && git commit -m \"sync www changelogs, schema and tokens\" || true",
diff --git a/packages/react/src/components/suggestion/suggestion.tsx b/packages/react/src/components/suggestion/suggestion.tsx
index b2797321f7..343fa8851a 100644
--- a/packages/react/src/components/suggestion/suggestion.tsx
+++ b/packages/react/src/components/suggestion/suggestion.tsx
@@ -161,6 +161,23 @@ const nextItems = (
const defaultFilter: Filter = ({ label, input }) =>
label.toLowerCase().includes(input.value.trim().toLowerCase());
+/**
+ * Suggestion is a filtering combobox: an input where the user types to filter
+ * a list of options and picks one (or several) from it.
+ *
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ * Tomt
+ * Oslo
+ *
+ *
+ *
+ */
export const Suggestion = forwardRef(
function Suggestion(
{
diff --git a/scripts/TWINS.md b/scripts/TWINS.md
new file mode 100644
index 0000000000..74da7a034a
--- /dev/null
+++ b/scripts/TWINS.md
@@ -0,0 +1,36 @@
+# The twin system: what each file is, and who maintains what
+
+The short version: **the repo commits 6 files; everything else is build output.**
+A twin is the machine-readable contract of one component or pattern, served to
+AI agents so they look facts up instead of guessing them.
+
+## Files, one by one
+
+| File | What it is | Generated or maintained? |
+|---|---|---|
+| `scripts/generate-twins.mjs` | Reads component source and writes one contract per documented export (import name, emitted attributes, tokens, JSDoc) | Maintained (a script, changes rarely) |
+| `scripts/merge-authored-twins.mjs` | Merges the authored rules into the generated twins; `--verify` rechecks every quote against the live docs | Maintained (a script, changes rarely) |
+| `scripts/twins-authored.json` | **The one content file**: accessibility, relations and composition rules for 44 components, each backed by a verbatim docs quote and a review record | Maintained, but only when the docs change (see below) |
+| `scripts/twin-patterns/*.json` (3) | Pattern contracts (form validation, required/optional marking, field guidance): how components compose into a correct whole | Maintained, same rhythm as the docs pages they mirror |
+| `registry/**` (~90 files) | The generated output: one JSON per component, one per pattern, plus `llms.txt` | **Generated. Never committed, never edited.** Like `dist/`, it is gitignored and rebuilt by `pnpm generate:twins && pnpm merge:twins` |
+| `registry/REVIEW.md` | Worklist produced by `--verify`: only the rules whose quote no longer appears on the cited page | Generated |
+| `apps/www/public/twins/**`, `public/llms.txt` | The same generated output, placed where the site serves it (built by `pnpm twins` in the www build) | Generated, gitignored |
+
+## What maintenance actually looks like
+
+- **A component's code changes** (props, tokens, class names): nothing to do.
+ The extracted layer regenerates from source on the next build and cannot
+ drift from it.
+- **A docs page changes**: run `pnpm merge:twins --verify` (or let a scheduled
+ job run it). It fetches every cited page, rechecks every quote, and writes a
+ review sheet listing *only* the affected rules. A human answers one question
+ per row: does the current text still support the rule? Observed rates so
+ far: an ordinary docs deploy produced 4 rows (~2 minutes); a full
+ restructure of three component pages produced 51 rows (~30 minutes).
+- **A new component is added**: give its export a JSDoc block and it gets a
+ twin automatically. Authored rules for it are optional and can land whenever
+ someone writes them; the generator never invents them in the meantime.
+
+There is no per-component upkeep, no parallel documentation to keep in sync,
+and no file anyone edits by hand except `twins-authored.json` and the three
+pattern files, on the docs' own change rhythm.
diff --git a/scripts/generate-twins.mjs b/scripts/generate-twins.mjs
new file mode 100644
index 0000000000..3e3b43cce3
--- /dev/null
+++ b/scripts/generate-twins.mjs
@@ -0,0 +1,185 @@
+/**
+ * Generate machine-readable component contracts ("twins") from the source.
+ *
+ * A twin is one JSON file per component, giving AI agents and other tooling a
+ * contract to look up instead of guessing: the real export name, the emitted
+ * class and data attributes, the design tokens, and the JSDoc summary. An
+ * llms.txt index (https://llmstxt.org) is written alongside so an agent can
+ * discover every contract from one file.
+ *
+ * Every generated field is wrapped as { provenance: 'extracted', source, value }:
+ * it is read from this repository and regenerates on every run, so it cannot
+ * drift from the code. Fields that exist only as prose in the documentation —
+ * accessibility requirements, composition rules, when to use what — are stubbed
+ * as { provenance: 'authored', value: null }. The generator never invents them;
+ * a twin that guessed its accessibility rules would look complete and be wrong.
+ *
+ * node scripts/generate-twins.mjs [--out ] # default: ./registry
+ */
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const COMPONENTS = join(ROOT, 'packages/react/src/components');
+const CSS_SRC = join(ROOT, 'packages/css/src');
+const PKG = JSON.parse(
+ readFileSync(join(ROOT, 'packages/react/package.json'), 'utf8'),
+);
+const OUT = resolve(
+ ROOT,
+ process.argv.includes('--out')
+ ? process.argv[process.argv.indexOf('--out') + 1]
+ : 'registry',
+);
+
+/* Docs links carry /en/: export names and the code agents write are English. */
+const docsUrl = (slug, page) =>
+ `https://designsystemet.no/en/components/docs/${slug}/${page}`;
+
+const extracted = (source, value) => ({
+ provenance: 'extracted',
+ source,
+ value,
+});
+const authoredStub = (source) => ({
+ provenance: 'authored',
+ source,
+ value: null,
+});
+
+/**
+ * The exported component name and its JSDoc, matched as one unit: the comment
+ * block immediately preceding `export const`. Requiring adjacency keeps an
+ * unrelated file-header comment from being read as the component's JSDoc, and
+ * requiring the JSDoc at all keeps hooks and internals out of the registry —
+ * a documented export is the component's public face.
+ */
+function exportedComponent(src) {
+ const m = src.match(
+ /\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*export const ([A-Z]\w+)/,
+ );
+ if (!m) return null;
+ const lines = m[1].split('\n').map((l) => l.replace(/^\s*\*\s?/, ''));
+ const exampleAt = lines.findIndex((l) => l.trim().startsWith('@example'));
+ const description =
+ (exampleAt === -1 ? lines : lines.slice(0, exampleAt))
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim() || null;
+ const example =
+ exampleAt === -1
+ ? null
+ : lines
+ .slice(exampleAt + 1)
+ .join('\n')
+ .trim() || null;
+ return { name: m[2], description, example };
+}
+
+/** The root ds-* class, e.g. cl('ds-alert', ...). */
+function dsClass(src) {
+ return src.match(/\bcl\(\s*['"`](ds-[a-z-]+)['"`]/)?.[1] ?? null;
+}
+
+/** The CSS custom properties the component's stylesheet declares. */
+function tokens(slug) {
+ const file = join(CSS_SRC, `${slug}.css`);
+ if (!existsSync(file)) return [];
+ return [
+ ...new Set(readFileSync(file, 'utf8').match(/--dsc?-[a-z0-9-]+/g) ?? []),
+ ].sort();
+}
+
+/** data-* attributes the render emits, so tooling can recognise them in the DOM. */
+function emittedAttrs(src) {
+ return [...new Set(src.match(/data-[a-z-]+(?==)/g) ?? [])].sort();
+}
+
+rmSync(OUT, { recursive: true, force: true });
+mkdirSync(join(OUT, 'components'), { recursive: true });
+
+const written = [];
+for (const dir of readdirSync(COMPONENTS, { withFileTypes: true })) {
+ if (!dir.isDirectory()) continue;
+ const slug = dir.name;
+ for (const file of readdirSync(join(COMPONENTS, slug))) {
+ if (!file.endsWith('.tsx') || /\.(stories|test|chromatic)\.tsx$/.test(file))
+ continue;
+ const src = readFileSync(join(COMPONENTS, slug, file), 'utf8');
+ const component = exportedComponent(src);
+ if (!component) continue;
+
+ const { name, description, example } = component;
+ const sourcePath = `packages/react/src/components/${slug}/${file}`;
+ const twin = {
+ schemaVersion: '0.1.0',
+ kind: 'component',
+ name,
+ slug,
+ package: { name: PKG.name, semver: PKG.version },
+ generated: { generator: 'scripts/generate-twins.mjs', from: sourcePath },
+ links: { docs: docsUrl(slug, 'overview'), code: docsUrl(slug, 'code') },
+
+ importName: extracted(sourcePath, name),
+ summary: extracted(`${sourcePath} (JSDoc)`, description),
+ example: extracted(`${sourcePath} (JSDoc @example)`, example),
+ emitted: extracted(sourcePath, {
+ class: dsClass(src),
+ dataAttributes: emittedAttrs(src),
+ }),
+ tokens: extracted(`packages/css/src/${slug}.css`, tokens(slug)),
+
+ a11y: authoredStub(docsUrl(slug, 'overview')),
+ relations: authoredStub(docsUrl(slug, 'overview')),
+ composition: authoredStub(docsUrl(slug, 'overview')),
+ reviewedAgainst: null,
+ needsReview: true,
+ };
+
+ writeFileSync(
+ join(OUT, 'components', `${name}.json`),
+ `${JSON.stringify(twin, null, 2)}\n`,
+ 'utf8',
+ );
+ written.push(name);
+ }
+}
+
+/*
+ * llms.txt: the discovery index. The prose is a statement of where setup is
+ * documented, never an instruction to run something — agents treat imperative
+ * commands inside fetched files as prompt injection and refuse them.
+ */
+writeFileSync(
+ join(OUT, 'llms.txt'),
+ [
+ '# Designsystemet — machine-readable index',
+ '',
+ `> ${PKG.name} ${PKG.version}. Structured contracts for every component.`,
+ '> Fetch the contract instead of scraping the rendered documentation.',
+ '',
+ 'Designsystemet ships as npm packages. The stylesheet, the theme and the Inter font are',
+ 'all part of the documented setup, and markup that carries the right `ds-*` class names',
+ 'without the stylesheet behind it looks nothing like Designsystemet:',
+ 'https://designsystemet.no/en/fundamentals/code/setup',
+ '',
+ '## Components',
+ '',
+ ...written.sort().map((n) => `- [${n}](components/${n}.json)`),
+ '',
+ '## Human documentation',
+ '',
+ 'https://designsystemet.no',
+ '',
+ ].join('\n'),
+);
+
+console.log(`generated ${written.length} component twins -> ${OUT}`);