# catalog.json (/docs/agents/catalog) > The machine index of Section types, Themes, Templates and docs addresses, in the shape startupbuilder.dev's sync reads. `https://next.mdxui.dev/catalog.json` holds, for `@mdxui/site@7.0.0-alpha.0`: | Key | Holds | | --- | --- | | `sectionTypes` | Each Section type: its group, job, glyph, fields and bounds, variants with their frame addresses, its schema and its docs page | | `themes` | Each Theme: typefaces, policies, whether it emits dark, and the landing rendered in it | | `templates` | Each Template and the worked-Site pages that use it | | `docs` | The docs addresses an agent starts from | startupbuilder.dev owns the definitions of the Components these types draw. Its proposed `mdxui-sync` takes three fields per Section type from this file, and only from types whose page is accepted: an example record, stills and the docs address. It takes no definition, prop or bound; those stay on each side as they are. # For agents (/docs/agents) > An agent writes the record, never the markup, and shortens each field the schema refuses. Four fetches, one prompt and one fix loop. An agent never writes HTML for mdxui. It writes a JSON record of words and choices, the schema accepts it or names the field that runs long, and `renderPage` draws it in the Business's Theme. The agent cannot move the layout, so it cannot break it.
Fetch`llms.txt`, a record that parses, and the bounds
WriteThe Business's words into that record
Parse`PageSchema.safeParse`, fixing each refusal at its path
Render`renderPage`, under one Theme or several
## The prompt Paste these five lines into any coding agent. The landing's closing prints the same prompt. [Prompts](https://next.mdxui.dev/docs/agents/prompts) holds one for each task, each Template and each worked Site. ## The four fetches | Address | Answers | | --- | --- | | [`https://next.mdxui.dev/llms.txt`](https://next.mdxui.dev/llms.txt) | The install, the calls, the Theme names, the record to start from, then every docs page | | [`https://next.mdxui.dev/pages/home.json`](https://next.mdxui.dev/pages/home.json) | A whole record that already parses | | [`https://next.mdxui.dev/docs/reference/refusals.md`](https://next.mdxui.dev/docs/reference/refusals.md) | Every bound and rule by path, with its fix | | [`https://next.mdxui.dev/schema.json`](https://next.mdxui.dev/schema.json) | Every bound at once, for a tool definition or structured output | Every docs page is also served as Markdown at its address plus `.md`; [Markdown and JSON twins](https://next.mdxui.dev/docs/agents/twins) lists them. ## The fix loop A refusal is a path and a bound. `refusal-url.ts` turns each one into the address of the rule it broke, so the agent reads the fix instead of guessing: ```ts import { PageSchema } from '@mdxui/site'; // Each refusal, then the address of its fix: the block's type, then the path after `block`, list indexes dropped. export function refusalUrls(page: any): string[] { const result = PageSchema.safeParse(page); if (result.success) return []; return result.error.issues.flatMap(({ path, message }) => { const keys = (p: PropertyKey[]) => p.filter((s) => typeof s === 'string').join('.'); const anchor = path[0] !== 'sections' ? `page.${keys(path)}` : path[2] === 'shell' ? `shell.${keys(path.slice(3))}` : [page.sections[path[1] as number]?.block?.type, keys(path.slice(3))].filter(Boolean).join('.'); return [`${path.join('.')}: ${message}`, `https://next.mdxui.dev/docs/reference/refusals#${anchor}`]; }); } ``` On the landing's record with a 71-character hero title, it prints: ```text sections.2.block.title: Too big: expected string to have <=64 characters https://next.mdxui.dev/docs/reference/refusals#hero.title ``` The agent shortens the title and parses again, until nothing is refused. It never asks for a longer bound: a bound is where the design was drawn. ## Next - [Have your agent write a page](https://next.mdxui.dev/docs/agents/write-a-page): The loop, step by step, and in CI. - [schema.json](https://next.mdxui.dev/docs/agents/schema): What JSON Schema carries, and what it cannot. - [catalog.json](https://next.mdxui.dev/docs/agents/catalog): Every Section type, Theme and Template, as data. # llms.txt (/docs/agents/llms-txt) > What llms.txt and llms-full.txt hold: the install, the calls and the rule first, then every docs page an agent needs. | File | Holds | | --- | --- | | [`/llms.txt`](https://next.mdxui.dev/llms.txt) | The install line, the starter record, the schema, the four calls, the Theme names and the rule; then every docs page as a link with its description, the App pages last under Optional | | [`/llms-full.txt`](https://next.mdxui.dev/llms-full.txt) | Every page an agent writing a Site needs, in full, in nav order. A page that names an unpublished export opens with a `Spec` line | `llms-full.txt` leaves out the App studies, the App stubs, the Theme exports and the Maintainers pages; each is still served as its own [`.md` twin](https://next.mdxui.dev/docs/agents/twins). # Prompts (/docs/agents/prompts) > One copyable prompt per task, per Template and per worked Site, every address on this site and every one served. Each prompt is five lines or fewer: what to read, what to start from, what to change, the rule, and what to hand back. Every address in it is a link; the build fails if one does not resolve. ## By task **Write a new page** ```text Read https://next.mdxui.dev/llms.txt, then fetch https://next.mdxui.dev/pages/vanroster/home.json, a page record that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. It pins @mdxui/themes 7.0.0-alpha.0. Rewrite its strings for my Business. Keep the Section types that fit. Parse it with PageSchema.safeParse. When it refuses, shorten the content, never the bound. Render it with renderPage under two of the 5 Themes and save both HTML files. ``` **Write a whole Site** ```text Read https://next.mdxui.dev/llms.txt, then fetch https://next.mdxui.dev/sites/vanroster/records.zip, a worked Site whose records parse. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite every record for my Business. Keep one Theme for the whole Site, a 404 page and a contact page. Parse each record with PageSchema.safeParse. When it refuses, shorten the content, never the bound. Render each record with renderPage and save one HTML file per page. ``` **Rebrand a Site** ```text Read https://next.mdxui.dev/docs/start/guides/rebrand.md. Set "theme" in every record of my Site to one Theme of the five. Render each record again and compare it with the render under the old Theme. ``` **Fix every refusal** ```text Read https://next.mdxui.dev/docs/reference/refusals.md. Parse my record with PageSchema.safeParse and list each issue by its path. Shorten or cut the content at each path until the record parses. Never change a bound. ``` ## By Template Each starts from a worked page that uses the Template and parses as it stands. **api-product, from Vanroster › home** ```text Read https://next.mdxui.dev/docs/showcase/templates/api-product.md, then fetch https://next.mdxui.dev/pages/vanroster/home.json, a api-product page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **article, from Vanroster › security** ```text Read https://next.mdxui.dev/docs/showcase/templates/article.md, then fetch https://next.mdxui.dev/pages/vanroster/security.json, a article page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **blog, from Vanroster › changelog** ```text Read https://next.mdxui.dev/docs/showcase/templates/blog.md, then fetch https://next.mdxui.dev/pages/vanroster/changelog.json, a blog page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **contact, from Vanroster › contact** ```text Read https://next.mdxui.dev/docs/showcase/templates/contact.md, then fetch https://next.mdxui.dev/pages/vanroster/contact.json, a contact page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **directory-list, from Benchlist › home** ```text Read https://next.mdxui.dev/docs/showcase/templates/directory-list.md, then fetch https://next.mdxui.dev/pages/benchlist/home.json, a directory-list page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **directory-product, from Vanroster › product/dispatch** ```text Read https://next.mdxui.dev/docs/showcase/templates/directory-product.md, then fetch https://next.mdxui.dev/pages/vanroster/product/dispatch.json, a directory-product page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **post, from Vanroster › changelog/route-swaps** ```text Read https://next.mdxui.dev/docs/showcase/templates/post.md, then fetch https://next.mdxui.dev/pages/vanroster/changelog/route-swaps.json, a post page that parses. Run pnpm add @mdxui/site@7.0.0-alpha.0. Rewrite its strings for my Business. Keep its Section types in their order; drop one only when my Business has nothing to say there. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` ## By worked Site Each starts from every record of one worked Business, as one download. **A Site like Vanroster** ```text Read https://next.mdxui.dev/docs/showcase/vanroster.md and https://next.mdxui.dev/docs/start/ship.md. Download https://next.mdxui.dev/sites/vanroster/records.zip: 9 records of a b2b saas that parse, under callboard. Rewrite every record for my Business. Keep one Theme for every record, the 404 page and the contact flow. Parse each record with PageSchema.safeParse. When one refuses, shorten the content at its path, never the bound. Build the Site with ship.ts and show me dist/. ``` **A Site like Ledgerhouse** ```text Read https://next.mdxui.dev/docs/showcase/ledgerhouse.md and https://next.mdxui.dev/docs/start/ship.md. Download https://next.mdxui.dev/sites/ledgerhouse/records.zip: 9 records of a professional services that parse, under vaultline. Rewrite every record for my Business. Keep one Theme for every record, the 404 page and the contact flow. Parse each record with PageSchema.safeParse. When one refuses, shorten the content at its path, never the bound. Build the Site with ship.ts and show me dist/. ``` **A Site like Diffkeel** ```text Read https://next.mdxui.dev/docs/showcase/diffkeel.md and https://next.mdxui.dev/docs/start/ship.md. Download https://next.mdxui.dev/sites/diffkeel/records.zip: 9 records of a developer tool that parse, under certificate. Rewrite every record for my Business. Keep one Theme for every record, the 404 page and the contact flow. Parse each record with PageSchema.safeParse. When one refuses, shorten the content at its path, never the bound. Build the Site with ship.ts and show me dist/. ``` **A Site like Benchlist** ```text Read https://next.mdxui.dev/docs/showcase/benchlist.md and https://next.mdxui.dev/docs/start/ship.md. Download https://next.mdxui.dev/sites/benchlist/records.zip: 8 records of a directory and marketplace that parse, under directory. Rewrite every record for my Business. Keep one Theme for every record, the 404 page and the contact flow. Parse each record with PageSchema.safeParse. When one refuses, shorten the content at its path, never the bound. Build the Site with ship.ts and show me dist/. ``` **A Site like Sidework** ```text Read https://next.mdxui.dev/docs/showcase/sidework.md and https://next.mdxui.dev/docs/start/ship.md. Download https://next.mdxui.dev/sites/sidework/records.zip: 8 records of a publication that parse, under vaultline. Rewrite every record for my Business. Keep one Theme for every record, the 404 page and the contact flow. Parse each record with PageSchema.safeParse. When one refuses, shorten the content at its path, never the bound. Build the Site with ship.ts and show me dist/. ``` **A Site like Harbor tides** ```text Read https://next.mdxui.dev/docs/showcase/harbor.md and https://next.mdxui.dev/docs/start/ship.md. Download https://next.mdxui.dev/sites/harbor/records.zip: 10 records of a api product that parse, under quaypoint. Rewrite every record for my Business. Keep one Theme for every record, the 404 page and the contact flow. Parse each record with PageSchema.safeParse. When one refuses, shorten the content at its path, never the bound. Build the Site with ship.ts and show me dist/. ``` ## By guide **Run the Quickstart** ```text Read https://next.mdxui.dev/docs/start/quickstart.md and https://next.mdxui.dev/llms.txt. Download https://next.mdxui.dev/starter.zip and unzip it. Run pnpm install, then pnpm render. Rewrite the strings of pages/home.json for my Business. Keep the Section types that fit. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it under its own Theme and one more, and show me both HTML files. ``` **Ship a Site** ```text Read https://next.mdxui.dev/docs/start/ship.md. Run pnpm add @mdxui/site@7.0.0-alpha.0, then save ship.ts, site.ts and write-a-theme.ts as the page shows them. Put every record of my Site in pages/, with home.json, 404.json and one Theme for every record. Run node ship.ts. Fix each refusal at its path by shortening the content. Deploy dist/ and confirm an unknown address answers 404 with my 404 page. ``` **Write a page from a description** ```text Read https://next.mdxui.dev/llms.txt and https://next.mdxui.dev/docs/agents/write-a-page.md. Fetch https://next.mdxui.dev/schema.json and use it as the structured output schema for one page record. Write the record for the page I describe. Keep every string inside its bound. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` ## The rule in every prompt When `PageSchema.safeParse` refuses, the agent shortens the content at the path it names. It never raises a bound, never adds a field the schema does not name, and never writes markup into a string. # schema.json (/docs/agents/schema) > The page schema and one schema per Section type, as JSON Schema, and the rules JSON Schema does not carry, so agents check with PageSchema. | Address | Holds | | --- | --- | | [`/schema.json`](https://next.mdxui.dev/schema.json) | `PageSchema.toJSONSchema()`: the whole record, every bound as `maxLength`, `minItems` and `maxItems` | | `/schema/sections/.json` | One Section type's schema, such as [`/schema/sections/stats.json`](https://next.mdxui.dev/schema/sections/stats.json), for all 27 types | Hand `/schema.json` to a model as its structured output or tool schema. Then check what it returns with `PageSchema.safeParse`, never with a JSON Schema validator alone. ## What JSON Schema leaves out Some rules span fields, or allow only some counts inside a range. `/schema.json` does not carry them; `PageSchema` does. Each section below keeps every bound in `/schema.json`, and `schema-gap.ts` shows `PageSchema` refusing it: ```ts // Three sections that keep every maxLength, minItems and maxItems in /schema.json, // and that PageSchema still refuses: rules JSON Schema has no keyword for. const brand = { href: '/' }; const probes: Record = { 'a brand with neither wordmark nor mark': { type: 'nav', menu: 'none', brand, links: [] }, 'a feature grid of five': { type: 'feature-grid', cellHead: 'none', items: Array.from({ length: 5 }, () => ({ title: 'A feature' })) }, 'a footer in columns with no "For agents" column': { type: 'footer', layout: 'columns', brand: { href: '/', text: 'Vanroster' }, note: ['Vanroster', 'Rendered by @mdxui/site.'], columns: [{ title: 'Product', links: [['/pricing', 'Pricing']] }, { title: 'Company', links: [['/contact', 'Contact']] }], }, }; export function gaps(): string[] { return Object.entries(probes).map(([what, block]) => { const page = { theme: 'callboard', title: 'Probe', description: 'A probe.', frame: 'none', sections: Array.from({ length: 3 }, () => ({ kind: 'section', shell: { inset: 'contained', density: 'section' }, block })) }; const result = PageSchema.safeParse(page); const issue = result.success ? undefined : result.error.issues[0]; return `${what}: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'accepted'}`; }); } ``` ```text title="node schema-gap.ts" a brand with neither wordmark nor mark: sections.0.block.brand: a brand needs a wordmark or a mark a feature grid of five: sections.0.block.items: a feature grid holds 2, 3, 4, 6, 8 cells: a count that fills its rows at every width a footer in columns with no "For agents" column: sections.0.block: a footer in columns has a "For agents" column ``` [Refusals](https://next.mdxui.dev/docs/reference/refusals) lists every rule with its message. # Markdown and JSON twins (/docs/agents/twins) > Every docs page as Markdown at its address plus .md, and every record a page renders as JSON beside its render. An agent reads text, not layout. Every page here has a twin an agent can fetch whole: Markdown for a docs page, JSON for a rendered one. ## Markdown for every docs page Add `.md` to any docs address: [`/docs/start/quickstart`](https://next.mdxui.dev/docs/start/quickstart) is [`/docs/start/quickstart.md`](https://next.mdxui.dev/docs/start/quickstart.md). The twin holds what the page holds: - every included example inlined, as the page shows it; - every address written in full on this site's origin; - a Section type preview as the address of the manifest that lists its frames; - no MDX comments; a component stays as a tag around its content, so every step of a guide is in the text. Copy Markdown and Open, under every page title, use the same twin. ## JSON for every rendered page | You have | Its record | | --- | --- | | A worked-Site page, such as [`/sites/harbor/pricing`](https://next.mdxui.dev/sites/harbor/pricing) | [`/pages/harbor/pricing.json`](https://next.mdxui.dev/pages/harbor/pricing.json) | | The landing, [`/`](https://next.mdxui.dev/) | [`/pages/home.json`](https://next.mdxui.dev/pages/home.json) | | A whole worked Site | [`/sites/harbor/records.zip`](https://next.mdxui.dev/sites/harbor/records.zip) | | A docs page that renders or cites a record | View as JSON, under its title | | A Section type exemplar | Its `record` in [`/components/manifest.json`](https://next.mdxui.dev/components/manifest.json) | A record is served byte for byte as the build renders it, so the JSON an agent copies parses. ## Why both The Markdown says what to do; the JSON is what to change. An agent that reads the guide's twin and starts from the record's twin edits words in a structure that already parses, and fetches no HTML. # Have your agent write a page (/docs/agents/write-a-page) > Prompt, record, safeParse, fix at the path, render; then the same check on every push, so no refused record ships. **Using an agent? Paste this prompt** ```text Read https://next.mdxui.dev/llms.txt and https://next.mdxui.dev/docs/agents/write-a-page.md. Fetch https://next.mdxui.dev/schema.json and use it as the structured output schema for one page record. Write the record for the page I describe. Keep every string inside its bound. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` Copy the five-line prompt that runs these steps. You describe the page; the agent writes the record, parses it, fixes each refusal at its path and renders it. Then one script keeps every record parsing on every push. ## Prerequisites - `@mdxui/site` installed, as in the [Quickstart](https://next.mdxui.dev/docs/start/quickstart). - A coding agent that can fetch an address and run a script. **File: prompt** **Write a page from a description** ```text Read https://next.mdxui.dev/llms.txt and https://next.mdxui.dev/docs/agents/write-a-page.md. Fetch https://next.mdxui.dev/schema.json and use it as the structured output schema for one page record. Write the record for the page I describe. Keep every string inside its bound. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it with renderPage and save the HTML file. ``` **File: refusal-url.ts** ```ts import { PageSchema } from '@mdxui/site'; // Each refusal, then the address of its fix: the block's type, then the path after `block`, list indexes dropped. export function refusalUrls(page: any): string[] { const result = PageSchema.safeParse(page); if (result.success) return []; return result.error.issues.flatMap(({ path, message }) => { const keys = (p: PropertyKey[]) => p.filter((s) => typeof s === 'string').join('.'); const anchor = path[0] !== 'sections' ? `page.${keys(path)}` : path[2] === 'shell' ? `shell.${keys(path.slice(3))}` : [page.sections[path[1] as number]?.block?.type, keys(path.slice(3))].filter(Boolean).join('.'); return [`${path.join('.')}: ${message}`, `https://next.mdxui.dev/docs/reference/refusals#${anchor}`]; }); } ``` **File: check-records.ts** ```ts // node check-records.ts: every record in pages/ parses, or each refusal prints with the address of its fix. let refused = 0; const files = (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json')).sort(); for (const file of files) { const lines = refusalUrls(JSON.parse(await readFile(`pages/${file}`, 'utf8'))); if (lines.length) refused++; for (let i = 0; i < lines.length; i += 2) console.error(`pages/${file}: ${lines[i]}\n fix: ${lines[i + 1]}`); } if (refused) process.exitCode = 1; else console.log(`${files.length} records parse`); ``` ## Hand it the prompt and the schema The prompt names what to read and the rule. `/schema.json` makes a good structured output schema: the model sees every bound as `maxLength`, `minItems` and `maxItems` while it writes. It cannot see every rule, so the next step still parses. ## Parse what it wrote `PageSchema.safeParse` accepts the record or returns its issues, each a path and a message. Nothing renders until it accepts. A hero title of 71 characters returns: ```text sections.1.block.title: Too big: expected string to have <=64 characters ``` ## Fix it at the path `refusalUrls` turns each issue into the address of its rule on [Refusals](https://next.mdxui.dev/docs/reference/refusals). The agent reads the rule, shortens the content at that path and parses again. It never raises a bound or drops the section to make the error go away. ## Render it Once the record parses, `renderPage(record)` returns the page, and `renderPage(record, { theme })` the same page under another Theme. The agent hands back the record and the HTML; you review a JSON diff of words. ## Check on every push Run `node check-records.ts` in CI. It parses every record in `pages/` and fails the run on any refusal, with the address of its fix. On records that parse: ```text title="node check-records.ts" 10 records parse ``` With one plan named `Every berth, all year`, 21 characters: ```text title="node check-records.ts" pages/pricing.json: sections.1.block.tiers.0.name: Too big: expected string to have <=16 characters fix: https://next.mdxui.dev/docs/reference/refusals#pricing.tiers.name ``` ## Result Every record the agent writes parses before it renders, and none that is refused reaches a deploy. ## Next steps - [Prompts](https://next.mdxui.dev/docs/agents/prompts): One for each task, Template and worked Site. - [schema.json](https://next.mdxui.dev/docs/agents/schema): What the schema carries, and what it leaves out. - [Refusals](https://next.mdxui.dev/docs/reference/refusals): Every rule, by path, with its message. # Brand and Theme (/docs/components/brand-and-theme) > startupbuilder.dev defines the Brand and the Theme; mdxui draws a Theme as a Brand's tokens plus its render policies, named in one field. > A Brand is a name, a voice, a logo, and a style under which a Startup makes Offers. > > startupbuilder.dev, [Brand](https://startupbuilder.dev/business/nouns/brand) > A Theme is the design tokens of a Brand, its color, type, spacing, radius, and motion, that every Component reads when it renders. > > startupbuilder.dev, [Theme](https://startupbuilder.dev/business/nouns/theme) mdxui draws a Theme as the Brand's tokens plus its render policies. A policy is a choice a design makes once, such as what falls between two sections. A Page names its Theme in one field, `theme`. ## What mdxui renders One record, Harbor's home page, under each of the five bundled Themes. Only the `theme` field differs. ## How it maps - **Brand name** (alpha): `brand.text` in the `nav` and `footer` sections, with an optional accent `suffix` - **Brand voice** (gap): No field: the agent writes in the voice, and the bounds keep it short - **Brand logo** (gap): `brand.mark`, a glyph from [Icons](https://next.mdxui.dev/docs/reference/icons); no logo file - **Brand style** (alpha): The Theme the record names - **Theme tokens** (alpha): Roles: 21 every Theme sets, the rest derived with defaults - **light and dark modes** (alpha): A dark palette, and the `scheme` policy that decides whether a page follows the reader - **Theme css** (gap): None, by design: no page carries its own CSS - **every Component reads it** (alpha): Every Section type reads roles, never a literal color ## Status The five Themes and `compileTheme`, which turns your own tokens and policies into a Theme, ship in `@mdxui/themes@7.0.0-alpha.0`. The Brand's voice and logo have no field: the [Site record](https://next.mdxui.dev/docs/components/site#intended-noun) drafts the logo. ## Related - [Themes](https://next.mdxui.dev/docs/themes): The five, each worn by a Business. - [Write a Theme](https://next.mdxui.dev/docs/themes/write-a-theme): Your Brand's tokens, compiled. - [The Theme contract](https://next.mdxui.dev/docs/themes/contract): Every role a Theme fills. # Components (/docs/components) > startupbuilder.dev defines every Component; mdxui shows how each one looks and behaves, which Section type draws it, and what is still a gap. startupbuilder.dev owns each definition and its props. Every page here quotes that definition word for word, renders what mdxui draws for it, and states each gap. ## What startupbuilder.dev defines, what mdxui draws - **Site** (spec): [Site](https://next.mdxui.dev/docs/components/site): a folder of Page records under one Theme - **Page** (alpha): [Page](https://next.mdxui.dev/docs/components/page): one record, one document - **Component** (alpha): [Section](https://next.mdxui.dev/docs/components/section): a kind, a shell and one Section type - **Template** (alpha): [Template](https://next.mdxui.dev/docs/components/template): seven page kinds - **Brand, Theme** (alpha): [Brand and Theme](https://next.mdxui.dev/docs/components/brand-and-theme): tokens plus render policies - **Product** (spec): [The Product record](https://next.mdxui.dev/docs/components/product-record): one record behind every rendering - **Interface** (alpha): [Interface](https://next.mdxui.dev/docs/components/interface): the Site, and Interface against rendering - **App** (study): [App](https://next.mdxui.dev/docs/components/app), with 13 [App types](https://next.mdxui.dev/docs/app) studied - **View** (study): [View](https://next.mdxui.dev/docs/components/view); startupbuilder.dev has no View Noun - **Dashboard** (study): [Dashboard](https://next.mdxui.dev/docs/components/dashboard), which extends View ## The UI catalog, mapped startupbuilder.dev's [UI catalog](https://startupbuilder.dev/code/ui) names 26 components, each with a web, a native and a Markdown renderer. mdxui draws the web rendering of the Site ones with Section types. The App and Dashboard ones are App and View territory, studied in the Apps tab. - **Hero** (alpha): The headline is `title`, the line is `lede`; an action is a link, not a bound Offer - **Features** (alpha): Lines the agent writes, not drawn from the Product's Nouns and Verbs - **PriceTable** (alpha): Plans as OfferTiles with prices and actions; the buying Verb is a link - **Faq** (alpha): Question and answer pairs, written out rather than bound to records - **CallToAction** (alpha): Actions under a closing head - **WaitlistJoin** (alpha): A form that posts to its `action`; the join is the host's - **Reserve** (gap): A form; no deposit and no Evidence for a Hypothesis - **Changelog** (alpha): Posts newest first, each linked; no link to the Version that shipped it - **Footer** (alpha): The Brand, link columns and a note; the terms and privacy links are written out - **Navigation** (alpha): The same links for every reader; no Persona grants - **VerbReference** (alpha): One call on each Interface: `terminal` tabs, or `api-call` examples - **VerbSheet and 4 more** (study): ActionStatus, ThingDetail, Board and Timeline: App territory; see [App types](https://next.mdxui.dev/docs/app) - **MetricTile and 9 more** (study): KeyResult, GoalTree, PendingFeed, TaskInbox, ApprovalSheet, VarianceAlert, DataTable, LensGrid and Benchmark: View territory; see [Dashboard](https://next.mdxui.dev/docs/components/dashboard) Twelve Section types draw no catalog entry: `statement`, `stats`, `facts`, `compare`, `steps`, `text-stack`, `command-row`, `agent-prompt`, `page-head`, `article`, `product-head` and `product-list`. All 27 are on [Section types](https://next.mdxui.dev/docs/sections). # Interface (/docs/components/interface) > A Site and an App are each an Interface of a Product; a rendering is one output format of a record, and an Interface has several. > An Interface is how a person or an Agent reaches a Product: an API, MCP server, CLI, SDK, Site, or App. > > startupbuilder.dev, [Interface](https://startupbuilder.dev/business/nouns/interface) A Site and an [App](https://next.mdxui.dev/docs/components/app) each extend Interface on startupbuilder.dev. mdxui renders a Site's pages today, one `renderPage` call per record, with `site()` for a whole Site written as Spec; the App's design is open. ## Interface and rendering The two words never trade places: | | Interface | Rendering | | --- | --- | --- | | Is | How a person or an agent reaches a Product | One output format of one record | | Belongs to | A Product | A record | | Examples | A Site, an App, an API, a CLI | HTML, JSON, Markdown, a share image, an email | | On this site | [Site](https://next.mdxui.dev/docs/components/site) | [Renderings](https://next.mdxui.dev/docs/components/renderings) | ## What mdxui renders One page of one Site Interface, in two renderings. The Preview tab is the HTML a person reads; the Record tab is the JSON an agent reads and writes. The rendered page: https://next.mdxui.dev/sites/harbor/; its record: https://next.mdxui.dev/pages/harbor/home.json ## How it maps startupbuilder.dev's eight Interface kinds. mdxui serves one, the Site, and draws the Site pages that document four more. - **site** (alpha): Page records, rendered under one Theme - **api** (alpha): Documented on a Site: a call with its response, and the list of endpoints - **cli** (alpha): Documented on a Site: the install line and a session - **sdk** (alpha): Documented on a Site: one call per language, as tabs - **mcp** (alpha): Documented on a Site: the prompt a reader pastes into an agent - **app** (study): Studied for 13 App types; see [App types](https://next.mdxui.dev/docs/app) - **docs** (gap): No docs Interface: these docs run on Fumadocs, beside the renderer - **webhooks** (gap): No Section type of its own; `endpoints` lists webhook paths as rows - **path** (host): Where the host mounts the Site on its Domain ## Status The Site Interface renders today in two renderings, HTML and JSON. The Markdown, share image and email renderings are Spec, each on its own page under [Renderings](https://next.mdxui.dev/docs/components/renderings). ## Related - [Site](https://next.mdxui.dev/docs/components/site): The Interface mdxui renders. - [Renderings](https://next.mdxui.dev/docs/components/renderings): One record, every output format. - [App types](https://next.mdxui.dev/docs/app): The other Interface, studied. # Page (/docs/components/page) > A Page is one record: its Theme, an optional Template, its frame, its head and three to twenty sections, rendered as one document. > A Page is an addressable page of a Site, of a named type such as pricing, about one Thing or each member of a Collection. > > startupbuilder.dev, [Page](https://startupbuilder.dev/business/nouns/page) ## What mdxui renders One record, parsed by `PageSchema` and rendered by `renderPage`. This is Harbor's pricing page, five sections; open the Record tab to read the record that drew it, or pick another Theme. The rendered page: https://next.mdxui.dev/sites/harbor/pricing; its record: https://next.mdxui.dev/pages/harbor/pricing.json Each field decides one thing; a section decides its own layout and content. From `@mdxui/site@7.0.0-alpha.0`. | Field | Type and bound | Required | Decides | | --- | --- | --- | --- | | `theme` | `"certificate" \| "vaultline" \| "quaypoint" \| "callboard" \| "directory"` | yes | The Brand the page wears: tokens, typefaces and policies | | `template` | `"api-product" \| "article" \| "blog" \| "post" \| "contact" \| "directory-list" \| "directory-product"` | no | The page kind; it names the slots the sections fill | | `title` | `string ≤64` | yes | The document title and the share title | | `description` | `string ≤320` | yes | The meta and share description | | `meta` | `object` | no | The head: canonical address, icons, share image, `noindex` | | `meta.canonical` | `string ≤160` | no | — | | `meta.type` | `"website" \| "article"` | no | — | | `meta.icon` | `string ≤160` | no | — | | `meta.iconFallback` | `string ≤160` | no | — | | `meta.appleTouchIcon` | `string ≤160` | no | — | | `meta.image` | `object` | no | — | | `meta.noindex` | `boolean` | no | — | | `jsonLd` | `0–4 × object` | no | Structured data, written into the head as given | | `frame` | `"none" \| "sheet" \| "split"` | yes | The frame around every section: none, one ruled sheet, or a split with an aside | | `sections` | `3–20 × section` | yes | The page itself, in order | Every bound, with its refusal, is on [Page fields and bounds](https://next.mdxui.dev/docs/reference/page-schema). ## How it maps - **path** (gap): Not in the record: the file's place decides it, so `pages/harbor/pricing.json` serves `/pricing` - **kind** (alpha): `template`, one of seven. A `landing` or `pricing` page is `api-product`; a `terms` page is `article` - **from** (gap): Not in the record: no lineage back to a Hypothesis slot - **uses Components** (alpha): `sections`, three to twenty, each one Section type - **about one Thing** (gap): Literal props: the agent writes the Thing's facts into the record - **each member of a Collection** (gap): One record per member, written out ## Status A Page record parses and renders today, and none of its fields is a Spec. The record binds to no other record: an agent fills every field with literal words, and the bounds keep those words inside the design. ## Intended Noun The three Page props the record lacks, drafted as the fields that close the gap. ```yaml title="intended Noun: Page record, the startupbuilder.dev props (mdxui.dev namespace)" noun: Page record projects: startupbuilder.dev Page, quoted above; defines nothing new adds: path: { type: string, pattern: "^/[a-z0-9/-]*$", max: 160, required: true, note: the address under its Site; today the file path } kind: { type: enum, values: the startupbuilder.dev page types, required: false, note: maps onto template } from: { type: string, pattern: "Hypothesis#@v", required: false, note: lineage, never rendered } about: { type: Thing reference, required: false, note: the Thing whose fields fill the sections } ``` ## Related - [Section](https://next.mdxui.dev/docs/components/section): What one entry of `sections` holds. - [Template](https://next.mdxui.dev/docs/components/template): The seven page kinds. - [Pages are data](https://next.mdxui.dev/docs/start/pages-are-data): Why a page is a record. # The Product record (/docs/components/product-record) > Spec: written ahead of the package. Not in the published packages: agentPrompt, og, email > One bounded record of a product feeds its landing, emails, share image, directory entry and agent prompt, so it says one thing everywhere. > A Product is what a Customer gets from a Startup, with a promise the Customer can rely on. > > startupbuilder.dev, [Product](https://startupbuilder.dev/business/nouns/product) > An Offer is a Product, under a Brand, at a price, on terms, made by a Startup. > > startupbuilder.dev, [Offer](https://startupbuilder.dev/business/nouns/offer) The Product record is one bounded record of a product: its name, Theme, addresses, plans and first calls. Every rendering of the product reads it, so the landing, the emails and the agent prompt never disagree. It projects startupbuilder.dev's Product, its Offers and their Prices, and defines no second Product. ## What mdxui renders Today each Page record carries the product's facts itself. Harbor's plans below are written into `pages/harbor/pricing.json` by hand, and its home page repeats the name and the first call. The rendered page: https://next.mdxui.dev/sites/harbor/pricing; its record: https://next.mdxui.dev/pages/harbor/pricing.json ## How it maps - **the landing** (alpha): The name, the Theme, the plans and the first call, written into each record by hand - **the directory entry** (alpha): The category, the tags, the summary and the date it listed, written by hand - **the agent prompt** (spec): The quickstart calls in agent order: the CLI, the MCP server, the SDKs, then `curl` - **the share image** (spec): The name, the summary and the Theme; see [Share image](https://next.mdxui.dev/docs/components/renderings/share-image) - **the emails** (spec): The name, the Theme and the dashboard address; see [Email](https://next.mdxui.dev/docs/components/renderings/email) ## Status The record and every rendering that reads it are Spec. The landing and the directory entry render today from Page records that repeat the same facts. A price changed in one record and not another is a disagreement no parse catches. ## Intended Noun ```yaml title="intended Noun: Product record (mdxui.dev namespace)" noun: Product record projects: startupbuilder.dev Product, its Offers and Prices; defines nothing new fields: name: { type: string, bound: open, required: true } theme: { type: Theme, required: true } mark: { type: icon name, required: false } apiBase: { type: URL, required: true } docsUrl: { type: URL, required: true } dashboardUrl: { type: URL, required: false } contactUrl: { type: URL, required: false } statusUrl: { type: URL, required: false } keyPrefix: { type: string, bound: open, required: false } quickstart: { type: list of call, count: open, required: true, order: [cli, mcp, sdk, curl] } plans: { type: list of Offer, count: open, required: true, each: [price or contact, limit, retention, seats, feature lines, action] } questions: { type: list of onboarding question, count: open } directory: { category: string, tags: list of string, summary: string, since: date } ``` Every bound is open until the record ships. Each string gets one, measured from real products, as every Section type's strings have. ## Related - [Renderings](https://next.mdxui.dev/docs/components/renderings): Every output format of one record. - [pricing](https://next.mdxui.dev/docs/sections/pricing): Plans as OfferTiles. - [Harbor tides](https://next.mdxui.dev/docs/showcase/harbor): The product these facts describe. # Document and head (/docs/components/renderings/document-and-head) > The document around a page: its head lines, the Theme's typefaces and browser color, its color scheme and one style element. `document()` wraps a page body in its head and one stylesheet. Run the four steps of `renderPage` by hand and the result is the same string. ## Prerequisites - The [quickstart](https://next.mdxui.dev/docs/start/quickstart) runs in your project, with `@mdxui/themes` installed as on [Packages and versions](https://next.mdxui.dev/docs/start/packages). ## Steps 1. Parse the record: ```ts const page = PageSchema.parse({ theme: 'callboard', title: 'Harbor tides', description: 'Tide tables for every berth.', frame: 'none', sections: [ { kind: 'hero', shell: { inset: 'contained', density: 'hero' }, block: { type: 'hero', layout: 'centered', stack: 560, title: 'Every berth, every tide.', lede: 'One call returns the tide table for any berth.', actions: [], fine: [] } }, { kind: 'band', shell: { inset: 'contained', density: 'section' }, block: { type: 'statement', text: 'A tide table is a *promise*.' } }, { kind: 'section', shell: { inset: 'contained', density: 'section' }, block: { type: 'command-row', command: 'npm i @example/harbor-tides' } }, ], }); ``` 2. Render each section under the record's Theme, then compose the body: ```ts const { theme, css, head } = THEMES[page.theme]; const sections = page.sections.map((s) => ({ kind: s.kind, shell: s.shell, html: renderBlock(s.block, context(theme, s.shell.surface ?? 'paper', theme.icons)), })); const body = compose({ frame: page.frame, sections, dividers: theme.dividers }); ``` 3. Wrap the body. The Theme supplies the head lines and the tokens: ```ts const html = document({ title: page.title, description: page.description, colorScheme: theme.dark && theme.scheme !== 'light' ? 'light dark' : 'light', jsonLd: page.jsonLd ?? [], head, css: `${css()}${baseCss}${primitivesCss}${shellCss}${blocksCss}`, body, }); ``` 4. Compare it with `renderPage`: ```ts if (html !== renderPage(page)) throw new Error('the document differs from renderPage'); await writeFile('harbor.html', html); ``` ## Result The document equals `renderPage(page)` byte for byte; `pnpm check` runs the example and fails otherwise. With a `meta` of a canonical address and a type, its head reads: | Head line | From | | --- | --- | | `` | Fixed | | `` | Fixed | | `Harbor tides` | `title` | | `` | `description` | | `` | The Theme: `light dark` with a dark palette under `scheme: auto` | | `` | `title`, `description` | | `` | The Theme's head lines | | `` | The Theme's head lines: its typefaces | | `` | The Theme's head lines: its typefaces | | `` | `meta.canonical` | | `` | `meta.type` | Then 1 ` ${html} `; ``` 3. Write `embed` into the host page's HTML, where the browser attaches the shadow root as it parses. ## Result `embed.html`: a host paragraph, then the `command-row` in `quaypoint`, styled by its own sheet and untouched by the host's. A React page that is prerendered, as a Fumadocs page is, writes the same string into its HTML. A page rendered in the browser attaches a shadow root with `attachShadow` and sets its `innerHTML` instead. This site's previews take a third path: each is a frame, an `iframe` of one prerendered section. ## Related - [HTML](https://next.mdxui.dev/docs/components/renderings/html): The four steps `renderBlock` is one of. - [The Theme contract](https://next.mdxui.dev/docs/themes/contract): The tokens the shadow root carries. - [command-row](https://next.mdxui.dev/docs/sections/command-row): The section this example embeds. # HTML (/docs/components/renderings/html) > Parse, render each Section type, compose, then wrap: how one Page record becomes one HTML document with one stylesheet and no framework. `renderPage(page, { theme })` runs four steps and returns one HTML string: a document with one ``; return createElement( 'div', { className: 'mdxui-section' }, createElement('template', { shadowrootmode: 'open', dangerouslySetInnerHTML: { __html: `${styles}${html}` } }), ); } ``` Use it as `
`. The block is any section's `block` from a record; `BlockSchema.parse` refuses one past its bounds at its path. ## Attach a section on the client A client component that receives the section's HTML as a string attaches the shadow root itself, after hydration: ```ts /** * In a client component, after hydration: attach the shadow root yourself, * from HTML your server rendered with renderBlock and sent as a string. */ export function attachSection(host: HTMLElement, html: string): void { const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }); root.innerHTML = html; } ``` Render the string on the server with `renderBlock`, as in the server component, and send it to the client as a prop. ## Next steps - [Ship a Site](https://next.mdxui.dev/docs/start/ship): Every record in pages/ as one Site, on Node, Vite, React Router or a Worker. - [Embed one section](https://next.mdxui.dev/docs/components/renderings/embed): renderBlock in a shadow root, in any page. # Rebrand a Site in one line (/docs/start/guides/rebrand) > Change the theme field in every record, check that each still parses, and compare every page of the Site under the old Theme and the new. A record names its Theme in one field, `"theme"`. Change it in every record of a Site and the whole Site redraws in another Brand: color, typefaces, dividers, how the featured plan stands out. No word moves. ## Prerequisites - A Site's records in `pages/`, all under one Theme. [Harbor tides](https://next.mdxui.dev/sites/harbor/records.zip) has ten. **File: pages/home.json** **File: rebrand.ts** ```ts // node rebrand.ts directory: every record in pages/ moves to one Theme, and still parses. const theme = process.argv[2] ?? 'directory'; if (!(THEME_NAMES as readonly string[]).includes(theme)) throw new Error(`${theme} is not one of ${THEME_NAMES.join(', ')}`); let moved = 0; for (const file of (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json'))) { const record = JSON.parse(await readFile(`pages/${file}`, 'utf8')); if (record.theme === theme) continue; record.theme = theme; PageSchema.parse(record); // a Theme changes no bound, so a record that parsed still parses await writeFile(`pages/${file}`, `${JSON.stringify(record, null, 2)}\n`); moved++; } console.log(`${moved} records now wear ${theme}`); ``` **File: Result** The result, as this site renders it: https://next.mdxui.dev/pages/harbor/pricing.json ## Pick the Theme The field takes one of five: `certificate`, `vaultline`, `quaypoint`, `callboard` or `directory`. Each [Theme](https://next.mdxui.dev/docs/themes) is a Brand's tokens plus four render policies. To wear your own Brand, [write a Theme](https://next.mdxui.dev/docs/themes/write-a-theme). ## Change one line in every record A Site wears one Theme, so change every record at once. Run `node rebrand.ts directory`; it sets the field, parses each record and writes it back. A Theme changes no bound, so a record that parsed still parses. ## Compare before you ship These docs render every page of every worked Site under all five Themes. Flip the chips on the Result, or flip the whole Site below, before you commit a Brand. Check the pages where the policies show most: pricing for the featured plan, long pages for the dividers. ## Result Every record names the new Theme and parses, and every page renders in one Brand. Build it with [Ship a Site](https://next.mdxui.dev/docs/start/ship). ## Next steps - [Themes](https://next.mdxui.dev/docs/themes): All five, side by side. - [Write a Theme](https://next.mdxui.dev/docs/themes/write-a-theme): Your Brand, under every record. - [Ship a Site](https://next.mdxui.dev/docs/start/ship): Build the rebranded Site. # Introduction (/docs/start) > mdxui renders a Business's pages from records its agent writes, in the Business's own brand, and the design holds whatever the agent writes. A page is a JSON record: the words, the plans, the questions. `@mdxui/site` lays it out from 27 Section types designed by hand, and a Theme from `@mdxui/themes` dresses it in the Business's Brand. Below, one worked Business's home record under three Themes; not one word differs between them. ## Who it is for | You are | You use mdxui to | Start at | | --- | --- | --- | | A founder | Put every page a Business needs in front of customers, written by your agent, in your Brand | [Showcase](https://next.mdxui.dev/docs/showcase) | | A developer | Build those records into a Site on your own stack, with a 404 and a contact flow | [Quickstart](https://next.mdxui.dev/docs/start/quickstart) | | An agent | Write records that parse, and fix each refusal at the path it names | [For agents](https://next.mdxui.dev/docs/agents) | ## Four ideas 1. **Your Business, not a component kit.** Six worked Businesses, from a B2B SaaS to a publication, each built as real pages. Find the one closest to yours and copy its records. 2. **Your agent writes words, never markup.** The record holds no HTML, class names or CSS, so an agent can write it, diff it and fix it, and cannot move the layout. 3. **Your brand is one line.** Change `"theme"` and every page redraws in another Brand's tokens and render policies. 4. **Long copy never breaks the layout.** Every field has a maximum the design was drawn for, such as 64 characters for a hero title, so whatever the agent writes, the page keeps its shape. [Why the design holds](https://next.mdxui.dev/docs/sections/bounds) shows how. ## What ships | Part | Status | Read | | --- | --- | --- | | `@mdxui/site@7.0.0-alpha.0`: the page schema, `renderPage` and 27 Section types | Alpha, on npm | [Packages](https://next.mdxui.dev/docs/start/packages) | | `@mdxui/themes@7.0.0-alpha.0`: the Theme contract and 5 Themes | Alpha, on npm | [Themes](https://next.mdxui.dev/docs/themes) | | HTML and JSON renderings of every record | Alpha | [Renderings](https://next.mdxui.dev/docs/components/renderings) | | Markdown, share image and email renderings | Spec | [Renderings](https://next.mdxui.dev/docs/components/renderings) | | `site()`: a whole Site in one call | Spec | [Ship a Site](https://next.mdxui.dev/docs/start/ship) | | App, View and Dashboard | Study: 13 App types, no package | [Apps](https://next.mdxui.dev/docs/app) | Both packages are unlicensed alpha releases, and their source is closed while they are alpha. Every schema, bound and render they produce is shown on these docs. The bare `mdxui` name on npm is an older, unrelated line, despite its higher version number. ## Where the definitions live [startupbuilder.dev](https://startupbuilder.dev) defines the Business-side Nouns: [Site](https://startupbuilder.dev/business/nouns/site), [Page](https://startupbuilder.dev/business/nouns/page), [Brand](https://startupbuilder.dev/business/nouns/brand), [Theme](https://startupbuilder.dev/business/nouns/theme) and [Component](https://startupbuilder.dev/business/nouns/component). These docs show how mdxui draws each one; [How mdxui fits a Business](https://next.mdxui.dev/docs/start/business) maps them. ## Next - [Quickstart](https://next.mdxui.dev/docs/start/quickstart): One record, two Themes, made yours. - [Showcase](https://next.mdxui.dev/docs/showcase): Find the Business closest to yours and copy its records. - [Guides](https://next.mdxui.dev/docs/start/guides): A landing page, pricing, a blog, a directory, a contact flow. - [For agents](https://next.mdxui.dev/docs/agents): The prompt, the four fetches and the fix loop. # Packages and versions (/docs/start/packages) > The two published packages, the exact version to pin, and the three npm names that install something else. Two packages make up mdxui 7: `@mdxui/site` renders a record, and `@mdxui/themes` holds the Themes it renders under. Both are at `7.0.0-alpha.0`. ## Install the pinned pair ```bash pnpm add @mdxui/site@7.0.0-alpha.0 ``` `@mdxui/site` depends on `@mdxui/themes` at exactly its own version, so one install brings both. To import `@mdxui/themes` yourself, as [Write a Theme](https://next.mdxui.dev/docs/themes/write-a-theme) does, add it at that same version: ```bash pnpm add @mdxui/themes@7.0.0-alpha.0 ``` ## Names that install something else | Never install | What it installs | Why it breaks | | --- | --- | --- | | `mdxui` | `7.0.0-alpha.1`, from an earlier, unrelated line | Types and schemas only, with no renderer; it predates the records these docs describe | | `@mdxui/app` | `6.0.1`, from the 6 line | The App renderer of these docs is unpublished; App types are [studies](https://next.mdxui.dev/docs/app) | | `@mdxui/themes` with no version | `6.3.0`, the `latest` tag of the 6 line | A second, older copy beside the one `@mdxui/site` pins | ## Why the exact version The packages are alpha, and an alpha can tighten or loosen any bound. A record that parses under one alpha may be refused under the next, at parse, by path, before anything renders. Pin the exact version, and run `PageSchema.safeParse` on every record in CI; [Have your agent write a page](https://next.mdxui.dev/docs/agents/write-a-page) shows the check. Read [Release notes](https://next.mdxui.dev/docs/reference/changelog) before you move the pin. ## What each package holds | Package | Holds | dist-tags | | --- | --- | --- | | `@mdxui/site` | The page schema, `renderPage`, the composer, the section shells, the document, and every Section type with its CSS | `latest` and `alpha`: `7.0.0-alpha.0` | | `@mdxui/themes` | The Theme contract, `compileTheme`, `resolveTokens` and the 5 Themes | `alpha`: `7.0.0-alpha.0`; `latest`: `6.3.0` | | Package | Installed here | Depends on | | --- | --- | --- | | `@mdxui/site` | `7.0.0-alpha.0` | `@iconify-json/lucide@1.2.131`, `@iconify-json/simple-icons@1.2.95`, `@mdxui/themes@7.0.0-alpha.0`, `lucide-static@1.26.0`, `simple-icons@16.27.0`, `zod@4.6.2` | | `@mdxui/themes` | `7.0.0-alpha.0` | nothing | Both declare Node.js `>=22`. The guides run their TypeScript files directly, which needs Node.js 22.18 or later. ## License and source Both packages declare `license: UNLICENSED`. The npm tarballs hold the built JavaScript and the type declarations; the source is closed while the packages are alpha. `UNLICENSED` grants no license, and no terms of use are published for the alpha. Evaluate 7.0.0-alpha.0; before you ship it in a product, wait for a published license. Every export is listed in [@mdxui/site exports](https://next.mdxui.dev/docs/reference/site-exports) and [@mdxui/themes exports](https://next.mdxui.dev/docs/reference/themes-exports). # Pages are data (/docs/start/pages-are-data) > A page is one JSON record of words and choices, never markup. What that buys an agent, and the four layers that turn it into HTML. A page is one JSON record that `PageSchema` accepts or refuses, and `renderPage` turns it into one HTML document. The record holds content only: no markup, no class names, no CSS. Open the Record tab to read the same page as data: The rendered page: https://next.mdxui.dev/sites/harbor/; its record: https://next.mdxui.dev/pages/harbor/home.json ## What a record buys an agent | An agent writing markup | An agent writing a record | | --- | --- | | Redraws the layout on every edit | Changes words; the layout is the Section type's | | Drifts from the brand page by page | Every page wears the one Theme it names | | Produces a diff of tags and classes to review | Produces a JSON diff of the words that changed | | Breaks the layout with a long headline | Is refused at the field, with its maximum, before anything renders | ## The smallest record ```json title="25 lines; PageSchema.parse accepts it" { "theme": "quaypoint", "title": "Harbor tides", "description": "Tide tables for every berth.", "frame": "none", "sections": [ { "kind": "hero", "shell": { "inset": "contained", "density": "hero" }, "block": { "type": "hero", "layout": "centered", "stack": 560, "title": "Every berth, every tide.", "lede": "One call returns the tide table for any berth.", "actions": [], "fine": [] } }, { "kind": "band", "shell": { "inset": "contained", "density": "section" }, "block": { "type": "statement", "text": "A tide table is a *promise*." } }, { "kind": "section", "shell": { "inset": "contained", "density": "section" }, "block": { "type": "command-row", "command": "npm i @example/harbor-tides" } } ] } ``` | Field | Says | | --- | --- | | `theme` | Which bundled Theme the page wears | | `frame` | The frame around every section: `none`, one ruled `sheet`, or a `split` with an aside | | `sections` | Three to twenty sections, in page order | | `kind` | What the section is to its neighbors; it picks the landmark and the divider | | `shell.inset`, `shell.density` | The section's width and its vertical pace | | `block.type` | The Section type that lays the content out | | `stack` | The width in pixels below which the hero's actions stack | ## Four layers Block is the renderer's word for a Section type's code; these docs say Section type everywhere else. Each layer owns one thing and never reaches into another. | Layer | Holds | Never knows | | --- | --- | --- | | Block | One Section type's layout: one root element, colors by role | What is painted behind it, its neighbors, the Theme, the page | | Shell | What is painted behind and between: `surface`, `background`, inset, density, stickiness, the frame | What the content says | | Theme | Tokens and [policies](https://next.mdxui.dev/docs/themes/policies), compiled to custom properties | Which page it dresses | | Composition | The record: Theme, frame and sections | Markup and CSS | ## The rules the layers keep | Rule | It prevents | | --- | --- | | No model writes markup or CSS; a Section type is drawn once by hand | A page that re-guesses its design on every edit | | A token never changes layout; a layout change is a variant | A Theme that breaks a page it never saw | | Painted behind content is shell; positioning content is block | Two layers fighting over one box | | A Section type never knows its neighbors; the composer derives each divider | A divider right between two sections and wrong between two others | | Every string is bounded | A headline three times the length the design was drawn for | ## Edit with the schema `PageSchema.toJSONSchema()` writes every bound as `maxLength`, `minItems` and `maxItems`, the schema this site serves at `/schema.json`: ```ts import { PageSchema } from '@mdxui/site'; const schema = PageSchema.toJSONSchema(); ``` A record may open with a `$schema` key naming that address; `PageSchema` strips unknown keys as it parses. startupbuilder.dev defines the [Page](https://startupbuilder.dev/business/nouns/page); the record is how mdxui holds one. [Page](https://next.mdxui.dev/docs/components/page) maps each part, and [How mdxui fits a Business](https://next.mdxui.dev/docs/start/business) places it in a Business. # Quickstart (/docs/start/quickstart) > Render a worked Business's home page from one JSON record under two Themes, make it yours, and ship it. **Using an agent? Paste this prompt** ```text Read https://next.mdxui.dev/docs/start/quickstart.md and https://next.mdxui.dev/llms.txt. Download https://next.mdxui.dev/starter.zip and unzip it. Run pnpm install, then pnpm render. Rewrite the strings of pages/home.json for my Business. Keep the Section types that fit. Parse it with PageSchema.safeParse. When it refuses, shorten the content at the path it names, never the bound. Render it under its own Theme and one more, and show me both HTML files. ``` Copy a five-line prompt: your agent downloads the starter, rewrites the record for your Business, fixes each refusal at its path and renders it under two Themes. You start from Vanroster, a worked B2B SaaS on these docs, and leave with its home page as two HTML files. Or take the finished files: [Download starter.zip](https://next.mdxui.dev/starter.zip): `pages/home.json`, `render.ts` and a `package.json`. ## Prerequisites - Node.js 22.18 or later, which runs `render.ts` with no build step. - An empty directory that is not inside another pnpm project. Mark it as an ES module first: ```bash pnpm init && npm pkg set type=module ``` The starter download above already holds a `package.json` with `"type": "module"`; with it, run `pnpm install && pnpm render` and skip to step 3. **File: install** ```bash pnpm add @mdxui/site@7.0.0-alpha.0 ``` **File: pages/home.json** ```json { "$schema": "https://next.mdxui.dev/schema.json", "theme": "callboard", "frame": "none", "template": "api-product", "title": "Vanroster: every crew on the right job", "description": "Crew scheduling for field-service teams. Vanroster matches each job to a crew with the skills, the van and the shortest drive.", "sections": [ { "kind": "nav", "shell": { "inset": "bleed", "density": "none", "surface": "glass", "sticky": true }, "block": { "type": "nav", "menu": "push", "collapse": 880, "brand": { "href": "/", "text": "Vanroster", "mark": "radar" }, "links": [ ["/product/dispatch", "Dispatch"], ["/pricing", "Pricing"], ["/changelog", "Changelog"], ["/security", "Security"] ], "cta": { "href": "/contact", "label": "Book a walkthrough" } } }, { "kind": "hero", "shell": { "inset": "contained", "density": "hero", "background": "glow" }, "block": { "type": "hero", "layout": "split", "stack": 640, "title": "Every crew on the right job.", "lede": "Vanroster matches each job to a crew with the right skills, the right van and the shortest drive. Move a job on the board, and every crew phone follows.", "actions": [ { "href": "/contact", "label": "Book a walkthrough", "kind": "solid" }, { "href": "/pricing", "label": "See the plans", "kind": "ghost" } ], "fine": ["14-day trial, no payment details", "Office users free on every plan"], "specimen": { "panel": "field", "blocks": [ { "type": "terminal", "variant": "plain", "title": "Board · Tue 13 Oct · 07:42", "lines": [ "@dim{CREW VAN JOB WINDOW DRIVE}", "A-1 V-12 Boiler service 08:00-10:00 14 min", "A-1 V-12 Heat pump install 10:30-13:00 9 min", "@dim{B-2 V-07 Out today: 2 jobs moved}", "C-3 V-03 Panel upgrade 08:00-11:00 22 min", "C-3 V-03 @acc{Boiler service} ←B2 11:30-12:30 @pass{6 min}", "D-4 V-15 @acc{Gas leak check} ←B2 13:00-14:00 @pass{11 min}", "D-4 V-15 Water heater swap 14:30-16:30 18 min", "", "@pass{2 customers texted a new window}" ] } ] } } }, { "kind": "band", "shell": { "inset": "contained", "density": "compact" }, "block": { "type": "stats", "items": [ { "value": "400", "label": "Crews on one board, on every plan", "icon": "layout-grid" }, { "value": "12", "label": "Skills a job can require, from gas to height", "icon": "badge-check" }, { "value": "30 min", "label": "Arrival window texted to each customer", "icon": "clock" } ] } }, { "kind": "section", "shell": { "inset": "contained", "density": "section" }, "block": { "type": "feature-grid", "cellHead": "icon", "head": { "eyebrow": "The board", "title": "One board runs the whole day.", "lede": "Office staff plan on the board. Crews work from one ordered list on their phones." }, "items": [ { "icon": "map-pin", "title": "Drive time, not distance", "text": "Jobs are ordered by the drive between them, from road times at the hour of the job." }, { "icon": "badge-check", "title": "Skills before speed", "text": "A gas job reaches only a crew with a gas licence on file and in date." }, { "icon": "refresh-cw", "title": "Swaps in one drag", "text": "Drag a job to another crew. Both crew phones update before the drag ends." }, { "icon": "bell", "title": "Customers told once", "text": "Each customer gets one text with a 30-minute window, and one when the van leaves." }, { "icon": "clock", "title": "Overtime flagged", "text": "A day that keeps a crew past 10 hours turns amber before anyone assigns it." }, { "icon": "shield-check", "title": "Every change logged", "text": "Who moved which job, when and why, kept for up to 7 years." } ] } }, { "kind": "section", "shell": { "inset": "contained", "density": "section", "surface": "tint" }, "block": { "type": "feature-split", "head": { "eyebrow": "A sick call at 07:40", "title": "Re-plan the day in one move." }, "items": [ { "icon": "x", "title": "Mark the crew out", "text": "One tap clears the crew and lists every job it held that day." }, { "icon": "radar", "title": "Accept the re-plan", "text": "Each job goes to the nearest crew that holds every skill it needs." }, { "icon": "message-square", "title": "Tell the customers", "text": "Each changed window goes out as one text, with the new time." } ], "specimen": { "blocks": [ { "type": "terminal", "variant": "plain", "title": "Text to the customer · 07:43", "lines": [ "Your boiler service today moved to 11:30-12:30.", "Crew C-3 is coming in van V-03.", "", "@dim{A second text arrives when the van leaves.}", "@dim{Reply 1 to keep the time, 2 to pick another day.}" ] } ] } } }, { "kind": "section", "shell": { "inset": "contained", "density": "section" }, "block": { "type": "compare", "head": { "eyebrow": "Against the whiteboard", "title": "A board that moves with the day." }, "cons": { "label": "Whiteboard and calls", "items": ["Jobs assigned from memory", "Licences checked by phone", "Customers call to ask", "Swaps lost by noon"] }, "pros": { "label": "Vanroster", "items": ["Jobs matched to skills", "Licences checked on assign", "Customers get a window", "Every swap on record"] } } }, { "kind": "closing", "shell": { "inset": "contained", "density": "closing", "surface": "plate" }, "block": { "type": "closing", "layout": "split", "head": { "eyebrow": "14-day trial", "title": "Plan tomorrow on Vanroster.", "lede": "Import crews and jobs from three spreadsheets. The first board is ready the same afternoon." }, "specimen": { "blocks": [ { "type": "terminal", "variant": "plain", "title": "Import · 3 files", "lines": [ "crews.csv 14 crews, 14 vans, 2 depots", "skills.csv 38 licences, @acc{3 expire this year}", "jobs.csv 212 jobs over the next 10 days", "", "@pass{Board ready: 212 jobs on 14 crews}" ] } ] }, "actions": [ { "href": "/contact", "label": "Book a walkthrough", "kind": "solid" }, { "href": "/product/dispatch", "label": "Tour Dispatch", "kind": "ghost" } ] } }, { "kind": "footer", "shell": { "inset": "contained", "density": "footer" }, "block": { "type": "footer", "layout": "areas", "brand": { "href": "/", "text": "Vanroster", "mark": "radar" }, "blurb": "Crew scheduling for field-service teams: heating, electrical, plumbing, cleaning and grounds.", "columns": [ { "title": "Product", "links": [ ["/product/dispatch", "Dispatch"], ["/pricing", "Pricing"], ["/changelog", "Changelog"] ] }, { "title": "Company", "links": [ ["/security", "Security"], ["/contact", "Contact"] ] }, { "title": "For agents", "links": [ ["/product/dispatch", "The Dispatch API"], ["/records.zip", "Site records (.zip)"] ] } ], "cta": { "href": "/contact", "label": "Book a walkthrough", "icon": "arrow-right" }, "note": ["Vanroster is a worked example on mdxui.dev, not a customer.", "Rendered by @mdxui/site. Forms here do not send."] } } ] } ``` **File: render.ts** ```ts // Renders each record in pages/ twice: under its own Theme, and under ALSO. // A string over its bound stops the run and names the field. import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { PageSchema, renderPage } from '@mdxui/site'; // certificate, vaultline, quaypoint, callboard or directory const ALSO = 'vaultline'; const pages = []; for (const file of (await readdir('pages')).filter((f) => f.endsWith('.json'))) { const json = JSON.parse(await readFile(`pages/${file}`, 'utf8')); const result = PageSchema.safeParse(json); if (!result.success) { for (const { path, message } of result.error.issues) { console.error(`pages/${file}: ${path.join('.')}: ${message}`); } process.exit(1); } pages.push({ name: file.replace(/\.json$/, ''), page: result.data }); } await mkdir('out', { recursive: true }); for (const { name, page } of pages) { await writeFile(`out/${name}.html`, renderPage(page)); await writeFile(`out/${name}.${ALSO}.html`, renderPage(page, { theme: ALSO })); console.log(`out/${name}.html ${page.theme}`); console.log(`out/${name}.${ALSO}.html ${ALSO}`); } ``` **File: Result** The record as this site renders it, under each Theme}> The result, as this site renders it: https://next.mdxui.dev/pages/vanroster/home.json ## Install Install `@mdxui/site` at its exact version. It brings `@mdxui/themes` at the same version, so you name one package. The bare `mdxui` package shows a higher version, `7.0.0-alpha.1` from May 2026, but it is an older, unrelated line. Install `@mdxui/site@7.0.0-alpha.0`; [Packages and versions](https://next.mdxui.dev/docs/start/packages) lists every name to avoid. ## Write the record Copy Vanroster's home record into `pages/home.json`. It runs from the nav to the footer, and it parses as it stands. ## Render it under two Themes Save `render.ts` and run `node render.ts`. `renderPage` returns one HTML document per record: once under the record's own `theme`, and once under the Theme in `ALSO`. ```text title="node render.ts" out/home.html callboard out/home.vaultline.html vaultline ``` The words are the same in both files. The color, typefaces, dividers and plan styling come from the Theme. ## Make it yours Set `theme` to the Theme your Brand wears, and rewrite the hero `title` as your Business's promise: 64 characters fit, and the `lede` under it takes 260. Run `node render.ts` again and open `out/home.html`. Long copy never breaks the layout. If the schema says no, the run names the field, such as `sections.1.block.title`, and writes nothing; shorten the content at that path. [Refusals](https://next.mdxui.dev/docs/reference/refusals#hero.title) lists every bound. ## Result `out/home.html` is Vanroster's home page in your words. The Result tab renders the same record on this site: pick a Theme chip to see it in each of the five. ## Ship it Your page is one HTML file that any static host serves. [Ship a Site](https://next.mdxui.dev/docs/start/ship) turns a folder of records into a whole Site with a 404, on Node, Vite, React Router or a Worker. ## Next steps - [Ship a Site](https://next.mdxui.dev/docs/start/ship): A folder of records as one live Site with a 404. - [Build a landing page](https://next.mdxui.dev/docs/start/guides/landing-page): Every section of the home page, nav to footer. - [Write a Theme](https://next.mdxui.dev/docs/themes/write-a-theme): Your Brand's tokens and policies, under every record. - [Have your agent write a page](https://next.mdxui.dev/docs/agents/write-a-page): The same loop, with an agent writing the record. # Ship a Site (/docs/start/ship) > Turn a folder of records into one live Site with its 404 page and a contact flow, from a Node script, Vite, React Router or a Cloudflare Worker. **Using an agent? Paste this prompt** ```text Read https://next.mdxui.dev/docs/start/ship.md. Run pnpm add @mdxui/site@7.0.0-alpha.0, then save ship.ts, site.ts and write-a-theme.ts as the page shows them. Put every record of my Site in pages/, with home.json, 404.json and one Theme for every record. Run node ship.ts. Fix each refusal at its path by shortening the content. Deploy dist/ and confirm an unknown address answers 404 with my 404 page. ``` Copy a five-line prompt: your agent saves the files for the Node script target, builds your records into `dist/` and checks the 404. Pick where the Site ships. For pages inside an existing React or Next.js app, see [Use it in a React app](https://next.mdxui.dev/docs/start/guides/react-app). Every target below runs in this site's checks, on published exports only, and each file pane shows the whole file, imports included. **Spec: site().** `site` builds a whole Site in one call: every record under one Theme, the 404, `sitemap.xml` and `robots.txt`. It is not in `@mdxui/site@7.0.0-alpha.0`; each target below does its work in one file. ## Prerequisites - Node.js 22.18 or later, and `@mdxui/site` installed as in the [Quickstart](https://next.mdxui.dev/docs/start/quickstart). - Records in `pages/`: `home.json`, a `404.json`, and one Theme across all of them. The 10 records of [Harbor tides](https://next.mdxui.dev/sites/harbor/records.zip) are a whole Site to start from; the zip unzips to `pages/`. - Or take every file of the Node script target at once: [ship-starter.zip](https://next.mdxui.dev/ship-starter.zip) holds `package.json`, `ship.ts` and Harbor tides' `pages/`. Run `pnpm install && node ship.ts`. **File: ship.ts** ```ts import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { PageSchema, renderPage, type Page } from '@mdxui/site'; const origin = 'https://harbor-tides.example'; // the Site's origin, for sitemap.xml // Every record in pages/: home.json is /, contact/sent.json is /contact/sent. const pages: { path: string; page: Page }[] = []; for (const file of (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json'))) { const page = PageSchema.parse(JSON.parse(await readFile(`pages/${file}`, 'utf8'))); pages.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page }); } pages.sort((a, b) => a.path.localeCompare(b.path)); ``` **File: records.ts** ```ts import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { PageSchema, type Page } from '@mdxui/site'; /** Every record under `dir`, parsed, with its address: home.json is /, contact/sent.json is /contact/sent. */ export async function readRecords(dir = 'pages'): Promise<{ path: string; page: Page }[]> { const out: { path: string; page: Page }[] = []; for (const file of (await readdir(dir, { recursive: true })).filter((f) => f.endsWith('.json')).sort()) { const result = PageSchema.safeParse(JSON.parse(await readFile(join(dir, file), 'utf8'))); if (!result.success) { const [{ path, message }] = result.error.issues; throw new Error(`${dir}/${file}: ${path.join('.')}: ${message}`); } out.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page: result.data }); } return out; } /** The file a static host serves for an address: / is index.html, /pricing is pricing.html. */ export const fileFor = (path: string) => (path === '/' ? 'index.html' : `${path.slice(1)}.html`); ``` **File: ship.ts · whole** ```ts // A Site renders whole or not at all: a 404 page, and one Theme for every Page. if (!pages.some((p) => p.path === '/404')) throw new Error('pages/404.json is missing: a Site has a 404 page'); const themes = new Set(pages.map((p) => p.page.theme)); if (themes.size > 1) throw new Error(`the records name ${themes.size} Themes, ${[...themes].join(' and ')}: a Site has one`); ``` **File: ship.ts · write** ```ts const write = async (file: string, text: string) => { await mkdir(dirname(file), { recursive: true }); await writeFile(file, text); console.log(file); }; for (const { path, page } of pages) await write(`dist${path === '/' ? '/index' : path}.html`, renderPage(page)); // Every page a search engine may list: not the 404, and no record marked noindex. const listed = pages.filter((p) => p.path !== '/404' && !p.page.meta?.noindex); await write( 'dist/sitemap.xml', `\n\n${listed.map((p) => ` ${origin}${p.path}`).join('\n')}\n\n`, ); await write('dist/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n`); ``` **File: vite-plugin.ts** ```ts import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { renderPage } from '@mdxui/site'; import type { Plugin, ResolvedConfig } from 'vite'; import { fileFor, readRecords } from './records.ts'; /** * Every record in pages/ as a page of a Vite project: rendered on each request * under `vite dev`, so an edit to a record shows on reload, and written into * the build's outDir by `vite build`. */ export function mdxuiPages({ dir = 'pages' } = {}): Plugin { let config: ResolvedConfig; return { name: 'mdxui-pages', configResolved(resolved) { config = resolved; }, configureServer(server) { server.middlewares.use(async (req, res, next) => { const path = new URL(req.url ?? '/', 'http://dev').pathname.replace(/(.)\/$/, '$1'); try { const hit = (await readRecords(join(config.root, dir))).find((r) => r.path === path); if (!hit) return next(); res.setHeader('content-type', 'text/html; charset=utf-8'); res.end(renderPage(hit.page)); } catch (error) { next(error); // a refused record answers with its path and bound } }); }, async writeBundle(_options, bundle) { const out = resolve(config.root, config.build.outDir); for (const { path, page } of await readRecords(join(config.root, dir))) { const file = fileFor(path); if (file in bundle) throw new Error(`${file} is already in the build: give the record or the entry another address`); await mkdir(dirname(join(out, file)), { recursive: true }); await writeFile(join(out, file), renderPage(page)); } }, }; } ``` **File: react-router.ts** ```ts import { access, mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { renderPage } from '@mdxui/site'; import { readRecords } from './records.ts'; /** * Run after `react-router build`: every record in pages/ becomes a file in the * prerendered client build, beside the routes React Router wrote. A record * never replaces a prerendered route; the landing record takes index.html, * the address React Router gives its SPA fallback when / is not prerendered. */ export async function renderInto(client = 'build/client', dir = 'pages'): Promise { const written: string[] = []; for (const { path, page } of await readRecords(dir)) { const file = path === '/' ? 'index.html' : `${path.slice(1)}/index.html`; const taken = await access(join(client, file)).then(() => true, () => false); if (taken && path !== '/') throw new Error(`${file} is a prerendered route: give the record or the route another address`); await mkdir(dirname(join(client, file)), { recursive: true }); await writeFile(join(client, file), renderPage(page)); written.push(file); } return written; } if (process.argv[1] === import.meta.filename) console.log((await renderInto()).join('\n')); ``` **File: wrangler.jsonc** ```jsonc { "name": "harbor-site", "compatibility_date": "2026-10-01", "main": "worker.ts", "assets": { "directory": "./dist", "not_found_handling": "404-page", "html_handling": "auto-trailing-slash", "run_worker_first": ["/contact/send"] }, "vars": { "CONTACT": "https://forms.harbor-tides.example/inbox" } } ``` **File: worker.ts** ```ts // Runs only for POST /contact/send (wrangler.jsonc, assets.run_worker_first); every other path is a file in dist/. export default { async fetch(request: Request, env: { CONTACT: string }): Promise { if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } }); const form = await request.formData(); // Forward the form, field for field, to the Site's contact address. const sent = await fetch(env.CONTACT, { method: 'POST', body: form }); if (!sent.ok) return new Response('The message was not sent. Try again.', { status: 502 }); return Response.redirect(new URL('/contact/sent', request.url), 303); }, }; ``` **File: Result** The result, as this site renders it: https://next.mdxui.dev/pages/harbor/404.json ## Read every record Each file in `pages/` is parsed and given its address: `contact/sent.json` is `/contact/sent`. A record that does not parse stops the run at its path. ## Build whole or not at all Before it writes a file, `ship.ts` stops on a missing 404 page or a second Theme. Without `pages/404.json`, then with one record under `directory`: ```text Error: pages/404.json is missing: a Site has a 404 page Error: the records name 2 Themes, quaypoint and directory: a Site has one ``` ## Render every page `node ship.ts` writes each page, then `sitemap.xml` and `robots.txt`. Run on the Harbor tides records: ```text dist/index.html dist/404.html dist/blog.html dist/blog/tide-windows.html dist/contact.html dist/contact/sent.html dist/integrations.html dist/integrations/chartplot.html dist/pricing.html dist/terms.html dist/sitemap.xml dist/robots.txt ``` 10 pages, then `sitemap.xml`, which lists 8 of them: ```xml title="dist/sitemap.xml" https://harbor-tides.example/ https://harbor-tides.example/blog https://harbor-tides.example/blog/tide-windows https://harbor-tides.example/contact https://harbor-tides.example/integrations https://harbor-tides.example/integrations/chartplot https://harbor-tides.example/pricing https://harbor-tides.example/terms ``` Add `mdxuiPages()` to `plugins` in `vite.config.ts`. `vite dev` renders a record on each request, so an edit shows on reload, and `vite build` writes every page beside your entry. A record never takes the address of a file your entry builds. Run `node react-router.ts` after `react-router build`. Every record joins the prerendered client build; a record never replaces a prerendered route. These docs ship their worked Sites this way. To render under your own Theme, swap `renderPage` for `render` from [site.ts](https://next.mdxui.dev/docs/themes/write-a-theme). ## Serve it, 404 included Upload the output to a static host whose not-found page is `404.html`. On Cloudflare, `wrangler.jsonc` sets `not_found_handling`, so any unknown address answers 404 with that page, and runs the Worker only for `/contact/send`. Deploy with `pnpm wrangler deploy`. ## Receive the contact form [`cta-form`](https://next.mdxui.dev/docs/sections/cta-form) posts, and a static host answers only GET. `worker.ts` takes the post, forwards the fields to `CONTACT` and redirects to the sent page: ```text POST /contact/send forwards POST https://forms.harbor-tides.example/inbox: name, email, topic, message answers 303 See Other, location: /contact/sent ``` The worked Sites on these docs are served as static files, so their forms do not send. ## Result Every record is a page at its address, and any other address answers 404 with your 404 page. The Harbor tides records ship the same way, at [/sites/harbor/](https://next.mdxui.dev/sites/harbor/). ## Next steps - [Contact flow and 404](https://next.mdxui.dev/docs/start/guides/contact-and-404): The form, its sent page and every 404. - [Site](https://next.mdxui.dev/docs/components/site): Each part of a Site, and what builds it. - [Rebrand a Site](https://next.mdxui.dev/docs/start/guides/rebrand): One line moves every page to another Theme. # callboard (/docs/themes/callboard) > Violet, Bricolage Grotesque and DM Mono. Best for B2B SaaS with a loud brand. **Best for.** B2B SaaS with a loud brand, events and venues. House paper, stage black, a violet wash light and poster lettering in Bricolage Grotesque. ## Swatches From `resolveTokens(THEMES.callboard.theme)` in `@mdxui/themes@7.0.0-alpha.0`. | Role | Light | | --- | --- | | `paper` | `#F6F5F8` | | `panel` | `#FFFFFF` | | `ink` | `#16121F` | | `ink-soft` | `#332B42` | | `rule` | `#DAD6E2` | | `rule-soft` | `#E7E4ED` | | `accent` | `#6D28D9` | | `accent-ink` | `#FFFFFF` | | `plate` | `#17121F` | | `plate-ink` | `#F5F3F8` | | `plate-rule` | `#342B44` | | `pass` | `#15803D` | | `warn` | `#B45309` | | `fail` | `#B91C1C` | ## Typefaces | Role | Typeface | | --- | --- | | Display | Bricolage Grotesque | | Text | Instrument Sans | | Mono | DM Mono | The head lines load Bricolage Grotesque, Instrument Sans and DM Mono. ## Policies Four policies change the markup a page renders. Each pair below holds the tokens still and changes the markup. | Policy | Value | | --- | --- | | Dividers | `rule`, after article `none` | | Markers | off | | Featured plan | `plate` | | Icons | `inline` | | Scheme | `light`, light only | ### Dividers ### Markers ### Featured plan ### Icons ## Schemes ## Worn by ## Same page, other Themes ## Use it # certificate (/docs/themes/certificate) > Teal on cool paper, IBM Plex, hatched rules, light and dark. Best for developer tools and API products. **Best for.** Developer tools and API products whose proof is a test or a measurement. Lab paper, one engineering superfamily, hatched dividers, and a dark palette that follows the reader. ## Swatches From `resolveTokens(THEMES.certificate.theme)` in `@mdxui/themes@7.0.0-alpha.0`, with the dark palette the Theme emits. | Role | Light | Dark | | --- | --- | --- | | `paper` | `oklch(0.930 0.004 175)` | `oklch(0.185 0.014 210)` | | `panel` | `oklch(0.968 0.003 175)` | `oklch(0.225 0.016 212)` | | `ink` | `oklch(0.205 0.021 210)` | `oklch(0.930 0.010 185)` | | `ink-soft` | `oklch(0.445 0.018 200)` | `oklch(0.680 0.016 195)` | | `rule` | `oklch(0.845 0.008 190)` | `oklch(0.320 0.016 212)` | | `rule-soft` | `oklch(0.890 0.006 190)` | `oklch(0.270 0.014 212)` | | `accent` | `oklch(0.470 0.110 185)` | `oklch(0.760 0.128 178)` | | `accent-ink` | `oklch(0.992 0.006 180)` | `oklch(0.185 0.014 210)` | | `plate` | `oklch(0.128 0.022 222)` | `oklch(0.128 0.022 222)` | | `plate-ink` | `oklch(0.910 0.014 190)` | `oklch(0.910 0.014 190)` | | `plate-rule` | `oklch(0.280 0.020 220)` | `oklch(0.280 0.020 220)` | | `pass` | `oklch(0.500 0.135 158)` | `oklch(0.760 0.145 160)` | | `warn` | `oklch(0.545 0.130 70)` | `oklch(0.790 0.140 70)` | | `fail` | `oklch(0.545 0.195 27)` | `oklch(0.700 0.180 27)` | ## Typefaces | Role | Typeface | | --- | --- | | Display | IBM Plex Sans | | Text | IBM Plex Sans | | Mono | IBM Plex Mono | The head lines load IBM Plex Sans and IBM Plex Mono. ## Policies Four policies change the markup a page renders. Each pair below holds the tokens still and changes the markup. | Policy | Value | | --- | --- | | Dividers | `hatch`, after hero `hatch`, around band `rule`, closing `rule` | | Markers | on | | Featured plan | `rule` | | Icons | `inline` | | Scheme | `auto`, light and dark | ### Dividers ### Markers ### Featured plan ### Icons ## Schemes ## Worn by ## Same page, other Themes ## Use it # The Theme contract (/docs/themes/contract) > The 21 core roles every Theme sets, the derived roles that default through var(), the invariant roles, the plate remap, and what compileTheme refuses. [startupbuilder.dev defines the Theme](https://startupbuilder.dev/business/nouns/theme): "A Theme is the design tokens of a Brand, its color, type, spacing, radius, and motion, that every Component reads when it renders." mdxui adds five [policies](https://next.mdxui.dev/docs/themes/policies) and compiles the tokens to CSS custom properties. ## Three kinds of role A role names a use, not a look: `ink-lede` is the color a lede is set in, whatever that color is. Every Section type reads roles and never a literal color, typeface or size. | Kind | A Theme | Example | | --- | --- | --- | | Core | Sets every one, in `light` or `invariant` | `paper`, `ink`, `accent` | | Derived | May override any; each defaults to a core role through `var()` | `ink-lede` defaults to the secondary ink | | Invariant | Declares once and never redefines for dark | certificate's `plate` family | From `@mdxui/themes@7.0.0-alpha.0`: 21 core roles, which every Theme sets, and 325 derived roles, each with a default. `paper`, `panel`, `ink`, `ink-soft`, `rule`, `rule-soft`, `accent`, `accent-ink`, `pass`, `fail`, `warn`, `plate`, `plate-ink`, `plate-rule`, `sans`, `mono`, `text`, `lh`, `shell-max`, `shell-inset`, `shell-d-section` ## Core roles Each square is one role under one Theme, as `resolveTokens` returns it. Hover for the value; click to copy the custom property. Dark shows the palette a Theme emits, so the three light-only Themes keep their light values. ## Derived roles A Theme that sets only the core roles renders: every derived role falls back through `var()` to a core role. A richer Theme overrides the families its design needs. The bars count how many roles of each family a Theme sets itself. [`@mdxui/themes` exports](https://next.mdxui.dev/docs/reference/themes-exports#roles) lists every derived role with its default. ## Invariant roles An invariant role is declared once and never redefined for dark. `compileTheme` refuses a Theme that names one in `dark`. certificate keeps its plate and its scrim invariant, so a code window is the same dark plate in both schemes. ## On a plate A plate section re-tokens its subtree: a Section type on a plate reads `--ink` and gets the plate's ink. `PLATE_REMAP` names each pair, so one Section type renders on paper and on a plate with no second set of styles. ## The Theme object ```ts interface Theme { name: string; light: Record; // the full light palette, every core role included dark?: Record; // only what changes for dark invariant?: Record; // declared once, never redefined for dark dividers: DividerPolicy; markers: boolean; featured: 'rule' | 'plate'; icons: 'inline' | 'image'; scheme?: 'auto' | 'light'; } ``` ## Compiling `compileTheme(theme)` checks a Theme against the contract and returns its custom properties for `:root`. It refuses a Theme missing a core role and names every missing one. For a Theme that sets only `paper`: ```text theme "fieldnote" is missing core roles: panel, ink, ink-soft, rule, rule-soft, accent, accent-ink, pass, fail, warn, plate, plate-ink, plate-rule, sans, mono, text, lh, shell-max, shell-inset, shell-d-section ``` `resolveTokens(theme)` returns the light palette with every `var()` chain followed to its value, for wherever a role is needed as a literal. The [playground](https://next.mdxui.dev/docs/themes/playground) runs both on every edit. ## Prior definitions | Prior | Definition | Disposition | | --- | --- | --- | | startupbuilder.dev Theme | The design tokens of a Brand, its color, type, spacing, radius, and motion | Adopted word for word. The 5 policies are the mdxui addition. Its `css` property, extra CSS after the tokens, has no counterpart: no page carries its own CSS. | | `@mdxui/themes` contract | Role and value pairs compiled to CSS custom properties | Kept as the rendering view of the same Noun | | Legacy mdxui `theme` prop | `light`, `dark` or `system` on a Site wrapper | Superseded by the `scheme` policy and the dark palette | # Dark mode (/docs/themes/dark-mode) > Light is the full palette on :root. Dark redefines only the roles that change, under the reader's prefers-color-scheme. ## What each Theme emits | Theme | Dark palette | `scheme` | Document `color-scheme` | | --- | --- | --- | --- | | `certificate` | yes | `auto` | `light dark` | | `vaultline` | no | `auto` | `light` | | `quaypoint` | yes | `light` | `light` | | `callboard` | yes | `light` | `light` | | `directory` | yes | `auto` | `light dark` | - **certificate** and **directory** emit dark. The page follows the reader's scheme. - **quaypoint** and **callboard** declare a dark palette and set `scheme: 'light'`. `compileTheme` checks the palette and emits none of it. - **vaultline** declares no dark palette. Its page is light in every scheme. ## See it Pick a Theme. Where it emits dark, the toggle swaps between its two real renders: the dark one is the build's twin with the Theme's own dark media query switched on. Where it does not, the page shows the declared palette as values and draws no dark render. ## The rules - No color gets its only definition inside the dark media query. Every role has a light value first. - An invariant role is declared once and never redefined for dark. `compileTheme` refuses one that appears in `dark`. The code window is dark in both schemes. - `scheme: 'light'` keeps the dark palette declared and checked, and emits none of it. Changing that one word to `auto` turns dark mode on. - The document's `color-scheme` is `light dark` when the Theme emits a dark palette, and `light` otherwise. ## Previews on these docs Every preview on these docs follows the same rule. The scheme control is on only for certificate and directory, and it swaps to the dark twin; for the other three it stays off, with "Light only by this Theme's policy". No preview inverts a light render. # directory (/docs/themes/directory) > Monochrome Inter with no dividers, light and dark. Best for directories and listings. **Best for.** Directories, listings and marketplaces, and the docs beside a landing. Monochrome, Inter for everything, no dividers, light and dark. ## Swatches From `resolveTokens(THEMES.directory.theme)` in `@mdxui/themes@7.0.0-alpha.0`, with the dark palette the Theme emits. | Role | Light | Dark | | --- | --- | --- | | `paper` | `#FFFFFF` | `#0A0A0A` | | `panel` | `#FAFAFA` | `#111111` | | `ink` | `#171717` | `#EDEDED` | | `ink-soft` | `#404040` | `#D4D4D4` | | `rule` | `#E5E5E5` | `#262626` | | `rule-soft` | `#EBEBEB` | `#1F1F1F` | | `accent` | `#171717` | `#EDEDED` | | `accent-ink` | `#FFFFFF` | `#0A0A0A` | | `plate` | `#0A0A0A` | `#FFFFFF` | | `plate-ink` | `#EDEDED` | `#171717` | | `plate-rule` | `#262626` | `#E5E5E5` | | `pass` | `#15803D` | `#4ADE80` | | `warn` | `#B45309` | `#FBBF24` | | `fail` | `#B91C1C` | `#F87171` | ## Typefaces | Role | Typeface | | --- | --- | | Display | Inter | | Text | Inter | | Mono | JetBrains Mono | The head lines load Inter and JetBrains Mono. ## Policies Four policies change the markup a page renders. Each pair below holds the tokens still and changes the markup. | Policy | Value | | --- | --- | | Dividers | `none` | | Markers | off | | Featured plan | `rule` | | Icons | `inline` | | Scheme | `auto`, light and dark | ### Dividers ### Markers ### Featured plan ### Icons ## Schemes ## Worn by ## Same page, other Themes ## Use it # Theming the docs (/docs/themes/docs) > Dress a Fumadocs site in a Business's Theme: each Fumadocs color reads one resolved role, light and dark. These docs wear certificate this way. These docs and the landing share one paper, one ink and one accent: certificate's, read through `resolveTokens`. No color in the chrome is typed by hand. ## Prerequisites - A Fumadocs site, such as one from `create-fumadocs-app`. - `@mdxui/themes` at the pinned version, as on [Packages and versions](https://next.mdxui.dev/docs/start/packages). ## Steps 1. Map each Fumadocs color to one role. Saved as `docs-theme.ts`: ```ts // Each Fumadocs color, read from one Theme role. A value in brackets is mixed from two roles. const MAP: Record = { background: '[panel 55% paper]', foreground: 'ink', card: '[accent-ink 55% panel]', 'card-foreground': 'ink', popover: '[accent-ink 55% panel]', 'popover-foreground': 'ink', muted: 'panel', 'muted-foreground': 'ink-soft', secondary: 'panel', 'secondary-foreground': 'ink', accent: '[accent 8% panel]', 'accent-foreground': 'ink', border: 'rule', primary: 'accent', 'primary-foreground': 'accent-ink', ring: 'accent', }; // In dark, the paper is the darkest ground, so a raised panel is the panel role itself. const DARK: Record = { card: 'panel', popover: 'panel' }; // The roles the docs' own components paint with: rules, the hatch, the plates code sits on, the status colors and the code roles. const EXTRA = ['rule-soft', 'hatch', 'plate', 'plate-ink', 'plate-ink-soft', 'plate-rule', 'plate-accent', 'pass', 'warn', 'fail', 'code-ink', 'code-comment', 'code-string', 'code-keyword']; ``` 2. Resolve the roles to literals and write the stylesheet. A Theme under `scheme: 'light'` writes no `.dark` block: ```ts const { theme } = THEMES.certificate; const light = resolveTokens(theme); // The dark palette redefines only what changes; resolving it again lets every derived role follow. const dark = theme.dark && theme.scheme !== 'light' ? resolveTokens({ ...theme, light: { ...theme.light, ...theme.dark } }) : undefined; const value = (roles: Record, role: string) => { const mix = role.match(/^\[(\S+) (\d+)% (\S+)\]$/); return mix ? `color-mix(in oklab, ${roles[mix[1]]} ${mix[2]}%, ${roles[mix[3]]})` : roles[role]; }; const block = (selector: string, roles: Record, over: Record = {}) => `${selector} {\n${[ ...Object.entries({ ...MAP, ...over }).map(([fd, role]) => ` --color-fd-${fd}: ${value(roles, role)};`), ...EXTRA.map((role) => ` --mx-${role}: ${roles[role]};`), ].join('\n')}\n}`; const radii = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'].map((r) => ` --radius-${r}: ${light['radius-control']};`).join('\n'); const css = [ block(':root', light), dark ? block('.dark', dark, DARK) : '', `:root {\n --font-sans: ${light.sans};\n --font-mono: ${light.mono};\n${radii}\n}`, ] .filter(Boolean) .join('\n\n'); await writeFile('docs-theme.css', `${css}\n`); ``` 3. Run it, and import `docs-theme.css` after the Fumadocs preset in the site's global CSS. This site imports the same file after the preset: ```css @import 'fumadocs-ui/css/preset.css'; @import './docs-theme.css'; ``` 4. Serve every page as Markdown. Fumadocs' `llms()` renders a page as Markdown, and `MarkdownCopyButton` puts the copy control under each title. This site serves each page at its address plus `.md`. ## Result For `certificate`, the Theme this site's landing and docs wear. The generator writes the same file into this site's styles, and the check fails if the two drift: ```css title="docs-theme.css, 32 variables" :root { --color-fd-background: color-mix(in oklab, oklch(0.968 0.003 175) 55%, oklch(0.930 0.004 175)); --color-fd-foreground: oklch(0.205 0.021 210); --color-fd-card: color-mix(in oklab, oklch(0.992 0.006 180) 55%, oklch(0.968 0.003 175)); --color-fd-card-foreground: oklch(0.205 0.021 210); --color-fd-popover: color-mix(in oklab, oklch(0.992 0.006 180) 55%, oklch(0.968 0.003 175)); --color-fd-popover-foreground: oklch(0.205 0.021 210); --color-fd-muted: oklch(0.968 0.003 175); --color-fd-muted-foreground: oklch(0.445 0.018 200); --color-fd-secondary: oklch(0.968 0.003 175); --color-fd-secondary-foreground: oklch(0.205 0.021 210); --color-fd-accent: color-mix(in oklab, oklch(0.470 0.110 185) 8%, oklch(0.968 0.003 175)); --color-fd-accent-foreground: oklch(0.205 0.021 210); --color-fd-border: oklch(0.845 0.008 190); --color-fd-primary: oklch(0.470 0.110 185); --color-fd-primary-foreground: oklch(0.992 0.006 180); --color-fd-ring: oklch(0.470 0.110 185); --mx-rule-soft: oklch(0.890 0.006 190); --mx-hatch: oklch(0.205 0.021 210 / 0.16); --mx-plate: oklch(0.128 0.022 222); --mx-plate-ink: oklch(0.910 0.014 190); --mx-plate-ink-soft: oklch(0.680 0.016 195); --mx-plate-rule: oklch(0.280 0.020 220); --mx-plate-accent: oklch(0.800 0.130 175); --mx-pass: oklch(0.500 0.135 158); --mx-warn: oklch(0.545 0.130 70); --mx-fail: oklch(0.545 0.195 27); --mx-code-ink: oklch(0.910 0.014 190); --mx-code-comment: oklch(0.680 0.016 195); --mx-code-string: oklch(0.800 0.130 175); --mx-code-keyword: oklch(0.800 0.130 175); } .dark { --color-fd-background: color-mix(in oklab, oklch(0.225 0.016 212) 55%, oklch(0.185 0.014 210)); --color-fd-foreground: oklch(0.930 0.010 185); --color-fd-card: oklch(0.225 0.016 212); --color-fd-card-foreground: oklch(0.930 0.010 185); --color-fd-popover: oklch(0.225 0.016 212); --color-fd-popover-foreground: oklch(0.930 0.010 185); --color-fd-muted: oklch(0.225 0.016 212); --color-fd-muted-foreground: oklch(0.680 0.016 195); --color-fd-secondary: oklch(0.225 0.016 212); --color-fd-secondary-foreground: oklch(0.930 0.010 185); --color-fd-accent: color-mix(in oklab, oklch(0.760 0.128 178) 8%, oklch(0.225 0.016 212)); --color-fd-accent-foreground: oklch(0.930 0.010 185); --color-fd-border: oklch(0.320 0.016 212); --color-fd-primary: oklch(0.760 0.128 178); --color-fd-primary-foreground: oklch(0.185 0.014 210); --color-fd-ring: oklch(0.760 0.128 178); --mx-rule-soft: oklch(0.270 0.014 212); --mx-hatch: oklch(0.930 0.010 185 / 0.13); --mx-plate: oklch(0.128 0.022 222); --mx-plate-ink: oklch(0.910 0.014 190); --mx-plate-ink-soft: oklch(0.680 0.016 195); --mx-plate-rule: oklch(0.280 0.020 220); --mx-plate-accent: oklch(0.800 0.130 175); --mx-pass: oklch(0.760 0.145 160); --mx-warn: oklch(0.790 0.140 70); --mx-fail: oklch(0.700 0.180 27); --mx-code-ink: oklch(0.910 0.014 190); --mx-code-comment: oklch(0.680 0.016 195); --mx-code-string: oklch(0.800 0.130 175); --mx-code-keyword: oklch(0.800 0.130 175); } :root { --font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; --radius-xs: 0; --radius-sm: 0; --radius-md: 0; --radius-lg: 0; --radius-xl: 0; --radius-2xl: 0; --radius-3xl: 0; --radius-4xl: 0; } ``` ## Try it on these docs The generator also writes one scope per other bundled Theme, `[data-docs-theme=""]`, from the same roles. Wear one here, and the chrome redraws in its paper, ink, accent and typefaces: ## Next steps - [The Theme contract](https://next.mdxui.dev/docs/themes/contract): every role the map can read. - [Renderings](https://next.mdxui.dev/docs/components/renderings): the Markdown of a Page record. # Theme gallery (/docs/themes) > Five Themes ship in @mdxui/themes. Each Tile is the same Business in that Theme: pick one, or set two side by side. > A Theme is the design tokens of a Brand, its color, type, spacing, radius, and motion, that every Component reads when it renders. > > startupbuilder.dev, [Theme](https://startupbuilder.dev/business/nouns/theme) In mdxui a Theme is a Brand's tokens plus its render policies. A record names one in `"theme"`, and every page of the Site redraws in it. ## Compare two The same record under two Themes, one over the other. Drag the divider across the page. ## At a glance ## How a Theme works - [The Theme contract](https://next.mdxui.dev/docs/themes/contract): The 21 core roles, the derived and invariant roles, and the plate remap. - [Render policies](https://next.mdxui.dev/docs/themes/policies): Dividers, markers, featured and icons, each as two renders. - [Typefaces](https://next.mdxui.dev/docs/themes/typefaces): Each Theme's display, text and mono typefaces, and the lines that load them. - [Dark mode](https://next.mdxui.dev/docs/themes/dark-mode): Which Themes emit dark, and which only declare it. ## Make one - [Write a Theme](https://next.mdxui.dev/docs/themes/write-a-theme): Start from a bundled Theme and render any record under yours. - [Theme your docs](https://next.mdxui.dev/docs/themes/docs): Dress a Fumadocs site in the same roles, as these docs do. - [Theme playground](https://next.mdxui.dev/docs/themes/playground): Edit tokens over a live render and copy the Theme object. # Theme playground (/docs/themes/playground) > Spec: written ahead of the package. Not in the published packages: renderWithTheme > Edit paper, ink, accent, rule and plate over a real render with the published compileTheme, then copy the Theme object. **What is written ahead.** Every edit runs the published `compileTheme` in your browser. The frame is a page rendered at build under the Theme you start from, and the edit replaces its token stylesheet. Rendering a whole page under a Theme of your own in one call, `renderWithTheme`, is the unpublished part. ## How it runs - The first edit loads `@mdxui/themes`, at most 17 KB gzipped, and runs `compileTheme` on your draft. - The output replaces the frame's `