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,