diff --git a/apps/docs-tests/docs/copy-react.spec.ts b/apps/docs-tests/docs/copy-react.spec.ts
new file mode 100644
index 00000000..98bba361
--- /dev/null
+++ b/apps/docs-tests/docs/copy-react.spec.ts
@@ -0,0 +1,95 @@
+import { expect, test } from '@playwright/test';
+
+/**
+ * The component page header's Copy React button and its menu. The button
+ * reads the demo's control store through the copy source bridge, so the
+ * copied snippet has to carry the current params, and the menu has to line
+ * up with the button box rather than the chevron inside it.
+ */
+
+// Wide enough that the shader column reaches the 4xl cap the header shares,
+// so the button box, the menu, and the shader all end on one edge.
+test.use({ viewport: { width: 1728, height: 1000 } });
+
+test.beforeEach(async ({ context }) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+});
+
+test('Copy React copies the demo with its current params', async ({ page }) => {
+ await page.goto('/components/vignette');
+ await page.waitForLoadState('networkidle');
+
+ const button = page.getByRole('button', { name: 'Copy React' });
+
+ await expect(button).toBeEnabled();
+
+ // A slider write lands in the store immediately, so the copied snippet
+ // has to show the new value rather than the page's initial one.
+ const intensity = page.getByRole('slider', { name: 'Intensity' });
+
+ await intensity.focus();
+ await page.keyboard.press('End');
+
+ await button.click();
+
+ await expect(button).toHaveAttribute('data-copied', 'true');
+ await expect(page.getByText('Copied', { exact: true })).toBeAttached();
+
+ const copied = await page.evaluate(() => navigator.clipboard.readText());
+
+ expect(copied).toContain(
+ "import { LinearGradient, ShaderScene, Vignette } from '@camp-dev/shaders'",
+ );
+ expect(copied).toContain('');
+ expect(copied).toContain('intensity={1}');
+
+ // The feedback clears on its own.
+ await expect(button).not.toHaveAttribute('data-copied', 'true', { timeout: 3000 });
+});
+
+test('a two-word page copies its PascalCase tag, not its label', async ({ page }) => {
+ await page.goto('/components/conic-gradient');
+ await page.waitForLoadState('networkidle');
+
+ const button = page.getByRole('button', { name: 'Copy React' });
+
+ await expect(button).toBeEnabled();
+ await button.click();
+ await expect(button).toHaveAttribute('data-copied', 'true');
+
+ const copied = await page.evaluate(() => navigator.clipboard.readText());
+
+ expect(copied).toContain("import { ConicGradient, ShaderScene } from '@camp-dev/shaders'");
+ expect(copied).toContain(' {
+ await page.goto('/components/aurora');
+ await page.waitForLoadState('networkidle');
+
+ const chevron = page.getByRole('button', { name: 'More copy options' });
+
+ await chevron.click();
+
+ const menu = page.getByRole('menu');
+
+ await expect(menu).toBeVisible();
+
+ const [boxRight, menuRight, shaderRight] = await Promise.all([
+ chevron.evaluate((element) => element.parentElement!.getBoundingClientRect().right),
+ menu.evaluate((element) => element.getBoundingClientRect().right),
+ page.locator('[data-shader-demo]').evaluate((element) => element.getBoundingClientRect().right),
+ ]);
+
+ expect(menuRight).toBe(boxRight);
+ expect(shaderRight).toBe(boxRight);
+
+ // The left half of the button is Copy React, so the menu holds only the
+ // markdown rows, both disabled until the export ships.
+ const rows = page.getByRole('menuitem');
+
+ await expect(rows).toHaveText(['Copy as markdown', 'View as markdown']);
+ await expect(rows.first()).toHaveAttribute('aria-disabled', 'true');
+ await expect(rows.last()).toHaveAttribute('aria-disabled', 'true');
+});
diff --git a/apps/docs/src/app/components/[slug]/page.module.css b/apps/docs/src/app/components/[slug]/page.module.css
index f6fdb074..df416237 100644
--- a/apps/docs/src/app/components/[slug]/page.module.css
+++ b/apps/docs/src/app/components/[slug]/page.module.css
@@ -15,11 +15,26 @@
margin: var(--spacing-4) 0 0;
}
+/* The header is two columns: the title and description on the left, the
+ copy actions on the right, bottom-aligned so the button sits on the
+ description's line as in the mock. The text column wraps below a
+ comfortable reading width (the sm container) rather than squeezing, which
+ drops the actions onto their own line under it on a narrow viewport. */
.header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--spacing-4) var(--spacing-6);
max-width: var(--container-4xl);
margin: var(--spacing-6) 0 0;
}
+.heading {
+ flex: 1 1 var(--container-sm);
+ min-width: 0;
+}
+
.title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-semibold);
diff --git a/apps/docs/src/app/components/[slug]/page.tsx b/apps/docs/src/app/components/[slug]/page.tsx
index dd2d2e26..7abee492 100644
--- a/apps/docs/src/app/components/[slug]/page.tsx
+++ b/apps/docs/src/app/components/[slug]/page.tsx
@@ -1,7 +1,8 @@
/**
* Shared shell for every converted component page, in the mock's section
* order: breadcrumbs and header above the demo, Usage and API Reference
- * below it, then prev/next pagination. Titles and descriptions come from the
+ * below it, then prev/next pagination. The header carries the page's copy
+ * actions beside the title (page-actions/). Titles and descriptions come from the
* catalog (content/components.ts) and page order from its taxonomy tree; the
* interactive demo and Usage content come from the demo registry, which
* every component page has an entry in.
@@ -17,7 +18,9 @@ import { notFound } from 'next/navigation';
import { Breadcrumbs } from '@/components/breadcrumbs/breadcrumbs';
import { CodeBlock } from '@/components/code-block/code-block';
+import { CopySourceProvider } from '@/components/controls';
import { ChevronDownIcon } from '@/components/icons/chevron-down';
+import { PageActions } from '@/components/page-actions/page-actions';
import { PageToc, type PageTocSection } from '@/components/page-toc/page-toc';
import { PropsTable } from '@/components/props-table/props-table';
import { getComponentsCatalog, getComponentsTree } from '@/content/catalog';
@@ -86,13 +89,20 @@ export default async function ComponentPage({ params }: PageProps) {
return (
-
-
-
{record.label}
-
{record.description}
-
-
-
+ {/* The header's Copy React reads the island's control store through
+ this provider; see the copy source in controls/context.tsx. */}
+
+
+
+
+
{record.label}
+
{record.description}
+
+
+
+
+
+
{/* Sits right after the demo grid so its lines can measure back up to
the shader's center; see .dock in page-toc.module.css. */}
diff --git a/apps/docs/src/app/components/demo-registry.tsx b/apps/docs/src/app/components/demo-registry.tsx
index bcfb5dd3..47dea07e 100644
--- a/apps/docs/src/app/components/demo-registry.tsx
+++ b/apps/docs/src/app/components/demo-registry.tsx
@@ -34,6 +34,13 @@ export interface ComponentPageEntry {
* component tag used in the snippet lands in the import automatically.
*/
usageSnippet: string;
+ /**
+ * Layers the demo scene renders under the component, as JSX, for pages
+ * whose scene composes a background. Copy React (page-actions/) emits them
+ * ahead of the component so the copied snippet reproduces the demo. Absent
+ * when the component fills the scene by itself.
+ */
+ copySiblings?: readonly string[];
/**
* Optional prose above the snippet. Single-paragraph notes are bare
* content; multi-paragraph notes bring their own
tags.
@@ -58,6 +65,9 @@ export const COMPONENT_PAGES: Record = {
},
blobs: {
Island: BlobsIsland,
+ copySiblings: [
+ "",
+ ],
usageSnippet: `
@@ -77,6 +87,7 @@ export const COMPONENT_PAGES: Record = {
},
dither: {
Island: DitherIsland,
+ copySiblings: [''],
usageSnippet: `
@@ -127,6 +138,7 @@ export const COMPONENT_PAGES: Record = {
},
grain: {
Island: GrainIsland,
+ copySiblings: [''],
usageSnippet: `
@@ -179,6 +191,7 @@ export const COMPONENT_PAGES: Record = {
},
vignette: {
Island: VignetteIsland,
+ copySiblings: [''],
usageSnippet: `
diff --git a/apps/docs/src/components/controls/ColorPopoverContents.tsx b/apps/docs/src/components/controls/ColorPopoverContents.tsx
index e7a0d4b4..921a0f37 100644
--- a/apps/docs/src/components/controls/ColorPopoverContents.tsx
+++ b/apps/docs/src/components/controls/ColorPopoverContents.tsx
@@ -13,6 +13,7 @@ import { oklchInGamut, oklchToGamut } from '@camp-dev/shaders/color';
import { useDisplayGamut } from '@camp-dev/shaders/gamut';
import { CopyIcon } from '@/components/icons/copy';
+import { COPY_ANNOUNCEMENTS, useClipboardCopy } from '@/lib/use-clipboard-copy';
import { ChannelSlider } from './color/ChannelSlider';
import { formatOklch, type OklchColor, parseToOklch } from './color/oklch';
@@ -139,16 +140,6 @@ export function ColorPopoverContents({ path, label }: { path: PathInput; label:
);
}
-const COPIED_FEEDBACK_MS = 1200;
-
-type CopyStatus = 'idle' | 'copied' | 'failed';
-
-const COPY_ANNOUNCEMENTS: Record = {
- idle: '',
- copied: 'Copied',
- failed: 'Copy failed',
-};
-
/**
* Copies the current color string. The glyph turns lime for a moment as the
* only visible feedback, and a live region says "Copied" for screen readers,
@@ -158,31 +149,7 @@ const COPY_ANNOUNCEMENTS: Record = {
* way rather than left as a silent rejection.
*/
function CopyButton({ label, text }: { label: string; text: string }) {
- const [status, setStatus] = useState('idle');
- const feedbackTimeoutRef = useRef | null>(null);
-
- useEffect(() => {
- return () => {
- if (feedbackTimeoutRef.current !== null) clearTimeout(feedbackTimeoutRef.current);
- };
- }, []);
-
- const copy = () => {
- // The write starts inside a promise chain: `navigator.clipboard` is
- // undefined outside a secure context, and a plain call would throw out
- // of the click handler, whereas here the throw lands in the rejection
- // path with every other failure.
- void Promise.resolve()
- .then(() => navigator.clipboard.writeText(text))
- .then(
- () => setStatus('copied'),
- () => setStatus('failed'),
- );
-
- if (feedbackTimeoutRef.current !== null) clearTimeout(feedbackTimeoutRef.current);
-
- feedbackTimeoutRef.current = setTimeout(() => setStatus('idle'), COPIED_FEEDBACK_MS);
- };
+ const { status, copy } = useClipboardCopy();
return (
<>
@@ -190,7 +157,7 @@ function CopyButton({ label, text }: { label: string; text: string }) {
aria-label={`Copy ${label}`}
className={styles.copyButton}
data-copied={status === 'copied' || undefined}
- onClick={copy}
+ onClick={() => copy(text)}
title="Copy"
type="button"
>
diff --git a/apps/docs/src/components/controls/context.tsx b/apps/docs/src/components/controls/context.tsx
index 7ca810b1..23395709 100644
--- a/apps/docs/src/components/controls/context.tsx
+++ b/apps/docs/src/components/controls/context.tsx
@@ -1,21 +1,56 @@
'use client';
/**
- * Three contexts. ControlsProvider carries the page's store down to every
+ * Four contexts. ControlsProvider carries the page's store down to every
* control. PathPrefixProvider is how list rows work: ListInput wraps each row
* in a prefix like ['stops', 2], so the ColorInput inside that row can say
* path="color" and land on stops[2].color without knowing its own index.
* ListRowProvider carries the row's name ("stop 2") alongside, so a control
* inside a row can drop its own visible label and still name itself fully
- * for a screen reader.
+ * for a screen reader. CopySourceProvider runs the other way: it sits above
+ * the island, ControlsProvider publishes its store up into it, and the page
+ * header's Copy React button (page-actions/) reads the store from there.
*/
-import { createContext, type ReactNode, useContext, useMemo } from 'react';
+import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react';
import type { ControlPath, ControlStore, PathSegment } from './store';
const StoreContext = createContext | null>(null);
const PathPrefixContext = createContext([]);
+// ----------------------------------------------------------------------------
+// The copy source
+// ----------------------------------------------------------------------------
+
+/**
+ * The page's store as seen from outside the island. The component page
+ * template renders the header and the demo island as siblings, and only
+ * the island creates the store, so the header can't reach it through
+ * ordinary context. This pair of contexts bridges the gap: the outer one
+ * hands ControlsProvider a setter to publish into, the inner one hands the
+ * header whatever was published. Null until an island has mounted, and on
+ * pages with no island.
+ */
+const CopySourceContext = createContext | null>(null);
+const PublishCopySourceContext = createContext<
+ ((store: ControlStore