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 | null) => void) | null +>(null); + +export function CopySourceProvider({ children }: { children: ReactNode }) { + const [store, setStore] = useState | null>(null); + + return ( + + {children} + + ); +} + +/** The mounted island's store, or null before it mounts. */ +export function useCopySource(): ControlStore | null { + return useContext(CopySourceContext); +} + /** * Trail of ancestor row labels, outermost first ("line 5", then "stop 2"). * Empty outside any list. Controls read it for two things: whether they are @@ -35,6 +70,20 @@ export function ControlsProvider({ store: ControlStore; children: ReactNode; }) { + const publish = useContext(PublishCopySourceContext); + + // Publishes the store to the page header for as long as this island is + // mounted. Islands render inside CopySourceProvider only on the component + // pages; the dev playgrounds have no header and no provider, so there is + // nothing to publish to there. + useEffect(() => { + if (publish === null) return; + + publish(store); + + return () => publish(null); + }, [publish, store]); + return {children}; } diff --git a/apps/docs/src/components/controls/index.ts b/apps/docs/src/components/controls/index.ts index 13fd03bf..6afd1416 100644 --- a/apps/docs/src/components/controls/index.ts +++ b/apps/docs/src/components/controls/index.ts @@ -1,5 +1,12 @@ export { ColorInput } from './ColorInput'; -export { ControlsProvider, ListRowProvider, PathPrefixProvider, useListRowTrail } from './context'; +export { + ControlsProvider, + CopySourceProvider, + ListRowProvider, + PathPrefixProvider, + useCopySource, + useListRowTrail, +} from './context'; export { ControlPanel } from './ControlPanel'; export { formatJsx, formatParams } from './copy'; export type { CopyConfig } from './copy'; diff --git a/apps/docs/src/components/icons/check.tsx b/apps/docs/src/components/icons/check.tsx new file mode 100644 index 00000000..2ce9775b --- /dev/null +++ b/apps/docs/src/components/icons/check.tsx @@ -0,0 +1,25 @@ +/** + * Pixel-style check from the Figma icon set (component "icons", name + * "check"): a short and a long stroke of 2-unit pixels on a 24-unit grid, + * which is the export's own grid. The path is the export's data on whole + * units. The fill is swapped for currentColor so the icon takes its color + * from the surrounding button, and it draws at 16px by default, the size + * the header's copy button swaps it in at. + */ +import type { SVGProps } from 'react'; + +export function CheckIcon(props: SVGProps) { + return ( + + ); +} diff --git a/apps/docs/src/components/page-actions/page-actions.module.css b/apps/docs/src/components/page-actions/page-actions.module.css new file mode 100644 index 00000000..75f654b6 --- /dev/null +++ b/apps/docs/src/components/page-actions/page-actions.module.css @@ -0,0 +1,224 @@ +/* The header's split "Copy React" button and the menu it opens. The box, + its two buttons, and the menu follow the Figma mock; the button's hover + and press feel copy the control panel's text button, and the menu's + panel and rows copy the floating table of contents, so the site's three + popups read as one family. */ + +/* ---- The box ---- */ + +/* One bordered 32px box around two buttons: the mock's darkest gray with + the hairline and 6px corners. The buttons carry no border of their own, + so the box's hairline is the only line around them. The press is felt + through a slight scale of the whole box, whichever half is pressed: + scaling one half alone pulls it away from the border and shows the box + behind it, so the box is the thing that moves. It scales to 0.98 rather + than the site's usual 0.97 because the box is about twice the width of + the panel's text button, and 2% of it is the same pixel of give that 3% + is there. */ +.actions { + display: inline-flex; + flex: none; + align-items: stretch; + height: var(--spacing-8); + background: var(--gray-900); + border: var(--border-width-1) solid var(--white-a5); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + line-height: var(--leading-5); + transition: transform var(--duration-sm) var(--ease-out); +} + +.actions:has(.copy:active), +.actions:has(.more:active) { + transform: scale(0.98); +} + +/* Shared by both buttons: the label gray on a transparent fill, hover to + the faint white fill under full-white text. Each button rounds only its + outer corners, so the hover fill follows the box's shape without clipping + the focus ring. */ +.copy, +.more { + display: inline-flex; + align-items: center; + margin: 0; + padding: 0; + background: transparent; + color: var(--gray-50); + border: 0; + font: inherit; + cursor: pointer; + transition: + color var(--fade-xs) var(--ease-hover), + background-color var(--fade-xs) var(--ease-hover); +} + +.copy svg, +.more svg { + flex: none; + transition: color var(--fade-xs) var(--ease-hover); +} + +@media (hover: hover) and (pointer: fine) { + .copy:hover, + .more:hover { + background: var(--white-a5); + color: var(--fg); + } + + .copy:hover .copyGlyph, + .more:hover svg { + color: var(--fg); + } +} + +.copy:focus-visible, +.more:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; +} + +/* The copy action: a 16px glyph and the label, 8px apart, with 8px on each + side. The mock draws the glyph one step lighter than the chevron. */ +.copy { + gap: var(--spacing-2); + padding: 0 var(--spacing-2); + border-radius: var(--radius-md) 0 0 var(--radius-md); +} + +.copy .copyGlyph { + color: var(--gray-100); +} + +.copy:disabled { + cursor: default; +} + +/* ---- The copied swap ---- */ + +/* The glyph is a 16px box with both icons laid over each other, and a + successful copy swaps one for the other: the copy glyph fades and shrinks + a touch as the check fades in and grows to full size, in lime, the same + lime the picker's copy button turns. The two run together so the swap + reads as one glyph changing rather than one leaving and another arriving. + The check starts at 0.9 and not 0, so it reads as already almost there; + opacity rides a fade token and the scale a duration token, so Reduce + Motion keeps the crossfade and drops the growth. */ +.glyph { + position: relative; + flex: none; + width: var(--spacing-4); + height: var(--spacing-4); +} + +.copyGlyph, +.checkGlyph { + position: absolute; + inset: 0; + transition: + opacity var(--fade-sm) var(--ease-out), + transform var(--duration-sm) var(--ease-out); +} + +.copy .checkGlyph { + opacity: 0; + transform: scale(0.9); + color: var(--accent); +} + +.copy[data-copied] .copyGlyph { + opacity: 0; + transform: scale(0.9); +} + +.copy[data-copied] .checkGlyph { + opacity: 1; + transform: scale(1); +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The chevron cell: a 32px square behind a hairline, with the glyph + centered. The rule is drawn as a left border so the cell and its divider + are one box, and it stops 1px short of the top and bottom as in the mock + because the box's own border already covers those pixels. */ +.more { + justify-content: center; + width: var(--spacing-8); + border-left: var(--border-width-1) solid var(--white-a5); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + color: var(--gray-200); +} + +/* ---- The menu ---- */ + +/* Portaled to , so it sets its own type. The mock's panel, shared + with the table of contents: the darkest gray with a hairline and 12px + corners, 6px of padding, 6px below the chevron. This one is 208px wide, + the three labels' width with room to spare, where the table of contents + takes the 3xs container. It scales in from the edge nearest the chevron, + the site's popup pattern; Base UI sets --transform-origin from where the + popup was placed. */ +.popup { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-0-5); + width: var(--spacing-52); + padding: var(--spacing-1-5); + background: var(--gray-900); + color: var(--fg); + border: var(--border-width-1) solid var(--white-a5); + border-radius: var(--radius-xl); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); + font-size: var(--font-size-sm); + line-height: var(--leading-5); + transform-origin: var(--transform-origin); + transition: + transform var(--duration-sm) var(--ease-out), + opacity var(--fade-sm) var(--ease-out); +} + +.popup[data-starting-style], +.popup[data-ending-style] { + opacity: 0; + transform: scale(0.95); +} + +/* A row is the button's shape: a 14px label in a 32px box with 12px on the + left and 8px on the right, 6px corners, 2px from the next row. Base UI + marks the row under the pointer or the keyboard cursor as highlighted, + which the mock draws as the darker gray fill, the same fill the table of + contents gives its current row. A disabled row, Copy React before the + island has mounted, dims to the muted gray. */ +.row { + display: flex; + align-items: center; + height: var(--spacing-8); + padding: 0 var(--spacing-2) 0 var(--spacing-3); + border-radius: var(--radius-md); + color: var(--fg); + text-decoration: none; + cursor: pointer; + outline: none; + transition: background-color var(--fade-xs) var(--ease-hover); +} + +.row[data-highlighted] { + background: var(--gray-800); + text-decoration: none; +} + +.row[data-disabled] { + color: var(--fg-muted); + cursor: default; +} diff --git a/apps/docs/src/components/page-actions/page-actions.tsx b/apps/docs/src/components/page-actions/page-actions.tsx new file mode 100644 index 00000000..6fbe518a --- /dev/null +++ b/apps/docs/src/components/page-actions/page-actions.tsx @@ -0,0 +1,110 @@ +'use client'; + +/** + * The split "Copy React" button in a component page's header, after the + * Figma mock: a bordered box holding the copy action on the left and a + * chevron cell on the right that opens a menu of further actions. Copy + * React copies the demo's current props as JSX; the menu's two markdown + * rows will copy and open the page's markdown export once it ships + * (SHA-115). The shared + * components/[slug] template renders it beside the title and description, + * inside the CopySourceProvider that the demo island publishes its control + * store into (controls/context.tsx). + * The menu is Base UI's Menu rather than the controls' Select, because the + * rows are actions and not a value, but it borrows the select's popup + * pattern: portaled, offset from its trigger, and scaled in from the edge + * nearest it. + */ +import { useRef } from 'react'; + +import { Menu } from '@base-ui/react/menu'; + +import { formatJsx, useCopySource } from '@/components/controls'; +import { CheckIcon } from '@/components/icons/check'; +import { ChevronDownIcon } from '@/components/icons/chevron-down'; +import { CopyIcon } from '@/components/icons/copy'; +import { deriveUsageImport } from '@/lib/usage-import'; +import { COPY_ANNOUNCEMENTS, useClipboardCopy } from '@/lib/use-clipboard-copy'; + +import styles from './page-actions.module.css'; + +interface PageActionsProps { + /** The component as written in JSX, e.g. 'WaveLines'. */ + componentName: string; + /** Layers the demo renders under the component, as JSX, if any. */ + siblings?: readonly string[]; +} + +// The 12-unit copy glyph drawn on a 16px box, which is exactly the mock's +// 16px export of the same icon (every coordinate is the 12-unit path times +// four thirds). +const COPY_ICON_SIZE = 16; + +export function PageActions({ componentName, siblings }: PageActionsProps) { + const store = useCopySource(); + const { status, copy } = useClipboardCopy(); + const boxRef = useRef(null); + + // The demo as it stands right now: the import line for every tag in the + // snippet, then the scene with the store's current params as props. Read + // at click time rather than subscribed, so dragging a slider never + // re-renders the header. + const copyReact = () => { + if (store === null) return; + + const jsx = formatJsx({ componentName, siblings }, store.getSnapshot()); + + copy(`${deriveUsageImport(jsx)}\n\n${jsx}`); + }; + + return ( +
+ {/* Until the island mounts there is no store to read, and the button + is disabled rather than copying nothing. */} + + + {COPY_ANNOUNCEMENTS[status]} + + + + + + + {/* Anchored to the whole box rather than the chevron, so the + menu's right edge meets the box's outer edge, which is the + shader's edge on a wide viewport. The chevron button sits + 1px inside that edge, behind the box's border. */} + + + {/* Each row is its own action and runs on click; the left + half of the button always means Copy React, so the menu + does not repeat it. Both rows are disabled until the + markdown export ships (SHA-115): the copy row will run + through the same clipboard hook as Copy React, and the + view row becomes a Menu.LinkItem to the export in a new + tab. */} + + Copy as markdown + + + View as markdown + + + + + +
+ ); +} diff --git a/apps/docs/src/components/page-toc/page-toc.module.css b/apps/docs/src/components/page-toc/page-toc.module.css index bba9728c..94ee7cf1 100644 --- a/apps/docs/src/components/page-toc/page-toc.module.css +++ b/apps/docs/src/components/page-toc/page-toc.module.css @@ -114,14 +114,15 @@ /* ---- The menu ---- */ -/* Portaled to , so it sets its own type. The mock's panel: 256px - wide, 12px of padding, the darkest gray with a hairline and 12px corners. - It scales in from the edge nearest the lines, the site's popup pattern; - Base UI sets --transform-origin from where the popup was placed. */ +/* Portaled to , so it sets its own type. The mock's panel, shared + with the header's copy menu (page-actions/): 256px wide, 6px of padding, + the darkest gray with a hairline and 12px corners, 6px from the lines. It + scales in from the edge nearest the lines, the site's popup pattern; Base + UI sets --transform-origin from where the popup was placed. */ .popup { box-sizing: border-box; width: var(--container-3xs); - padding: var(--spacing-3); + padding: var(--spacing-1-5); background: var(--gray-900); color: var(--fg); border: var(--border-width-1) solid var(--white-a5); @@ -150,15 +151,16 @@ list-style: none; } -/* A row is the sidebar's row: 14px muted text in a 36px box with 12px on - the left and 8px elsewhere, 6px corners. Hover borrows the sidebar's - faint white fill. The current section is the mock's darker gray fill - under full-white text, rather than the sidebar's lime, because it marks - where the reader is on the page and not a page they chose. */ +/* A row is the header button's shape: 14px muted text in a 32px box with + 12px on the left and 8px on the right, 6px corners. Hover borrows the + sidebar's faint white fill. The current section is the mock's darker + gray fill under full-white text, rather than the sidebar's lime, because + it marks where the reader is on the page and not a page they chose. */ .row { - display: block; - height: var(--spacing-9); - padding: var(--spacing-2) var(--spacing-2) var(--spacing-2) var(--spacing-3); + display: flex; + align-items: center; + height: var(--spacing-8); + padding: 0 var(--spacing-2) 0 var(--spacing-3); border-radius: var(--radius-md); color: var(--fg-muted); text-decoration: none; diff --git a/apps/docs/src/components/page-toc/page-toc.tsx b/apps/docs/src/components/page-toc/page-toc.tsx index b1ad2204..e0956fab 100644 --- a/apps/docs/src/components/page-toc/page-toc.tsx +++ b/apps/docs/src/components/page-toc/page-toc.tsx @@ -137,7 +137,7 @@ export function PageToc({ sections }: { sections: PageTocSection[] }) { the lines with a document-coordinate update one frame behind the scroll, which reads as jitter. */} - +