diff --git a/.changeset/remove-builtin-theme-seeds.md b/.changeset/remove-builtin-theme-seeds.md
new file mode 100644
index 00000000..43fcd7b9
--- /dev/null
+++ b/.changeset/remove-builtin-theme-seeds.md
@@ -0,0 +1,7 @@
+---
+'@pmndrs/docs': patch
+---
+
+Remove the built-in `ThemeSeeds` component and its static fragment renderer. The component now belongs to the design-system documentation and must be registered through consumer configuration when needed.
+
+Consumers using `` must provide their own component in `docs.config.ts`, including a static renderer for standalone HTML fragments. See the [custom components guide](https://docs.pmnd.rs/authoring/custom-components) and the [design-system implementation](https://github.com/pmndrs/design-system/pull/73). Shared theme controls and preset persistence remain available. The former authoring page redirects to the design-system Colors page.
diff --git a/docs/authoring/introduction.mdx b/docs/authoring/introduction.mdx
index ff66de00..3d83c3ec 100644
--- a/docs/authoring/introduction.mdx
+++ b/docs/authoring/introduction.mdx
@@ -84,7 +84,5 @@ Plus:
See the following pages for detailed documentation on each custom component.
-For named source-seed previews and their seed table, see [`ThemeSeeds`](theme-seeds).
-
To register your own React components and shared data for MDX pages, see
[Custom components and data](custom-components).
diff --git a/docs/authoring/theme-seeds.mdx b/docs/authoring/theme-seeds.mdx
deleted file mode 100644
index de356af7..00000000
--- a/docs/authoring/theme-seeds.mdx
+++ /dev/null
@@ -1,101 +0,0 @@
----
-title: "`ThemeSeeds`"
-description: Preview named source-seed presets with the site's theme
-sourcecode: docs/authoring/theme-seeds.mdx
-nav: 19
----
-
-
- A dropdown of named seed presets, followed by the active theme's defined seed settings.
-
-
-## Usage
-
-Pass `presets` as an array of unique `name`, display `label` and seed `config` values:
-
-```md
-
-```
-
-result:
-
-
-
-This is a small example. For a design-system catalog, generate the array from the same canonical
-preset definitions used to build its installable themes, so the preview and installed palette agree.
-
-## Preview behaviour
-
-Selecting a preset applies its `source` and optional `neutral` / `neutralVariant` seeds.
-Omit either neutral seed to let Material Design 3 derive that palette from the source. The site's
-other seed settings stay in place; this component is a source-preset preview, not a general theme editor.
-
-Selection also restores `colorMatch` and `scheme` from the preset, defaulting to `false` and
-`tonalSpot` when omitted. `colorMatch` takes precedence over `scheme` in the existing builder.
-Contrast remains the reader's preference, as does light/dark mode: leave contrast out of presets.
-
-The dropdown and footer theme controls share stored preferences. Selected source and neutral seeds
-remain active across other pages and reloads, even when this component is not mounted. A footer colour or display setting
-that no longer matches a preset labels the selection `Custom`. The table follows the active builder;
-undefined settings and contrast are omitted.
-
-On initial load, a Skeleton appears until saved preferences have been applied, avoiding a flash of
-the site's default preset. The panel remains visible during subsequent changes.
-
-## HTML fragments
-
-The website supplies the theme provider and interactive controls. A standalone HTML fragment has
-neither: `pmndrs-docs build --format fragment` renders a static seed table from the **first** preset,
-without a dropdown or client JavaScript. Put the intended default first; an empty array renders nothing.
diff --git a/next.config.mts b/next.config.mts
index 8747000e..204dfc48 100644
--- a/next.config.mts
+++ b/next.config.mts
@@ -109,6 +109,11 @@ const nextConfig = {
rewrites: output === 'export' ? undefined : rewrites,
async redirects() {
return [
+ {
+ source: '/authoring/theme-seeds',
+ destination: 'https://pmndrs.github.io/design-system/tokens/colors#seeds',
+ permanent: true,
+ },
// Where the markdown of a page was first published. Redirects only apply to the request
// as it comes in, never to the destination of a rewrite, so this does not loop with the
// rewrite above, which still serves the markdown from `/md`.
diff --git a/src/app/theme-presets/page.test.ts b/src/app/theme-presets/page.test.ts
index 97a8462f..8be41824 100644
--- a/src/app/theme-presets/page.test.ts
+++ b/src/app/theme-presets/page.test.ts
@@ -1,29 +1,11 @@
import { expect, test, type Page } from '@playwright/test'
import { builder } from 'material-theme-builder'
-declare global {
- interface Window {
- presetLabels: string[]
- }
-}
-
test.setTimeout(60000)
-const presetPage = '/authoring/theme-seeds'
const surfaceInk = () =>
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface').trim()
-function recordPresetFrames() {
- const view = window
- view.presetLabels = []
- function sample() {
- const button = document.querySelector('button[aria-label="Seed preset"]')
- if (button) view.presetLabels.push(button.textContent?.trim() ?? '')
- requestAnimationFrame(sample)
- }
- requestAnimationFrame(sample)
-}
-
function expectedSurfaceInk(source: string, contrast: number, neutral?: string) {
const css = builder(source, {
colorMatch: true,
@@ -38,105 +20,53 @@ function expectedSurfaceInk(source: string, contrast: number, neutral?: string)
}
async function showThemeControls(page: Page) {
- if (!(await page.getByRole('button', { name: /^Contrast:/ }).isVisible())) {
+ const contrast = page.getByRole('button', { name: /^Contrast:/ })
+ if (page.viewportSize()!.width < 1024 && !(await contrast.isVisible())) {
await page.getByRole('button', { name: 'Toggle Sidebar', exact: true }).click()
}
+ await expect(contrast).toBeVisible()
}
for (const preset of [
{ label: 'Purple', source: '#D855F9', neutral: undefined },
{ label: 'Lime', source: '#CAF543', neutral: '#c1b793' },
]) {
- test(`${preset.label} keeps its native neutral palette across navigation, contrast and reload`, async ({
- page,
- }) => {
- await page.addInitScript(() => localStorage.setItem('theme', 'light'))
- await page.goto(presetPage)
- const selector = page.getByRole('button', { name: 'Seed preset', exact: true })
- await selector.click()
- await page.getByRole('menuitemradio', { name: preset.label, exact: true }).click()
- await page.keyboard.press('Escape')
- await expect(selector).toHaveText(preset.label)
+ test(`${preset.label} stored seeds survive navigation, contrast and reload`, async ({ page }) => {
+ await page.goto('/getting-started/introduction')
+ // Seed once, as a consumer component would. Later reloads must use persisted choices.
+ await page.evaluate(({ source, neutral }) => {
+ localStorage.setItem('theme', 'light')
+ localStorage.setItem('pmndrs-docs:primary-color', source)
+ localStorage.setItem('pmndrs-docs:color-match', 'true')
+ localStorage.setItem(
+ 'pmndrs-docs:source-preset',
+ JSON.stringify({ source, neutral, neutralVariant: neutral ? '#495720' : undefined }),
+ )
+ }, preset)
+ await page.reload()
const ink = () => page.evaluate(surfaceInk)
await expect.poll(ink).toBe(expectedSurfaceInk(preset.source, 0, preset.neutral))
- const introduction = page.getByRole('link', { name: 'getting started', exact: true })
- if (!(await introduction.isVisible())) {
- await page.getByRole('button', { name: 'Toggle Sidebar', exact: true }).click()
- }
- await introduction.click()
- await expect(page).toHaveURL(/\/getting-started\/introduction/)
+
+ await page.getByRole('link', { name: 'reusable workflow', exact: true }).click()
+ await expect(page).toHaveURL(/\/github-actions\/introduction/)
+ await expect.poll(ink).toBe(expectedSurfaceInk(preset.source, 0, preset.neutral))
await showThemeControls(page)
await page.getByRole('button', { name: /Contrast: standard, switch to medium/ }).click()
await expect.poll(ink).toBe(expectedSurfaceInk(preset.source, 0.5, preset.neutral))
await page.reload()
await expect.poll(ink).toBe(expectedSurfaceInk(preset.source, 0.5, preset.neutral))
- await page.goto(presetPage)
- await expect(selector).toHaveText(preset.label)
- await expect.poll(ink).toBe(expectedSurfaceInk(preset.source, 0.5, preset.neutral))
- await selector.click()
- await page.getByRole('menuitemradio', { name: 'Lime', exact: true }).click()
- await page.keyboard.press('Escape')
- await expect.poll(ink).toBe(expectedSurfaceInk('#CAF543', 0.5, '#c1b793'))
+
await showThemeControls(page)
await expect(
page.getByRole('button', { name: /Contrast: medium, switch to high/ }),
).toBeVisible()
+ // A different footer source must stop applying the saved preset's neutral overrides.
+ await page.locator('input[type="color"]').last().fill('#123456', { force: true })
+ await expect.poll(ink).toBe(expectedSurfaceInk('#123456', 0.5, '#c1b793'))
+ await page.reload()
+ await expect.poll(ink).toBe(expectedSurfaceInk('#123456', 0.5, '#c1b793'))
+ await showThemeControls(page)
+ await page.locator('input[type="color"]').last().fill(preset.source, { force: true })
+ await expect.poll(ink).toBe(expectedSurfaceInk(preset.source, 0.5, preset.neutral))
})
}
-
-test('switches native Teal and Cyan presets without resetting the saved contrast', async ({
- page,
-}) => {
- await page.addInitScript(recordPresetFrames)
- await page.addInitScript(() => localStorage.setItem('theme', 'light'))
- await page.goto(presetPage)
- const selector = page.getByRole('button', { name: 'Seed preset', exact: true })
- await selector.click()
- await page.getByRole('menuitemradio', { name: 'Teal', exact: true }).click()
- await page.keyboard.press('Escape')
- await showThemeControls(page)
- await page.getByRole('button', { name: /Contrast: standard, switch to medium/ }).click()
- await expect.poll(() => page.evaluate(surfaceInk)).toBe(expectedSurfaceInk('#00F7A3', 0.5))
- if (await page.getByRole('dialog', { name: 'Sidebar', exact: true }).isVisible()) {
- await page.locator('[data-slot="sheet-overlay"]').click({
- position: { x: page.viewportSize()!.width - 2, y: 2 },
- })
- await expect(page.getByRole('dialog', { name: 'Sidebar', exact: true })).not.toBeVisible()
- }
- await page.evaluate(() => {
- window.presetLabels = []
- })
- await selector.click()
- await page.getByRole('menuitemradio', { name: 'Cyan', exact: true }).click()
- await page.keyboard.press('Escape')
- await expect(selector).toHaveText('Cyan')
- await expect.poll(() => page.evaluate(surfaceInk)).toBe(expectedSurfaceInk('#2BDCF6', 0.5))
- const transitionLabels = await page.evaluate(() => window.presetLabels)
- expect(transitionLabels.every((label) => label === 'Teal' || label === 'Cyan')).toBe(true)
- await page.reload()
- await expect(selector).toHaveText('Cyan')
- await expect.poll(() => page.evaluate(surfaceInk)).toBe(expectedSurfaceInk('#2BDCF6', 0.5))
- const reloadLabels = await page.evaluate(() => window.presetLabels)
- expect(reloadLabels.length).toBeGreaterThan(0)
- expect(reloadLabels.every((label) => label === 'Cyan')).toBe(true)
- await showThemeControls(page)
- await expect(page.getByRole('button', { name: /Contrast: medium, switch to high/ })).toBeVisible()
-})
-
-test('recognizes a saved footer source and preserves custom footer colours', async ({ page }) => {
- await page.addInitScript(() => {
- localStorage.setItem('theme', 'light')
- localStorage.setItem('pmndrs-docs:primary-color', '#d855f9')
- localStorage.setItem('pmndrs-docs:color-match', 'true')
- })
- await page.goto(presetPage)
- const selector = page.getByRole('button', { name: 'Seed preset', exact: true })
- await expect(selector).toHaveText('Purple')
- await expect.poll(() => page.evaluate(surfaceInk)).toBe(expectedSurfaceInk('#D855F9', 0))
- await page.locator('input[type="color"]').last().fill('#123456', { force: true })
- await expect(selector).toHaveText('Custom')
- await expect(page.getByRole('table', { name: 'Current theme seeds' })).toContainText('#123456')
- await expect
- .poll(() => page.evaluate(surfaceInk))
- .toBe(expectedSurfaceInk('#123456', 0, '#c1b793'))
-})
diff --git a/src/cli/render.test.tsx b/src/cli/render.test.tsx
index f42a4d76..d04aecc3 100644
--- a/src/cli/render.test.tsx
+++ b/src/cli/render.test.tsx
@@ -9,47 +9,6 @@ import { renderFragment } from './render'
const render = (source: string) =>
renderFragment(source, { absoluteFilePath: join(process.cwd(), 'docs/authoring/fixture.mdx') })
-test('exports the first seed preset as a static table without theme providers', async () => {
- const $ = load(
- await render(
- '',
- ),
- )
- const table = $('table[aria-label="Current theme seeds"]')
- expect(table).toHaveLength(1)
- expect(
- table
- .find('tbody tr')
- .map((_, row) =>
- $(row)
- .find('code')
- .map((_, cell) => $(cell).text())
- .get(),
- )
- .get(),
- ).toEqual(['source', '#CAF543', 'colorMatch', 'true', 'neutral', '#c1b793'])
- expect($('button[aria-label="Seed preset"]')).toHaveLength(0)
- expect(table.text()).not.toContain('#00F7A3')
-})
-
-test('omits undefined neutral seeds from the fragment table', async () => {
- const $ = load(
- await render(
- '',
- ),
- )
- expect(
- $('tbody tr')
- .map((_, row) =>
- $(row)
- .find('code')
- .map((_, cell) => $(cell).text())
- .get(),
- )
- .get(),
- ).toEqual(['source', '#D855F9', 'colorMatch', 'true'])
-})
-
/**
* One case per authoring module, asserting the markup it is recognizable by rather than the
* whole of its output — the latter changes every time a class name does.
diff --git a/src/cli/render.tsx b/src/cli/render.tsx
index 497f827d..b0ad01b2 100644
--- a/src/cli/render.tsx
+++ b/src/cli/render.tsx
@@ -1,7 +1,5 @@
import type { DocToC } from '@/app/[...slug]/DocsContext'
import type { DocsConfig } from '@/config'
-import type { SeedPreset } from '@/components/mdx/ThemeSeeds'
-import { ThemeSeedsTable } from '@/components/mdx/ThemeSeedsTable'
import { compileMdxContent } from '@/utils/compileMdxContent'
import matter from 'gray-matter'
import type { ComponentProps, ReactNode } from 'react'
@@ -12,9 +10,6 @@ import { renderToReadableStream } from 'react-dom/server'
* its `h1` renders nothing. A fragment has no layout, so it must keep the title it is given.
*/
export const fragmentComponents = {
- /** A standalone fragment has no theme provider or controls: show the first preset's seeds. */
- ThemeSeeds: ({ presets }: { presets: SeedPreset[] }) =>
- presets[0] ? : null,
h1: (props: ComponentProps<'h1'>) => (
),
diff --git a/src/components/mdx/ThemeSeeds.tsx b/src/components/mdx/ThemeSeeds.tsx
deleted file mode 100644
index a0df87a8..00000000
--- a/src/components/mdx/ThemeSeeds.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-'use client'
-
-import { ThemeSeedsTable } from '@/components/mdx/ThemeSeedsTable'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuLabel,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import { useColorMatch } from '@/hooks/useColorMatch'
-import { useContrastLevel } from '@/hooks/useContrastLevel'
-import { useIsHydrated } from '@/hooks/useIsHydrated'
-import { usePrimaryColor } from '@/hooks/usePrimaryColor'
-import { useScheme } from '@/hooks/useScheme'
-import { useSourcePreset } from '@/hooks/useSourcePreset'
-import { Color } from '@/registry/color/color'
-import { ChevronDownIcon } from 'lucide-react'
-import type { MtbConfig } from 'material-theme-builder'
-import { useMtb } from 'material-theme-builder/react'
-import { useLayoutEffect, useState } from 'react'
-
-export type SeedPreset = { name: string; label: string; config: MtbConfig }
-
-/**
- * Presets supplied by the consuming repository, previewed by the site's existing theme controls.
- * Source and neutral overrides follow the preset; contrast remains the reader's preference.
- * The table follows the live builder config, including changes made with the footer's controls.
- */
-export function ThemeSeeds({ presets }: { presets: SeedPreset[] }) {
- const [source, setSource] = usePrimaryColor()
- const [scheme, setScheme] = useScheme()
- const [colorMatch, setColorMatch] = useColorMatch()
- const [contrast] = useContrastLevel()
- const isHydrated = useIsHydrated()
- const [hasRevealed, setHasRevealed] = useState(false)
- const { mtbConfig } = useMtb()
- const [storedPreset, setSourcePreset] = useSourcePreset()
- const sourcePreset = presets.find(
- ({ config }) => config.source.toLowerCase() === source.toLowerCase(),
- )
-
- // Remember a named source already chosen through the footer too. The root theme applies
- // these seeds globally; this panel only supplies preset data, never writes the native config.
- useLayoutEffect(() => {
- if (!isHydrated || !sourcePreset) return
- const { source: presetSource, neutral, neutralVariant } = sourcePreset.config
- if (
- storedPreset?.source.toLowerCase() === presetSource.toLowerCase() &&
- storedPreset.neutral === neutral &&
- storedPreset.neutralVariant === neutralVariant
- )
- return
- setSourcePreset(sourcePreset.config)
- }, [isHydrated, sourcePreset, storedPreset, setSourcePreset])
-
- // Static HTML cannot know stored preferences. Reveal once the native builder has caught up
- // with them, then keep the panel visible throughout normal preset changes.
- useLayoutEffect(() => {
- if (
- hasRevealed ||
- !isHydrated ||
- mtbConfig.source.toLowerCase() !== source.toLowerCase() ||
- mtbConfig.scheme !== scheme ||
- mtbConfig.colorMatch !== colorMatch ||
- mtbConfig.contrast !== contrast ||
- (sourcePreset &&
- (mtbConfig.neutral !== sourcePreset.config.neutral ||
- mtbConfig.neutralVariant !== sourcePreset.config.neutralVariant))
- )
- return
- setHasRevealed(true)
- }, [hasRevealed, isHydrated, mtbConfig, source, scheme, colorMatch, contrast, sourcePreset])
- const selected = presets.find(
- ({ config }) =>
- config.source.toLowerCase() === source.toLowerCase() &&
- (config.scheme ?? 'tonalSpot') === scheme &&
- (config.colorMatch ?? false) === colorMatch,
- )
-
- function selectPreset(name: string) {
- const preset = presets.find((preset) => preset.name === name)
- if (!preset) return
- setSourcePreset(preset.config)
- setSource(preset.config.source)
- setScheme(preset.config.scheme ?? 'tonalSpot')
- setColorMatch(preset.config.colorMatch ?? false)
- }
-
- if (!hasRevealed) {
- return (
-
-
-
-
- )
- }
-
- return (
-
-
-
- }>
-
- {selected?.label ?? 'Custom'}
-
-
-
-
- Seed preset
-
- {presets.map((preset) => (
-
-
- {preset.label}
-
- ))}
-
-
-
-
-
-
-
- )
-}
diff --git a/src/components/mdx/ThemeSeedsTable.tsx b/src/components/mdx/ThemeSeedsTable.tsx
deleted file mode 100644
index 66336766..00000000
--- a/src/components/mdx/ThemeSeedsTable.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { code as Code, table as Table } from '@/components/mdx'
-import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
-import { Color } from '@/registry/color/color'
-import type { MtbConfig } from 'material-theme-builder'
-
-/** Seed table shared by the live website and the CLI's standalone HTML fragments. */
-export function ThemeSeedsTable({ config }: { config: MtbConfig }) {
- const options = [
- 'source',
- 'colorMatch',
- 'scheme',
- 'primary',
- 'secondary',
- 'tertiary',
- 'neutral',
- 'neutralVariant',
- 'error',
- ] as const satisfies readonly (keyof MtbConfig)[]
-
- return (
-
-
-
-
- Option
- Value
-
-
-
- {options.map((option) => {
- const value = config[option]
- if (value === undefined || (option === 'scheme' && config.colorMatch)) return null
- return (
-
-
- {option}
-
-
- {typeof value === 'string' && value.startsWith('#') && }{' '}
- {String(value)}
-
-
- )
- })}
-
-
-
- )
-}
diff --git a/src/utils/compileMdxContent.test.ts b/src/utils/compileMdxContent.test.ts
index 21ec57d2..52d8aab0 100644
--- a/src/utils/compileMdxContent.test.ts
+++ b/src/utils/compileMdxContent.test.ts
@@ -1,9 +1,7 @@
import { renderToHtml } from '@/cli/render'
-import { PrimaryColorMtb } from '@/components/PrimaryColorMtb'
import { load } from 'cheerio'
import { join } from 'node:path'
import { renderToString } from 'react-dom/server'
-import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { compileMdxContent, compileMdxFrontmatter } from './compileMdxContent'
@@ -326,50 +324,6 @@ describe('MDX expressions', () => {
})
})
-describe('ThemeSeeds', () => {
- const render = async (source: string, contrast = 0) => {
- const result = await compileMdxContent(
- '',
- {
- relFilePath: '/tokens/colors.mdx',
- absoluteFilePath: '/tokens/colors.mdx',
- title: 'Colors',
- url: '/tokens/colors',
- tableOfContents: [],
- entries: [],
- },
- )
- return load(
- renderToString(
- createElement(
- PrimaryColorMtb,
- {
- source,
- colorMatch: true,
- contrast,
- neutral: '#c1b793',
- error: '#FF4980',
- },
- result.content,
- ),
- ),
- )
- }
-
- it.each(['#CAF543', '#123456'])(
- 'keeps server HTML neutral until stored choices are available (%s)',
- async (source) => {
- const $ = await render(source, 0.5)
- expect($('[role="status"][aria-label="Loading seed preset"]')).toHaveLength(1)
- expect($('[data-slot="skeleton"]')).toHaveLength(2)
- expect($('button[aria-label="Seed preset"]')).toHaveLength(0)
- expect($('table[aria-label="Current theme seeds"]')).toHaveLength(0)
- expect($('[role="status"]').text()).not.toContain('#CAF543')
- expect($('[role="status"]').text()).not.toContain('#123456')
- },
- )
-})
-
/**
* CodeSandbox no longer serves sandbox screenshots, so the preview is the author's `img`, or a
* placeholder.
diff --git a/src/utils/compileMdxContent.tsx b/src/utils/compileMdxContent.tsx
index fcaeaf69..5c7d860f 100644
--- a/src/utils/compileMdxContent.tsx
+++ b/src/utils/compileMdxContent.tsx
@@ -47,7 +47,6 @@ import { Sandpack } from '@/components/mdx/Sandpack'
import { rehypeSandpack } from '@/components/mdx/Sandpack/rehypeSandpack'
import { Step, Steps } from '@/components/mdx/Steps'
import { Summary } from '@/components/mdx/Summary'
-import { ThemeSeeds } from '@/components/mdx/ThemeSeeds'
import { rehypeSummary } from '@/components/mdx/Summary/rehypeSummary'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/mdx/Tabs'
import { rehypeToc } from '@/components/mdx/Toc/rehypeToc'
@@ -152,7 +151,6 @@ export async function compileMdxContent(source: string, options: CompileMdxConte
Step,
Steps,
Summary,
- ThemeSeeds,
Tabs,
TabsList,
TabsTrigger,