From 496176cb49420368772ed2176992adfbae3422c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:37:44 +0000 Subject: [PATCH 01/66] website: demo playground layout and a design system for demos A `demo` Playground prop (through HooksPlayground and FrameworkPlayground) puts the preview first at the playground's full width, with room for a realistic app, and keeps the code behind a Code toggle in the preview's header: pressed, the code (row's file tabs) takes the result's place and the label reads Editor; never both at once. The result stays mounted, so the app's state and the Store survive, and the code is in the HTML as always; Monaco mounts when the code is first shown. The result and the code share one minimum height so pages and code trade places without moving the page. The Store opens beside the result as when stacked, and over the code its header toggle brings the preview back. DesignSystem/design-system.css grows into a kit for demo content, scoped to the preview in both themes: tokens (text, muted, surface, border, accent, radii, spacing, type scale), element styles (headings, text, links, plain buttons with a `primary` variant, inputs) and classes for an app header, cards, lists with dividers (`listItem`, restyled), bylines and meta text. The demo styles from styles.module.css move there. Avatar takes a `size`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- jest.config.js | 2 + website/i18n/es/code.json | 6 + website/i18n/pt-BR/code.json | 6 + website/i18n/zh-Hans/code.json | 6 + .../src/components/FrameworkPlayground.tsx | 6 +- website/src/components/HooksPlayground.tsx | 2 + .../Playground/DesignSystem/Avatar.tsx | 9 +- .../Playground/DesignSystem/design-system.css | 378 +++++++++++++++++- website/src/components/Playground/README.md | 46 ++- .../Playground/__tests__/demoLayout.test.tsx | 79 ++++ .../Playground/editor/EditorSurface.tsx | 17 +- website/src/components/Playground/index.tsx | 83 +++- .../components/Playground/monaco/typeLibs.ts | 2 +- .../Playground/preview/LivePreview.tsx | 12 +- .../Playground/preview/PreviewWrapper.tsx | 33 +- .../Playground/preview/StoreToggle.tsx | 37 +- .../preview/__tests__/PreviewWrapper.test.tsx | 35 ++ .../components/Playground/styles.module.css | 268 +++---------- .../testing/docusaurusThemeCommon.ts | 16 + .../docusaurusUseIsomorphicLayoutEffect.ts | 2 + 20 files changed, 796 insertions(+), 249 deletions(-) create mode 100644 website/src/components/Playground/__tests__/demoLayout.test.tsx create mode 100644 website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts diff --git a/jest.config.js b/jest.config.js index cd37aa340ffb..b1fc1f0ade2d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -101,6 +101,8 @@ const projects = [ '/website/src/components/Playground/testing/docusaurusBrowserOnly.tsx', '^@docusaurus/useIsBrowser$': '/website/src/components/Playground/testing/docusaurusUseIsBrowser.ts', + '^@docusaurus/useIsomorphicLayoutEffect$': + '/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts', '^@theme/CodeBlock$': '/website/src/components/Playground/testing/themeCodeBlock.tsx', }, diff --git a/website/i18n/es/code.json b/website/i18n/es/code.json index 8eb6d38f74d8..fd9e7cf32579 100644 --- a/website/i18n/es/code.json +++ b/website/i18n/es/code.json @@ -848,6 +848,12 @@ "playground.code": { "message": "Código" }, + "playground.code.show": { + "message": "Mostrar el código" + }, + "playground.code.hide": { + "message": "Ocultar el código" + }, "dataColocation.label": { "message": "Árbol de componentes donde cada componente que renderiza datos los lee directamente del store con useSuspense(): User para CurrentUser y Avatar, Post para PostBody y Comment para CommentList. Un cambio vuelve a renderizar solo esos componentes y nada más del árbol" }, diff --git a/website/i18n/pt-BR/code.json b/website/i18n/pt-BR/code.json index c0cec8abbcd7..e4436c8265fa 100644 --- a/website/i18n/pt-BR/code.json +++ b/website/i18n/pt-BR/code.json @@ -848,6 +848,12 @@ "playground.code": { "message": "Código" }, + "playground.code.show": { + "message": "Mostrar o código" + }, + "playground.code.hide": { + "message": "Ocultar o código" + }, "dataColocation.label": { "message": "Árvore de componentes em que cada componente que renderiza dados os lê diretamente do store com useSuspense(): User para CurrentUser e Avatar, Post para PostBody e Comment para CommentList. Uma mudança renderiza de novo apenas esses componentes e nada mais da árvore" }, diff --git a/website/i18n/zh-Hans/code.json b/website/i18n/zh-Hans/code.json index f4adadbc3181..fe04f27aa25b 100644 --- a/website/i18n/zh-Hans/code.json +++ b/website/i18n/zh-Hans/code.json @@ -848,6 +848,12 @@ "playground.code": { "message": "代码" }, + "playground.code.show": { + "message": "显示代码" + }, + "playground.code.hide": { + "message": "隐藏代码" + }, "dataColocation.label": { "message": "组件树:每个渲染数据的组件都通过 useSuspense() 直接从 store 读取数据:CurrentUser 和 Avatar 读取 User,PostBody 读取 Post,CommentList 读取 Comment。数据变化只会让这些组件重新渲染,树中其余部分都不会" }, diff --git a/website/src/components/FrameworkPlayground.tsx b/website/src/components/FrameworkPlayground.tsx index f9c2b6457bdd..81d45a687bb0 100644 --- a/website/src/components/FrameworkPlayground.tsx +++ b/website/src/components/FrameworkPlayground.tsx @@ -16,7 +16,11 @@ export default function FrameworkPlayground( const framework = useFramework(); if (framework === 'vue') return ( - + // a demo's code shows as row's tabs + {props.children} ); diff --git a/website/src/components/HooksPlayground.tsx b/website/src/components/HooksPlayground.tsx index 401545daa207..6e3b09bcfea9 100644 --- a/website/src/components/HooksPlayground.tsx +++ b/website/src/components/HooksPlayground.tsx @@ -9,6 +9,7 @@ const HooksPlayground = ({ hidden = false, defaultOpen = 'n', row = false, + demo = false, fixtures = [], defaultTab, headerControls, @@ -21,6 +22,7 @@ const HooksPlayground = ({ groupId={groupId} defaultOpen={defaultOpen} row={row} + demo={demo} hidden={hidden} fixtures={fixtures} getInitialInterceptorData={getInitialInterceptorData} diff --git a/website/src/components/Playground/DesignSystem/Avatar.tsx b/website/src/components/Playground/DesignSystem/Avatar.tsx index 378da65bd0b3..2cbb4255ff49 100644 --- a/website/src/components/Playground/DesignSystem/Avatar.tsx +++ b/website/src/components/Playground/DesignSystem/Avatar.tsx @@ -1,6 +1,7 @@ import React from 'react'; -export function Avatar({ src }: { src: string }) { +/** A round profile picture; 32px, or `size` (24 beside a comment's name) */ +export function Avatar({ src, size = 32 }: { src: string; size?: number }) { return ( .listItem) { + padding: 0; +} +.playground-preview .card > .listItem { + padding: var(--ds-space-3) var(--ds-space-4); +} +.playground-preview .card > :last-child { + margin-bottom: 0; +} + +/* A list's rows: side by side (an avatar, a checkbox, then the text), a + rule between them */ +.playground-preview .listItem { + display: flex; + align-items: flex-start; + gap: var(--ds-space-3); + padding: var(--ds-space-2) 0; +} +.playground-preview .listItem + .listItem { + border-top: 1px solid var(--ds-border); +} +.playground-preview .listItem > * { + min-width: 0; +} +.playground-preview .listItem p:last-child { + margin-bottom: 0; +} +.playground-preview .listItem.nogap { + align-items: center; + gap: 0; +} +/* kept for the demos written before rows had rules of their own */ +.playground-preview .listItem.spaced { + gap: 1em; +} +.playground-preview .listItem > label { + flex: 1 1 100%; + line-height: 1.1; +} +.playground-preview .listItem > label input[type='text'] { + width: calc(100% - 21px); +} +/* An avatar with a name: beside each other in a row (a comment's author), or + the name under it (`author`, a post's) */ +.playground-preview .byline { + display: inline-flex; + align-items: center; + gap: var(--ds-space-2); + font-size: var(--ds-text-sm); + color: var(--ds-text-muted); +} +.playground-preview .byline b, +.playground-preview .byline strong { + color: var(--ds-text); + font-weight: 500; +} +.playground-preview .author { + flex: none; + text-align: center; +} +.playground-preview .author small { + display: block; + line-height: 1.2; +} + +/* Controls. Base skin for plain ones and `primary` only; classed ones + (rt-*, vote arrows) keep their own chrome. :where() keeps specificity + unchanged, so a demo's own class wins. */ +.playground-preview button { + margin-bottom: 8px; + margin-right: 8px; + padding: 2px 6px; +} +.playground-preview button:where(:not([class]), .primary), +.playground-preview input[type='text']:where(:not([class])), +.playground-preview textarea:where(:not([class])) { + border: 1px solid var(--ds-border-strong); + border-radius: var(--ds-radius-sm); + background: var(--ds-surface); + color: var(--ds-text); + font: inherit; +} +.playground-preview button:where(:not([class]), .primary) { + padding: 0.375rem 0.875rem; + font-size: var(--ds-text-sm); + font-weight: 500; + line-height: 1.4; + cursor: pointer; + transition: + background-color var(--motion-snappy), + border-color var(--motion-snappy), + color var(--motion-snappy); +} +.playground-preview button:where(:not([class])):hover { + border-color: var(--ds-accent); + color: var(--ds-accent); +} +.playground-preview button:where(:not([class]), .primary):focus-visible { + outline: 2px solid var(--ds-accent); + outline-offset: 2px; +} +/* the one action a page is for */ +.playground-preview button.primary { + background: var(--ds-accent); + border-color: var(--ds-accent); + color: var(--ds-on-accent); +} +.playground-preview button.primary:hover { + background: var(--ds-accent-strong); + border-color: var(--ds-accent-strong); +} +.playground-preview input[type='text']:where(:not([class])), +.playground-preview textarea:where(:not([class])) { + padding: 0.3rem 0.5rem; +} +/* A demo's links between its pages (Link), the one shown marked as current */ +.playground-preview nav:where(:not([class])) { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-bottom: 0.5rem; +} +.playground-preview nav a[aria-current='page'] { + color: var(--ds-text); + font-weight: 600; + text-decoration: underline 2px var(--ds-accent); + text-underline-offset: 0.35em; +} +/* Groups a form's fields so one `disabled` locks them all */ +.playground-preview fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.playground-preview fieldset:disabled, +.playground-preview fieldset:disabled * { + cursor: progress; +} +/* While a form saves, a light traces its submit button's border */ +@property --pg-trace-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} +.playground-preview form[aria-busy='true'] button[type='submit'] { + /* 1px wider border, 1px less padding: same size, room for the light */ + border: 2px solid transparent; + padding: calc(0.375rem - 1px) calc(0.875rem - 1px); + background: + linear-gradient(var(--ds-surface), var(--ds-surface)) padding-box, + conic-gradient( + from var(--pg-trace-angle), + var(--ifm-color-emphasis-200) 0 45%, + var(--ifm-color-primary) 75%, + var(--ifm-color-emphasis-200) 76% + ) + border-box; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 15%, transparent); + color: var(--ifm-color-primary); + animation: trace 1.2s linear infinite; +} +@keyframes trace { + to { + --pg-trace-angle: 360deg; + } +} +@media (prefers-reduced-motion: reduce) { + .playground-preview form[aria-busy='true'] button[type='submit'] { + animation: none; + } +} +.playground-preview input[type='checkbox'] { + width: 1rem; + height: 1rem; + vertical-align: -2px; + accent-color: var(--ifm-color-primary); +} +.playground-preview form input[type='text'], +.playground-preview form textarea { + width: 100%; +} + +/* Votes (docs/core/shared/_VoteDemo.mdx) */ +.playground-preview .voteBlock { + display: flex; + float: left; + gap: 0.5em; + margin-right: 0.5em; +} +.playground-preview .vote { + flex: 0 0 32px; + text-align: center; +} +.playground-preview .vote .up:hover { + color: var(--ifm-color-primary); + background: url('../../../../static/img/up-arrow-outline-hover.svg') no-repeat + center; +} +.playground-preview .vote .up { + border: none; + cursor: pointer; + width: 20px; + height: 20px; + margin-bottom: -8px; + margin-right: 0; + background: url('../../../../static/img/up-arrow-outline.svg') no-repeat + center; + display: inline-block; +} +/* A board's columns (RestEndpoint's move demo): wells holding cards */ +.playground-preview .boardColumn { + background: var(--ifm-color-emphasis-100); + border-radius: var(--ds-radius); + padding: 0.4rem; + margin-bottom: 0.5rem; +} +.playground-preview .boardColumn h4 { + margin: 0 0 0.3rem; + padding: 0 0.25rem; +} +.playground-preview .boardColumn .listItem { + align-items: center; + background: var(--ds-surface); + border: 0; + border-radius: var(--ds-radius-sm); + padding: 0.25rem 0.5rem; + margin-bottom: 0.3rem; +} +.playground-preview .boardColumn button { + margin: 0; + padding: 0 4px; + line-height: 1; + background: none; + border: none; + cursor: pointer; + opacity: 0.5; +} +.playground-preview .boardColumn button:hover { + opacity: 1; +} /* Root container for the text field */ .rt-TextFieldRoot { display: flex; diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 86e553637bdd..d304a4e93142 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -17,7 +17,7 @@ indexable and hydration-safe, and those behaviors are easy to drop silently. ## Layout ``` -index.tsx Playground: code model, editor/preview ordering, hidden handling +index.tsx Playground: code model, editor/preview ordering (row, stacked, demo), hidden handling userAgent.ts isBot / isMobileOrBot gates PlaygroundEditor.tsx editor entry used by EditorSurface (aliases editor/InteractiveEditor) PlaygroundLiveEditor.tsx editable react-live editor (mobile/bot fallback) @@ -47,7 +47,9 @@ preview/ live execution (loaded lazily, never on the server) autorun/ the `autoRun` demo player (see Auto-run) ... editor-types/ .d.ts bundles fed to Monaco (raw-loader) -DesignSystem/ components injected into preview scope +DesignSystem/ the demos' design system: design-system.css (tokens, element + styles and classes for demo content) and the components + injected into preview scope ``` ## Invariants @@ -94,7 +96,8 @@ DesignSystem/ components injected into preview scope `website/chunks-plugin.js` splits site source used by 2+ pages (the eagerly loaded Playground modules among it) into shared chunks instead of a copy per docs page. -- Mobile and bots download none of Monaco or the type libs. +- Mobile and bots download none of Monaco or the type libs. A `demo` + playground mounts its editor only once its code is first shown. ### Mobile and bots @@ -121,6 +124,24 @@ DesignSystem/ components injected into preview scope - `row` layout: non-`column` files are tabs (focus switches tab); `column` files stack with collapsible headers. Stacked layout: every titled file gets a header, collapsible when there are several files or fixtures. +- `demo` layout (the `demo` prop, over `row`): the preview comes first at + the playground's full width and the code waits behind the Code toggle in + the preview's header (`CodeHeaderToggle`, `aria-pressed`), where the + label trades for "Editor" (`labelSwap`) while it shows. Pressed, the code + takes the result's place (`data-code` on the container hides the result + and shows the editor, with `row`'s tabs as `subtabs` under the header); + never both at once. The result stays mounted, so the app's state and the + Store survive, and edits apply live as usual. Use it when the point is + what happens on screen, or the app needs the room: the result and the + code share one minimum height (`--pg-demo-min`), so the app's pages and + the code trade places without moving the page. The code is in the HTML + (`StaticEditor`) as always; Monaco mounts when the code is first shown + (the `hidden` latch), so a demo never shown costs no editor. The Store + opens as when stacked: beside the result (under it when narrow), from + the side strip or the header's icon. The code and the Store never show + together: over the code, the header's Store toggle brings the preview + back with whatever it says (Show Store opens it, Hide Store closes it). + Pressing Code is reader input, so it ends an `autoRun`. - Collapsed/unselected files stay mounted (`display: none`), never unmounted: their Monaco models must exist for cross-file types, go-to-definition and to keep edits. Monaco is uncontrolled (`defaultValue`), so re-renders never @@ -154,6 +175,25 @@ DesignSystem/ components injected into preview scope their built-in types (they're type-only exports). - Each playground gets its own `DataProvider` store (`MockResolver` serves `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. +- Design system (`DesignSystem/design-system.css`, loaded with the preview): + what a demo renders is styled under `.playground-preview` so it reads as a + real app, in both themes, without the demo importing anything. Tokens + (`--ds-text`, `--ds-text-muted`, `--ds-surface`, `--ds-border`, + `--ds-border-strong`, `--ds-accent`, `--ds-radius`, `--ds-space-*`, + `--ds-text-*`) and the preview's type (15px base, Infima's heading + variables); element styles for headings, `p`, `small`, links (in a heading + they keep its color with a faint underline), plain `button`s (`primary` + fills one with the accent), text inputs, `nav`, `fieldset`; and classes: + `appHeader` + `appTitle` (the app's bar), `card` (a lifted surface; holding + `listItem`s it loses its padding so their dividers reach its edges), + `listItem` (a row with a rule between siblings; `nogap`, `spaced`), + `meta` (secondary text), `byline` (avatar beside a name; `Avatar` takes a + `size`), `author` (name under avatar), plus the older `voteBlock`/`vote` + and `boardColumn`. Prefer a class or element style to a new component; + a new scope global goes in `preview/scope.ts`, `monaco/typeLibs.ts` and + `website/framework-docs/checkExamples.mjs`. Keep docs demos that use + these classes (grep `docs/` and `website/src/components/Demo/code`) in + mind when restyling one. - Store inspector open state persists per `groupId` via tab storage and avoids scroll jumps. In `row` layout it slides over the code from the preview's edge (portaled into a layer `EditorSurface` stacks on the code, diff --git a/website/src/components/Playground/__tests__/demoLayout.test.tsx b/website/src/components/Playground/__tests__/demoLayout.test.tsx new file mode 100644 index 000000000000..936649671cb4 --- /dev/null +++ b/website/src/components/Playground/__tests__/demoLayout.test.tsx @@ -0,0 +1,79 @@ +/// +/// + +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import Playground from '..'; + +// a crawler: the live preview never loads, so the preview stays its loading +// frame (whose Code toggle already works), and no Monaco +jest.mock('../userAgent', () => ({ isBot: true, isMobileOrBot: () => true })); +jest.mock('../PlaygroundEditor', () => ({ + __esModule: true, + default: ({ code }: { code: string }) => + require('react').createElement('textarea', { defaultValue: code }), +})); + +// one Prism token each, so the HTML and the DOM's text can be searched for them +const LIST = + 'const listOfPosts = 1;\nexport default function PostList() { return null; }'; +const DETAIL = 'export default function PostDetail() { return null; }'; + +/** A fence as MDX hands it over */ +const code = (key: string, metastring: string, children: string) => + React.createElement( + 'code', + { key, className: 'language-tsx', metastring }, + children, + ); + +const fences = () => [ + code('list', 'title="PostList" route="/"', LIST), + code('detail', 'title="PostDetail" route="/posts/:id" collapsed', DETAIL), +]; + +describe('demo layout', () => { + test('the server HTML has the code and the Code toggle, preview first', () => { + const html = renderToString({fences()}); + expect(html).toContain('listOfPosts'); + expect(html).toContain('aria-label="Code"'); + expect(html).toContain('aria-pressed="false"'); + expect(html).not.toContain('data-code'); + expect(html.indexOf('Live Preview')).toBeLessThan( + html.indexOf('listOfPosts'), + ); + }); + + test('the Code toggle trades the result for the code, and back', () => { + const { container } = render({fences()}); + const playground = container.querySelector('[data-playground] > div')!; + const toggle = screen.getByRole('button', { name: 'Code' }); + // the code is there, hidden by the layout, before it is shown + expect(screen.getByText('listOfPosts')).toBeTruthy(); + expect(playground.hasAttribute('data-code')).toBe(false); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(toggle.title).toBe('Show code'); + + fireEvent.click(toggle); + expect(playground.hasAttribute('data-code')).toBe(true); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + expect(toggle.title).toBe('Hide code'); + // the header names the code in the label's place + expect(screen.getByText('Editor').getAttribute('aria-hidden')).toBe( + 'false', + ); + // files are tabs, as in row + expect(screen.getByRole('tab', { name: 'PostDetail' })).toBeTruthy(); + + fireEvent.click(toggle); + expect(playground.hasAttribute('data-code')).toBe(false); + expect(screen.getByText('Editor').getAttribute('aria-hidden')).toBe('true'); + }); + + test('without demo there is no Code toggle', () => { + render({fences()}); + expect(screen.queryByRole('button', { name: 'Code' })).toBeNull(); + }); +}); diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index b8efbd9da9df..14b720e4b1ae 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -17,7 +17,8 @@ import type { InteractiveEditorProps } from './InteractiveEditor'; import StaticEditor from './StaticEditor'; export interface EditorSurfaceProps extends CodeModel { - layout: 'row' | 'stacked'; + /** `demo`: tabs like `row`, under the preview's header (see ../index.tsx) */ + layout: 'row' | 'stacked' | 'demo'; variant: 'playground' | 'standalone'; /** When false, keep static SSR-friendly editors (no Monaco). Defaults true. */ interactive?: boolean; @@ -44,7 +45,7 @@ export default function EditorSurface({ coverTitle, }: EditorSurfaceProps) { const id = useModelId(); - const row = layout === 'row'; + const row = layout !== 'stacked'; const [closedList, setClosed] = useState(() => documents.map(({ collapsed }) => collapsed), ); @@ -97,7 +98,7 @@ export default function EditorSurface({ closedList={closedList} onClick={handleTabSwitch} compact={variant === 'standalone'} - hasHeaderControls={headerControls != null} + subtabs={headerControls != null || layout === 'demo'} /> : null; // the file tabs only pick which file shows, so they go under the cover @@ -251,13 +252,15 @@ function EditorTabs({ closedList, onClick, compact, - hasHeaderControls, + subtabs, }: { documents: readonly CodeDocument[]; closedList: readonly boolean[]; onClick: (index: number) => void; compact: boolean; - hasHeaderControls: boolean; + /** Under another header (the controls', or the preview's in the demo + * layout): a step above the bar, so the two rows read apart */ + subtabs: boolean; }) { const tabs = documents .map((document, index) => ({ document, index })) @@ -266,11 +269,11 @@ function EditorTabs({ return (
({ diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 493121a95715..311794795232 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -1,7 +1,7 @@ import Translate from '@docusaurus/Translate'; import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; import clsx from 'clsx'; -import React, { lazy, useDeferredValue, useState } from 'react'; +import React, { lazy, useCallback, useDeferredValue, useState } from 'react'; import Boundary from './Boundary'; import { useCodeDocuments } from './editor/codeModel'; @@ -13,6 +13,7 @@ import type LivePreviewType from './preview/LivePreview'; import PreviewWrapper from './preview/PreviewWrapper'; import type { LogOptions } from './preview/store/actionLog'; import { + CodeHeaderToggle, StoreBadge, StoreHeaderToggle, StoreToggle, @@ -34,6 +35,12 @@ export interface PlaygroundProps { * the Store opens by default (it would cover the code), or when the preview * renders a long list (it gets tall in the narrow column). */ row?: boolean; + /** Preview first, at full width, with room for a realistic app; the code + * waits behind a Code toggle in its header (file tabs like `row`) and + * takes the preview's place while it shows. Use when the point is what + * happens on screen, not the code, or the app needs the room. The Store + * opens beside the result as when stacked; `row` is ignored. */ + demo?: boolean; /** Inactive (e.g. unselected Demo tab): no Monaco until first shown, no preview */ hidden?: boolean; fixtures?: FixtureOrInterceptor[]; @@ -60,7 +67,8 @@ export default function Playground({ children, groupId = 'playground', defaultOpen = 'n', - row = false, + row: rowLayout = false, + demo = false, hidden = false, fixtures = [], getInitialInterceptorData, @@ -76,6 +84,8 @@ export default function Playground({ // Defer preview transpilation so editor input remains responsive. const documents = useDeferredValue(model.documents); + // demo has a layout of its own + const row = rowLayout && !demo; const [storeOpen, toggleStore, showStore] = useStoreOpen( groupId, defaultOpen, @@ -84,22 +94,41 @@ export default function Playground({ const [storeHost, setStoreHost] = useState(null); const codeCovered = row && storeOpen; + // Demo layout: the code takes the result's place while shown + const [codeShown, setCodeShown] = useState(false); + const toggleCode = useCallback(() => setCodeShown(shown => !shown), []); + // the Store lives with the result, so over the code its toggle brings the + // preview back along with what it says (Show Store, Hide Store) + const toggleStoreInPreview = useCallback( + (event: React.MouseEvent) => { + setCodeShown(false); + toggleStore(event); + }, + [toggleStore], + ); + // Hydrate Monaco on first show and keep it (preserves undo / go-to-def). - const [editorInteractive, setEditorInteractive] = useState(!hidden); + // A demo's code is first shown by its Code toggle. + const [editorInteractive, setEditorInteractive] = useState(!hidden && !demo); useIsomorphicLayoutEffect(() => { - if (!hidden) setEditorInteractive(true); - }, [hidden]); + if (!hidden && (!demo || codeShown)) setEditorInteractive(true); + }, [hidden, demo, codeShown]); const fixtureContent = fixtures.length ? : undefined; - const previewFixtures = row ? fixtureContent : undefined; + const previewFixtures = row || demo ? fixtureContent : undefined; const editor = ( ({ /> ); + // the demo layout's Code toggle; the code is there whether or not the live + // preview has loaded + const code = demo ? { codeShown, toggleCode } : undefined; // Live preview only while visible — unmounts when hidden (resets store). - const loading = ; + const loading = ( + + ); const preview = hidden ? loading : ( @@ -125,7 +159,7 @@ export default function Playground({ documents={documents} fixtureContent={previewFixtures} storeOpen={storeOpen} - toggleStore={toggleStore} + toggleStore={demo ? toggleStoreInPreview : toggleStore} row={row} storeHost={storeHost} fixtures={fixtures} @@ -134,6 +168,7 @@ export default function Playground({ actionLog={actionLog} autoRun={autoRun} showStore={showStore} + {...code} /> ); @@ -149,10 +184,20 @@ export default function Playground({
- {editor} - {preview} + {demo ? + <> + {preview} + {editor} + + : <> + {editor} + {preview} + + }
); @@ -160,18 +205,30 @@ export default function Playground({ /** SSR, crawler, hidden and loading state: empty preview frame + Store * toggle (the side strip, unless the code's corner badge is it), and row's - * fixtures, already usable */ + * fixtures, already usable; so is a demo's Code toggle */ function PreviewLoading({ row, fixtureContent, + codeShown = false, + toggleCode, }: { row: boolean; fixtureContent?: React.ReactNode; + codeShown?: boolean; + toggleCode?: () => void; }) { return ( } + headerControls={ + <> + + {toggleCode ? + + : null} + + } fixtures={fixtureContent} + codeShown={toggleCode ? codeShown : undefined} >
{row ? null : } diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts index 31d69f822e28..4bec6c5adf94 100644 --- a/website/src/components/Playground/monaco/typeLibs.ts +++ b/website/src/components/Playground/monaco/typeLibs.ts @@ -188,7 +188,7 @@ const PREVIEW_SCOPE_DECLARATIONS = `declare function render(component:JSX.Elemen declare function uuid(): string; declare function CurrentTime(props: {}):JSX.Element; declare function CancelButton(props: { onClick?: () => void }):JSX.Element; - declare function Avatar(props: { src: string }):JSX.Element; + declare function Avatar(props: { src: string; size?: number }):JSX.Element; declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element declare function TextInput(props:Omit, 'size'> & { label?: React.ReactNode; loading?: boolean; size?: 'large' | 'medium' | 'small'; }):JSX.Element; declare function TextArea(props:React.TextareaHTMLAttributes & { label?: React.ReactNode;}):JSX.Element; diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 7d8f5950fb99..2da547ad226b 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -13,7 +13,7 @@ import type { CodeDocument } from '../editor/codeModel'; import type { PreviewProps } from '../types'; import type { LogOptions } from './store/actionLog'; import SchemaRegistry from './store/schemaRegistry'; -import { StoreHeaderToggle } from './StoreToggle'; +import { CodeHeaderToggle, StoreHeaderToggle } from './StoreToggle'; import { useCodeManagers } from './useCodeManagers'; import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion'; import { usePreviewReset } from './usePreviewReset'; @@ -27,6 +27,10 @@ export interface LivePreviewProps extends PreviewProps { autoRun?: AutoRunScript; /** Opens or closes the Store for the demo, without saving it */ showStore: (open: boolean) => void; + /** Demo layout: whether the code shows in the result's place, and the + * header's Code toggle (see ../index.tsx) */ + codeShown?: boolean; + toggleCode?: () => void; } export default function LivePreview({ @@ -42,6 +46,8 @@ export default function LivePreview({ actionLog, autoRun, showStore, + codeShown = false, + toggleCode, }: LivePreviewProps) { usePlaygroundConsoleDemotion(); const { onCommit, badge } = useRenderCount(renderCount); @@ -91,10 +97,14 @@ export default function LivePreview({ : undefined } fixtures={fixtureContent} + codeShown={toggleCode ? codeShown : undefined} headerControls={ <> {badge} + {toggleCode ? + + : null} } diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index c145e31508ce..b59ef75e1a87 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -1,4 +1,4 @@ -import { translate } from '@docusaurus/Translate'; +import Translate, { translate } from '@docusaurus/Translate'; import clsx from 'clsx'; import React, { useId, useState } from 'react'; @@ -13,6 +13,7 @@ export default function PreviewWrapper({ headerControls, label, fixtures, + codeShown, ref, }: Props) { const [routerSlot, setRouterSlot] = useState(null); @@ -22,14 +23,33 @@ export default function PreviewWrapper({ id: 'playground.fixtures', message: 'Fixtures', }); + const liveLabelNode = label ?? ( + + {liveLabel()} + + ); return (
- {label ?? ( - - {liveLabel()} + {codeShown === undefined ? + liveLabelNode + : + + {liveLabelNode} + + + + Editor + + - )} + } {fixtures != null ? @@ -77,5 +97,8 @@ interface Props { /** Mock responses to inspect over the result, from a network icon in the * header: previewed on hover, pinned open by a click or tap */ fixtures?: React.ReactNode; + /** Demo layout: the header names the code ("Editor") in the label's place + * while it shows in the result's; `undefined` where the code never does */ + codeShown?: boolean; ref?: React.Ref; } diff --git a/website/src/components/Playground/preview/StoreToggle.tsx b/website/src/components/Playground/preview/StoreToggle.tsx index 6ab6548d5700..805830a50214 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -131,7 +131,7 @@ export function StoreBadge({ Store - + Code @@ -143,11 +143,42 @@ const toggleTitle = (open: boolean) => translate({ id: 'playground.store.hide', message: 'Hide Store' }) : translate({ id: 'playground.store.show', message: 'Show Store' }); +/** The Code toggle in the preview's header (the demo layout, ../index.tsx): + * pressed, the code takes the result's place */ +export function CodeHeaderToggle({ + onClick, + open, +}: { + onClick: () => void; + open: boolean; +}) { + return ( + + ); +} + /** Angle brackets around a slash */ -function CodeIcon() { +function CodeIcon({ className }: { className?: string }) { return ( { expect(screen.queryByRole('button', { name: /Fixtures/ })).toBeNull(); }); }); + +describe('PreviewWrapper label', () => { + test("names the code in the label's place while it shows", () => { + const { rerender } = render( + +
result
+ , + ); + const live = screen.getByText('Live Preview'); + const editor = screen.getByText('Editor'); + expect(live.closest('[aria-hidden]')!.getAttribute('aria-hidden')).toBe( + 'false', + ); + expect(editor.getAttribute('aria-hidden')).toBe('true'); + + rerender( + +
result
+
, + ); + expect(live.closest('[aria-hidden]')!.getAttribute('aria-hidden')).toBe( + 'true', + ); + expect(editor.getAttribute('aria-hidden')).toBe('false'); + }); + + test('is the label alone where the code never shows', () => { + render( + +
result
+
, + ); + expect(screen.queryByText('Editor')).toBeNull(); + }); +}); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index b1e9f2ae8a7a..7c97217aa7c5 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -533,6 +533,12 @@ div.playgroundTextEdit > .playgroundHeader, --swap-in: 100% 0; --swap-out: -40% 0; } +/* the preview's label makes the same trade for "Editor" while the code + shows (demo layout); padding inside the clip keeps the dot's ring whole */ +.labelSwap { + margin: -6px; + padding: 6px; +} /* live editor v3 */ :global(.prism-code) { @@ -650,10 +656,7 @@ div.playgroundTextEdit > .playgroundHeader, var(--ifm-color-emphasis-100) 50% ); } -:root .playgroundPreview { - --ifm-heading-margin-bottom: 0.3ex; - --ifm-heading-font-weight: 500; - --ifm-heading-line-height: 1.2; +.playgroundPreview { overflow: auto; } /* The router's navigation indicator (preview/router.tsx): a 3px bar along @@ -756,210 +759,8 @@ div.playgroundTextEdit > .playgroundHeader, text-overflow: ellipsis; opacity: 0.8; } -/** DEMO STYLES -* Place any styles needed for demos here -*/ -.playgroundPreview button { - margin-bottom: 8px; - margin-right: 8px; - padding: 2px 6px; -} -/* Base skin for plain controls only; classed ones (DesignSystem rt-*, vote - arrows) keep their own chrome. :where() keeps specificity unchanged. */ -.playgroundPreview button:where(:not([class])), -.playgroundPreview input[type='text']:where(:not([class])), -.playgroundPreview textarea:where(:not([class])) { - border: 1px solid var(--ifm-color-emphasis-400); - border-radius: 6px; - background: var(--ifm-background-surface-color); - color: var(--ifm-font-color-base); - font: inherit; -} -.playgroundPreview button:where(:not([class])) { - padding: 0.3rem 0.8rem; - font-size: 0.9rem; - font-weight: 500; - line-height: 1.4; - cursor: pointer; -} -.playgroundPreview button:where(:not([class])):hover { - border-color: var(--ifm-color-primary); - color: var(--ifm-color-primary); -} -.playgroundPreview input[type='text']:where(:not([class])), -.playgroundPreview textarea:where(:not([class])) { - padding: 0.3rem 0.5rem; -} -/* A demo's links between its pages (Link), the one shown marked as current */ -.playgroundPreview nav:where(:not([class])) { - display: flex; - flex-wrap: wrap; - gap: 0.25rem 1rem; - margin-bottom: 0.5rem; -} -.playgroundPreview nav a[aria-current='page'] { - color: var(--ifm-font-color-base); - font-weight: 600; - text-decoration: underline 2px var(--ifm-color-primary); - text-underline-offset: 0.35em; -} -/* Groups a form's fields so one `disabled` locks them all */ -.playgroundPreview fieldset { - min-width: 0; - margin: 0; - padding: 0; - border: 0; -} -.playgroundPreview fieldset:disabled, -.playgroundPreview fieldset:disabled * { - cursor: progress; -} -/* While a form saves, a light traces its submit button's border */ -@property --pg-trace-angle { - syntax: ''; - inherits: false; - initial-value: 0deg; -} -.playgroundPreview form[aria-busy='true'] button[type='submit'] { - /* 1px wider border, 1px less padding: same size, room for the light */ - border: 2px solid transparent; - padding: calc(0.3rem - 1px) calc(0.8rem - 1px); - background: - linear-gradient( - var(--ifm-background-surface-color), - var(--ifm-background-surface-color) - ) - padding-box, - conic-gradient( - from var(--pg-trace-angle), - var(--ifm-color-emphasis-200) 0 45%, - var(--ifm-color-primary) 75%, - var(--ifm-color-emphasis-200) 76% - ) - border-box; - box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 15%, transparent); - color: var(--ifm-color-primary); - animation: trace 1.2s linear infinite; -} -@keyframes trace { - to { - --pg-trace-angle: 360deg; - } -} -@media (prefers-reduced-motion: reduce) { - .playgroundPreview form[aria-busy='true'] button[type='submit'] { - animation: none; - } -} -.playgroundPreview input[type='checkbox'] { - width: 1rem; - height: 1rem; - vertical-align: -2px; - accent-color: var(--ifm-color-primary); -} -.playgroundPreview form input[type='text'] { - width: 100%; -} -.playgroundPreview form textarea { - width: 100%; -} -.playgroundPreview section { - margin-bottom: 2ex; -} -.playgroundPreview h4 + small { - display: block; -} -.playgroundPreview :global(.listItem) { - display: flex; - gap: 1em; -} -.playgroundPreview :global(.listItem.nogap) { - gap: 0; -} -.playgroundPreview :global(.listItem) > label { - flex: 1 1 100%; - line-height: 1.1; - margin-bottom: 4px; -} -.playgroundPreview :global(.listItem) > label input[type='text'] { - width: calc(100% - 21px); -} -.playgroundPreview :global(.listItem.spaced) { - display: flex; - gap: 1em; - margin-bottom: 10px; -} -.playgroundPreview :global(.author) { - text-align: center; -} -.playgroundPreview :global(.author) small { - display: block; - line-height: 1; -} -.playgroundPreview :global(.voteBlock) { - display: flex; - float: left; - gap: 0.5em; - margin-right: 0.5em; -} -.playgroundPreview :global(.vote) { - flex: 0 0 32px; - text-align: center; -} -.playgroundPreview :global(.vote .up):hover { - color: var(--ifm-color-primary); - background: url('../../../static/img/up-arrow-outline-hover.svg') no-repeat - center; -} -.playgroundPreview :global(.vote .up) { - border: none; - cursor: pointer; - width: 20px; - height: 20px; - margin-bottom: -8px; - margin-right: 0; - background: url('../../../static/img/up-arrow-outline.svg') no-repeat center; - display: inline-block; -} -.playgroundPreview :global(.boardColumn) { - background: var(--ifm-color-emphasis-100); - border-radius: var(--ifm-global-radius); - padding: 0.4rem; - margin-bottom: 0.5rem; -} -.playgroundPreview :global(.boardColumn) h4 { - margin: 0 0 0.3rem; - padding: 0 0.25rem; -} -.playgroundPreview :global(.boardColumn) :global(.listItem) { - background: var(--ifm-background-color); - border-radius: calc(var(--ifm-global-radius) * 0.75); - padding: 0.25rem 0.5rem; - margin-bottom: 0.3rem; -} -/* Dark: cards lift off the well-colored column, as light mode lifts white - cards off a grey one */ -:root[data-theme='dark'] - .playgroundPreview - :global(.boardColumn) - :global(.listItem) { - background: hsl(229, 7%, 22%); -} -.playgroundPreview :global(.boardColumn) button { - margin: 0; - padding: 0 4px; - line-height: 1; - background: none; - border: none; - cursor: pointer; - opacity: 0.5; -} -.playgroundPreview :global(.boardColumn) button:hover { - opacity: 1; -} -.playgroundPreview p { - line-height: var(--ifm-heading-line-height); -} +/* Demo content (what demos render) is styled by the preview's design system, + DesignSystem/design-system.css, loaded with the preview */ /* Endpoint chips shared by the Store inspector (store.module.css composes them) and the Fixtures panel: the method before a path, and a status pill */ @@ -1244,6 +1045,53 @@ div.fixtureJson + div.fixtureJson { } } +/* Demo layout: the preview first, at the playground's full width, with room + for a realistic app; the code waits behind the Code toggle in the + preview's header and takes the result's place while it shows (`data-code` + on the container). Both keep one minimum height, so pages of the app and + the code trade places without moving the page under the reader. The Store + opens beside the result as in the stacked layout. */ +.playgroundContainer.demo { + --pg-demo-min: min(30rem, 80svh); + /* the fixtures hang from the header over whichever panel shows: + positioned by the container, so the preview's clip never cuts them */ + position: relative; + background-color: var(--ifm-background-color); +} +.demo > .previewWrapper { + position: static; + border-radius: var(--ifm-global-radius); +} +.demo[data-code] > .previewWrapper { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.demo .playgroundPreview { + --pg-preview-pad: 1.25rem; + min-height: var(--pg-demo-min); +} +.demo[data-code] .playgroundResult { + display: none; +} +.demo > .playgroundTextEdit { + display: none; + min-height: var(--pg-demo-min); + background-color: var(--monoco-code-background); +} +.demo[data-code] > .playgroundTextEdit { + display: block; +} +/* under the preview's header, the file tabs are no longer the top corner */ +.playgroundContainer.demo > .playgroundTextEdit > .playgroundHeader { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +@container playground (min-width: 720px) { + .demo .playgroundPreview { + --pg-preview-pad: 2rem; + } +} + /* EndpointPlayground: left tab bar and right fixture headers share one height */ .endpointPlayground .playgroundHeader { display: flex; @@ -1404,8 +1252,10 @@ div.fixtureJson + div.fixtureJson { .headerButton.storeHeaderToggle { display: none; } -/* the fixtures toggle (row) is lit the same while they show */ +/* the fixtures toggle (row) and the Code toggle (demo) are lit the same + while what they show does */ .storeHeaderToggle[aria-pressed='true'], +.codeHeaderToggle[aria-pressed='true'], .fixturesToggle[aria-expanded='true'], .previewHeader:has(+ .fixturesAnchor > .fixturesPopover:hover) .fixturesToggle { background-color: var(--pg-tab-hover-bg); diff --git a/website/src/components/Playground/testing/docusaurusThemeCommon.ts b/website/src/components/Playground/testing/docusaurusThemeCommon.ts index 23381afd4e42..fe4311172c13 100644 --- a/website/src/components/Playground/testing/docusaurusThemeCommon.ts +++ b/website/src/components/Playground/testing/docusaurusThemeCommon.ts @@ -1,4 +1,20 @@ // Jest stand-in for @docusaurus/theme-common, whose ESM build Jest doesn't transform +import { useState } from 'react'; + +/** No Prism theme: react-live renders unstyled */ +export function usePrismTheme() { + return undefined; +} + +/** A storage slot kept in state for the test */ +export function useStorageSlot(key: string | null) { + const [value, set] = useState(null); + return [ + key ? value : null, + { set, del: () => set(null), listen: () => () => undefined }, + ] as const; +} + export function usePluralForm() { return { // English rules: "one|other" diff --git a/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts b/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts new file mode 100644 index 000000000000..b93fa711d020 --- /dev/null +++ b/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts @@ -0,0 +1,2 @@ +// Jest stand-in for @docusaurus/useIsomorphicLayoutEffect: jsdom is a browser +export { useLayoutEffect as default } from 'react'; From 615c1e49b8b05431903bf6a925d2dbc0f687ce91 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:12:29 +0000 Subject: [PATCH 02/66] Playground demo layout: review fixes - Code toggle and Store toggle share one HeaderToggle; the Code control (CodeControl: shown + toggle) reaches PreviewWrapper from index.tsx, so it is in the header before the live preview loads, and the header's label trades for "Editor" (editorLabel, shared with EditorSurface) - Store toggle over the code brings the preview back with whatever it says (toggleStoreInPreview) - demo layout: code panel squares its top corners under the header (data-code), preview and code share --pg-demo-min, first Code press keeps the page in place - design system: tokens scoped to .playground-preview (--ds-*), dark surfaces step from --pg-preview-bg, h1 sizing over theme-classic's first-child rule, generic link rules dropped (alerts keep theirs) - tests: PreviewWrapper label swap via the code control; demoLayoutLive mocks the lazy LivePreview (its chunk's ESM deps can't load in jest) - translations finalized for playground.code.show/hide Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 73 +++++++----- .../Playground/__tests__/demoLayout.test.tsx | 5 +- .../__tests__/demoLayoutLive.test.tsx | 79 +++++++++++++ .../Playground/editor/EditorSurface.tsx | 10 +- website/src/components/Playground/index.tsx | 62 +++++----- .../src/components/Playground/liveLabel.ts | 9 ++ .../Playground/preview/LivePreview.tsx | 18 +-- .../Playground/preview/PreviewWrapper.tsx | 35 +++--- .../Playground/preview/StoreToggle.tsx | 107 +++++++++++------- .../preview/__tests__/PreviewWrapper.test.tsx | 6 +- .../components/Playground/styles.module.css | 32 ++---- website/src/components/Playground/types.ts | 10 +- website/translate/lock/es.json | 4 +- website/translate/lock/pt-BR.json | 4 +- website/translate/lock/zh-Hans.json | 4 +- 15 files changed, 284 insertions(+), 174 deletions(-) create mode 100644 website/src/components/Playground/__tests__/demoLayoutLive.test.tsx diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 57caf5c9ad7c..7f7a52635376 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -20,6 +20,9 @@ --ds-space-5: 1.5rem; --ds-radius: 10px; --ds-radius-sm: 6px; + /* a button's or a field's inset */ + --ds-control-pad-y: 0.375rem; + --ds-control-pad-x: 0.875rem; --ds-text: var(--ifm-font-color-base); --ds-text-muted: var(--ifm-color-emphasis-700); /* a card over the preview's ground */ @@ -32,27 +35,43 @@ --ds-on-accent: #fff; --ds-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); --ifm-color-formfield-active: var(--ifm-link-color); - /* Infima's headings read these */ - --ifm-h1-font-size: var(--ds-text-xl); + /* Infima's headings read these (h1 is set below: theme-classic sizes a + first-child h1 as a page title) */ --ifm-h2-font-size: var(--ds-text-xl); --ifm-h3-font-size: var(--ds-text-lg); - --ifm-h4-font-size: 1rem; --ifm-heading-font-weight: 600; - --ifm-heading-line-height: 1.25; --ifm-heading-margin-bottom: var(--ds-space-1); --ifm-paragraph-margin-bottom: var(--ds-space-3); font-size: var(--ds-text-base); line-height: 1.5; color: var(--ds-text); } +/* Dark: steps from the preview's slate ground (--pg-preview-bg, set with the + frame in ../styles.module.css): a card a step above it, control wells a + step below */ html[data-theme='dark'] .playground-preview { --ds-text: #e3e8f2; --ds-text-muted: hsl(229, 12%, 72%); - /* a step above the preview's slate (--pg-preview-bg) */ - --ds-surface: hsl(229, 7%, 23%); + --ds-surface: color-mix(in srgb, var(--pg-preview-bg) 94%, white); --ds-border: rgba(255, 255, 255, 0.1); --ds-accent-strong: var(--ifm-color-primary-lighter); --ds-shadow: none; + /* the fields (rt-*) read Infima's: wells, and a 3:1 edge on both the well + and the ground */ + --ifm-background-surface-color: color-mix( + in srgb, + var(--pg-preview-bg) 85%, + black + ); + --ifm-color-emphasis-100: var(--ifm-background-surface-color); + --ifm-color-emphasis-400: hsl(229, 5%, 48%); +} +/* The floating label straddles the field's top edge, so it blends into both */ +html[data-theme='dark'] .playground-preview .rt-TextFieldLabel { + background: linear-gradient( + var(--pg-preview-bg) 50%, + var(--ifm-color-emphasis-100) 50% + ); } /* Text */ @@ -61,8 +80,12 @@ html[data-theme='dark'] .playground-preview { .playground-preview h3 { letter-spacing: -0.01em; } -.playground-preview p { - line-height: 1.5; +/* an app's h1 is no page title: outranks theme-classic's + `.markdown h1:first-child` (3rem, and a page's gap under it) */ +.playground-preview h1, +div.playground-preview h1:first-child { + font-size: var(--ds-text-xl); + margin-bottom: var(--ifm-heading-margin-bottom); } .playground-preview small, .playground-preview .meta { @@ -79,16 +102,9 @@ html[data-theme='dark'] .playground-preview { margin-bottom: 0; } -/* Links: accent-colored; in a heading they keep its color, with a faint - underline that fills on hover, so a title that opens a page still shows it */ -.playground-preview a { - color: var(--ds-accent); - text-decoration: none; -} -.playground-preview a:hover, -.playground-preview a:focus-visible { - text-decoration: underline; -} +/* Links keep Infima's (--ds-accent is its link color); in a heading they + keep its color, with a faint underline that fills on hover, so a title + that opens a page still shows it */ .playground-preview :is(h1, h2, h3, h4) a { color: inherit; text-decoration: underline; @@ -112,11 +128,11 @@ html[data-theme='dark'] .playground-preview { padding-bottom: var(--ds-space-3); border-bottom: 1px solid var(--ds-border); } -.playground-preview .appTitle { +/* through .appHeader to outrank Infima's `.markdown h1:first-child` margin */ +.playground-preview .appHeader .appTitle { margin: 0; font-size: var(--ds-text-lg); font-weight: 600; - letter-spacing: -0.01em; } .playground-preview .appHeader nav { margin-bottom: 0; @@ -221,7 +237,7 @@ html[data-theme='dark'] .playground-preview { font: inherit; } .playground-preview button:where(:not([class]), .primary) { - padding: 0.375rem 0.875rem; + padding: var(--ds-control-pad-y) var(--ds-control-pad-x); font-size: var(--ds-text-sm); font-weight: 500; line-height: 1.4; @@ -286,21 +302,22 @@ html[data-theme='dark'] .playground-preview { .playground-preview form[aria-busy='true'] button[type='submit'] { /* 1px wider border, 1px less padding: same size, room for the light */ border: 2px solid transparent; - padding: calc(0.375rem - 1px) calc(0.875rem - 1px); + padding: calc(var(--ds-control-pad-y) - 1px) + calc(var(--ds-control-pad-x) - 1px); background: linear-gradient(var(--ds-surface), var(--ds-surface)) padding-box, conic-gradient( from var(--pg-trace-angle), var(--ifm-color-emphasis-200) 0 45%, - var(--ifm-color-primary) 75%, + var(--ds-accent) 75%, var(--ifm-color-emphasis-200) 76% ) border-box; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 15%, transparent); - color: var(--ifm-color-primary); - animation: trace 1.2s linear infinite; + color: var(--ds-accent); + animation: ds-trace 1.2s linear infinite; } -@keyframes trace { +@keyframes ds-trace { to { --pg-trace-angle: 360deg; } @@ -314,7 +331,7 @@ html[data-theme='dark'] .playground-preview { width: 1rem; height: 1rem; vertical-align: -2px; - accent-color: var(--ifm-color-primary); + accent-color: var(--ds-accent); } .playground-preview form input[type='text'], .playground-preview form textarea { @@ -333,7 +350,7 @@ html[data-theme='dark'] .playground-preview { text-align: center; } .playground-preview .vote .up:hover { - color: var(--ifm-color-primary); + color: var(--ds-accent); background: url('../../../../static/img/up-arrow-outline-hover.svg') no-repeat center; } diff --git a/website/src/components/Playground/__tests__/demoLayout.test.tsx b/website/src/components/Playground/__tests__/demoLayout.test.tsx index 936649671cb4..488e2187f1a9 100644 --- a/website/src/components/Playground/__tests__/demoLayout.test.tsx +++ b/website/src/components/Playground/__tests__/demoLayout.test.tsx @@ -1,5 +1,6 @@ /// /// +/// import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; @@ -38,8 +39,8 @@ describe('demo layout', () => { test('the server HTML has the code and the Code toggle, preview first', () => { const html = renderToString({fences()}); expect(html).toContain('listOfPosts'); - expect(html).toContain('aria-label="Code"'); - expect(html).toContain('aria-pressed="false"'); + const codeToggle = html.match(/]*aria-label="Code"[^>]*>/)?.[0]; + expect(codeToggle).toContain('aria-pressed="false"'); expect(html).not.toContain('data-code'); expect(html.indexOf('Live Preview')).toBeLessThan( html.indexOf('listOfPosts'), diff --git a/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx new file mode 100644 index 000000000000..e03fc5801654 --- /dev/null +++ b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx @@ -0,0 +1,79 @@ +/// +/// +/// + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import Playground from '..'; + +// a phone: the live preview loads (not a bot), with the react-live editor +jest.mock('../userAgent', () => ({ isBot: false, isMobileOrBot: () => true })); +jest.mock('../PlaygroundEditor', () => ({ + __esModule: true, + default: ({ code }: { code: string }) => + require('react').createElement('textarea', { defaultValue: code }), +})); +jest.mock( + '@docusaurus/useBaseUrl', + () => ({ __esModule: true, default: (url: string) => url }), + { virtual: true }, +); +// The real chunk pulls in the whole preview scope (ESM-only packages jest +// can't load); what the demo layout needs of it is the header it renders +// (PreviewWrapper with its Store toggle) and the toggleStore it is handed. +jest.mock('../preview/LivePreview', () => { + const { createElement } = require('react'); + const PreviewWrapper = require('../preview/PreviewWrapper').default; + const { StoreHeaderToggle } = require('../preview/StoreToggle'); + return { + __esModule: true, + default: ({ storeOpen, toggleStore, code }: any) => + createElement( + PreviewWrapper, + { + code, + headerControls: createElement(StoreHeaderToggle, { + open: storeOpen, + onClick: toggleStore, + }), + }, + 'the app', + ), + }; +}); + +const APP = 'render(
the app
);'; + +describe('demo layout with the live preview', () => { + test('the Store toggle brings the preview back from over the code', async () => { + const { container } = render({APP}); + const playground = container.querySelector('[data-playground] > div')!; + // the live preview has loaded once its Store toggle is enabled: a new + // button, in the loading fallback's place (whose code also says "the app") + const store = await waitFor(() => { + const button = screen.getByRole('button', { name: 'Store' }); + expect(button.hasAttribute('disabled')).toBe(false); + return button; + }); + const code = screen.getByRole('button', { name: 'Code' }); + + fireEvent.click(code); + expect(playground.hasAttribute('data-code')).toBe(true); + expect(code.getAttribute('aria-pressed')).toBe('true'); + expect(store.getAttribute('aria-pressed')).toBe('false'); + + // "Show Store": the preview returns with the Store open + fireEvent.click(store); + expect(playground.hasAttribute('data-code')).toBe(false); + expect(store.getAttribute('aria-pressed')).toBe('true'); + expect(code.getAttribute('aria-pressed')).toBe('false'); + + // "Hide Store" from over the code: the preview returns, Store closed + fireEvent.click(code); + expect(playground.hasAttribute('data-code')).toBe(true); + fireEvent.click(store); + expect(playground.hasAttribute('data-code')).toBe(false); + expect(store.getAttribute('aria-pressed')).toBe('false'); + }); +}); diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index 14b720e4b1ae..02403b785cc9 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -6,6 +6,7 @@ import type * as Monaco from 'monaco-editor'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import Header from '../Header'; +import { editorLabel } from '../liveLabel'; import { modelPath, useModelId } from '../monaco/modelPath'; import { createMissingModels } from '../monaco/models'; import NetworkIcon from '../NetworkIcon'; @@ -288,14 +289,7 @@ function EditorTabs({ } function EditorHeader({ - title = ( - - Editor - - ), + title = editorLabel(), covered, coverTitle, fixtureContent, diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 311794795232..c2423e1e4bce 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -20,7 +20,7 @@ import { useStoreOpen, } from './preview/StoreToggle'; import styles from './styles.module.css'; -import type { FixtureOrInterceptor } from './types'; +import type { CodeControl, FixtureOrInterceptor } from './types'; import { isBot } from './userAgent'; export interface PlaygroundProps { @@ -84,8 +84,11 @@ export default function Playground({ // Defer preview transpilation so editor input remains responsive. const documents = useDeferredValue(model.documents); - // demo has a layout of its own - const row = rowLayout && !demo; + const layout = + demo ? 'demo' + : rowLayout ? 'row' + : 'stacked'; + const row = layout === 'row'; const [storeOpen, toggleStore, showStore] = useStoreOpen( groupId, defaultOpen, @@ -107,28 +110,24 @@ export default function Playground({ [toggleStore], ); - // Hydrate Monaco on first show and keep it (preserves undo / go-to-def). - // A demo's code is first shown by its Code toggle. - const [editorInteractive, setEditorInteractive] = useState(!hidden && !demo); + // Hydrate Monaco on first show (a demo's code is first shown by its Code + // toggle) and keep it (preserves undo / go-to-def). + const codeVisible = !hidden && (!demo || codeShown); + const [editorInteractive, setEditorInteractive] = useState(codeVisible); useIsomorphicLayoutEffect(() => { - if (!hidden && (!demo || codeShown)) setEditorInteractive(true); - }, [hidden, demo, codeShown]); + if (codeVisible) setEditorInteractive(true); + }, [codeVisible]); const fixtureContent = fixtures.length ? : undefined; - const previewFixtures = row || demo ? fixtureContent : undefined; + const previewFixtures = layout === 'stacked' ? undefined : fixtureContent; const editor = ( ({ /> ); - // the demo layout's Code toggle; the code is there whether or not the live - // preview has loaded - const code = demo ? { codeShown, toggleCode } : undefined; + // the demo layout's Code toggle: in the preview's header whether or not + // the live preview has loaded + const code: CodeControl | undefined = + demo ? + { + shown: codeShown, + toggle: , + } + : undefined; // Live preview only while visible — unmounts when hidden (resets store). const loading = ( - + ); const preview = hidden ? loading : ( @@ -168,7 +173,7 @@ export default function Playground({ actionLog={actionLog} autoRun={autoRun} showStore={showStore} - {...code} + code={code} /> ); @@ -209,26 +214,17 @@ export default function Playground({ function PreviewLoading({ row, fixtureContent, - codeShown = false, - toggleCode, + code, }: { row: boolean; fixtureContent?: React.ReactNode; - codeShown?: boolean; - toggleCode?: () => void; + code?: CodeControl; }) { return ( - - {toggleCode ? - - : null} - - } + headerControls={} fixtures={fixtureContent} - codeShown={toggleCode ? codeShown : undefined} + code={code} >
{row ? null : } diff --git a/website/src/components/Playground/liveLabel.ts b/website/src/components/Playground/liveLabel.ts index eb2e84ffee49..b1083d24ea4c 100644 --- a/website/src/components/Playground/liveLabel.ts +++ b/website/src/components/Playground/liveLabel.ts @@ -7,3 +7,12 @@ export const liveLabel = () => description: 'The result label of the live codeblocks', message: 'Live Preview', }); + +/** The code's "Editor" label text (the editor's header; the preview's + * header while a demo's code shows) */ +export const editorLabel = () => + translate({ + id: 'theme.Playground.liveEditor', + description: 'The live editor label of the live codeblocks', + message: 'Editor', + }); diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 2da547ad226b..aea0f8838bc6 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -10,10 +10,10 @@ import PreviewWrapper from './PreviewWrapper'; import { useRenderCount } from './RenderCount'; import { routerTrailer } from './router'; import type { CodeDocument } from '../editor/codeModel'; -import type { PreviewProps } from '../types'; +import type { CodeControl, PreviewProps } from '../types'; import type { LogOptions } from './store/actionLog'; import SchemaRegistry from './store/schemaRegistry'; -import { CodeHeaderToggle, StoreHeaderToggle } from './StoreToggle'; +import { StoreHeaderToggle } from './StoreToggle'; import { useCodeManagers } from './useCodeManagers'; import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion'; import { usePreviewReset } from './usePreviewReset'; @@ -27,10 +27,8 @@ export interface LivePreviewProps extends PreviewProps { autoRun?: AutoRunScript; /** Opens or closes the Store for the demo, without saving it */ showStore: (open: boolean) => void; - /** Demo layout: whether the code shows in the result's place, and the - * header's Code toggle (see ../index.tsx) */ - codeShown?: boolean; - toggleCode?: () => void; + /** Demo layout: the code in the result's place, and its toggle (../index.tsx) */ + code?: CodeControl; } export default function LivePreview({ @@ -46,8 +44,7 @@ export default function LivePreview({ actionLog, autoRun, showStore, - codeShown = false, - toggleCode, + code: codeControl, }: LivePreviewProps) { usePlaygroundConsoleDemotion(); const { onCommit, badge } = useRenderCount(renderCount); @@ -97,14 +94,11 @@ export default function LivePreview({ : undefined } fixtures={fixtureContent} - codeShown={toggleCode ? codeShown : undefined} + code={codeControl} headerControls={ <> {badge} - {toggleCode ? - - : null} } diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index b59ef75e1a87..0bb64daa12b2 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -1,11 +1,12 @@ -import Translate, { translate } from '@docusaurus/Translate'; +import { translate } from '@docusaurus/Translate'; import clsx from 'clsx'; import React, { useId, useState } from 'react'; import Header from '../Header'; -import { liveLabel } from '../liveLabel'; +import { editorLabel, liveLabel } from '../liveLabel'; import NetworkIcon from '../NetworkIcon'; import styles from '../styles.module.css'; +import type { CodeControl } from '../types'; import { RouterControlsSlot } from './routerSlot'; export default function PreviewWrapper({ @@ -13,7 +14,7 @@ export default function PreviewWrapper({ headerControls, label, fixtures, - codeShown, + code, ref, }: Props) { const [routerSlot, setRouterSlot] = useState(null); @@ -31,25 +32,17 @@ export default function PreviewWrapper({ return (
- {codeShown === undefined ? - liveLabelNode - : - + {liveLabelNode} - - - Editor - - + {editorLabel()} - } + : liveLabelNode} {fixtures != null ? @@ -69,6 +62,7 @@ export default function PreviewWrapper({ : null} + {code?.toggle} {headerControls}
@@ -97,8 +91,9 @@ interface Props { /** Mock responses to inspect over the result, from a network icon in the * header: previewed on hover, pinned open by a click or tap */ fixtures?: React.ReactNode; - /** Demo layout: the header names the code ("Editor") in the label's place - * while it shows in the result's; `undefined` where the code never does */ - codeShown?: boolean; + /** Demo layout: the code's toggle joins the header's controls, and while + * the code shows in the result's place the header names it ("Editor") in + * the label's */ + code?: CodeControl; ref?: React.Ref; } diff --git a/website/src/components/Playground/preview/StoreToggle.tsx b/website/src/components/Playground/preview/StoreToggle.tsx index 805830a50214..46d80afc3bfc 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -85,23 +85,81 @@ export function StoreHeaderToggle({ }: { onClick?: React.MouseEventHandler; open?: boolean; +}) { + return ( + + + + ); +} + +/** The Code toggle in the preview's header (the demo layout, ../index.tsx): + * pressed, the code takes the result's place */ +export function CodeHeaderToggle({ + onClick, + open, +}: { + onClick: () => void; + open: boolean; +}) { + return ( + + + + ); +} + +/** An icon button in the preview's header that reads pressed while what it + * shows does; disabled without a handler (the loading fallback holds the + * layout with one) */ +function HeaderToggle({ + className, + title, + label, + open, + onClick, + children, + ...rest +}: { + className: string; + /** What pressing it does now */ + title: string; + /** Its name, the same either way */ + label: string; + open: boolean; + onClick?: React.MouseEventHandler; + children: React.ReactNode; + 'data-store-toggle'?: true; }) { return ( ); } @@ -143,37 +201,6 @@ const toggleTitle = (open: boolean) => translate({ id: 'playground.store.hide', message: 'Hide Store' }) : translate({ id: 'playground.store.show', message: 'Show Store' }); -/** The Code toggle in the preview's header (the demo layout, ../index.tsx): - * pressed, the code takes the result's place */ -export function CodeHeaderToggle({ - onClick, - open, -}: { - onClick: () => void; - open: boolean; -}) { - return ( - - ); -} - /** Angle brackets around a slash */ function CodeIcon({ className }: { className?: string }) { return ( diff --git a/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx b/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx index 8bfdbc27a9e2..fafcb69f86fd 100644 --- a/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx +++ b/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx @@ -42,10 +42,12 @@ describe('PreviewWrapper fixtures', () => { describe('PreviewWrapper label', () => { test("names the code in the label's place while it shows", () => { const { rerender } = render( - + Code }}>
result
, ); + // the toggle joins the header's controls + expect(screen.getByRole('button', { name: 'Code' })).toBeTruthy(); const live = screen.getByText('Live Preview'); const editor = screen.getByText('Editor'); expect(live.closest('[aria-hidden]')!.getAttribute('aria-hidden')).toBe( @@ -54,7 +56,7 @@ describe('PreviewWrapper label', () => { expect(editor.getAttribute('aria-hidden')).toBe('true'); rerender( - + Code }}>
result
, ); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 7c97217aa7c5..a25669ff080b 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -149,7 +149,7 @@ div.playgroundTextEdit > .playgroundHeader, .endpointPlayground .playgroundHeader { box-shadow: inset 0 -1px 0 var(--pg-bar-edge); } -.playgroundContainer:not(.row) +.playgroundContainer:not(.row, .demo) > div.playgroundTextEdit > .playgroundHeader:first-child { border-top-right-radius: var(--ifm-global-radius); @@ -176,7 +176,7 @@ div.playgroundTextEdit > .playgroundHeader, .playgroundEditor :global(.prism-code) { font: 13px / 19px var(--ifm-font-family-monospace) !important; } -.playgroundContainer:not(.row) { +.playgroundContainer:not(.row, .demo) { background-color: var(--monoco-code-background); } .closed { @@ -640,21 +640,10 @@ div.playgroundTextEdit > .playgroundHeader, } :root[data-theme='dark'] .playgroundPreview { - /* The demo's content is quieter than the frame: a hint of the code's hue */ + /* The demo's content is quieter than the frame: a hint of the code's hue + (the design system's dark surfaces are steps from it) */ --pg-preview-bg: hsl(229, 7%, 19%); background-color: var(--pg-preview-bg); - /* Controls read these for fills and borders: wells a step below the body */ - --ifm-background-surface-color: hsl(229, 7%, 15%); - --ifm-color-emphasis-100: var(--ifm-background-surface-color); - /* 3:1 against both the well and the body, so field edges stay legible */ - --ifm-color-emphasis-400: hsl(229, 5%, 48%); -} -/* The floating label straddles the field's top edge, so it blends into both */ -:root[data-theme='dark'] .playgroundPreview :global(.rt-TextFieldLabel) { - background: linear-gradient( - var(--pg-preview-bg) 50%, - var(--ifm-color-emphasis-100) 50% - ); } .playgroundPreview { overflow: auto; @@ -1056,7 +1045,6 @@ div.fixtureJson + div.fixtureJson { /* the fixtures hang from the header over whichever panel shows: positioned by the container, so the preview's clip never cuts them */ position: relative; - background-color: var(--ifm-background-color); } .demo > .previewWrapper { position: static; @@ -1073,19 +1061,13 @@ div.fixtureJson + div.fixtureJson { .demo[data-code] .playgroundResult { display: none; } -.demo > .playgroundTextEdit { +.demo:not([data-code]) > .playgroundTextEdit { display: none; +} +.demo > .playgroundTextEdit { min-height: var(--pg-demo-min); background-color: var(--monoco-code-background); } -.demo[data-code] > .playgroundTextEdit { - display: block; -} -/* under the preview's header, the file tabs are no longer the top corner */ -.playgroundContainer.demo > .playgroundTextEdit > .playgroundHeader { - border-top-left-radius: 0; - border-top-right-radius: 0; -} @container playground (min-width: 720px) { .demo .playgroundPreview { --pg-preview-pad: 2rem; diff --git a/website/src/components/Playground/types.ts b/website/src/components/Playground/types.ts index 5bc60f6ad4c0..2d4789404dea 100644 --- a/website/src/components/Playground/types.ts +++ b/website/src/components/Playground/types.ts @@ -1,8 +1,16 @@ import type { Fixture, Interceptor } from '@data-client/test'; -import type { MouseEventHandler } from 'react'; +import type { MouseEventHandler, ReactNode } from 'react'; export type FixtureOrInterceptor = Fixture | Interceptor; +/** The demo layout's code, as the preview's header shows it (../index.tsx) */ +export interface CodeControl { + /** The code shows in the result's place */ + shown: boolean; + /** The header's Code toggle */ + toggle: ReactNode; +} + export interface PreviewProps { storeOpen: boolean; toggleStore: MouseEventHandler; diff --git a/website/translate/lock/es.json b/website/translate/lock/es.json index b733b6de4f8c..0244c26adfce 100644 --- a/website/translate/lock/es.json +++ b/website/translate/lock/es.json @@ -400,7 +400,9 @@ "optimisticRace.optimistic": "optimistic", "frameworkSelector.selectProtocol": "Select protocol", "playground.fixtures.error": "error", - "playground.fixtures.delay": "delay" + "playground.fixtures.delay": "delay", + "playground.code.hide": "Hide code", + "playground.code.show": "Show code" }, "docusaurus-plugin-content-blog/options.json": { "title": "Blog", diff --git a/website/translate/lock/pt-BR.json b/website/translate/lock/pt-BR.json index 4467fdbc44c4..0fae991f454c 100644 --- a/website/translate/lock/pt-BR.json +++ b/website/translate/lock/pt-BR.json @@ -400,7 +400,9 @@ "optimisticRace.optimistic": "optimistic", "frameworkSelector.selectProtocol": "Select protocol", "playground.fixtures.error": "error", - "playground.fixtures.delay": "delay" + "playground.fixtures.delay": "delay", + "playground.code.hide": "Hide code", + "playground.code.show": "Show code" }, "docusaurus-plugin-content-blog/options.json": { "sidebar.title": "All posts", diff --git a/website/translate/lock/zh-Hans.json b/website/translate/lock/zh-Hans.json index 93a53b397a0b..3816dd7e6278 100644 --- a/website/translate/lock/zh-Hans.json +++ b/website/translate/lock/zh-Hans.json @@ -400,7 +400,9 @@ "optimisticRace.optimistic": "optimistic", "frameworkSelector.selectProtocol": "Select protocol", "playground.fixtures.error": "error", - "playground.fixtures.delay": "delay" + "playground.fixtures.delay": "delay", + "playground.code.hide": "Hide code", + "playground.code.show": "Show code" }, "docusaurus-plugin-content-blog/options.json": { "title": "Blog", From a16b2c052c16bd6cfcc4b242e7504f33cc04e227 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:22:45 +0000 Subject: [PATCH 03/66] Playground demo layout: keep the Store toggle over the code On a wide demo the Store's side strip hides with the preview while the code shows, so the header's Store toggle stands in for it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/DesignSystem/Avatar.tsx | 2 +- website/src/components/Playground/styles.module.css | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/website/src/components/Playground/DesignSystem/Avatar.tsx b/website/src/components/Playground/DesignSystem/Avatar.tsx index 2cbb4255ff49..32c5421c7ad8 100644 --- a/website/src/components/Playground/DesignSystem/Avatar.tsx +++ b/website/src/components/Playground/DesignSystem/Avatar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -/** A round profile picture; 32px, or `size` (24 beside a comment's name) */ +/** A round profile picture; 32px, or `size` to match the text beside it (smaller in a byline or a comment) */ export function Avatar({ src, size = 32 }: { src: string; size?: number }) { return ( Date: Mon, 19 Oct 2026 19:21:32 +0000 Subject: [PATCH 04/66] Playground demos: the Control Room console look Demo apps and the preview frame follow the site redesign's "live store console": flat panels ruled by 1px lines, its light and dark palette, small mono uppercase section labels, one blue for links, primary buttons and focus, and a 44px mono preview header with a green live dot. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 269 +++++++++++------- website/src/components/Playground/README.md | 22 +- .../components/Playground/styles.module.css | 86 ++++-- 3 files changed, 242 insertions(+), 135 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 7f7a52635376..1a723480c124 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -6,39 +6,68 @@ Tokens (`--ds-*`) on the preview, in both themes; element styles for headings, text, links and plain controls; and classes for the pieces most demos need (an app header, cards, lists with dividers, bylines). Add a - component (./index.ts) only for markup every demo would repeat. */ + component (./index.ts) only for markup every demo would repeat. + + The look is the site's "control room" console: flat panels ruled with 1px + lines, muted secondary text, small mono uppercase labels over groups of + rows, and one blue for links, primary actions and focus. Cards are the + console's view cards (a 1px line on the panel, radius 10, padding 12); + wells (a board's columns, a field's inset) step to the second panel color. */ .playground-preview { /* type: one step down from the docs, like an app beside its prose */ --ds-text-sm: 0.8125rem; --ds-text-base: 0.9375rem; --ds-text-lg: 1.125rem; --ds-text-xl: 1.375rem; + /* a group's label (ENTITIES, VIEWS): small mono caps */ + --ds-text-label: 11px; --ds-space-1: 0.25rem; --ds-space-2: 0.5rem; --ds-space-3: 0.75rem; --ds-space-4: 1rem; --ds-space-5: 1.5rem; --ds-radius: 10px; - --ds-radius-sm: 6px; + --ds-radius-sm: 8px; + --ds-radius-xs: 4px; /* a button's or a field's inset */ --ds-control-pad-y: 0.375rem; --ds-control-pad-x: 0.875rem; - --ds-text: var(--ifm-font-color-base); - --ds-text-muted: var(--ifm-color-emphasis-700); - /* a card over the preview's ground */ - --ds-surface: var(--ifm-background-color); - --ds-border: var(--ifm-color-emphasis-300); - /* 3:1 on both the surface and the ground, so field edges stay legible */ - --ds-border-strong: var(--ifm-color-emphasis-400); - --ds-accent: var(--ifm-link-color); - --ds-accent-strong: var(--ifm-color-primary-darker); + /* The palette is the frame's (--pg-*, ../styles.module.css, switching with + the theme there): the panel is the preview's ground, which cards share, + ruled off by their border; a well a step below it holds a board's + column or a field's inset */ + --ds-text: var(--pg-text); + --ds-text-muted: var(--pg-muted); + --ds-surface: var(--pg-panel); + --ds-surface-2: var(--pg-panel-2); + --ds-border: var(--pg-line); + /* labels and hints: the quietest text that still reads (4.5:1 on the panel) */ + --ds-faint: #66748a; + /* 3:1 on the panel, so field and button edges stay legible */ + --ds-border-strong: #78889d; + --ds-accent: #1b6fb8; + --ds-accent-strong: #155b99; + /* the accent's wash: a highlighted row, a changed value, a focus ring */ + --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 10%, transparent); --ds-on-accent: #fff; - --ds-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - --ifm-color-formfield-active: var(--ifm-link-color); + /* Infima's own vars, re-pointed so what reads them (the rt-* fields, links, + the error panel's text) follows this palette in both themes */ + --ifm-color-emphasis-0: var(--ds-surface); + --ifm-color-emphasis-100: var(--ds-surface-2); + --ifm-color-emphasis-400: var(--ds-border-strong); + --ifm-color-emphasis-500: var(--ds-border-strong); + --ifm-color-emphasis-700: var(--ds-text-muted); + --ifm-color-content: var(--ds-text); + --ifm-font-color-base: var(--ds-text); + /* links keep the accent under the pointer */ + --ifm-link-color: var(--ds-accent); + --ifm-link-hover-color: var(--ds-accent); + --ifm-color-formfield-active: var(--ds-accent); /* Infima's headings read these (h1 is set below: theme-classic sizes a first-child h1 as a page title) */ --ifm-h2-font-size: var(--ds-text-xl); --ifm-h3-font-size: var(--ds-text-lg); + --ifm-h4-font-size: var(--ds-text-base); --ifm-heading-font-weight: 600; --ifm-heading-margin-bottom: var(--ds-space-1); --ifm-paragraph-margin-bottom: var(--ds-space-3); @@ -46,32 +75,21 @@ line-height: 1.5; color: var(--ds-text); } -/* Dark: steps from the preview's slate ground (--pg-preview-bg, set with the - frame in ../styles.module.css): a card a step above it, control wells a - step below */ +/* Dark: the same console at night (the panel, well, line and text follow + --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { - --ds-text: #e3e8f2; - --ds-text-muted: hsl(229, 12%, 72%); - --ds-surface: color-mix(in srgb, var(--pg-preview-bg) 94%, white); - --ds-border: rgba(255, 255, 255, 0.1); - --ds-accent-strong: var(--ifm-color-primary-lighter); - --ds-shadow: none; - /* the fields (rt-*) read Infima's: wells, and a 3:1 edge on both the well - and the ground */ - --ifm-background-surface-color: color-mix( - in srgb, - var(--pg-preview-bg) 85%, - black - ); - --ifm-color-emphasis-100: var(--ifm-background-surface-color); - --ifm-color-emphasis-400: hsl(229, 5%, 48%); + --ds-faint: #6e7d94; + --ds-border-strong: #58697f; + --ds-accent: #53aef0; + --ds-accent-strong: #7cc1f4; + --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); + --ds-on-accent: #04121f; + /* fields sit in the well, a step above the panel */ + --ifm-color-emphasis-0: var(--ds-surface-2); } /* The floating label straddles the field's top edge, so it blends into both */ html[data-theme='dark'] .playground-preview .rt-TextFieldLabel { - background: linear-gradient( - var(--pg-preview-bg) 50%, - var(--ifm-color-emphasis-100) 50% - ); + background: linear-gradient(var(--ds-surface) 50%, var(--ds-surface-2) 50%); } /* Text */ @@ -101,10 +119,30 @@ div.playground-preview h1:first-child { .playground-preview section:last-child { margin-bottom: 0; } +/* A group's label over its rows (a section's "Comments", a board column's + name): the console's small mono caps, not a heading. A row's own title + (a post's h3 in a list) stays a heading */ +.playground-preview section > h3, +.playground-preview .boardColumn > h4 { + margin: 0 0 var(--ds-space-2); + font: 500 var(--ds-text-label) / 1.4 var(--ifm-font-family-monospace); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ds-faint); +} +/* A value that just changed: washed in the accent and ringed by it */ +.playground-preview mark { + padding: 1px 4px; + margin: -1px -4px; + border-radius: var(--ds-radius-xs); + background: var(--ds-accent-soft); + box-shadow: 0 0 0 1px var(--ds-accent); + color: inherit; +} -/* Links keep Infima's (--ds-accent is its link color); in a heading they - keep its color, with a faint underline that fills on hover, so a title - that opens a page still shows it */ +/* Links take the accent (Infima's `a` reads --ifm-link-color above); in a + heading they keep its color, with a faint underline that fills on hover, + so a title that opens a page still shows it */ .playground-preview :is(h1, h2, h3, h4) a { color: inherit; text-decoration: underline; @@ -115,6 +153,11 @@ div.playground-preview h1:first-child { .playground-preview :is(h1, h2, h3, h4) a:focus-visible { text-decoration-color: currentColor; } +.playground-preview a:focus-visible { + outline: 2px solid var(--ds-accent); + outline-offset: 2px; + border-radius: var(--ds-radius-xs); +} /* The app's header: its title at the left, its navigation or actions at the right, ruled off from the page */ @@ -141,24 +184,25 @@ div.playground-preview h1:first-child { margin: 0; } -/* A card: a surface lifted off the ground. Holding list items, it has no - padding of its own and the items take the room, so their dividers reach - its edges. Infima's .card is a flex column with a shadow; this replaces it */ +/* A card: a view card, ruled off the panel by a 1px line. Holding list + items, it has no padding of its own and the items take the room, so their + dividers reach its edges. Infima's .card is a flex column with a shadow; + this replaces it */ .playground-preview .card { display: block; overflow: visible; margin-bottom: var(--ds-space-4); - padding: var(--ds-space-4); + padding: var(--ds-space-3); background: var(--ds-surface); border: 1px solid var(--ds-border); border-radius: var(--ds-radius); - box-shadow: var(--ds-shadow); + box-shadow: none; } .playground-preview .card:has(> .listItem) { padding: 0; } .playground-preview .card > .listItem { - padding: var(--ds-space-3) var(--ds-space-4); + padding: 10px var(--ds-space-3); } .playground-preview .card > :last-child { margin-bottom: 0; @@ -208,7 +252,7 @@ div.playground-preview h1:first-child { .playground-preview .byline b, .playground-preview .byline strong { color: var(--ds-text); - font-weight: 500; + font-weight: 600; } .playground-preview .author { flex: none; @@ -247,27 +291,46 @@ div.playground-preview h1:first-child { border-color var(--motion-snappy), color var(--motion-snappy); } -.playground-preview button:where(:not([class])):hover { +/* hover washes it in the accent; a press (touch too) deepens the wash */ +.playground-preview button:where(:not([class])):is(:hover, :active) { border-color: var(--ds-accent); + background: var(--ds-accent-soft); color: var(--ds-accent); } +.playground-preview button:where(:not([class])):active { + background: color-mix(in srgb, var(--ds-accent) 18%, var(--ds-surface)); +} .playground-preview button:where(:not([class]), .primary):focus-visible { outline: 2px solid var(--ds-accent); outline-offset: 2px; } +.playground-preview button:where(:not([class]), .primary):disabled { + cursor: default; + opacity: 0.5; +} /* the one action a page is for */ .playground-preview button.primary { background: var(--ds-accent); border-color: var(--ds-accent); color: var(--ds-on-accent); } -.playground-preview button.primary:hover { +.playground-preview button.primary:hover, +.playground-preview button.primary:active { background: var(--ds-accent-strong); border-color: var(--ds-accent-strong); } .playground-preview input[type='text']:where(:not([class])), .playground-preview textarea:where(:not([class])) { - padding: 0.3rem 0.5rem; + padding: 0.3rem 0.625rem; + transition: + border-color var(--motion-snappy), + box-shadow var(--motion-snappy); +} +.playground-preview input[type='text']:where(:not([class])):focus-visible, +.playground-preview textarea:where(:not([class])):focus-visible { + outline: none; + border-color: var(--ds-accent); + box-shadow: 0 0 0 3px var(--ds-accent-soft); } /* A demo's links between its pages (Link), the one shown marked as current */ .playground-preview nav:where(:not([class])) { @@ -308,12 +371,12 @@ div.playground-preview h1:first-child { linear-gradient(var(--ds-surface), var(--ds-surface)) padding-box, conic-gradient( from var(--pg-trace-angle), - var(--ifm-color-emphasis-200) 0 45%, + var(--ds-border) 0 45%, var(--ds-accent) 75%, - var(--ifm-color-emphasis-200) 76% + var(--ds-border) 76% ) border-box; - box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 15%, transparent); + box-shadow: 0 0 0 3px var(--ds-accent-soft); color: var(--ds-accent); animation: ds-trace 1.2s linear infinite; } @@ -333,6 +396,10 @@ div.playground-preview h1:first-child { vertical-align: -2px; accent-color: var(--ds-accent); } +.playground-preview input[type='checkbox']:focus-visible { + outline: 2px solid var(--ds-accent); + outline-offset: 2px; +} .playground-preview form input[type='text'], .playground-preview form textarea { width: 100%; @@ -365,24 +432,27 @@ div.playground-preview h1:first-child { center; display: inline-block; } -/* A board's columns (RestEndpoint's move demo): wells holding cards */ +/* A board's columns (RestEndpoint's move demo): wells holding cards, each a + row like the console's (radius 8, 9px 10px) */ .playground-preview .boardColumn { - background: var(--ifm-color-emphasis-100); + background: var(--ds-surface-2); border-radius: var(--ds-radius); - padding: 0.4rem; + padding: var(--ds-space-2); margin-bottom: 0.5rem; } -.playground-preview .boardColumn h4 { - margin: 0 0 0.3rem; - padding: 0 0.25rem; +.playground-preview .boardColumn > h4 { + padding: 0 var(--ds-space-1); } .playground-preview .boardColumn .listItem { align-items: center; background: var(--ds-surface); - border: 0; + border: 1px solid var(--ds-border); border-radius: var(--ds-radius-sm); - padding: 0.25rem 0.5rem; - margin-bottom: 0.3rem; + padding: 9px 10px; + margin-bottom: 6px; +} +.playground-preview .boardColumn .listItem:last-child { + margin-bottom: 0; } .playground-preview .boardColumn button { margin: 0; @@ -390,11 +460,11 @@ div.playground-preview h1:first-child { line-height: 1; background: none; border: none; - cursor: pointer; - opacity: 0.5; + border-radius: var(--ds-radius-xs); + color: var(--ds-text-muted); } -.playground-preview .boardColumn button:hover { - opacity: 1; +.playground-preview .boardColumn button:is(:hover, :active) { + color: var(--ds-accent); } /* Root container for the text field */ .rt-TextFieldRoot { @@ -404,14 +474,13 @@ div.playground-preview h1:first-child { padding: 0.5em; background-color: var(--ifm-color-emphasis-0); border: 1px solid var(--ifm-color-emphasis-400); - border-radius: 4px; + border-radius: var(--ds-radius-sm); position: relative; - margin-bottom:1ex; - transition: opacity var(--motion-snappy); -} -html[data-theme='dark'] .rt-TextFieldRoot, -html[data-theme='dark'] .rt-TextAreaRoot { - background-color: var(--ifm-color-emphasis-100); + margin-bottom: 1ex; + transition: + opacity var(--motion-snappy), + border-color var(--motion-snappy), + box-shadow var(--motion-snappy); } /* The slot element (left side) */ @@ -461,7 +530,7 @@ html[data-theme='dark'] .rt-TextAreaRoot { left: 0.75em; background-color: var(--ifm-color-emphasis-0); padding: 0 0.25em; - font-size: 0.70rem; + font-size: 0.7rem; color: var(--ifm-color-emphasis-700); pointer-events: none; } @@ -473,9 +542,9 @@ html[data-theme='dark'] .rt-TextAreaRoot { .rt-TextFieldRoot:focus-within { border-color: var(--ifm-color-formfield-active); + box-shadow: 0 0 0 3px var(--ds-accent-soft); } - .large.rt-TextFieldRoot { font-size: 16px; } @@ -490,38 +559,30 @@ html[data-theme='dark'] .rt-TextAreaRoot { } .small.rt-TextFieldRoot { display: inline-flex; - border: 1px solid transparent; /* Transparent borders on all sides */ - border-bottom-color: var(--ifm-color-emphasis-500); /* Visible bottom border */ + border: 1px solid transparent; /* Transparent borders on all sides */ + border-bottom-color: var( + --ifm-color-emphasis-500 + ); /* Visible bottom border */ background: none; outline: none; - padding: 5px; /* Consistent padding */ - height: 30px; /* Fixed height to prevent shifting */ - box-sizing: border-box; /* Include padding and border in element's size */ - transition: all 0.2s ease-in-out; /* Quick animation for transitions */ + padding: 5px; /* Consistent padding */ + height: 30px; /* Fixed height to prevent shifting */ + box-sizing: border-box; /* Include padding and border in element's size */ + transition: all 0.2s ease-in-out; /* Quick animation for transitions */ border-radius: 0; - margin-bottom:0; - margin-top:-4px; -} -html[data-theme='dark'] .small.rt-TextFieldRoot { - background: none; + margin-bottom: 0; + margin-top: -4px; } .small.rt-TextFieldRoot:hover, .small.rt-TextFieldRoot:focus-within { background-color: var(--ifm-color-emphasis-0); - border-color: var(--ifm-color-emphasis-400); /* Make all borders visible */ - border-radius: 4px; + border-color: var(--ifm-color-emphasis-400); /* Make all borders visible */ + border-radius: 6px; } .small.rt-TextFieldRoot:focus-within { - border-color: var(--ifm-color-formfield-active); /* Make all borders visible */ -} -/* Dark: a faint lift over the panel instead of a near-black well, and the - same blue as the checked checkboxes for focus */ -html[data-theme='dark'] .small.rt-TextFieldRoot:hover, -html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { - background-color: rgba(255, 255, 255, 0.06); -} -html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { - border-color: var(--ifm-color-primary); + border-color: var( + --ifm-color-formfield-active + ); /* Make all borders visible */ } /* Typed text matches the todo titles beside it */ .small .rt-TextFieldInput { @@ -543,9 +604,6 @@ html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { min-width: 0; } - - - /* Disabled fields (on their own or in a disabled fieldset) fade back */ .rt-TextFieldRoot:has(> .rt-TextFieldInput:disabled), .rt-TextAreaRoot:has(> .rt-TextAreaInput:disabled) { @@ -558,11 +616,13 @@ html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { width: 100%; background-color: var(--ifm-color-emphasis-0); border: 1px solid var(--ifm-color-emphasis-400); - border-radius: 4px; - padding-bottom:-5px; + border-radius: var(--ds-radius-sm); /* overflow: hidden; */ - margin: 1.5ex 0 .5ex; - transition: opacity var(--motion-snappy); + margin: 1.5ex 0 0.5ex; + transition: + opacity var(--motion-snappy), + border-color var(--motion-snappy), + box-shadow var(--motion-snappy); } /* The textarea element inside the component */ @@ -571,6 +631,7 @@ html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { width: 100%; border: none; outline: none; + font: inherit; font-size: 14px; color: var(--ifm-color-content); background: transparent; @@ -580,7 +641,6 @@ html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { margin-bottom: -4px; } - /* Focus state for the textarea */ .rt-TextAreaInput:focus + .rt-TextFieldLabel { color: var(--ifm-color-formfield-active); @@ -588,4 +648,5 @@ html[data-theme='dark'] .small.rt-TextFieldRoot:focus-within { .rt-TextAreaRoot:focus-within { border-color: var(--ifm-color-formfield-active); + box-shadow: 0 0 0 3px var(--ds-accent-soft); } diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index d304a4e93142..2d17194a1eca 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -178,14 +178,20 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - Design system (`DesignSystem/design-system.css`, loaded with the preview): what a demo renders is styled under `.playground-preview` so it reads as a real app, in both themes, without the demo importing anything. Tokens - (`--ds-text`, `--ds-text-muted`, `--ds-surface`, `--ds-border`, - `--ds-border-strong`, `--ds-accent`, `--ds-radius`, `--ds-space-*`, - `--ds-text-*`) and the preview's type (15px base, Infima's heading - variables); element styles for headings, `p`, `small`, links (in a heading - they keep its color with a faint underline), plain `button`s (`primary` - fills one with the accent), text inputs, `nav`, `fieldset`; and classes: - `appHeader` + `appTitle` (the app's bar), `card` (a lifted surface; holding - `listItem`s it loses its padding so their dividers reach its edges), + (`--ds-text`, `--ds-text-muted`, `--ds-faint`, `--ds-surface`, + `--ds-surface-2` (a well a step below the panel), `--ds-border`, + `--ds-border-strong`, `--ds-accent`, `--ds-accent-soft` (its wash), + `--ds-radius`, `--ds-radius-sm`, `--ds-radius-xs`, `--ds-space-*`, + `--ds-text-*` including `--ds-text-label` for small mono caps over a + group of rows; the palette reads the frame's `--pg-*` in + `../styles.module.css`, which switch with the theme) and the preview's + type (15px base, Infima's heading variables); element styles for + headings, `p`, `small`, links (in a heading they keep its color with a + faint underline), plain `button`s (`primary` fills one with the accent), + text inputs, `nav`, `fieldset`; and classes: `appHeader` + `appTitle` + (the app's bar), `card` (a flat surface ruled off the panel by a 1px line; + holding `listItem`s it loses its padding so their dividers reach its + edges), `listItem` (a row with a rule between siblings; `nogap`, `spaced`), `meta` (secondary text), `byline` (avatar beside a name; `Avatar` takes a `size`), `author` (name under avatar), plus the older `voteBlock`/`vote` diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index e18db333f8b6..b616e8968bbc 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -31,8 +31,22 @@ --store-record: rgba(130, 170, 255, 0.07); --store-tip: #3a4057; --store-bg: var(--monoco-code-background); + /* The preview's console palette (DesignSystem/design-system.css reads it + into --ds-*): the panel and the well below it, the 1px line that rules + them, its text, and the live dot */ + --pg-panel: #fff; + --pg-panel-2: #eef2f7; + --pg-line: #d8e0ea; + --pg-text: #0b1220; + --pg-muted: #4c5a6e; + --pg-live: #0d8257; + --pg-live-soft: color-mix(in srgb, var(--pg-live) 14%, transparent); + --pg-shadow: rgba(15, 30, 60, 0.28); border-radius: var(--ifm-global-radius); - box-shadow: var(--ifm-global-shadow-lw); + /* the line around the frame, and a soft drop under it */ + box-shadow: + 0 0 0 1px var(--pg-line), + 0 24px 60px -30px var(--pg-shadow); overflow: visible; display: flex; align-items: stretch; @@ -134,6 +148,16 @@ --pg-bar: hsl(229, 9%, 21%); --pg-tab-hover-bg: hsl(229, 9%, 26%); } +:root[data-theme='dark'] .playgroundContainer { + --pg-panel: #0b1017; + --pg-panel-2: #111824; + --pg-line: #1d2836; + --pg-text: #e8eef6; + --pg-muted: #94a2b6; + --pg-live: #3ddc97; + --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); + --pg-shadow: rgba(0, 0, 0, 0.8); +} /* EndpointPlayground's right column is code, so it always uses the dark bar */ .endpointPlayground { --pg-bar: hsl(229, 8%, 26%); @@ -214,7 +238,6 @@ div.playgroundTextEdit > .playgroundHeader, /* the frame's inset; .navProgress reads it to reach the frame's edges */ --pg-preview-pad: 1rem; padding: var(--pg-preview-pad); - background-color: var(--ifm-pre-background); flex: 1 4 30%; overflow: auto; } @@ -639,13 +662,10 @@ div.playgroundTextEdit > .playgroundHeader, --pg-tab-selected: var(--pg-tab-hover); } -:root[data-theme='dark'] .playgroundPreview { - /* The demo's content is quieter than the frame: a hint of the code's hue - (the design system's dark surfaces are steps from it) */ - --pg-preview-bg: hsl(229, 7%, 19%); - background-color: var(--pg-preview-bg); -} +/* The preview's ground is the console's panel (the design system's cards + and fields take their surface from it too, in both themes) */ .playgroundPreview { + background-color: var(--pg-panel); overflow: auto; } /* The router's navigation indicator (preview/router.tsx): a 3px bar along @@ -1111,22 +1131,40 @@ div.fixtureJson + div.fixtureJson { left: 0.75rem; } -/* Live Preview label: a drawn dot renders the same on every OS, unlike 🔴 */ +/* Live Preview label: the console's status dot (drawn, so it renders the + same on every OS) before the label in the panel's text; the rest of the + header is muted */ .liveLabel { display: inline-flex; align-items: center; - gap: 0.6em; + gap: 10px; + color: var(--pg-text); } .liveLabel::before { content: ''; - width: 0.55em; - height: 0.55em; + width: 8px; + height: 8px; border-radius: 50%; flex: none; /* optical nudge: the ring's outer edge lines up with the text inset */ margin-inline-start: 3px; - background: #ff5f56; - box-shadow: 0 0 0 3px rgba(255, 95, 86, 0.2); + background: var(--pg-live); + box-shadow: 0 0 0 3px var(--pg-live-soft); +} +/* The preview's header is the console's bar: 44px on the panel, ruled off + below, in small mono. Outranks .playgroundHeader's caps and bar color; + the editor's own headers keep theirs. */ +.playgroundHeader.previewHeader { + --pg-bar: var(--pg-panel); + --pg-tab: var(--pg-muted); + --pg-tab-hover-bg: var(--pg-panel-2); + --pg-tab-hover: var(--pg-text); + min-height: 44px; + padding: 0 0.75rem; + border-bottom: 1px solid var(--pg-line); + font: 500 12px / 1 var(--ifm-font-family-monospace); + letter-spacing: 0.01em; + text-transform: none; } /* With a demo's navigation in the header, a tight header keeps only the label's dot (still named for screen readers), so the path gets the room */ @@ -1149,7 +1187,6 @@ div.fixtureJson + div.fixtureJson { font: inherit; letter-spacing: inherit; text-transform: inherit; - color: inherit; cursor: pointer; } .demoLabel:focus-visible { @@ -1157,11 +1194,15 @@ div.fixtureJson + div.fixtureJson { outline-offset: 2px; border-radius: var(--ifm-global-radius); } +/* the ring widens under the pointer, and on a press (touch too) */ @media (hover: hover) { .demoLabel:hover::before { - box-shadow: 0 0 0 5px rgba(255, 95, 86, 0.3); + box-shadow: 0 0 0 5px var(--pg-live-soft); } } +.demoLabel:active::before { + box-shadow: 0 0 0 5px var(--pg-live-soft); +} .demoLabel::before { transition: box-shadow var(--motion-snappy); } @@ -1170,7 +1211,7 @@ div.fixtureJson + div.fixtureJson { } @keyframes demoPulse { 50% { - box-shadow: 0 0 0 6px rgba(255, 95, 86, 0); + box-shadow: 0 0 0 6px transparent; scale: 0.8; } } @@ -1178,7 +1219,7 @@ div.fixtureJson + div.fixtureJson { /* still shows it's playing, without the motion */ .demoLabel[data-playing]::before { animation: none; - box-shadow: 0 0 0 5px rgba(255, 95, 86, 0.35); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--pg-live) 35%, transparent); } } @@ -1191,12 +1232,11 @@ div.fixtureJson + div.fixtureJson { .renderCount { font: inherit; letter-spacing: normal; - text-transform: none; font-weight: normal; - padding: 0 0.5em; - border: none; - border-radius: var(--ifm-global-radius); - background: var(--ifm-color-emphasis-300); + padding: 1px 0.5em; + border: 1px solid var(--pg-line); + border-radius: 5px; + background: var(--pg-panel-2); color: inherit; cursor: pointer; } From c968df1934ab75600aaa7f0e1ac31c6e0c73c682 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:23:56 +0000 Subject: [PATCH 05/66] Playground demos: faint labels reach 4.5:1 on the well too Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/DesignSystem/design-system.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 1a723480c124..ba365cba9746 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -41,8 +41,8 @@ --ds-surface: var(--pg-panel); --ds-surface-2: var(--pg-panel-2); --ds-border: var(--pg-line); - /* labels and hints: the quietest text that still reads (4.5:1 on the panel) */ - --ds-faint: #66748a; + /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ + --ds-faint: #5f6d83; /* 3:1 on the panel, so field and button edges stay legible */ --ds-border-strong: #78889d; --ds-accent: #1b6fb8; @@ -78,7 +78,7 @@ /* Dark: the same console at night (the panel, well, line and text follow --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { - --ds-faint: #6e7d94; + --ds-faint: #74839a; --ds-border-strong: #58697f; --ds-accent: #53aef0; --ds-accent-strong: #7cc1f4; From f9c63f3884f9015309594e4674273b4fc50b4038 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:46:45 +0000 Subject: [PATCH 06/66] website(playground): list, round checkbox rows, Toaster/toast and row-end meta in the demo design system Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C8xTb3f4ys9x7gfbwPacsx --- website/framework-docs/README.md | 3 +- website/framework-docs/checkExamples.mjs | 24 +++- .../Playground/DesignSystem/Toaster.tsx | 56 ++++++++ .../DesignSystem/__tests__/Toaster.test.tsx | 31 +++++ .../Playground/DesignSystem/design-system.css | 124 +++++++++++++++++- .../Playground/DesignSystem/index.ts | 1 + website/src/components/Playground/README.md | 16 ++- .../components/Playground/monaco/typeLibs.ts | 2 + 8 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 website/src/components/Playground/DesignSystem/Toaster.tsx create mode 100644 website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx diff --git a/website/framework-docs/README.md b/website/framework-docs/README.md index d4fb0f1bef2b..476af92c1060 100644 --- a/website/framework-docs/README.md +++ b/website/framework-docs/README.md @@ -98,7 +98,8 @@ blocks or to stubs typed `any`. React playground would provide as globals, `Temporal` from `temporal-polyfill`, and the app's own modules (`import { ArticleResource } from './resources/Article'`). - The playground's design system (`website/src/components/Playground/DesignSystem`: `Loading`, - `Avatar`, `TextInput`, ...) stands for the app's own components and needs no import, like + `Avatar`, `TextInput`, `Toaster`, ...) stands for the app's own components, and its `toast` for the + app's toast library; neither needs an import, like `render()`, which mounts a React playground's app, React's `Link` and `useNavigate()`, which stand for the app's router (see "Router" in `website/src/components/Playground/README.md`), and Vue's `RouterLink` and `RouterView`. diff --git a/website/framework-docs/checkExamples.mjs b/website/framework-docs/checkExamples.mjs index 02f3b2db4851..e93add286d37 100644 --- a/website/framework-docs/checkExamples.mjs +++ b/website/framework-docs/checkExamples.mjs @@ -33,15 +33,23 @@ const PLAYGROUND = path.join(ROOT, 'website/src/components/Playground'); const TYPE_CHECK_FLAGS = require(`${PLAYGROUND}/monaco/typeCheckFlags.json`); const EXT = { ts: '.ts', typescript: '.ts', tsx: '.tsx', html: '.vue' }; /** - * The playground's design system: stand-ins for an app's own components, so - * examples use them without importing them. NumberFlow is a real library, so - * examples import it. + * The playground's design system: stand-ins for an app's own components (and + * helpers like `toast`), so examples use them without importing them. + * NumberFlow is a real library, so examples import it. */ -const PLACEHOLDERS = [ +const DESIGN_SYSTEM = [ ...fs .readFileSync(path.join(PLAYGROUND, 'DesignSystem/index.ts'), 'utf8') - .matchAll(/^export \{ (\w+) \}/gm), -].map(([, name]) => name); + .matchAll(/^export \{ ([\w, ]+) \}/gm), +] + .flatMap(([, names]) => names.split(/,\s*/)) + // `default as NumberFlow` re-exports the real library + .filter(name => /^\w+$/.test(name)); +// by name: components are capitalized (`Toaster`), helpers aren't (`toast`) +const PLACEHOLDERS = DESIGN_SYSTEM.filter(name => /^[A-Z]/.test(name)); +const HELPER_DECLARATIONS = DESIGN_SYSTEM.filter(name => /^[a-z]/.test(name)) + .map(name => ` const ${name}: any;`) + .join('\n'); const titleOf = block => block.meta?.match(/title="([^"]+)"/)?.[1]; /** A single file component, titled or not */ @@ -66,6 +74,7 @@ declare global { function Link(props: AnchorHTMLAttributes & { to: string }): ReactNode; function useNavigate(): (to: string) => void; ${PLACEHOLDERS.map(name => ` const ${name}: FC;`).join('\n')} +${HELPER_DECLARATIONS} } `, }, @@ -84,6 +93,9 @@ declare module 'vue' { ${[...PLACEHOLDERS, 'RouterLink', 'RouterView'].map(name => ` ${name}: DefineComponent;`).join('\n')} } } +declare global { +${HELPER_DECLARATIONS} +} `, }, }; diff --git a/website/src/components/Playground/DesignSystem/Toaster.tsx b/website/src/components/Playground/DesignSystem/Toaster.tsx new file mode 100644 index 000000000000..cc0cbb7fa35f --- /dev/null +++ b/website/src/components/Playground/DesignSystem/Toaster.tsx @@ -0,0 +1,56 @@ +import { useSyncExternalStore } from 'react'; + +interface Toast { + id: number; + kind: 'success' | 'error'; + message: string; +} + +/** How long a toast stays on screen */ +const SHOW_MS = 4000; + +// Module state, like a toast library's: the preview's code re-runs on every +// edit, but this module doesn't, so code kept from an earlier run (a Manager) +// and the current run's share it. +let toasts: readonly Toast[] = []; +let nextId = 0; +const listeners = new Set<() => void>(); + +function setToasts(next: readonly Toast[]) { + toasts = next; + listeners.forEach(listener => listener()); +} + +function show(kind: Toast['kind'], message: string) { + const added = { id: nextId++, kind, message }; + setToasts([...toasts, added]); + setTimeout(() => setToasts(toasts.filter(t => t !== added)), SHOW_MS); +} + +/** The app's notifications, as a toast library (e.g. sonner) gives a real app */ +export const toast = { + success: (message: string) => show('success', message), + error: (message: string) => show('error', message), +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; +const getToasts = () => toasts; + +/** Shows what `toast` sends: render it once, where the app's toasts go */ +export function Toaster() { + const list = useSyncExternalStore(subscribe, getToasts, getToasts); + return ( +
+ {list.map(t => ( +
+ {t.message} +
+ ))} +
+ ); +} diff --git a/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx b/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx new file mode 100644 index 000000000000..48f4789417f5 --- /dev/null +++ b/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx @@ -0,0 +1,31 @@ +/// +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { toast, Toaster } from '../Toaster'; + +describe('Toaster', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('shows what toast sends, then removes it', () => { + render(); + act(() => { + toast.success('Saved'); + toast.error('Failed'); + }); + expect(screen.getByText('Saved').dataset.kind).toBe('success'); + expect(screen.getByText('Failed').dataset.kind).toBe('error'); + + act(() => jest.advanceTimersByTime(4000)); + expect(screen.queryByText('Saved')).toBeNull(); + expect(screen.queryByText('Failed')).toBeNull(); + }); + + it('shows toasts sent before it mounted', () => { + toast.success('Early'); + render(); + expect(screen.queryByText('Early')).not.toBeNull(); + act(() => jest.advanceTimersByTime(4000)); + }); +}); diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index ba365cba9746..e8772f915542 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -208,8 +208,42 @@ div.playground-preview h1:first-child { margin-bottom: 0; } -/* A list's rows: side by side (an avatar, a checkbox, then the text), a - rule between them */ +/* A list (a todo list, prices): rows on the panel with no box or rules + between them, their text in line with the app header's; each row is a + rounded band reaching past its text, and a checkbox row lights up under + the pointer and fades once checked */ +.playground-preview .list { + margin-bottom: var(--ds-space-4); +} +.playground-preview .list > .listItem { + align-items: center; + margin: 0 calc(-1 * var(--ds-space-3)); + padding: var(--ds-space-3); + border-radius: var(--ds-radius-sm); + transition: background-color var(--motion-snappy); +} +.playground-preview .list > .listItem + .listItem { + border-top: 0; +} +/* touch screens skip it: a tapped row would stay lit */ +@media (hover: hover) { + .playground-preview .list > .listItem:has(> label):hover { + background: var(--ds-surface-2); + } +} +.playground-preview + .list + > .listItem + > label:has(> input[type='checkbox']:checked) { + /* the faintest text that still reads (4.5:1); the box fades with it */ + color: var(--ds-faint); +} +.playground-preview .list > .listItem > label > input[type='checkbox']:checked { + opacity: 0.7; +} + +/* Rows: side by side (an avatar, a checkbox, then the text), a rule + between them (a `list` drops it) */ .playground-preview .listItem { display: flex; align-items: flex-start; @@ -466,6 +500,92 @@ div.playground-preview h1:first-child { .playground-preview .boardColumn button:is(:hover, :active) { color: var(--ds-accent); } +/* A checkbox row (a todo): the box sits in its label, so the whole row + toggles it; the box is a round one drawn in the accent (focus keeps the + checkbox ring above). The rules after this one need no :first-child: + they only show on the box it unstyles */ +.playground-preview + .listItem + > label:has(> input[type='checkbox']:first-child) { + display: flex; + align-items: center; + gap: 10px; + line-height: 1.4; + cursor: pointer; +} +.playground-preview .listItem > label > input[type='checkbox']:first-child { + appearance: none; + flex: none; + display: grid; + place-content: center; + width: 20px; + height: 20px; + margin: 0; + border: 1.5px solid var(--ds-border-strong); + border-radius: 50%; + background: var(--ds-surface); + transition: + background-color var(--motion-snappy), + border-color var(--motion-snappy); +} +.playground-preview .listItem > label > input[type='checkbox']::after { + content: ''; + width: 10px; + height: 10px; + background: var(--ds-on-accent); + clip-path: polygon(14% 44%, 0 59%, 39% 96%, 100% 22%, 85% 8%, 38% 66%); + transform: scale(0); + transition: transform var(--motion-snappy); +} +.playground-preview .listItem > label > input[type='checkbox']:checked { + background: var(--ds-accent); + border-color: var(--ds-accent); +} +.playground-preview .listItem > label > input[type='checkbox']:checked::after { + transform: scale(1); +} +/* A row's trailing detail (when it was fetched, a count) sits at its end */ +.playground-preview .listItem > .meta:last-child { + margin-left: auto; +} + +/* Toasts (, ./Toaster.tsx): stacked under the app, each a row + with an edge bar in its outcome's color: the console's live green for + success, red for an error */ +.playground-preview .toaster { + display: flex; + flex-direction: column; + gap: var(--ds-space-2); + margin-top: var(--ds-space-3); +} +.playground-preview .toaster:empty { + display: none; +} +.playground-preview .toast { + --toast-color: var(--pg-live); + padding: var(--ds-space-2) var(--ds-space-3); + border: 1px solid var(--ds-border); + border-radius: var(--ds-radius-sm); + /* the edge bar: inset so the radius clips it */ + box-shadow: inset 3px 0 0 var(--toast-color); + background: color-mix(in srgb, var(--toast-color) 8%, var(--ds-surface)); + font-size: var(--ds-text-sm); + animation: ds-toast-in 160ms ease-out; +} +.playground-preview .toast[data-kind='error'] { + --toast-color: var(--ifm-color-danger); +} +@keyframes ds-toast-in { + from { + opacity: 0; + transform: translateY(4px); + } +} +@media (prefers-reduced-motion: reduce) { + .playground-preview .toast { + animation: none; + } +} /* Root container for the text field */ .rt-TextFieldRoot { display: flex; diff --git a/website/src/components/Playground/DesignSystem/index.ts b/website/src/components/Playground/DesignSystem/index.ts index 2d0171a8af77..bdb792d1d222 100644 --- a/website/src/components/Playground/DesignSystem/index.ts +++ b/website/src/components/Playground/DesignSystem/index.ts @@ -8,4 +8,5 @@ export { TextInput } from './TextInput'; export { TextArea } from './TextArea'; export { SearchIcon } from './SearchIcon'; export { Loading } from './Loading'; +export { Toaster, toast } from './Toaster'; export { default as NumberFlow } from '@number-flow/react'; diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 2d17194a1eca..e2840be410fa 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -191,9 +191,14 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element text inputs, `nav`, `fieldset`; and classes: `appHeader` + `appTitle` (the app's bar), `card` (a flat surface ruled off the panel by a 1px line; holding `listItem`s it loses its padding so their dividers reach its - edges), - `listItem` (a row with a rule between siblings; `nogap`, `spaced`), - `meta` (secondary text), `byline` (avatar beside a name; `Avatar` takes a + edges), `list` (rows with no box or rules, in line with the header; + rounded bands, checkbox rows light up under the pointer and fade when + checked), + `listItem` (a row with a rule between siblings; `nogap`, `spaced`; a + leading checkbox in its `label` is a round box drawn in the accent), + `meta` (secondary text; last in a `listItem`, it sits at the row's end), + `toaster`/`toast` (rendered by the `Toaster` component, which shows what + the `toast` helper sends), `byline` (avatar beside a name; `Avatar` takes a `size`), `author` (name under avatar), plus the older `voteBlock`/`vote` and `boardColumn`. Prefer a class or element style to a new component; a new scope global goes in `preview/scope.ts`, `monaco/typeLibs.ts` and @@ -379,6 +384,11 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - Only editing `getManagers` or a document declaring a name it uses (e.g. its Manager class) changes the managers: the store remounts with its data, so other edits keep sockets open. Code that fails to run keeps the old ones. + - Kept managers still close over the values of the run that built them, while + the UI re-runs with fresh ones (remounting on every edit would close their + sockets), so a manager that reaches the UI does it + through something every run shares: a design system module (`toast`, + `DesignSystem/Toaster.tsx`) or the store. - A manager that throws shows a "Manager error" card; the preview runs on without that step. - Errors show in one `ErrorPanel` card (`Playground/ErrorPanel.tsx`) labeled by the stage that failed: amber diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts index 4bec6c5adf94..d7f279837081 100644 --- a/website/src/components/Playground/monaco/typeLibs.ts +++ b/website/src/components/Playground/monaco/typeLibs.ts @@ -196,6 +196,8 @@ const PREVIEW_SCOPE_DECLARATIONS = `declare function render(component:JSX.Elemen declare function Link(props: React.AnchorHTMLAttributes & { to: string }):JSX.Element; declare function useNavigate(): (to: string) => void; declare function Loading():JSX.Element; + declare function Toaster():JSX.Element; + declare const toast: { success(message: string): void; error(message: string): void }; declare function ErrorMessage(props: { error: Error & { status?: number }; resetErrorBoundary?: () => void; className?: string }):JSX.Element; declare function randomFloatInRange(min: number, max: number, decimals?: number): number; declare function mockFetch(getResponse: (...args: any[]) => T, name?: string, delay?: number): (...args: any[]) => Promise; From c1fc4efe3e12cf55cc8e0ec59712017c16c82c7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:34:09 +0000 Subject: [PATCH 07/66] Playground demos: the app as one flush sheet under a tinted header The demo frame drops its console rules: the preview is a soft tonal stage that shows only as the header's tint, and the app is one sheet flush with the frame's sides and foot, its top corners rounding into the tint. The design system loses its decorative lines too: rows are set apart by space, groups by a label and their inset, and cards no longer draw a box. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 102 +++++++------- website/src/components/Playground/README.md | 76 ++++++---- .../components/Playground/styles.module.css | 130 +++++++++++------- 3 files changed, 178 insertions(+), 130 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index e8772f915542..5a2f037cad39 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -5,14 +5,13 @@ Tokens (`--ds-*`) on the preview, in both themes; element styles for headings, text, links and plain controls; and classes for the pieces most - demos need (an app header, cards, lists with dividers, bylines). Add a - component (./index.ts) only for markup every demo would repeat. + demos need (an app header, cards, lists, bylines). Add a component + (./index.ts) only for markup every demo would repeat. - The look is the site's "control room" console: flat panels ruled with 1px - lines, muted secondary text, small mono uppercase labels over groups of - rows, and one blue for links, primary actions and focus. Cards are the - console's view cards (a 1px line on the panel, radius 10, padding 12); - wells (a board's columns, a field's inset) step to the second panel color. */ + The look is quiet: no decorative lines anywhere. Rows are set apart by + space, groups by a label in weight and by their inset, and secondary text + by a muted color; one blue serves links, primary actions and focus. Only + form controls keep an edge (it is what makes them controls). */ .playground-preview { /* type: one step down from the docs, like an app beside its prose */ --ds-text-sm: 0.8125rem; @@ -33,14 +32,17 @@ --ds-control-pad-y: 0.375rem; --ds-control-pad-x: 0.875rem; /* The palette is the frame's (--pg-*, ../styles.module.css, switching with - the theme there): the panel is the preview's ground, which cards share, - ruled off by their border; a well a step below it holds a board's - column or a field's inset */ + the theme there): the panel is the app's surface; a well a step off it + holds a board's column or a field's inset */ --ds-text: var(--pg-text); --ds-text-muted: var(--pg-muted); --ds-surface: var(--pg-panel); --ds-surface-2: var(--pg-panel-2); + /* kept for demos that draw their own rule; nothing here uses it */ --ds-border: var(--pg-line); + /* an interactive row's band under the pointer: reads on the panel and on + a well alike */ + --ds-hover: color-mix(in srgb, var(--ds-text) 6%, transparent); /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ --ds-faint: #5f6d83; /* 3:1 on the panel, so field and button edges stay legible */ @@ -78,8 +80,10 @@ /* Dark: the same console at night (the panel, well, line and text follow --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { - --ds-faint: #74839a; - --ds-border-strong: #58697f; + /* 4.6:1 on the sheet */ + --ds-faint: #77869d; + /* 3.4:1 on the sheet, 3.1:1 on a well */ + --ds-border-strong: #5f7189; --ds-accent: #53aef0; --ds-accent-strong: #7cc1f4; --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); @@ -120,15 +124,15 @@ div.playground-preview h1:first-child { margin-bottom: 0; } /* A group's label over its rows (a section's "Comments", a board column's - name): the console's small mono caps, not a heading. A row's own title - (a post's h3 in a list) stays a heading */ + name): small, muted and semibold, so weight alone marks the group. A + row's own title (a post's h3 in a list) stays a heading */ .playground-preview section > h3, .playground-preview .boardColumn > h4 { margin: 0 0 var(--ds-space-2); - font: 500 var(--ds-text-label) / 1.4 var(--ifm-font-family-monospace); - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--ds-faint); + font: 600 var(--ds-text-sm) / 1.4 var(--ifm-font-family-base); + letter-spacing: 0; + text-transform: none; + color: var(--ds-text-muted); } /* A value that just changed: washed in the accent and ringed by it */ .playground-preview mark { @@ -160,16 +164,14 @@ div.playground-preview h1:first-child { } /* The app's header: its title at the left, its navigation or actions at - the right, ruled off from the page */ + the right; the space under it, not a rule, sets the page off */ .playground-preview .appHeader { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--ds-space-2) var(--ds-space-4); - margin-bottom: var(--ds-space-4); - padding-bottom: var(--ds-space-3); - border-bottom: 1px solid var(--ds-border); + margin-bottom: var(--ds-space-5); } /* through .appHeader to outrank Infima's `.markdown h1:first-child` margin */ .playground-preview .appHeader .appTitle { @@ -184,25 +186,20 @@ div.playground-preview h1:first-child { margin: 0; } -/* A card: a view card, ruled off the panel by a 1px line. Holding list - items, it has no padding of its own and the items take the room, so their - dividers reach its edges. Infima's .card is a flex column with a shadow; - this replaces it */ +/* A card: a group of content (a post list, a comment thread) with no box of + its own: the app's surface (the floating sheet the frame gives it, see + ../styles.module.css) is the card, so its rows sit in line with the + header and the space around the group marks it. Infima's .card is a + bordered flex column with a shadow; this replaces it */ .playground-preview .card { display: block; overflow: visible; margin-bottom: var(--ds-space-4); - padding: var(--ds-space-3); - background: var(--ds-surface); - border: 1px solid var(--ds-border); - border-radius: var(--ds-radius); - box-shadow: none; -} -.playground-preview .card:has(> .listItem) { padding: 0; -} -.playground-preview .card > .listItem { - padding: 10px var(--ds-space-3); + background: none; + border: 0; + border-radius: 0; + box-shadow: none; } .playground-preview .card > :last-child { margin-bottom: 0; @@ -222,13 +219,10 @@ div.playground-preview h1:first-child { border-radius: var(--ds-radius-sm); transition: background-color var(--motion-snappy); } -.playground-preview .list > .listItem + .listItem { - border-top: 0; -} /* touch screens skip it: a tapped row would stay lit */ @media (hover: hover) { .playground-preview .list > .listItem:has(> label):hover { - background: var(--ds-surface-2); + background: var(--ds-hover); } } .playground-preview @@ -242,16 +236,13 @@ div.playground-preview h1:first-child { opacity: 0.7; } -/* Rows: side by side (an avatar, a checkbox, then the text), a rule - between them (a `list` drops it) */ +/* Rows: side by side (an avatar, a checkbox, then the text), set apart by + space alone: 20px from one row's content to the next's */ .playground-preview .listItem { display: flex; align-items: flex-start; gap: var(--ds-space-3); - padding: var(--ds-space-2) 0; -} -.playground-preview .listItem + .listItem { - border-top: 1px solid var(--ds-border); + padding: 10px 0; } .playground-preview .listItem > * { min-width: 0; @@ -263,7 +254,7 @@ div.playground-preview h1:first-child { align-items: center; gap: 0; } -/* kept for the demos written before rows had rules of their own */ +/* kept for the demos written before rows had a gap of their own */ .playground-preview .listItem.spaced { gap: 1em; } @@ -466,13 +457,14 @@ div.playground-preview h1:first-child { center; display: inline-block; } -/* A board's columns (RestEndpoint's move demo): wells holding cards, each a - row like the console's (radius 8, 9px 10px) */ +/* A board's columns (RestEndpoint's move demo): wells a step off the + sheet, each holding its tasks as rows of the sheet's color (radius 8, no + edge), 6px apart */ .playground-preview .boardColumn { background: var(--ds-surface-2); - border-radius: var(--ds-radius); + border-radius: 12px; padding: var(--ds-space-2); - margin-bottom: 0.5rem; + margin-bottom: var(--ds-space-2); } .playground-preview .boardColumn > h4 { padding: 0 var(--ds-space-1); @@ -480,7 +472,6 @@ div.playground-preview h1:first-child { .playground-preview .boardColumn .listItem { align-items: center; background: var(--ds-surface); - border: 1px solid var(--ds-border); border-radius: var(--ds-radius-sm); padding: 9px 10px; margin-bottom: 6px; @@ -549,9 +540,9 @@ div.playground-preview h1:first-child { margin-left: auto; } -/* Toasts (, ./Toaster.tsx): stacked under the app, each a row - with an edge bar in its outcome's color: the console's live green for - success, red for an error */ +/* Toasts (, ./Toaster.tsx): stacked under the app, each a tinted + row with an edge bar in its outcome's color: the live green for success, + red for an error */ .playground-preview .toaster { display: flex; flex-direction: column; @@ -564,7 +555,6 @@ div.playground-preview h1:first-child { .playground-preview .toast { --toast-color: var(--pg-live); padding: var(--ds-space-2) var(--ds-space-3); - border: 1px solid var(--ds-border); border-radius: var(--ds-radius-sm); /* the edge bar: inset so the radius clips it */ box-shadow: inset 3px 0 0 var(--toast-color); diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 5df41e6668b8..3026907022b1 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -174,36 +174,56 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element their built-in types (they're type-only exports). - Each playground gets its own `DataProvider` store (`MockResolver` serves `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. +- The preview is a stage and the app a sheet on it (`../styles.module.css`): + the `previewWrapper` paints the stage (`--pg-stage`, a soft tonal + gradient in either theme), which shows as the header's tint with no rule + under it; react-live's output `div` (the preview's one child with no + class; the loading mark and the error card stand on the stage, and an + empty one hides) is the sheet: `--pg-panel`, flush with the frame's sides + and foot, its top corners rounded 12px into the tint, `--pg-sheet-pad` + inside (1.25rem; 1.5rem in a `demo` from 720px; 1rem under 560px), + `overflow: clip`. The header shares `--pg-sheet-pad` so its label lines + up with the sheet's text, and `.navProgress` reads it to run along the + sheet's top edge. The frame has no ring: a faint edge shadow and a soft + drop under it. The header is the site's text at 13px, muted, with the + live dot; the router's controls follow it after a gap, and `renderCount` + is a tonal pill (`--pg-hover`, a 7% wash of the text, is every header + control's hover band). - Design system (`DesignSystem/design-system.css`, loaded with the preview): what a demo renders is styled under `.playground-preview` so it reads as a - real app, in both themes, without the demo importing anything. Tokens - (`--ds-text`, `--ds-text-muted`, `--ds-faint`, `--ds-surface`, - `--ds-surface-2` (a well a step below the panel), `--ds-border`, - `--ds-border-strong`, `--ds-accent`, `--ds-accent-soft` (its wash), - `--ds-radius`, `--ds-radius-sm`, `--ds-radius-xs`, `--ds-space-*`, - `--ds-text-*` including `--ds-text-label` for small mono caps over a - group of rows; the palette reads the frame's `--pg-*` in - `../styles.module.css`, which switch with the theme) and the preview's - type (15px base, Infima's heading variables); element styles for - headings, `p`, `small`, links (in a heading they keep its color with a - faint underline), plain `button`s (`primary` fills one with the accent), - text inputs, `nav`, `fieldset`; and classes: `appHeader` + `appTitle` - (the app's bar), `card` (a flat surface ruled off the panel by a 1px line; - holding `listItem`s it loses its padding so their dividers reach its - edges), `list` (rows with no box or rules, in line with the header; - rounded bands, checkbox rows light up under the pointer and fade when - checked), - `listItem` (a row with a rule between siblings; `nogap`, `spaced`; a - leading checkbox in its `label` is a round box drawn in the accent), - `meta` (secondary text; last in a `listItem`, it sits at the row's end), - `toaster`/`toast` (rendered by the `Toaster` component, which shows what - the `toast` helper sends), `byline` (avatar beside a name; `Avatar` takes a - `size`), `author` (name under avatar), plus the older `voteBlock`/`vote` - and `boardColumn`. Prefer a class or element style to a new component; - a new scope global goes in `preview/scope.ts`, `monaco/typeLibs.ts` and - `website/framework-docs/checkExamples.mjs`. Keep docs demos that use - these classes (grep `docs/` and `website/src/components/Demo/code`) in - mind when restyling one. + real app, in both themes, without the demo importing anything. No + decorative lines: rows are set apart by space, groups by a label in weight + and their inset, secondary text by color; only form controls keep an edge. + Tokens (`--ds-text`, `--ds-text-muted`, `--ds-faint`, `--ds-surface` (the + sheet), `--ds-surface-2` (a well a step off it), `--ds-hover` (an + interactive row's band), `--ds-border` (kept for demos that draw their + own rule), `--ds-border-strong` (control edges, 3:1 on the sheet and on a + well), `--ds-accent`, `--ds-accent-soft` (its wash), `--ds-radius`, + `--ds-radius-sm`, `--ds-radius-xs`, `--ds-space-*`, `--ds-text-*` + (`--ds-text-label` is kept; labels now use `--ds-text-sm`); the palette + reads the frame's `--pg-*` in `../styles.module.css`, which switch with + the theme) and the preview's type (15px base, Infima's heading + variables); element styles for headings, `p`, `small`, links (in a heading + they keep its color with a faint underline), a `section > h3` or + `boardColumn > h4` as a group's label (13px semibold muted, sentence + case), plain `button`s (`primary` fills one with the accent), text + inputs, `nav`, `fieldset`; and classes: `appHeader` + `appTitle` (the + app's bar, set off by the space under it), `card` (a group with no box of + its own: the sheet is the card), `list` (rows in line with the header as + rounded bands; checkbox rows light up under the pointer and fade when + checked), `listItem` (a row 10px above and below, no rule; `nogap`, + `spaced`; a leading checkbox in its `label` is a round box drawn in the + accent), `meta` (secondary text; last in a `listItem`, it sits at the + row's end), `toaster`/`toast` (rendered by the `Toaster` component, which + shows what the `toast` helper sends: a tinted row with an edge bar, no + border), `byline` (avatar beside a name; `Avatar` takes a `size`), + `author` (name under avatar), `boardColumn` (a well, radius 12, holding + its tasks as rows of the sheet's color, radius 8, no edge), plus the + older `voteBlock`/`vote`. Prefer a class or element style to a new + component; a new scope global goes in `preview/scope.ts`, + `monaco/typeLibs.ts` and `website/framework-docs/checkExamples.mjs`. Keep + docs demos that use these classes (grep `docs/` and + `website/src/components/Demo/code`) in mind when restyling one. - Store inspector open state persists per `groupId` via tab storage and avoids scroll jumps. In `row` layout it slides over the code from the preview's edge (portaled into a layer `EditorSurface` stacks on the code, diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 69b273cff3b1..36668fda320f 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -31,21 +31,28 @@ --store-record: rgba(130, 170, 255, 0.07); --store-tip: #3a4057; --store-bg: var(--monoco-code-background); - /* The preview's console palette (DesignSystem/design-system.css reads it - into --ds-*): the panel and the well below it, the 1px line that rules - them, its text, and the live dot */ + /* The preview's palette (DesignSystem/design-system.css reads it into + --ds-*): the sheet the app is on (panel) and a well a step off it, a + line kept for demos that draw their own, its text, and the live dot */ --pg-panel: #fff; --pg-panel-2: #eef2f7; --pg-line: #d8e0ea; + /* the stage the sheet sits on, showing as the header's tint: a cool tonal + ground, lighter at the top */ + --pg-stage: linear-gradient(170deg, #eef3f9, #e2e9f2); --pg-text: #0b1220; --pg-muted: #4c5a6e; --pg-live: #0d8257; --pg-live-soft: color-mix(in srgb, var(--pg-live) 14%, transparent); + /* a resting control's band under the pointer, on any ground */ + --pg-hover: color-mix(in srgb, var(--pg-text) 7%, transparent); --pg-shadow: rgba(15, 30, 60, 0.28); + --pg-shadow-edge: rgba(15, 30, 60, 0.1); border-radius: var(--ifm-global-radius); - /* the line around the frame, and a soft drop under it */ + /* no line rules the frame off the page: a faint shadow at its edge lifts + it, and a soft drop sits under it */ box-shadow: - 0 0 0 1px var(--pg-line), + 0 2px 8px -2px var(--pg-shadow-edge), 0 24px 60px -30px var(--pg-shadow); overflow: visible; display: flex; @@ -149,14 +156,17 @@ --pg-tab-hover-bg: hsl(229, 9%, 26%); } :root[data-theme='dark'] .playgroundContainer { - --pg-panel: #0b1017; - --pg-panel-2: #111824; - --pg-line: #1d2836; + /* the sheet a step above the stage, its wells a step above that */ + --pg-panel: #131b27; + --pg-panel-2: #1b2534; + --pg-line: #27334a; + --pg-stage: linear-gradient(170deg, #0c121b, #0a1620); --pg-text: #e8eef6; --pg-muted: #94a2b6; --pg-live: #3ddc97; --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); --pg-shadow: rgba(0, 0, 0, 0.8); + --pg-shadow-edge: rgba(0, 0, 0, 0.5); } /* EndpointPlayground's right column is code, so it always uses the dark bar */ .endpointPlayground { @@ -234,10 +244,12 @@ div.playgroundTextEdit > .playgroundHeader, transform: rotateZ(90deg); } +/* The sheet's inset, set on the wrapper so the header shares it (its label + lines up with the sheet's text) and .navProgress reaches the sheet's edges */ +.previewWrapper { + --pg-sheet-pad: 1.25rem; +} :global(.col) .playgroundPreview { - /* the frame's inset; .navProgress reads it to reach the frame's edges */ - --pg-preview-pad: 1rem; - padding: var(--pg-preview-pad); flex: 1 4 30%; overflow: auto; } @@ -337,7 +349,8 @@ div.playgroundTextEdit > .playgroundHeader, line-height: 1.05; text-transform: uppercase; font-weight: 500; - background: var(--pg-bar); + /* on the stage, beside the sheet */ + background: transparent; color: var(--pg-tab); font-size: var(--ifm-code-font-size); padding: 0.7em 0.1em 1em 0.1em; @@ -664,12 +677,39 @@ div.playgroundTextEdit > .playgroundHeader, --pg-tab-selected: var(--pg-tab-hover); } -/* The preview's ground is the console's panel (the design system's cards - and fields take their surface from it too, in both themes) */ +/* The preview is a stage (on the wrapper, so it tints the header) and the + app one sheet filling the rest: the react-live output (the one child with + no class of its own; the loading mark and the error card stand on the + stage) is the sheet, flush with the frame's sides and foot, its top + corners notched into the header's tint, with its own inset. The design + system's surface is the sheet's color, so fields and wells read against + it */ +.previewWrapper { + background: var(--pg-stage); +} .playgroundPreview { - background-color: var(--pg-panel); + /* the sheet fills the preview's height */ + display: flex; + flex-direction: column; overflow: auto; } +.playgroundPreview > div:not([class]) { + flex: 1 0 auto; + padding: var(--pg-sheet-pad); + border-radius: 12px 12px 0 0; + background: var(--pg-panel); + /* the progress bar's corners stay inside the radius */ + overflow: clip; +} +.playgroundPreview > div:not([class]):empty { + display: none; +} +/* narrow, the sheet gives up some inset for the text */ +@container playground (max-width: 559px) { + .previewWrapper { + --pg-sheet-pad: 1rem; + } +} /* The router's navigation indicator (preview/router.tsx): a 3px bar along the frame's top edge while the next page loads. A 0-height block (so the layout below is untouched) stuck at the frame's inset (sticky insets @@ -682,7 +722,7 @@ div.playgroundTextEdit > .playgroundHeader, top: 0; z-index: 1; height: 0; - margin: 0 calc(-1 * var(--pg-preview-pad, 0px)); + margin: 0 calc(-1 * var(--pg-sheet-pad, 0px)); opacity: 0; pointer-events: none; transition: opacity var(--motion-snappy); @@ -694,7 +734,7 @@ div.playgroundTextEdit > .playgroundHeader, .navProgress::after { content: ''; position: absolute; - top: calc(-1 * var(--pg-preview-pad, 0px)); + top: calc(-1 * var(--pg-sheet-pad, 0px)); left: 0; right: 0; height: 3px; @@ -749,10 +789,8 @@ div.playgroundTextEdit > .playgroundHeader, display: flex; align-items: center; min-width: 0; - margin-inline-start: 0.75rem; - padding-inline-start: 0.5rem; - border-inline-start: 1px solid - color-mix(in srgb, currentColor 25%, transparent); + /* a gap, not a rule, sets them off from the label */ + margin-inline-start: 1rem; } .routerControls > .headerButton:disabled { opacity: 0.35; @@ -762,13 +800,11 @@ div.playgroundTextEdit > .playgroundHeader, min-width: 0; margin-inline-start: 0.375rem; overflow: hidden; - font-family: var(--ifm-font-family-monospace); - font-weight: normal; + font: 12px / 1 var(--ifm-font-family-monospace); letter-spacing: normal; text-transform: none; white-space: nowrap; text-overflow: ellipsis; - opacity: 0.8; } /* Demo content (what demos render) is styled by the preview's design system, DesignSystem/design-system.css, loaded with the preview */ @@ -1077,7 +1113,6 @@ div.fixtureJson + div.fixtureJson { border-bottom-right-radius: 0; } .demo .playgroundPreview { - --pg-preview-pad: 1.25rem; min-height: var(--pg-demo-min); } .demo[data-code] .playgroundResult { @@ -1091,8 +1126,8 @@ div.fixtureJson + div.fixtureJson { background-color: var(--monoco-code-background); } @container playground (min-width: 720px) { - .demo .playgroundPreview { - --pg-preview-pad: 2rem; + .demo > .previewWrapper { + --pg-sheet-pad: 1.5rem; } } @@ -1133,14 +1168,13 @@ div.fixtureJson + div.fixtureJson { left: 0.75rem; } -/* Live Preview label: the console's status dot (drawn, so it renders the - same on every OS) before the label in the panel's text; the rest of the - header is muted */ +/* Live Preview label: a live dot (drawn, so it renders the same on every + OS) before the label, in the header's muted text so the app's own title + stays the strongest thing in the frame */ .liveLabel { display: inline-flex; align-items: center; gap: 10px; - color: var(--pg-text); } .liveLabel::before { content: ''; @@ -1153,19 +1187,22 @@ div.fixtureJson + div.fixtureJson { background: var(--pg-live); box-shadow: 0 0 0 3px var(--pg-live-soft); } -/* The preview's header is the console's bar: 44px on the panel, ruled off - below, in small mono. Outranks .playgroundHeader's caps and bar color; - the editor's own headers keep theirs. */ +/* The preview's header: 44px of the preview's own ground, with no rule + under it (the inset below sets the app off), in the site's text at 13px, + muted, inset like the app so the label and its text line up. Outranks + .playgroundHeader's caps and bar color; the editor's own headers keep + theirs. */ .playgroundHeader.previewHeader { - --pg-bar: var(--pg-panel); + /* on the stage */ + --pg-bar: transparent; --pg-tab: var(--pg-muted); - --pg-tab-hover-bg: var(--pg-panel-2); + --pg-tab-hover-bg: var(--pg-hover); --pg-tab-hover: var(--pg-text); min-height: 44px; - padding: 0 0.75rem; - border-bottom: 1px solid var(--pg-line); - font: 500 12px / 1 var(--ifm-font-family-monospace); - letter-spacing: 0.01em; + /* in line with the sheet's text */ + padding: 0 var(--pg-sheet-pad); + font: 500 13px / 1 var(--ifm-font-family-base); + letter-spacing: 0; text-transform: none; } /* With a demo's navigation in the header, a tight header keeps only the @@ -1232,15 +1269,16 @@ div.fixtureJson + div.fixtureJson { align-items: center; } .renderCount { - font: inherit; + /* a tonal pill, no edge */ + font: 500 12px / 1.7 var(--ifm-font-family-base); letter-spacing: normal; - font-weight: normal; - padding: 1px 0.5em; - border: 1px solid var(--pg-line); - border-radius: 5px; - background: var(--pg-panel-2); + padding: 0 0.7em; + border: 0; + border-radius: 999px; + background: var(--pg-hover); color: inherit; cursor: pointer; + font-variant-numeric: tabular-nums; } .previewControls { display: flex; From fc2f0ea3e3b73ee92c4f3766f8e6855f894db3e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:41:35 +0000 Subject: [PATCH 08/66] Playground: name the preview's sheet instead of finding it as the classless child Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/DesignSystem/design-system.css | 3 ++- website/src/components/Playground/README.md | 6 +++--- .../src/components/Playground/preview/PreviewBlock.tsx | 5 +++-- website/src/components/Playground/styles.module.css | 9 ++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 5a2f037cad39..4f69b4cd5565 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -38,7 +38,8 @@ --ds-text-muted: var(--pg-muted); --ds-surface: var(--pg-panel); --ds-surface-2: var(--pg-panel-2); - /* kept for demos that draw their own rule; nothing here uses it */ + /* a faint line: the busy submit button's trace, and demos that draw their + own rule */ --ds-border: var(--pg-line); /* an interactive row's band under the pointer: reads on the panel and on a well alike */ diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 3026907022b1..501518b445fc 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -177,9 +177,9 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - The preview is a stage and the app a sheet on it (`../styles.module.css`): the `previewWrapper` paints the stage (`--pg-stage`, a soft tonal gradient in either theme), which shows as the header's tint with no rule - under it; react-live's output `div` (the preview's one child with no - class; the loading mark and the error card stand on the stage, and an - empty one hides) is the sheet: `--pg-panel`, flush with the frame's sides + under it; react-live's output `div` (`sheet`, named in + `preview/PreviewBlock.tsx`; the loading mark and the error card stand on + the stage, and an empty one hides) is the sheet: `--pg-panel`, flush with the frame's sides and foot, its top corners rounded 12px into the tint, `--pg-sheet-pad` inside (1.25rem; 1.5rem in a `demo` from 720px; 1rem under 560px), `overflow: clip`. The header shares `--pg-sheet-pad` so its label lines diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index 65e07e84c6b0..b369f144722f 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -4,6 +4,7 @@ import { LivePreview } from 'react-live'; import Boundary from '../Boundary'; import PreviewError, { type PreviewErrorProps } from './PreviewError'; import { Loading } from '../DesignSystem/Loading'; +import styles from '../styles.module.css'; function PreviewBlock({ onCommit, @@ -14,9 +15,9 @@ function PreviewBlock({ }> {onCommit ? - + - : } + : } diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 36668fda320f..129048385fa4 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -678,9 +678,8 @@ div.playgroundTextEdit > .playgroundHeader, } /* The preview is a stage (on the wrapper, so it tints the header) and the - app one sheet filling the rest: the react-live output (the one child with - no class of its own; the loading mark and the error card stand on the - stage) is the sheet, flush with the frame's sides and foot, its top + app one sheet filling the rest: the react-live output (.sheet; the + loading mark and the error card stand on the stage) is the sheet, flush with the frame's sides and foot, its top corners notched into the header's tint, with its own inset. The design system's surface is the sheet's color, so fields and wells read against it */ @@ -693,7 +692,7 @@ div.playgroundTextEdit > .playgroundHeader, flex-direction: column; overflow: auto; } -.playgroundPreview > div:not([class]) { +.sheet { flex: 1 0 auto; padding: var(--pg-sheet-pad); border-radius: 12px 12px 0 0; @@ -701,7 +700,7 @@ div.playgroundTextEdit > .playgroundHeader, /* the progress bar's corners stay inside the radius */ overflow: clip; } -.playgroundPreview > div:not([class]):empty { +.sheet:empty { display: none; } /* narrow, the sheet gives up some inset for the text */ From a8913c330e44276212f12ee979e1ad5d84287608 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:45:19 +0000 Subject: [PATCH 09/66] Demo design system: a quiet wordmark, a modest page title and a muted byline The app's name is a small tracked wordmark in the muted color, a post's title is a step up from the body, and its byline is all muted with a smaller avatar, so the title is the one thing that leads the page. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 4f69b4cd5565..79328f9ae99f 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -103,6 +103,15 @@ html[data-theme='dark'] .playground-preview .rt-TextFieldLabel { .playground-preview h3 { letter-spacing: -0.01em; } +/* A page's title (a post's): the one bold thing on the page, a step up + from the body, with the byline close under it */ +.playground-preview h2 { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; + margin-bottom: var(--ds-space-1); +} /* an app's h1 is no page title: outranks theme-classic's `.markdown h1:first-child` (3rem, and a page's gap under it) */ .playground-preview h1, @@ -164,21 +173,27 @@ div.playground-preview h1:first-child { border-radius: var(--ds-radius-xs); } -/* The app's header: its title at the left, its navigation or actions at - the right; the space under it, not a rule, sets the page off */ +/* The app's header: its name at the left, its navigation or actions at the + right; the space under it (28px), not a rule, sets the page off. The + name is a small tracked wordmark in the muted color, so the title below + carries the page */ .playground-preview .appHeader { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--ds-space-2) var(--ds-space-4); - margin-bottom: var(--ds-space-5); + margin-bottom: 1.75rem; } /* through .appHeader to outrank Infima's `.markdown h1:first-child` margin */ .playground-preview .appHeader .appTitle { margin: 0; - font-size: var(--ds-text-lg); + font-size: 0.75rem; font-weight: 600; + line-height: 1.4; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ds-text-muted); } .playground-preview .appHeader nav { margin-bottom: 0; @@ -266,19 +281,25 @@ div.playground-preview h1:first-child { .playground-preview .listItem > label input[type='text'] { width: calc(100% - 21px); } -/* An avatar with a name: beside each other in a row (a comment's author), or - the name under it (`author`, a post's) */ +/* An avatar with a name: beside each other in a row (a post's author under + its title, all of it muted at the body's secondary size, a 20px avatar, + and the body 20px below), or the name under it (`author`, a post's) */ .playground-preview .byline { display: inline-flex; align-items: center; gap: var(--ds-space-2); + margin-bottom: 1.25rem; font-size: var(--ds-text-sm); color: var(--ds-text-muted); } +.playground-preview .byline img { + width: 20px; + height: 20px; +} .playground-preview .byline b, .playground-preview .byline strong { - color: var(--ds-text); - font-weight: 600; + color: inherit; + font-weight: 400; } .playground-preview .author { flex: none; From 27f578f855cff5e35489ba79c6e487e25cdc64cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:31:20 +0000 Subject: [PATCH 10/66] Demo design system: a measured spacing rhythm, and tidier tokens Demo apps sit tighter and more evenly: less space under the app's name and the byline, rows 16px apart, and slimmer board wells and toasts. Also: --ds-hover reads the frame's --pg-hover, the 12px well radius is --ds-radius, the unused --ds-text-label token is gone, a byline's avatar size comes from Avatar's own size prop, and the sheet's rules share one home. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 58 +++++--------- website/src/components/Playground/README.md | 76 ++++++++----------- .../Playground/preview/PreviewBlock.tsx | 5 +- .../components/Playground/styles.module.css | 34 +++------ 4 files changed, 66 insertions(+), 107 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 79328f9ae99f..6baa0f7d5769 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -18,14 +18,13 @@ --ds-text-base: 0.9375rem; --ds-text-lg: 1.125rem; --ds-text-xl: 1.375rem; - /* a group's label (ENTITIES, VIEWS): small mono caps */ - --ds-text-label: 11px; --ds-space-1: 0.25rem; --ds-space-2: 0.5rem; --ds-space-3: 0.75rem; --ds-space-4: 1rem; --ds-space-5: 1.5rem; - --ds-radius: 10px; + /* a well (a board's column) */ + --ds-radius: 12px; --ds-radius-sm: 8px; --ds-radius-xs: 4px; /* a button's or a field's inset */ @@ -43,7 +42,7 @@ --ds-border: var(--pg-line); /* an interactive row's band under the pointer: reads on the panel and on a well alike */ - --ds-hover: color-mix(in srgb, var(--ds-text) 6%, transparent); + --ds-hover: var(--pg-hover); /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ --ds-faint: #5f6d83; /* 3:1 on the panel, so field and button edges stay legible */ @@ -68,7 +67,7 @@ --ifm-color-formfield-active: var(--ds-accent); /* Infima's headings read these (h1 is set below: theme-classic sizes a first-child h1 as a page title) */ - --ifm-h2-font-size: var(--ds-text-xl); + --ifm-h2-font-size: 1.25rem; --ifm-h3-font-size: var(--ds-text-lg); --ifm-h4-font-size: var(--ds-text-base); --ifm-heading-font-weight: 600; @@ -103,14 +102,11 @@ html[data-theme='dark'] .playground-preview .rt-TextFieldLabel { .playground-preview h3 { letter-spacing: -0.01em; } -/* A page's title (a post's): the one bold thing on the page, a step up - from the body, with the byline close under it */ +/* A page's title (a post's): a step up from the body, with the byline + close under it */ .playground-preview h2 { - font-size: 1.25rem; - font-weight: 600; line-height: 1.3; - letter-spacing: -0.01em; - margin-bottom: var(--ds-space-1); + margin-bottom: 2px; } /* an app's h1 is no page title: outranks theme-classic's `.markdown h1:first-child` (3rem, and a page's gap under it) */ @@ -138,10 +134,9 @@ div.playground-preview h1:first-child { row's own title (a post's h3 in a list) stays a heading */ .playground-preview section > h3, .playground-preview .boardColumn > h4 { - margin: 0 0 var(--ds-space-2); + margin: 0 0 6px; font: 600 var(--ds-text-sm) / 1.4 var(--ifm-font-family-base); letter-spacing: 0; - text-transform: none; color: var(--ds-text-muted); } /* A value that just changed: washed in the accent and ringed by it */ @@ -183,7 +178,7 @@ div.playground-preview h1:first-child { justify-content: space-between; flex-wrap: wrap; gap: var(--ds-space-2) var(--ds-space-4); - margin-bottom: 1.75rem; + margin-bottom: 1.25rem; } /* through .appHeader to outrank Infima's `.markdown h1:first-child` margin */ .playground-preview .appHeader .appTitle { @@ -213,8 +208,6 @@ div.playground-preview h1:first-child { margin-bottom: var(--ds-space-4); padding: 0; background: none; - border: 0; - border-radius: 0; box-shadow: none; } .playground-preview .card > :last-child { @@ -231,7 +224,7 @@ div.playground-preview h1:first-child { .playground-preview .list > .listItem { align-items: center; margin: 0 calc(-1 * var(--ds-space-3)); - padding: var(--ds-space-3); + padding: var(--ds-space-2) var(--ds-space-3); border-radius: var(--ds-radius-sm); transition: background-color var(--motion-snappy); } @@ -253,12 +246,12 @@ div.playground-preview h1:first-child { } /* Rows: side by side (an avatar, a checkbox, then the text), set apart by - space alone: 20px from one row's content to the next's */ + space alone: 16px from one row's content to the next's */ .playground-preview .listItem { display: flex; align-items: flex-start; gap: var(--ds-space-3); - padding: 10px 0; + padding: 8px 0; } .playground-preview .listItem > * { min-width: 0; @@ -282,25 +275,16 @@ div.playground-preview h1:first-child { width: calc(100% - 21px); } /* An avatar with a name: beside each other in a row (a post's author under - its title, all of it muted at the body's secondary size, a 20px avatar, - and the body 20px below), or the name under it (`author`, a post's) */ + its title, muted at the body's secondary size), or the name under it + (`author`, a post's) */ .playground-preview .byline { display: inline-flex; align-items: center; gap: var(--ds-space-2); - margin-bottom: 1.25rem; + margin-bottom: var(--ds-space-4); font-size: var(--ds-text-sm); color: var(--ds-text-muted); } -.playground-preview .byline img { - width: 20px; - height: 20px; -} -.playground-preview .byline b, -.playground-preview .byline strong { - color: inherit; - font-weight: 400; -} .playground-preview .author { flex: none; text-align: center; @@ -484,9 +468,9 @@ div.playground-preview h1:first-child { edge), 6px apart */ .playground-preview .boardColumn { background: var(--ds-surface-2); - border-radius: 12px; - padding: var(--ds-space-2); - margin-bottom: var(--ds-space-2); + border-radius: var(--ds-radius); + padding: 6px; + margin-bottom: 6px; } .playground-preview .boardColumn > h4 { padding: 0 var(--ds-space-1); @@ -495,8 +479,8 @@ div.playground-preview h1:first-child { align-items: center; background: var(--ds-surface); border-radius: var(--ds-radius-sm); - padding: 9px 10px; - margin-bottom: 6px; + padding: 7px 10px; + margin-bottom: var(--ds-space-1); } .playground-preview .boardColumn .listItem:last-child { margin-bottom: 0; @@ -568,7 +552,7 @@ div.playground-preview h1:first-child { .playground-preview .toaster { display: flex; flex-direction: column; - gap: var(--ds-space-2); + gap: 6px; margin-top: var(--ds-space-3); } .playground-preview .toaster:empty { diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 501518b445fc..c650f288ac6d 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -175,55 +175,39 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - Each playground gets its own `DataProvider` store (`MockResolver` serves `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. - The preview is a stage and the app a sheet on it (`../styles.module.css`): - the `previewWrapper` paints the stage (`--pg-stage`, a soft tonal - gradient in either theme), which shows as the header's tint with no rule - under it; react-live's output `div` (`sheet`, named in - `preview/PreviewBlock.tsx`; the loading mark and the error card stand on - the stage, and an empty one hides) is the sheet: `--pg-panel`, flush with the frame's sides - and foot, its top corners rounded 12px into the tint, `--pg-sheet-pad` - inside (1.25rem; 1.5rem in a `demo` from 720px; 1rem under 560px), - `overflow: clip`. The header shares `--pg-sheet-pad` so its label lines - up with the sheet's text, and `.navProgress` reads it to run along the - sheet's top edge. The frame has no ring: a faint edge shadow and a soft - drop under it. The header is the site's text at 13px, muted, with the - live dot; the router's controls follow it after a gap, and `renderCount` - is a tonal pill (`--pg-hover`, a 7% wash of the text, is every header - control's hover band). + the `previewWrapper` paints the stage (`--pg-stage`), which shows as the + header's tint with no rule under it; react-live's output `div` (`sheet`, + named in `preview/PreviewBlock.tsx`; the loading mark and the error card + stand on the stage, and an empty one hides) is the sheet, flush with the + frame's sides and foot. `--pg-sheet-pad` is its inset; the header shares + it so its label lines up with the sheet's text, and `.navProgress` reads + it to run along the sheet's top edge. The frame has no ring, only + shadows. `--pg-hover` is every header control's hover band. - Design system (`DesignSystem/design-system.css`, loaded with the preview): what a demo renders is styled under `.playground-preview` so it reads as a real app, in both themes, without the demo importing anything. No - decorative lines: rows are set apart by space, groups by a label in weight - and their inset, secondary text by color; only form controls keep an edge. - Tokens (`--ds-text`, `--ds-text-muted`, `--ds-faint`, `--ds-surface` (the - sheet), `--ds-surface-2` (a well a step off it), `--ds-hover` (an - interactive row's band), `--ds-border` (kept for demos that draw their - own rule), `--ds-border-strong` (control edges, 3:1 on the sheet and on a - well), `--ds-accent`, `--ds-accent-soft` (its wash), `--ds-radius`, - `--ds-radius-sm`, `--ds-radius-xs`, `--ds-space-*`, `--ds-text-*` - (`--ds-text-label` is kept; labels now use `--ds-text-sm`); the palette - reads the frame's `--pg-*` in `../styles.module.css`, which switch with - the theme) and the preview's type (15px base, Infima's heading - variables); element styles for headings, `p`, `small`, links (in a heading - they keep its color with a faint underline), a `section > h3` or - `boardColumn > h4` as a group's label (13px semibold muted, sentence - case), plain `button`s (`primary` fills one with the accent), text - inputs, `nav`, `fieldset`; and classes: `appHeader` + `appTitle` (the - app's bar, set off by the space under it), `card` (a group with no box of - its own: the sheet is the card), `list` (rows in line with the header as - rounded bands; checkbox rows light up under the pointer and fade when - checked), `listItem` (a row 10px above and below, no rule; `nogap`, - `spaced`; a leading checkbox in its `label` is a round box drawn in the - accent), `meta` (secondary text; last in a `listItem`, it sits at the - row's end), `toaster`/`toast` (rendered by the `Toaster` component, which - shows what the `toast` helper sends: a tinted row with an edge bar, no - border), `byline` (avatar beside a name; `Avatar` takes a `size`), - `author` (name under avatar), `boardColumn` (a well, radius 12, holding - its tasks as rows of the sheet's color, radius 8, no edge), plus the - older `voteBlock`/`vote`. Prefer a class or element style to a new - component; a new scope global goes in `preview/scope.ts`, - `monaco/typeLibs.ts` and `website/framework-docs/checkExamples.mjs`. Keep - docs demos that use these classes (grep `docs/` and - `website/src/components/Demo/code`) in mind when restyling one. + decorative lines: rows are set apart by space, groups by a label and + their inset, secondary text by color; only form controls keep an edge. + Tokens are `--ds-*` (the palette reads the frame's `--pg-*`, which switch + with the theme), with Infima's heading variables re-pointed; element + styles cover headings (an `h2` is a page's title; a `section > h3` or + `boardColumn > h4` is a group's label), `p`, `small`, links, plain + `button`s (`primary` fills one with the accent), text inputs, `nav` and + `fieldset`. Classes: `appHeader` + `appTitle` (the app's wordmark), + `card` (a group with no box of its own: the sheet is the card), `list` + (rows as rounded bands; checkbox rows light up under the pointer and fade + when checked), `listItem` (a row; `nogap`, `spaced`; a leading checkbox + in its `label` is a round box in the accent), `meta` (secondary text; + last in a `listItem`, it sits at the row's end), `toaster`/`toast` + (rendered by the `Toaster` component, which shows what the `toast` helper + sends), `byline` (avatar beside a muted name; `Avatar` takes a `size`), + `author` (name under avatar), `boardColumn` (a well holding its tasks as + rows of the sheet's color), plus the older `voteBlock`/`vote`. Prefer a + class or element style to a new component; a new scope global goes in + `preview/scope.ts`, `monaco/typeLibs.ts` and + `website/framework-docs/checkExamples.mjs`. Keep docs demos that use + these classes (grep `docs/` and `website/src/components/Demo/code`) in + mind when restyling one. - Store inspector open state persists per `groupId` via tab storage and avoids scroll jumps. In `row` layout it slides over the code from the preview's edge (portaled into a layer `EditorSurface` stacks on the code, diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index b369f144722f..777fcb1b1078 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -10,14 +10,15 @@ function PreviewBlock({ onCommit, ...errorProps }: PreviewErrorProps & { onCommit?: ProfilerOnRenderCallback }) { + const preview = ; return ( <> }> {onCommit ? - + {preview} - : } + : preview} diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 129048385fa4..5371131041fb 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -244,14 +244,8 @@ div.playgroundTextEdit > .playgroundHeader, transform: rotateZ(90deg); } -/* The sheet's inset, set on the wrapper so the header shares it (its label - lines up with the sheet's text) and .navProgress reaches the sheet's edges */ -.previewWrapper { - --pg-sheet-pad: 1.25rem; -} :global(.col) .playgroundPreview { flex: 1 4 30%; - overflow: auto; } /* Preview errors: one card, colored by the stage that failed. Amber means the code never ran (compile); red means it ran and threw (runtime). @@ -679,11 +673,14 @@ div.playgroundTextEdit > .playgroundHeader, /* The preview is a stage (on the wrapper, so it tints the header) and the app one sheet filling the rest: the react-live output (.sheet; the - loading mark and the error card stand on the stage) is the sheet, flush with the frame's sides and foot, its top - corners notched into the header's tint, with its own inset. The design - system's surface is the sheet's color, so fields and wells read against - it */ + loading mark and the error card stand on the stage) is the sheet, flush + with the frame's sides and foot, its top corners notched into the + header's tint, with its own inset. The design system's surface is the + sheet's color, so fields and wells read against it. The inset is set on + the wrapper so the header shares it (its label lines up with the sheet's + text) and .navProgress reaches the sheet's edges. */ .previewWrapper { + --pg-sheet-pad: 1.25rem; background: var(--pg-stage); } .playgroundPreview { @@ -703,12 +700,6 @@ div.playgroundTextEdit > .playgroundHeader, .sheet:empty { display: none; } -/* narrow, the sheet gives up some inset for the text */ -@container playground (max-width: 559px) { - .previewWrapper { - --pg-sheet-pad: 1rem; - } -} /* The router's navigation indicator (preview/router.tsx): a 3px bar along the frame's top edge while the next page loads. A 0-height block (so the layout below is untouched) stuck at the frame's inset (sticky insets @@ -1124,11 +1115,6 @@ div.fixtureJson + div.fixtureJson { min-height: var(--pg-demo-min); background-color: var(--monoco-code-background); } -@container playground (min-width: 720px) { - .demo > .previewWrapper { - --pg-sheet-pad: 1.5rem; - } -} /* EndpointPlayground: left tab bar and right fixture headers share one height */ .endpointPlayground .playgroundHeader { @@ -1361,8 +1347,12 @@ div.fixtureJson + div.fixtureJson { border-color: var(--pe-accent); } -/* Narrow playgrounds: the Store stacks under the preview at full width */ +/* Narrow playgrounds: the Store stacks under the preview at full width, and + the sheet gives up some inset for the text */ @container playground (max-width: 559px) { + .previewWrapper { + --pg-sheet-pad: 1rem; + } .playgroundResult { flex-direction: column; } From cbb154696d03a244f237a4a2bea5d1af8dfee8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:09:07 +0000 Subject: [PATCH 11/66] docs: Expiry and error policy demos use the demo design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, those demos printed bare "API time for 1: …" lines and inline buttons. Now each is a small clock app: labelled rows with aligned times, row-shaped loading placeholders, and actions set under the rows, in both React and Vue. Todo rows sit closer together, and the row's remove button is a real button with a label and a tap area that overlaps the row's inset instead of growing it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../references/error-policy.md | 20 ++- .../references/error-policy.vue.md | 18 ++- .../references/expiry-policy.md | 80 +++++------ .../references/expiry-policy.vue.md | 49 ++++--- docs/core/concepts/error-policy.md | 38 +++--- docs/core/concepts/expiry-policy.md | 129 ++++++++++-------- website/i18n/es/code.json | 3 + .../es/docs/core/concepts/error-policy.md | 38 +++--- .../es/docs/core/concepts/expiry-policy.md | 129 ++++++++++-------- website/i18n/pt-BR/code.json | 3 + .../pt-BR/docs/core/concepts/error-policy.md | 38 +++--- .../pt-BR/docs/core/concepts/expiry-policy.md | 129 ++++++++++-------- website/i18n/zh-Hans/code.json | 3 + .../docs/core/concepts/error-policy.md | 38 +++--- .../docs/core/concepts/expiry-policy.md | 129 ++++++++++-------- .../Playground/DesignSystem/CancelButton.tsx | 37 ++--- .../Playground/DesignSystem/design-system.css | 54 ++++++++ website/translate/lock/es.json | 7 +- website/translate/lock/pt-BR.json | 7 +- website/translate/lock/zh-Hans.json | 7 +- 20 files changed, 534 insertions(+), 422 deletions(-) diff --git a/.agents/skills/data-client-rest/references/error-policy.md b/.agents/skills/data-client-rest/references/error-policy.md index 087f5be6f386..b25d3e43124f 100644 --- a/.agents/skills/data-client-rest/references/error-policy.md +++ b/.agents/skills/data-client-rest/references/error-policy.md @@ -45,11 +45,9 @@ import { lastUpdated } from './api/lastUpdated'; export default function TimePage({ id }) { const { updatedAt } = useSuspense(lastUpdated, { id }); return ( -
- API time:{' '} - +
+ API time +
); } @@ -74,15 +72,13 @@ function ShowTime() { }; return (
- }> + loading...
}> -
- - - - -
+ + + +
); } diff --git a/.agents/skills/data-client-rest/references/error-policy.vue.md b/.agents/skills/data-client-rest/references/error-policy.vue.md index 010ff0fafd55..9dcf5793b0c5 100644 --- a/.agents/skills/data-client-rest/references/error-policy.vue.md +++ b/.agents/skills/data-client-rest/references/error-policy.vue.md @@ -48,8 +48,8 @@ export const lastUpdated = new RestEndpoint({ @@ -82,17 +82,15 @@ export const lastUpdated = new RestEndpoint({ ``` diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md index 4c635d65e488..9857a24bc11a 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -73,11 +73,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -92,8 +90,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -195,11 +194,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -214,8 +211,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -258,14 +256,15 @@ function ShowTime() { const { updatedAt } = useSuspense(lastUpdated, { id: '1' }); const ctrl = useController(); return ( -
- {' '} + <> +
+ API time + +
-
+ ); } render(); @@ -302,11 +301,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -314,7 +311,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -386,11 +387,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -398,7 +397,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -468,11 +471,9 @@ import { lastUpdated } from './api/lastUpdated'; export default function TimePage({ id }) { const { updatedAt } = useSuspense(lastUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -499,11 +500,12 @@ function ShowTime() { ); return (
- loading...
}> + loading...
}> -
- Current Time: +
+ Current time +
``` @@ -200,8 +204,8 @@ export const getUpdated = lastUpdated.extend({ @@ -222,9 +226,13 @@ export const getUpdated = lastUpdated.extend({ :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -267,10 +275,11 @@ export const lastUpdated = new RestEndpoint({ ``` @@ -307,8 +316,9 @@ export const lastUpdated = new RestEndpoint({ ``` @@ -326,7 +336,9 @@ export const lastUpdated = new RestEndpoint({
- + @@ -379,8 +391,9 @@ export const lastUpdated = new RestEndpoint({ ``` @@ -398,7 +411,9 @@ export const lastUpdated = new RestEndpoint({
- + @@ -443,8 +458,8 @@ export const lastUpdated = new RestEndpoint({ @@ -474,7 +489,7 @@ export const lastUpdated = new RestEndpoint({
- + - - - -
+ + + +
); } @@ -146,8 +142,8 @@ render(); @@ -180,17 +176,15 @@ render(); ``` diff --git a/docs/core/concepts/expiry-policy.md b/docs/core/concepts/expiry-policy.md index d274674f9ee5..6bd6ca602077 100644 --- a/docs/core/concepts/expiry-policy.md +++ b/docs/core/concepts/expiry-policy.md @@ -113,11 +113,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -132,8 +130,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -154,8 +153,8 @@ export default function Navigator({ children }) { @@ -176,9 +175,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -298,11 +301,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -317,8 +318,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -339,8 +341,8 @@ export default function Navigator({ children }) { @@ -361,9 +363,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -427,14 +433,15 @@ function ShowTime() { const { updatedAt } = useSuspense(lastUpdated, { id: '1' }); const ctrl = useController(); return ( -
- {' '} + <> +
+ API time + +
-
+ ); } render(); @@ -454,10 +461,11 @@ render(); ``` @@ -515,11 +523,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -527,7 +533,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -578,8 +588,9 @@ render(); ``` @@ -597,7 +608,9 @@ render();
- + @@ -675,11 +688,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -687,7 +698,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -740,8 +755,9 @@ render(); ``` @@ -759,7 +775,9 @@ render();
- + @@ -834,11 +852,9 @@ import { lastUpdated } from './api/lastUpdated'; export default function TimePage({ id }) { const { updatedAt } = useSuspense(lastUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -865,11 +881,12 @@ function ShowTime() { ); return (
- loading...
}> + loading...
}> -
- Current Time: +
+ Current time +
- - - -
+ + + +
); } @@ -146,8 +142,8 @@ render(); @@ -180,17 +176,15 @@ render(); ``` diff --git a/website/i18n/es/docs/core/concepts/expiry-policy.md b/website/i18n/es/docs/core/concepts/expiry-policy.md index 7c6e3c051de8..df385bb54acf 100644 --- a/website/i18n/es/docs/core/concepts/expiry-policy.md +++ b/website/i18n/es/docs/core/concepts/expiry-policy.md @@ -113,11 +113,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -132,8 +130,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -154,8 +153,8 @@ export default function Navigator({ children }) { @@ -176,9 +175,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -298,11 +301,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -317,8 +318,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -339,8 +341,8 @@ export default function Navigator({ children }) { @@ -361,9 +363,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -427,14 +433,15 @@ function ShowTime() { const { updatedAt } = useSuspense(lastUpdated, { id: '1' }); const ctrl = useController(); return ( -
- {' '} + <> +
+ API time + +
-
+ ); } render(); @@ -454,10 +461,11 @@ render(); ``` @@ -515,11 +523,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -527,7 +533,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -578,8 +588,9 @@ render(); ``` @@ -597,7 +608,9 @@ render();
- + @@ -675,11 +688,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -687,7 +698,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -740,8 +755,9 @@ render(); ``` @@ -759,7 +775,9 @@ render();
- + @@ -834,11 +852,9 @@ import { lastUpdated } from './api/lastUpdated'; export default function TimePage({ id }) { const { updatedAt } = useSuspense(lastUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -865,11 +881,12 @@ function ShowTime() { ); return (
- loading...
}> + loading...
}> -
- Current Time: +
+ Current time +
- - - -
+ + + +
); } @@ -146,8 +142,8 @@ render(); @@ -180,17 +176,15 @@ render(); ``` diff --git a/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md b/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md index 2d6e2780ba16..d75fbeb1b5f2 100644 --- a/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md +++ b/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md @@ -113,11 +113,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -132,8 +130,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -154,8 +153,8 @@ export default function Navigator({ children }) { @@ -176,9 +175,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -298,11 +301,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -317,8 +318,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -339,8 +341,8 @@ export default function Navigator({ children }) { @@ -361,9 +363,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -427,14 +433,15 @@ function ShowTime() { const { updatedAt } = useSuspense(lastUpdated, { id: '1' }); const ctrl = useController(); return ( -
- {' '} + <> +
+ API time + +
-
+ ); } render(); @@ -454,10 +461,11 @@ render(); ``` @@ -515,11 +523,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -527,7 +533,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -578,8 +588,9 @@ render(); ``` @@ -597,7 +608,9 @@ render();
- + @@ -675,11 +688,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -687,7 +698,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -740,8 +755,9 @@ render(); ``` @@ -759,7 +775,9 @@ render();
- + @@ -834,11 +852,9 @@ import { lastUpdated } from './api/lastUpdated'; export default function TimePage({ id }) { const { updatedAt } = useSuspense(lastUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -865,11 +881,12 @@ function ShowTime() { ); return (
- loading...
}> + loading...
}> -
- Current Time: +
+ Current time +
- - - -
+ + + +
); } @@ -146,8 +142,8 @@ render(); @@ -180,17 +176,15 @@ render(); ``` diff --git a/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md b/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md index 92d399f4d8ae..6b64d2444657 100644 --- a/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md +++ b/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md @@ -113,11 +113,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -132,8 +130,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -154,8 +153,8 @@ export default function Navigator({ children }) { @@ -176,9 +175,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -298,11 +301,9 @@ import { getUpdated } from './getUpdated'; export default function TimePage({ id }: { id: string }) { const { updatedAt } = useSuspense(getUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -317,8 +318,9 @@ export default function Navigator({ children }) { Second {children} -
- Current Time: +
+ Current time +
); @@ -339,8 +341,8 @@ export default function Navigator({ children }) { @@ -361,9 +363,13 @@ export default function Navigator({ children }) { :key="route.fullPath" :id="route.params.id" /> - + +
+ Current time + +
``` @@ -427,14 +433,15 @@ function ShowTime() { const { updatedAt } = useSuspense(lastUpdated, { id: '1' }); const ctrl = useController(); return ( -
- {' '} + <> +
+ API time + +
-
+ ); } render(); @@ -454,10 +461,11 @@ render(); ``` @@ -515,11 +523,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -527,7 +533,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -578,8 +588,9 @@ render(); ``` @@ -597,7 +608,9 @@ render();
- + @@ -675,11 +688,9 @@ export default function ShowTime({ id }: { id: string }) { const { updatedAt } = useSuspense(lastUpdated, { id }); const ctrl = useController(); return ( -
- {id}{' '} - +
+ {id} +
); } @@ -687,7 +698,11 @@ export default function ShowTime({ id }: { id: string }) { ```tsx title="Loading" collapsed export default function Loading({ id }: { id: string }) { - return
{id} Loading...
; + return ( +
+ {id} Loading... +
+ ); } ``` @@ -740,8 +755,9 @@ render(); ``` @@ -759,7 +775,9 @@ render();
- + @@ -834,11 +852,9 @@ import { lastUpdated } from './api/lastUpdated'; export default function TimePage({ id }) { const { updatedAt } = useSuspense(lastUpdated, { id }); return ( -
- API time for {id}:{' '} - +
+ API time for {id} +
); } @@ -865,11 +881,12 @@ function ShowTime() { ); return (
- loading...
}> + loading...
}> -
- Current Time: +
+ Current time +
); } diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 6baa0f7d5769..9fc618525a7e 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -123,6 +123,10 @@ div.playground-preview h1:first-child { .playground-preview h4 + small { display: block; } +/* a clock's digits keep their width as it ticks */ +.playground-preview time { + font-variant-numeric: tabular-nums; +} .playground-preview section { margin-bottom: var(--ds-space-5); } @@ -507,9 +511,16 @@ div.playground-preview h1:first-child { display: flex; align-items: center; gap: 10px; + /* the row's inset is the label's (6px, tighter than a row of text), so + the whole band toggles the box: 33px tall for a one-line title */ + padding: 6px 0; line-height: 1.4; cursor: pointer; } +.playground-preview + .listItem:has(> label > input[type='checkbox']:first-child) { + padding-block: 0; +} .playground-preview .listItem > label > input[type='checkbox']:first-child { appearance: none; flex: none; @@ -545,6 +556,49 @@ div.playground-preview h1:first-child { .playground-preview .listItem > .meta:last-child { margin-left: auto; } +/* Actions under rows (a clock's Refresh) stand 16px off the last one */ +.playground-preview .listItem + button { + margin-top: var(--ds-space-2); +} +/* A row led by its label (a clock's "API time", then its reading): the + label keeps a column of its own, so sibling rows' values line up, and the + row sits on the text's baseline */ +.playground-preview .listItem:has(> .meta:first-child) { + align-items: baseline; +} +.playground-preview .listItem > .meta:first-child { + flex: none; + min-width: 7rem; +} +/* A row's remove button (CancelButton): a 16px mark at the row's end whose + 32px hit area reaches into the row's inset, so the row keeps its height */ +.playground-preview button.cancel { + flex: none; + margin: -8px -8px -8px auto; + padding: 8px; + border: 0; + border-radius: var(--ds-radius-sm); + background: none; + line-height: 0; + cursor: pointer; + transition: background-color var(--motion-snappy); +} +.playground-preview button.cancel img { + display: block; +} +@media (hover: hover) { + .playground-preview button.cancel:hover { + background: var(--ds-hover); + } +} +.playground-preview button.cancel:focus-visible { + outline: 2px solid var(--ds-accent); + outline-offset: -2px; +} +/* with nothing to remove (a row being written) it only holds the place */ +.playground-preview button.cancel:disabled { + visibility: hidden; +} /* Toasts (, ./Toaster.tsx): stacked under the app, each a tinted row with an edge bar in its outcome's color: the live green for success, diff --git a/website/translate/lock/es.json b/website/translate/lock/es.json index a7810a2642e7..f5384dcbb88c 100644 --- a/website/translate/lock/es.json +++ b/website/translate/lock/es.json @@ -34,8 +34,8 @@ "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "ac04c4154461fc41535d4ff7d810d1b165209b08", - "docs/core/concepts/expiry-policy.md": "d274674f9ee59f3b598c991a2a14ed0ba37431b2", + "docs/core/concepts/error-policy.md": "29f2ce2d57433a7bb06e6c7600df2008da3d82f4", + "docs/core/concepts/expiry-policy.md": "6bd6ca60207720d588b61da295b9d3288b13cab5", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", @@ -412,7 +412,8 @@ "theme.CodeBlock.terminal": "Run in your terminal", "theme.CodeBlock.terminalTitle": "Terminal", "playground.code.hide": "Hide code", - "playground.code.show": "Show code" + "playground.code.show": "Show code", + "playground.cancelButton.label": "Remove" }, "docusaurus-plugin-content-blog/options.json": { "title": "Blog", diff --git a/website/translate/lock/pt-BR.json b/website/translate/lock/pt-BR.json index 7f5b3647cd1f..cbc31022e7b7 100644 --- a/website/translate/lock/pt-BR.json +++ b/website/translate/lock/pt-BR.json @@ -34,8 +34,8 @@ "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "ac04c4154461fc41535d4ff7d810d1b165209b08", - "docs/core/concepts/expiry-policy.md": "d274674f9ee59f3b598c991a2a14ed0ba37431b2", + "docs/core/concepts/error-policy.md": "29f2ce2d57433a7bb06e6c7600df2008da3d82f4", + "docs/core/concepts/expiry-policy.md": "6bd6ca60207720d588b61da295b9d3288b13cab5", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", @@ -412,7 +412,8 @@ "theme.CodeBlock.terminal": "Run in your terminal", "theme.CodeBlock.terminalTitle": "Terminal", "playground.code.hide": "Hide code", - "playground.code.show": "Show code" + "playground.code.show": "Show code", + "playground.cancelButton.label": "Remove" }, "docusaurus-plugin-content-blog/options.json": { "sidebar.title": "All posts", diff --git a/website/translate/lock/zh-Hans.json b/website/translate/lock/zh-Hans.json index 207184ab32a6..3d35d1722649 100644 --- a/website/translate/lock/zh-Hans.json +++ b/website/translate/lock/zh-Hans.json @@ -34,8 +34,8 @@ "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "ac04c4154461fc41535d4ff7d810d1b165209b08", - "docs/core/concepts/expiry-policy.md": "d274674f9ee59f3b598c991a2a14ed0ba37431b2", + "docs/core/concepts/error-policy.md": "29f2ce2d57433a7bb06e6c7600df2008da3d82f4", + "docs/core/concepts/expiry-policy.md": "6bd6ca60207720d588b61da295b9d3288b13cab5", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", @@ -412,7 +412,8 @@ "theme.CodeBlock.terminalTitle": "Terminal", "theme.CodeBlock.terminal": "Run in your terminal", "playground.code.hide": "Hide code", - "playground.code.show": "Show code" + "playground.code.show": "Show code", + "playground.cancelButton.label": "Remove" }, "docusaurus-plugin-content-blog/options.json": { "title": "Blog", From 56f2d1470ff70dedc80b06be8fb1dd0437bce983 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:24:10 +0000 Subject: [PATCH 12/66] docs: Keep the expiry demos' Navigator out of skill references Navigator only draws the preview's nav and clock, and nothing imports it, so it is docs-only (#4458) like other preview UI. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../references/expiry-policy.md | 36 ------------- .../references/expiry-policy.vue.md | 52 ------------------- docs/core/concepts/expiry-policy.md | 8 +-- .../es/docs/core/concepts/expiry-policy.md | 8 +-- .../pt-BR/docs/core/concepts/expiry-policy.md | 8 +-- .../docs/core/concepts/expiry-policy.md | 8 +-- website/translate/lock/es.json | 2 +- website/translate/lock/pt-BR.json | 2 +- website/translate/lock/zh-Hans.json | 2 +- 9 files changed, 19 insertions(+), 107 deletions(-) diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md index 9857a24bc11a..b9270d784b80 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -81,24 +81,6 @@ export default function TimePage({ id }: { id: string }) { } ``` -```tsx title="Navigator" layout -export default function Navigator({ children }) { - return ( -
- - {children} -
- Current time - -
-
- ); -} -``` -
@data-client/rest @@ -202,24 +184,6 @@ export default function TimePage({ id }: { id: string }) { } ``` -```tsx title="Navigator" layout -export default function Navigator({ children }) { - return ( -
- - {children} -
- Current time - -
-
- ); -} -``` - ## Force refresh We sometimes want to fetch new data; while continuing to show the old (stale) data. diff --git a/.agents/skills/data-client-rest/references/expiry-policy.vue.md b/.agents/skills/data-client-rest/references/expiry-policy.vue.md index 533cbd7cb3c2..c419ab21db07 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.vue.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.vue.md @@ -80,32 +80,6 @@ export const getUpdated = lastUpdated.extend({ dataExpiryLength: 10000 }); ``` -```html title="Navigator.vue" layout - -``` -
@data-client/rest @@ -211,32 +185,6 @@ export const getUpdated = lastUpdated.extend({ ``` -```html title="Navigator.vue" layout - -``` - ## Force refresh We sometimes want to fetch new data; while continuing to show the old (stale) data. diff --git a/docs/core/concepts/expiry-policy.md b/docs/core/concepts/expiry-policy.md index 6bd6ca602077..8970c97515c6 100644 --- a/docs/core/concepts/expiry-policy.md +++ b/docs/core/concepts/expiry-policy.md @@ -121,7 +121,7 @@ export default function TimePage({ id }: { id: string }) { } ``` -```tsx title="Navigator" layout collapsed +```tsx title="Navigator" layout collapsed docs-only export default function Navigator({ children }) { return (
@@ -160,7 +160,7 @@ export default function Navigator({ children }) { ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` -```html title="Navigator.vue" layout collapsed +```html title="Navigator.vue" layout collapsed docs-only ``` diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md index b9270d784b80..6e4f4716f28a 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -304,12 +304,14 @@ function Demo() { - - +
+ + +
); } @@ -390,14 +392,16 @@ function Demo() { - - +
+ + +
); } @@ -471,25 +475,27 @@ function ShowTime() { Current time
- - - +
+ + + +
); } diff --git a/.agents/skills/data-client-rest/references/expiry-policy.vue.md b/.agents/skills/data-client-rest/references/expiry-policy.vue.md index c419ab21db07..183f9c9a23c1 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.vue.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.vue.md @@ -289,10 +289,12 @@ export const lastUpdated = new RestEndpoint({ - - +
+ + +
``` @@ -364,10 +366,12 @@ export const lastUpdated = new RestEndpoint({ - - +
+ + +
``` @@ -439,17 +443,19 @@ export const lastUpdated = new RestEndpoint({ - - - +
+ + + +
``` diff --git a/docs/core/concepts/error-policy.md b/docs/core/concepts/error-policy.md index 29f2ce2d5743..0a0845b29392 100644 --- a/docs/core/concepts/error-policy.md +++ b/docs/core/concepts/error-policy.md @@ -117,10 +117,12 @@ function ShowTime() { loading...
}> - - - - +
+ + + + +
); } @@ -181,10 +183,12 @@ render(); - - - - +
+ + + + +
``` diff --git a/docs/core/concepts/expiry-policy.md b/docs/core/concepts/expiry-policy.md index 8970c97515c6..4337ef900fb8 100644 --- a/docs/core/concepts/expiry-policy.md +++ b/docs/core/concepts/expiry-policy.md @@ -562,12 +562,14 @@ function Demo() { - - +
+ + +
); } @@ -613,10 +615,12 @@ render(); - - +
+ + +
``` @@ -727,14 +731,16 @@ function Demo() { - - +
+ + +
); } @@ -780,10 +786,12 @@ render(); - - +
+ + +
``` @@ -888,25 +896,27 @@ function ShowTime() { Current time
- - - +
+ + + +
); } @@ -960,17 +970,19 @@ render(); - - - +
+ + + +
``` diff --git a/website/i18n/es/docs/core/concepts/error-policy.md b/website/i18n/es/docs/core/concepts/error-policy.md index d4eec109d960..fbde0808ea7c 100644 --- a/website/i18n/es/docs/core/concepts/error-policy.md +++ b/website/i18n/es/docs/core/concepts/error-policy.md @@ -117,10 +117,12 @@ function ShowTime() { loading...
}> - - - - +
+ + + + +
); } @@ -181,10 +183,12 @@ render(); - - - - +
+ + + + +
``` diff --git a/website/i18n/es/docs/core/concepts/expiry-policy.md b/website/i18n/es/docs/core/concepts/expiry-policy.md index 827ef62e4c91..1098184735e2 100644 --- a/website/i18n/es/docs/core/concepts/expiry-policy.md +++ b/website/i18n/es/docs/core/concepts/expiry-policy.md @@ -562,12 +562,14 @@ function Demo() { - - +
+ + +
); } @@ -613,10 +615,12 @@ render(); - - +
+ + +
``` @@ -727,14 +731,16 @@ function Demo() { - - +
+ + +
); } @@ -780,10 +786,12 @@ render(); - - +
+ + +
``` @@ -888,25 +896,27 @@ function ShowTime() { Current time - - - +
+ + + +
); } @@ -960,17 +970,19 @@ render(); - - - +
+ + + +
``` diff --git a/website/i18n/pt-BR/docs/core/concepts/error-policy.md b/website/i18n/pt-BR/docs/core/concepts/error-policy.md index 5d3f8bb0eab5..39a0764d9422 100644 --- a/website/i18n/pt-BR/docs/core/concepts/error-policy.md +++ b/website/i18n/pt-BR/docs/core/concepts/error-policy.md @@ -117,10 +117,12 @@ function ShowTime() { loading...}> - - - - +
+ + + + +
); } @@ -181,10 +183,12 @@ render(); - - - - +
+ + + + +
``` diff --git a/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md b/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md index 13fe70942011..9932f4b72b2e 100644 --- a/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md +++ b/website/i18n/pt-BR/docs/core/concepts/expiry-policy.md @@ -562,12 +562,14 @@ function Demo() { - - +
+ + +
); } @@ -613,10 +615,12 @@ render(); - - +
+ + +
``` @@ -727,14 +731,16 @@ function Demo() { - - +
+ + +
); } @@ -780,10 +786,12 @@ render(); - - +
+ + +
``` @@ -888,25 +896,27 @@ function ShowTime() { Current time - - - +
+ + + +
); } @@ -960,17 +970,19 @@ render(); - - - +
+ + + +
``` diff --git a/website/i18n/zh-Hans/docs/core/concepts/error-policy.md b/website/i18n/zh-Hans/docs/core/concepts/error-policy.md index 2ca1bb334263..77cb9aaff128 100644 --- a/website/i18n/zh-Hans/docs/core/concepts/error-policy.md +++ b/website/i18n/zh-Hans/docs/core/concepts/error-policy.md @@ -117,10 +117,12 @@ function ShowTime() { loading...}> - - - - +
+ + + + +
); } @@ -181,10 +183,12 @@ render(); - - - - +
+ + + + +
``` diff --git a/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md b/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md index 4df03a6bd64a..dfce6638c6e8 100644 --- a/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md +++ b/website/i18n/zh-Hans/docs/core/concepts/expiry-policy.md @@ -562,12 +562,14 @@ function Demo() { - - +
+ + +
); } @@ -613,10 +615,12 @@ render(); - - +
+ + +
``` @@ -727,14 +731,16 @@ function Demo() { - - +
+ + +
); } @@ -780,10 +786,12 @@ render(); - - +
+ + +
``` @@ -888,25 +896,27 @@ function ShowTime() { Current time - - - +
+ + + +
); } @@ -960,17 +970,19 @@ render(); - - - +
+ + + +
``` diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 9fc618525a7e..d0a3dd2555da 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -556,6 +556,17 @@ div.playground-preview h1:first-child { .playground-preview .listItem > .meta:last-child { margin-left: auto; } +/* A row of actions on what is above it (Fail Soft, Fail Hard): evenly + spaced, wrapping on a phone */ +.playground-preview .buttonGroup { + display: flex; + flex-wrap: wrap; + gap: var(--ds-space-2); + margin-top: var(--ds-space-2); +} +.playground-preview .buttonGroup > button { + margin: 0; +} /* Actions under rows (a clock's Refresh) stand 16px off the last one */ .playground-preview .listItem + button { margin-top: var(--ds-space-2); diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index c650f288ac6d..00723b8094a9 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -200,9 +200,10 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element in its `label` is a round box in the accent), `meta` (secondary text; last in a `listItem`, it sits at the row's end), `toaster`/`toast` (rendered by the `Toaster` component, which shows what the `toast` helper - sends), `byline` (avatar beside a muted name; `Avatar` takes a `size`), - `author` (name under avatar), `boardColumn` (a well holding its tasks as - rows of the sheet's color), plus the older `voteBlock`/`vote`. Prefer a + sends), `buttonGroup` (a row of actions, wrapping on a phone), `byline` + (avatar beside a muted name; `Avatar` takes a `size`), `author` (name + under avatar), `boardColumn` (a well holding its tasks as rows of the + sheet's color), plus the older `voteBlock`/`vote`. Prefer a class or element style to a new component; a new scope global goes in `preview/scope.ts`, `monaco/typeLibs.ts` and `website/framework-docs/checkExamples.mjs`. Keep docs demos that use diff --git a/website/translate/lock/es.json b/website/translate/lock/es.json index a5f436094fe3..804aca5f39c6 100644 --- a/website/translate/lock/es.json +++ b/website/translate/lock/es.json @@ -34,8 +34,8 @@ "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "29f2ce2d57433a7bb06e6c7600df2008da3d82f4", - "docs/core/concepts/expiry-policy.md": "8970c97515c6829fa6be0242e5e4affff4874e69", + "docs/core/concepts/error-policy.md": "0a0845b29392e74e5e31d3530b2334f68b811857", + "docs/core/concepts/expiry-policy.md": "4337ef900fb840f6fbfc92a9894d4b429c2db50f", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", diff --git a/website/translate/lock/pt-BR.json b/website/translate/lock/pt-BR.json index 8ca27d99b680..c42f764c0f8e 100644 --- a/website/translate/lock/pt-BR.json +++ b/website/translate/lock/pt-BR.json @@ -34,8 +34,8 @@ "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "29f2ce2d57433a7bb06e6c7600df2008da3d82f4", - "docs/core/concepts/expiry-policy.md": "8970c97515c6829fa6be0242e5e4affff4874e69", + "docs/core/concepts/error-policy.md": "0a0845b29392e74e5e31d3530b2334f68b811857", + "docs/core/concepts/expiry-policy.md": "4337ef900fb840f6fbfc92a9894d4b429c2db50f", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", diff --git a/website/translate/lock/zh-Hans.json b/website/translate/lock/zh-Hans.json index ea454e3d3fd8..017444a049c1 100644 --- a/website/translate/lock/zh-Hans.json +++ b/website/translate/lock/zh-Hans.json @@ -34,8 +34,8 @@ "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "29f2ce2d57433a7bb06e6c7600df2008da3d82f4", - "docs/core/concepts/expiry-policy.md": "8970c97515c6829fa6be0242e5e4affff4874e69", + "docs/core/concepts/error-policy.md": "0a0845b29392e74e5e31d3530b2334f68b811857", + "docs/core/concepts/expiry-policy.md": "4337ef900fb840f6fbfc92a9894d4b429c2db50f", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", From 30228f77b365ac658ac1ca654ce70d5ca0b9b06f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:03:43 +0000 Subject: [PATCH 14/66] docs: Make the demo design system's buttonGroup one segmented control Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 28 +++++++++++++++++-- website/src/components/Playground/README.md | 2 +- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index d0a3dd2555da..92acee944fb5 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -556,16 +556,38 @@ div.playground-preview h1:first-child { .playground-preview .listItem > .meta:last-child { margin-left: auto; } -/* A row of actions on what is above it (Fail Soft, Fail Hard): evenly - spaced, wrapping on a phone */ +/* Actions on what is above it (Fail Soft, Fail Hard) as one segmented + control: one edge around them all, a hairline between each. On a phone + the segments wrap and stretch, so long labels stack into a list. The + hairlines are the group's own color showing through a 1px gap. */ .playground-preview .buttonGroup { display: flex; flex-wrap: wrap; - gap: var(--ds-space-2); + gap: 1px; + width: fit-content; + max-width: 100%; margin-top: var(--ds-space-2); + border: 1px solid var(--ds-border-strong); + border-radius: var(--ds-radius-sm); + background: var(--ds-border-strong); + overflow: hidden; } .playground-preview .buttonGroup > button { + flex: 1 1 auto; margin: 0; + border: 0; + border-radius: 0; +} +/* opaque, so the hairlines don't show through the hover wash */ +.playground-preview .buttonGroup > button:not([class]):hover { + background: color-mix(in srgb, var(--ds-accent) 10%, var(--ds-surface)); +} +.playground-preview .buttonGroup > button:not([class]):active { + background: color-mix(in srgb, var(--ds-accent) 18%, var(--ds-surface)); +} +/* inside the group's edge, which clips anything outside it */ +.playground-preview .buttonGroup > button:focus-visible { + outline-offset: -2px; } /* Actions under rows (a clock's Refresh) stand 16px off the last one */ .playground-preview .listItem + button { diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 00723b8094a9..9721e274f39f 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -200,7 +200,7 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element in its `label` is a round box in the accent), `meta` (secondary text; last in a `listItem`, it sits at the row's end), `toaster`/`toast` (rendered by the `Toaster` component, which shows what the `toast` helper - sends), `buttonGroup` (a row of actions, wrapping on a phone), `byline` + sends), `buttonGroup` (actions as one segmented control), `byline` (avatar beside a muted name; `Avatar` takes a `size`), `author` (name under avatar), `boardColumn` (a well holding its tasks as rows of the sheet's color), plus the older `voteBlock`/`vote`. Prefer a From 940f7eab9ec5d6ccb8327c1fa0af121a738c838b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:31:14 +0000 Subject: [PATCH 15/66] docs: Show skeletons instead of loading text in the playground demos The demo design system gains `skeleton`, one line of text still loading, drawn in its value's place so nothing moves when it lands, and `Loading` (every playground's fallback) becomes a page of them on the app's sheet. A button with `aria-busy` traces its border like a saving form's submit. Docs examples that rendered "loading..." now use these. Simplify pass: the Code toggle passes a callback rather than an element, opens the editor in the click's own render, and the Store toggle no longer branches on demo; a shared useBaseUrl stand-in replaces a per-test mock; the text field label's dark-only background is one rule. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../references/Controller.vue.md | 2 +- .../data-client-react/references/useQuery.md | 2 +- .../references/error-policy.md | 9 +- .../references/error-policy.vue.md | 7 +- .../references/expiry-policy.md | 19 +++- .../references/expiry-policy.vue.md | 15 ++- .../references/Collection.md | 4 +- .../references/Collection.vue.md | 2 +- .../data-client-schema/references/Query.md | 10 +- .../references/Query.vue.md | 7 +- .../data-client-schema/references/Scalar.md | 8 +- .../references/Scalar.vue.md | 18 ++-- .../references/_ScalarDemo.md | 8 +- .../references/_ScalarDemo.vue.md | 18 ++-- .../data-client-vue/references/Controller.md | 2 +- .../data-client-vue/references/useQuery.md | 2 +- docs/core/api/Controller.md | 2 +- docs/core/api/useQuery.md | 4 +- docs/core/concepts/error-policy.md | 16 +++- docs/core/concepts/expiry-policy.md | 34 +++++-- docs/rest/api/Collection.md | 6 +- docs/rest/api/Query.md | 17 +++- docs/rest/shared/_ScalarDemo.mdx | 26 +++-- jest.config.js | 2 + website/framework-docs/checkExamples.mjs | 8 +- website/i18n/es/docs/core/api/useQuery.md | 4 +- .../es/docs/core/concepts/error-policy.md | 16 +++- .../es/docs/core/concepts/expiry-policy.md | 34 +++++-- website/i18n/es/docs/rest/api/Collection.md | 6 +- website/i18n/es/docs/rest/api/Query.md | 17 +++- .../i18n/es/docs/rest/shared/_ScalarDemo.mdx | 26 +++-- website/i18n/pt-BR/docs/core/api/useQuery.md | 4 +- .../pt-BR/docs/core/concepts/error-policy.md | 16 +++- .../pt-BR/docs/core/concepts/expiry-policy.md | 34 +++++-- .../i18n/pt-BR/docs/rest/api/Collection.md | 6 +- website/i18n/pt-BR/docs/rest/api/Query.md | 17 +++- .../pt-BR/docs/rest/shared/_ScalarDemo.mdx | 26 +++-- .../i18n/zh-Hans/docs/core/api/useQuery.md | 4 +- .../docs/core/concepts/error-policy.md | 16 +++- .../docs/core/concepts/expiry-policy.md | 34 +++++-- .../i18n/zh-Hans/docs/rest/api/Collection.md | 6 +- website/i18n/zh-Hans/docs/rest/api/Query.md | 17 +++- .../zh-Hans/docs/rest/shared/_ScalarDemo.mdx | 26 +++-- .../DesignSystem/Loading.module.css | 11 --- .../Playground/DesignSystem/Loading.tsx | 14 +-- .../Playground/DesignSystem/design-system.css | 95 +++++++++++++++++-- website/src/components/Playground/README.md | 11 ++- .../__tests__/demoLayoutLive.test.tsx | 5 - website/src/components/Playground/index.tsx | 21 ++-- .../Playground/preview/PreviewBlock.tsx | 8 +- .../Playground/preview/PreviewWrapper.tsx | 5 +- .../preview/__tests__/PreviewWrapper.test.tsx | 4 +- .../components/Playground/styles.module.css | 3 +- .../testing/docusaurusUseBaseUrl.ts | 4 + website/src/components/Playground/types.ts | 6 +- website/translate/lock/es.json | 12 +-- website/translate/lock/pt-BR.json | 12 +-- website/translate/lock/zh-Hans.json | 12 +-- 58 files changed, 542 insertions(+), 238 deletions(-) delete mode 100644 website/src/components/Playground/DesignSystem/Loading.module.css create mode 100644 website/src/components/Playground/testing/docusaurusUseBaseUrl.ts diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md index eb4ab5849bce..6074d38d8b45 100644 --- a/.agents/skills/data-client-manager/references/Controller.vue.md +++ b/.agents/skills/data-client-manager/references/Controller.vue.md @@ -266,7 +266,7 @@ until the refetch resolves. > > > -> +> > > > ``` diff --git a/.agents/skills/data-client-react/references/useQuery.md b/.agents/skills/data-client-react/references/useQuery.md index 8eb96b9af0ee..f0bb1c0a799b 100644 --- a/.agents/skills/data-client-react/references/useQuery.md +++ b/.agents/skills/data-client-react/references/useQuery.md @@ -295,7 +295,7 @@ function BuildingList({ dept }: { dept: Department }) { function DepartmentsPage() { useFetch(DepartmentResource.getList); const departments = useQuery(new All(Department)); - if (!departments) return
Loading...
; + if (!departments) return ; return (
{departments.map(dept => ( diff --git a/.agents/skills/data-client-rest/references/error-policy.md b/.agents/skills/data-client-rest/references/error-policy.md index b39858269c80..36624c11639e 100644 --- a/.agents/skills/data-client-rest/references/error-policy.md +++ b/.agents/skills/data-client-rest/references/error-policy.md @@ -72,7 +72,14 @@ function ShowTime() { }; return (
- loading...
}> + + API time + +
+ } + >
diff --git a/.agents/skills/data-client-rest/references/error-policy.vue.md b/.agents/skills/data-client-rest/references/error-policy.vue.md index 32e269441afa..5b9667c35538 100644 --- a/.agents/skills/data-client-rest/references/error-policy.vue.md +++ b/.agents/skills/data-client-rest/references/error-policy.vue.md @@ -85,7 +85,12 @@ export const lastUpdated = new RestEndpoint({ - +
diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md index 6e4f4716f28a..c39dc7c7b8f7 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -277,7 +277,8 @@ export default function ShowTime({ id }: { id: string }) { export default function Loading({ id }: { id: string }) { return (
- {id} Loading... + {id} +
); } @@ -365,7 +366,8 @@ export default function ShowTime({ id }: { id: string }) { export default function Loading({ id }: { id: string }) { return (
- {id} Loading... + {id} +
); } @@ -468,7 +470,14 @@ function ShowTime() { ); return (
- loading...
}> + + API time for 1 + +
+ } + >
@@ -476,8 +485,8 @@ function ShowTime() {
- +
); diff --git a/.agents/skills/data-client-schema/references/Collection.vue.md b/.agents/skills/data-client-schema/references/Collection.vue.md index b61253d77b09..9abd9c430a0d 100644 --- a/.agents/skills/data-client-schema/references/Collection.vue.md +++ b/.agents/skills/data-client-schema/references/Collection.vue.md @@ -367,7 +367,7 @@ export const getPosts = new RestEndpoint({ Clara - +
diff --git a/.agents/skills/data-client-schema/references/Query.md b/.agents/skills/data-client-schema/references/Query.md index 186f92a04b2b..ffe361853d3b 100644 --- a/.agents/skills/data-client-schema/references/Query.md +++ b/.agents/skills/data-client-schema/references/Query.md @@ -332,8 +332,14 @@ function PriceDisplay({ productId }: { productId: string }) { useFetch(StatsResource.get, { product_id: productId }); const price = useQuery(queryPrice, { product_id: productId }); - if (price === undefined) return
Loading...
; - return
Price: ${price}
; + return ( +
+ Price:{' '} + {price === undefined ? + + : `$${price}`} +
+ ); } render(); diff --git a/.agents/skills/data-client-schema/references/Query.vue.md b/.agents/skills/data-client-schema/references/Query.vue.md index 0345c3015b7a..e094a035617b 100644 --- a/.agents/skills/data-client-schema/references/Query.vue.md +++ b/.agents/skills/data-client-schema/references/Query.vue.md @@ -334,8 +334,11 @@ export const StatsResource = resource({ ``` diff --git a/.agents/skills/data-client-schema/references/Scalar.md b/.agents/skills/data-client-schema/references/Scalar.md index 3b1b88ff206d..8c4f23854710 100644 --- a/.agents/skills/data-client-schema/references/Scalar.md +++ b/.agents/skills/data-client-schema/references/Scalar.md @@ -93,11 +93,15 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { } function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; + return value === undefined ? + + : `${(value * 100).toFixed(1)}%`; } function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + return value === undefined ? + + : value.toLocaleString(); } ``` diff --git a/.agents/skills/data-client-schema/references/Scalar.vue.md b/.agents/skills/data-client-schema/references/Scalar.vue.md index 1670d4f9de49..9b074a3853d4 100644 --- a/.agents/skills/data-client-schema/references/Scalar.vue.md +++ b/.agents/skills/data-client-schema/references/Scalar.vue.md @@ -70,12 +70,8 @@ export const getPortfolioColumns = new RestEndpoint({ defineProps<{ companies: readonly Company[] }>(); - function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; - } - - function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } @@ -93,8 +89,14 @@ export const getPortfolioColumns = new RestEndpoint({ {{ c.name }} ${{ c.price.toFixed(2) }} - {{ formatPercent(c.pct_equity) }} - {{ formatShares(c.shares) }} + + + + + + + + diff --git a/.agents/skills/data-client-schema/references/_ScalarDemo.md b/.agents/skills/data-client-schema/references/_ScalarDemo.md index f46faa7a78e2..a8b8f5e418e0 100644 --- a/.agents/skills/data-client-schema/references/_ScalarDemo.md +++ b/.agents/skills/data-client-schema/references/_ScalarDemo.md @@ -69,11 +69,15 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { } function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; + return value === undefined ? + + : `${(value * 100).toFixed(1)}%`; } function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + return value === undefined ? + + : value.toLocaleString(); } ``` diff --git a/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md b/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md index ba31319837ab..f592ac0b0aa4 100644 --- a/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md +++ b/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md @@ -46,12 +46,8 @@ export const getPortfolioColumns = new RestEndpoint({ defineProps<{ companies: readonly Company[] }>(); - function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; - } - - function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } @@ -69,8 +65,14 @@ export const getPortfolioColumns = new RestEndpoint({ {{ c.name }} ${{ c.price.toFixed(2) }} - {{ formatPercent(c.pct_equity) }} - {{ formatShares(c.shares) }} + + + + + + + + diff --git a/.agents/skills/data-client-vue/references/Controller.md b/.agents/skills/data-client-vue/references/Controller.md index 5028bf0c4f40..5aaec56136a0 100644 --- a/.agents/skills/data-client-vue/references/Controller.md +++ b/.agents/skills/data-client-vue/references/Controller.md @@ -266,7 +266,7 @@ until the refetch resolves. > > > -> +> > > > ``` diff --git a/.agents/skills/data-client-vue/references/useQuery.md b/.agents/skills/data-client-vue/references/useQuery.md index bd686aae5bd5..5fe7ed232417 100644 --- a/.agents/skills/data-client-vue/references/useQuery.md +++ b/.agents/skills/data-client-vue/references/useQuery.md @@ -297,7 +297,7 @@ export const DepartmentResource = resource({ ``` diff --git a/docs/core/api/useQuery.md b/docs/core/api/useQuery.md index 8a73fcd2dfff..c4565b8afa53 100644 --- a/docs/core/api/useQuery.md +++ b/docs/core/api/useQuery.md @@ -291,7 +291,7 @@ function BuildingList({ dept }: { dept: Department }) { function DepartmentsPage() { useFetch(DepartmentResource.getList); const departments = useQuery(new All(Department)); - if (!departments) return
Loading...
; + if (!departments) return ; return (
{departments.map(dept => ( @@ -340,7 +340,7 @@ render(); diff --git a/docs/rest/api/Query.md b/docs/rest/api/Query.md index 8fad4ed18961..9341d2b64fb2 100644 --- a/docs/rest/api/Query.md +++ b/docs/rest/api/Query.md @@ -388,8 +388,14 @@ function PriceDisplay({ productId }: { productId: string }) { useFetch(StatsResource.get, { product_id: productId }); const price = useQuery(queryPrice, { product_id: productId }); - if (price === undefined) return
Loading...
; - return
Price: ${price}
; + return ( +
+ Price:{' '} + {price === undefined ? + + : `$${price}`} +
+ ); } render(); @@ -422,8 +428,11 @@ render(); ``` diff --git a/docs/rest/shared/_ScalarDemo.mdx b/docs/rest/shared/_ScalarDemo.mdx index 65b1fd201ce9..05a1bacb97c0 100644 --- a/docs/rest/shared/_ScalarDemo.mdx +++ b/docs/rest/shared/_ScalarDemo.mdx @@ -74,11 +74,15 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { } function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; + return value === undefined ? + + : `${(value * 100).toFixed(1)}%`; } function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + return value === undefined ? + + : value.toLocaleString(); } ``` @@ -133,12 +137,8 @@ render(); defineProps<{ companies: readonly Company[] }>(); - function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; - } - - function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } @@ -156,8 +156,14 @@ render(); {{ c.name }} ${{ c.price.toFixed(2) }} - {{ formatPercent(c.pct_equity) }} - {{ formatShares(c.shares) }} + + + + + + + + diff --git a/jest.config.js b/jest.config.js index 91360971e380..2562be50c9b9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -104,6 +104,8 @@ const projects = [ '/website/src/components/Playground/testing/docusaurusBrowserOnly.tsx', '^@docusaurus/useIsBrowser$': '/website/src/components/Playground/testing/docusaurusUseIsBrowser.ts', + '^@docusaurus/useBaseUrl$': + '/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts', '^@docusaurus/useIsomorphicLayoutEffect$': '/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts', '^@theme/CodeBlock$': diff --git a/website/framework-docs/checkExamples.mjs b/website/framework-docs/checkExamples.mjs index e93add286d37..0a16b754d90b 100644 --- a/website/framework-docs/checkExamples.mjs +++ b/website/framework-docs/checkExamples.mjs @@ -40,11 +40,9 @@ const EXT = { ts: '.ts', typescript: '.ts', tsx: '.tsx', html: '.vue' }; const DESIGN_SYSTEM = [ ...fs .readFileSync(path.join(PLAYGROUND, 'DesignSystem/index.ts'), 'utf8') - .matchAll(/^export \{ ([\w, ]+) \}/gm), -] - .flatMap(([, names]) => names.split(/,\s*/)) - // `default as NumberFlow` re-exports the real library - .filter(name => /^\w+$/.test(name)); + // its own modules (`./Avatar`), not the re-exported library + .matchAll(/^export \{ ([\w, ]+) \} from '\.\//gm), +].flatMap(([, names]) => names.split(/,\s*/)); // by name: components are capitalized (`Toaster`), helpers aren't (`toast`) const PLACEHOLDERS = DESIGN_SYSTEM.filter(name => /^[A-Z]/.test(name)); const HELPER_DECLARATIONS = DESIGN_SYSTEM.filter(name => /^[a-z]/.test(name)) diff --git a/website/i18n/es/docs/core/api/useQuery.md b/website/i18n/es/docs/core/api/useQuery.md index ba40c181d629..ea857d523c9b 100644 --- a/website/i18n/es/docs/core/api/useQuery.md +++ b/website/i18n/es/docs/core/api/useQuery.md @@ -291,7 +291,7 @@ function BuildingList({ dept }: { dept: Department }) { function DepartmentsPage() { useFetch(DepartmentResource.getList); const departments = useQuery(new All(Department)); - if (!departments) return
Loading...
; + if (!departments) return ; return (
{departments.map(dept => ( @@ -340,7 +340,7 @@ render(); diff --git a/website/i18n/es/docs/rest/api/Query.md b/website/i18n/es/docs/rest/api/Query.md index 810fce059a19..db4ca173c99a 100644 --- a/website/i18n/es/docs/rest/api/Query.md +++ b/website/i18n/es/docs/rest/api/Query.md @@ -388,8 +388,14 @@ function PriceDisplay({ productId }: { productId: string }) { useFetch(StatsResource.get, { product_id: productId }); const price = useQuery(queryPrice, { product_id: productId }); - if (price === undefined) return
Loading...
; - return
Price: ${price}
; + return ( +
+ Price:{' '} + {price === undefined ? + + : `$${price}`} +
+ ); } render(); @@ -422,8 +428,11 @@ render(); ``` diff --git a/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx b/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx index 65b1fd201ce9..05a1bacb97c0 100644 --- a/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx +++ b/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx @@ -74,11 +74,15 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { } function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; + return value === undefined ? + + : `${(value * 100).toFixed(1)}%`; } function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + return value === undefined ? + + : value.toLocaleString(); } ``` @@ -133,12 +137,8 @@ render(); defineProps<{ companies: readonly Company[] }>(); - function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; - } - - function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } @@ -156,8 +156,14 @@ render(); {{ c.name }} ${{ c.price.toFixed(2) }} - {{ formatPercent(c.pct_equity) }} - {{ formatShares(c.shares) }} + + + + + + + + diff --git a/website/i18n/pt-BR/docs/core/api/useQuery.md b/website/i18n/pt-BR/docs/core/api/useQuery.md index 6b9fb48a6797..d22fbbbe8922 100644 --- a/website/i18n/pt-BR/docs/core/api/useQuery.md +++ b/website/i18n/pt-BR/docs/core/api/useQuery.md @@ -291,7 +291,7 @@ function BuildingList({ dept }: { dept: Department }) { function DepartmentsPage() { useFetch(DepartmentResource.getList); const departments = useQuery(new All(Department)); - if (!departments) return
Loading...
; + if (!departments) return ; return (
{departments.map(dept => ( @@ -340,7 +340,7 @@ render(); diff --git a/website/i18n/pt-BR/docs/rest/api/Query.md b/website/i18n/pt-BR/docs/rest/api/Query.md index 7247f9d6e85d..d445a6458f4b 100644 --- a/website/i18n/pt-BR/docs/rest/api/Query.md +++ b/website/i18n/pt-BR/docs/rest/api/Query.md @@ -388,8 +388,14 @@ function PriceDisplay({ productId }: { productId: string }) { useFetch(StatsResource.get, { product_id: productId }); const price = useQuery(queryPrice, { product_id: productId }); - if (price === undefined) return
Loading...
; - return
Price: ${price}
; + return ( +
+ Price:{' '} + {price === undefined ? + + : `$${price}`} +
+ ); } render(); @@ -422,8 +428,11 @@ render(); ``` diff --git a/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx b/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx index 65b1fd201ce9..05a1bacb97c0 100644 --- a/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx +++ b/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx @@ -74,11 +74,15 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { } function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; + return value === undefined ? + + : `${(value * 100).toFixed(1)}%`; } function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + return value === undefined ? + + : value.toLocaleString(); } ``` @@ -133,12 +137,8 @@ render(); defineProps<{ companies: readonly Company[] }>(); - function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; - } - - function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } @@ -156,8 +156,14 @@ render(); {{ c.name }} ${{ c.price.toFixed(2) }} - {{ formatPercent(c.pct_equity) }} - {{ formatShares(c.shares) }} + + + + + + + + diff --git a/website/i18n/zh-Hans/docs/core/api/useQuery.md b/website/i18n/zh-Hans/docs/core/api/useQuery.md index 185b1efd8de6..cc6348e9cf55 100644 --- a/website/i18n/zh-Hans/docs/core/api/useQuery.md +++ b/website/i18n/zh-Hans/docs/core/api/useQuery.md @@ -291,7 +291,7 @@ function BuildingList({ dept }: { dept: Department }) { function DepartmentsPage() { useFetch(DepartmentResource.getList); const departments = useQuery(new All(Department)); - if (!departments) return
Loading...
; + if (!departments) return ; return (
{departments.map(dept => ( @@ -340,7 +340,7 @@ render(); diff --git a/website/i18n/zh-Hans/docs/rest/api/Query.md b/website/i18n/zh-Hans/docs/rest/api/Query.md index f4f71fc08069..3d37ffd1bc18 100644 --- a/website/i18n/zh-Hans/docs/rest/api/Query.md +++ b/website/i18n/zh-Hans/docs/rest/api/Query.md @@ -388,8 +388,14 @@ function PriceDisplay({ productId }: { productId: string }) { useFetch(StatsResource.get, { product_id: productId }); const price = useQuery(queryPrice, { product_id: productId }); - if (price === undefined) return
Loading...
; - return
Price: ${price}
; + return ( +
+ Price:{' '} + {price === undefined ? + + : `$${price}`} +
+ ); } render(); @@ -422,8 +428,11 @@ render(); ``` diff --git a/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx b/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx index 65b1fd201ce9..05a1bacb97c0 100644 --- a/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx +++ b/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx @@ -74,11 +74,15 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { } function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; + return value === undefined ? + + : `${(value * 100).toFixed(1)}%`; } function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + return value === undefined ? + + : value.toLocaleString(); } ``` @@ -133,12 +137,8 @@ render(); defineProps<{ companies: readonly Company[] }>(); - function formatPercent(value: number | undefined) { - return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; - } - - function formatShares(value: number | undefined) { - return value === undefined ? 'loading...' : value.toLocaleString(); + function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } @@ -156,8 +156,14 @@ render(); {{ c.name }} ${{ c.price.toFixed(2) }} - {{ formatPercent(c.pct_equity) }} - {{ formatShares(c.shares) }} + + + + + + + + diff --git a/website/src/components/Playground/DesignSystem/Loading.module.css b/website/src/components/Playground/DesignSystem/Loading.module.css deleted file mode 100644 index 26cdcf4a9473..000000000000 --- a/website/src/components/Playground/DesignSystem/Loading.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.loading { - display:flex; - justify-content: center; - align-items: center; - height: 100%; -} - -.loading img { - width:50px; - height:50px; -} diff --git a/website/src/components/Playground/DesignSystem/Loading.tsx b/website/src/components/Playground/DesignSystem/Loading.tsx index 4ffe6e9e8a9c..58cd61db8015 100644 --- a/website/src/components/Playground/DesignSystem/Loading.tsx +++ b/website/src/components/Playground/DesignSystem/Loading.tsx @@ -1,12 +1,12 @@ -import useBaseUrl from '@docusaurus/useBaseUrl'; - -import styles from './Loading.module.css'; - +/** A page while it loads: its title and a paragraph drawn as skeletons + * (design-system.css), standing where the content will land */ export function Loading() { - const loadingUrl = useBaseUrl('img/loading-logo.svg'); return ( -
- +
+ + + +
); } diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 92acee944fb5..a4ff36017dff 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -91,9 +91,13 @@ html[data-theme='dark'] .playground-preview { /* fields sit in the well, a step above the panel */ --ifm-color-emphasis-0: var(--ds-surface-2); } -/* The floating label straddles the field's top edge, so it blends into both */ -html[data-theme='dark'] .playground-preview .rt-TextFieldLabel { - background: linear-gradient(var(--ds-surface) 50%, var(--ds-surface-2) 50%); +/* The floating label straddles the field's top edge, so it blends into both + (the panel above, the field's own background below) */ +.playground-preview .rt-TextFieldLabel { + background: linear-gradient( + var(--ds-surface) 50%, + var(--ifm-color-emphasis-0) 50% + ); } /* Text */ @@ -391,13 +395,15 @@ div.playground-preview h1:first-child { .playground-preview fieldset:disabled * { cursor: progress; } -/* While a form saves, a light traces its submit button's border */ +/* While a form saves, a light traces its submit button's border; so too + for a button that is busy on its own (`aria-busy`, a pending action) */ @property --pg-trace-angle { syntax: ''; inherits: false; initial-value: 0deg; } -.playground-preview form[aria-busy='true'] button[type='submit'] { +.playground-preview form[aria-busy='true'] button[type='submit'], +.playground-preview button[aria-busy='true'] { /* 1px wider border, 1px less padding: same size, room for the light */ border: 2px solid transparent; padding: calc(var(--ds-control-pad-y) - 1px) @@ -421,7 +427,8 @@ div.playground-preview h1:first-child { } } @media (prefers-reduced-motion: reduce) { - .playground-preview form[aria-busy='true'] button[type='submit'] { + .playground-preview form[aria-busy='true'] button[type='submit'], + .playground-preview button[aria-busy='true'] { animation: none; } } @@ -585,6 +592,15 @@ div.playground-preview h1:first-child { .playground-preview .buttonGroup > button:not([class]):active { background: color-mix(in srgb, var(--ds-accent) 18%, var(--ds-surface)); } +/* a busy segment's light runs inside the group's edge: a segment has no + border, so the light's 2px come out of its padding, and it takes no + pointer, so the hover wash can't cover the light */ +.playground-preview .buttonGroup > button[aria-busy='true'] { + border: 2px solid transparent; + padding: calc(var(--ds-control-pad-y) - 2px) + calc(var(--ds-control-pad-x) - 2px); + pointer-events: none; +} /* inside the group's edge, which clips anything outside it */ .playground-preview .buttonGroup > button:focus-visible { outline-offset: -2px; @@ -633,6 +649,73 @@ div.playground-preview h1:first-child { visibility: hidden; } +/* Skeletons: what is loading, drawn where it will land in the shape it + will take, so nothing moves when it arrives. A `skeleton` is one line of + text: beside a row's label it holds the value's place (a clock's "API + time"), in a table cell a number's. (./Loading.tsx) draws a + page of them: a title and a paragraph. The zero-width space gives it a + real line, so it keeps the row's height and baseline; the bar is a + rounded band in that line, with a soft light sweeping across (still for + reduced motion). */ +.playground-preview .skeleton { + --ds-skeleton: color-mix(in srgb, var(--ds-text) 8%, transparent); + position: relative; + display: inline-block; + width: 8em; + max-width: 100%; +} +.playground-preview .skeleton::before { + content: '\200b'; +} +.playground-preview .skeleton::after { + content: ''; + position: absolute; + inset: 0; + height: 0.7em; + margin-block: auto; + border-radius: var(--ds-radius-xs); + background: linear-gradient( + 100deg, + transparent 35%, + color-mix(in srgb, var(--ds-text) 7%, transparent) 50%, + transparent 65% + ) + var(--ds-skeleton); + background-size: 300% 100%; + animation: ds-shimmer 1.6s ease-in-out infinite; +} +@keyframes ds-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: 0 0; + } +} +@media (prefers-reduced-motion: reduce) { + .playground-preview .skeleton::after { + animation: none; + } +} +.playground-preview td > .skeleton { + width: 3.5em; +} +/* a page: its title at the h2's size, then the paragraph's lines, the last + one short like a paragraph's */ +.playground-preview .skeletonPage > .skeleton { + display: block; + width: 100%; +} +.playground-preview .skeletonPage > .skeleton:first-child { + width: 55%; + margin-bottom: var(--ds-space-3); + font-size: var(--ifm-h2-font-size); + line-height: 1.3; +} +.playground-preview .skeletonPage > .skeleton:last-child { + width: 62%; +} + /* Toasts (, ./Toaster.tsx): stacked under the app, each a tinted row with an edge bar in its outcome's color: the live green for success, red for an error */ diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 9721e274f39f..4d77fd20f47c 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -177,9 +177,9 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - The preview is a stage and the app a sheet on it (`../styles.module.css`): the `previewWrapper` paints the stage (`--pg-stage`), which shows as the header's tint with no rule under it; react-live's output `div` (`sheet`, - named in `preview/PreviewBlock.tsx`; the loading mark and the error card - stand on the stage, and an empty one hides) is the sheet, flush with the - frame's sides and foot. `--pg-sheet-pad` is its inset; the header shares + named in `preview/PreviewBlock.tsx`, which puts the loading page on one + too; an empty one hides, and the error card stands on the stage) is the + sheet, flush with the frame's sides and foot. `--pg-sheet-pad` is its inset; the header shares it so its label lines up with the sheet's text, and `.navProgress` reads it to run along the sheet's top edge. The frame has no ring, only shadows. `--pg-hover` is every header control's hover band. @@ -200,7 +200,10 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element in its `label` is a round box in the accent), `meta` (secondary text; last in a `listItem`, it sits at the row's end), `toaster`/`toast` (rendered by the `Toaster` component, which shows what the `toast` helper - sends), `buttonGroup` (actions as one segmented control), `byline` + sends), `buttonGroup` (actions as one segmented control; a button with + `aria-busy` is pending, like a saving form's submit), `skeleton` (one + line of text still loading, in its value's place; `Loading` draws a page + of them, and is every playground's fallback), `byline` (avatar beside a muted name; `Avatar` takes a `size`), `author` (name under avatar), `boardColumn` (a well holding its tasks as rows of the sheet's color), plus the older `voteBlock`/`vote`. Prefer a diff --git a/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx index e03fc5801654..4c45d75fd328 100644 --- a/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx +++ b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx @@ -14,11 +14,6 @@ jest.mock('../PlaygroundEditor', () => ({ default: ({ code }: { code: string }) => require('react').createElement('textarea', { defaultValue: code }), })); -jest.mock( - '@docusaurus/useBaseUrl', - () => ({ __esModule: true, default: (url: string) => url }), - { virtual: true }, -); // The real chunk pulls in the whole preview scope (ESM-only packages jest // can't load); what the demo layout needs of it is the header it renders // (PreviewWrapper with its Store toggle) and the toggleStore it is handed. diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 6105e1da2a66..ac57473d672b 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -12,7 +12,6 @@ import type LivePreviewType from './preview/LivePreview'; import PreviewWrapper from './preview/PreviewWrapper'; import type { LogOptions } from './preview/store/actionLog'; import { - CodeHeaderToggle, DemoViewContext, StoreBadge, StoreHeaderToggle, @@ -99,9 +98,9 @@ export default function Playground({ // Demo layout: the code takes the result's place while shown const [codeShown, setCodeShown] = useState(false); - const toggleCode = useCallback(() => setCodeShown(shown => !shown), []); - // the Store lives with the result, so over the code its toggle brings the - // preview back along with what it says (Show Store, Hide Store) + // the Store lives with the result, so over a demo's code its toggle brings + // the preview back along with what it says (Show Store, Hide Store); with + // no code shown that is a no-op const toggleStoreInPreview = useCallback( (event: React.MouseEvent) => { setCodeShown(false); @@ -117,6 +116,11 @@ export default function Playground({ useIsomorphicLayoutEffect(() => { if (codeVisible) setEditorInteractive(true); }, [codeVisible]); + // in the click's own render, not a second one from the effect + const toggleCode = useCallback(() => { + setCodeShown(shown => !shown); + setEditorInteractive(true); + }, []); const fixtureContent = fixtures.length ? : undefined; @@ -145,12 +149,7 @@ export default function Playground({ // the demo layout's Code toggle: in the preview's header whether or not // the live preview has loaded const code: CodeControl | undefined = - demo ? - { - shown: codeShown, - toggle: , - } - : undefined; + demo ? { shown: codeShown, toggle: toggleCode } : undefined; // Live preview only while visible — unmounts when hidden (resets store). const loading = ( @@ -163,7 +162,7 @@ export default function Playground({ documents={documents} fixtureContent={previewFixtures} storeOpen={storeOpen} - toggleStore={demo ? toggleStoreInPreview : toggleStore} + toggleStore={toggleStoreInPreview} row={row} storeHost={storeHost} fixtures={fixtures} diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index 777fcb1b1078..42692f20f941 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -13,7 +13,13 @@ function PreviewBlock({ const preview = ; return ( <> - }> + + +
+ } + > {onCommit ? {preview} diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index 0bb64daa12b2..9dc88ec3e1be 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -8,6 +8,7 @@ import NetworkIcon from '../NetworkIcon'; import styles from '../styles.module.css'; import type { CodeControl } from '../types'; import { RouterControlsSlot } from './routerSlot'; +import { CodeHeaderToggle } from './StoreToggle'; export default function PreviewWrapper({ children, @@ -62,7 +63,9 @@ export default function PreviewWrapper({ : null} - {code?.toggle} + {code ? + + : null} {headerControls}
diff --git a/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx b/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx index fafcb69f86fd..199b498dce4c 100644 --- a/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx +++ b/website/src/components/Playground/preview/__tests__/PreviewWrapper.test.tsx @@ -42,7 +42,7 @@ describe('PreviewWrapper fixtures', () => { describe('PreviewWrapper label', () => { test("names the code in the label's place while it shows", () => { const { rerender } = render( - Code }}> + {} }}>
result
, ); @@ -56,7 +56,7 @@ describe('PreviewWrapper label', () => { expect(editor.getAttribute('aria-hidden')).toBe('true'); rerender( - Code }}> + {} }}>
result
, ); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 5371131041fb..357004890011 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -673,7 +673,8 @@ div.playgroundTextEdit > .playgroundHeader, /* The preview is a stage (on the wrapper, so it tints the header) and the app one sheet filling the rest: the react-live output (.sheet; the - loading mark and the error card stand on the stage) is the sheet, flush + loading page is a sheet too, and the error card stands on the stage) is + the sheet, flush with the frame's sides and foot, its top corners notched into the header's tint, with its own inset. The design system's surface is the sheet's color, so fields and wells read against it. The inset is set on diff --git a/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts b/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts new file mode 100644 index 000000000000..9fe0a8fa8b48 --- /dev/null +++ b/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts @@ -0,0 +1,4 @@ +// Jest stand-in for @docusaurus/useBaseUrl: the site is served from its root +export default function useBaseUrl(url: string) { + return url; +} diff --git a/website/src/components/Playground/types.ts b/website/src/components/Playground/types.ts index 2d4789404dea..c8a93411a2c4 100644 --- a/website/src/components/Playground/types.ts +++ b/website/src/components/Playground/types.ts @@ -1,5 +1,5 @@ import type { Fixture, Interceptor } from '@data-client/test'; -import type { MouseEventHandler, ReactNode } from 'react'; +import type { MouseEventHandler } from 'react'; export type FixtureOrInterceptor = Fixture | Interceptor; @@ -7,8 +7,8 @@ export type FixtureOrInterceptor = Fixture | Interceptor; export interface CodeControl { /** The code shows in the result's place */ shown: boolean; - /** The header's Code toggle */ - toggle: ReactNode; + /** Shows or hides it (the header's Code toggle) */ + toggle: () => void; } export interface PreviewProps { diff --git a/website/translate/lock/es.json b/website/translate/lock/es.json index 804aca5f39c6..20758c26f6b1 100644 --- a/website/translate/lock/es.json +++ b/website/translate/lock/es.json @@ -30,12 +30,12 @@ "docs/core/api/useFetch.md": "335654729f082f685ddad8273275910d48f415a8", "docs/core/api/useLive.md": "606c0c9fa383236d43c8a2217e794d6194ee2293", "docs/core/api/useLoading.md": "30cf8f355b41c1dcd865564cd106e477d3db4690", - "docs/core/api/useQuery.md": "8a73fcd2dfffb8ec372339f29a300c965a9ecdcc", + "docs/core/api/useQuery.md": "c4565b8afa53d20001ecb26a6200dfdd576b14c7", "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "0a0845b29392e74e5e31d3530b2334f68b811857", - "docs/core/concepts/expiry-policy.md": "4337ef900fb840f6fbfc92a9894d4b429c2db50f", + "docs/core/concepts/error-policy.md": "16c7ef734d9695cce74a5aee8a2cc07cae5f2279", + "docs/core/concepts/expiry-policy.md": "00c8d118e6c3a85d8236149b0f6976ed9a14052c", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", @@ -86,7 +86,7 @@ "docs/rest/api/_EndpointLifecycle.mdx": "563727e24a78b8b6f80c6747eac028d269e6924b", "docs/rest/api/All.md": "5185dbac3cef261efeef0df24f655177a377fac6", "docs/rest/api/Array.md": "87466f305d0dad93ec23e27fc163998c88e35f37", - "docs/rest/api/Collection.md": "342d653c28ccf3acb3723334761213a12160363f", + "docs/rest/api/Collection.md": "bbb3169708399ae69e4afeb8330cbb930773dd67", "docs/rest/api/Endpoint.md": "6883b2c843ab06bcd0641fe742ff41f44a49fcb3", "docs/rest/api/Entity.md": "55a51262e065c2b996712d74c714b564868b06c7", "docs/rest/api/EntityMixin.md": "215c187fb5e9b5d8d4a617da6e268485c0566027", @@ -94,7 +94,7 @@ "docs/rest/api/Invalidate.md": "77bc709e7698bf24f4567b3f828e5e77d987d86e", "docs/rest/api/Lazy.md": "aefafedf79c7b8af2956aee40e857ced613321d8", "docs/rest/api/Object.md": "7b508c210cb217b94a03d2f76397a4b6c0cab4e7", - "docs/rest/api/Query.md": "8fad4ed189616f2b10c1e1d2a4ffa1308200aff2", + "docs/rest/api/Query.md": "9341d2b64fb21b205b7b3dbd9036f503e6c2827c", "docs/rest/api/resource.md": "9608b6eb508dd1777381e6ec198813e7455be93e", "docs/rest/api/RestEndpoint.md": "fec186be11867a0b4c5a8173b57fa5a62fd6cf5e", "docs/rest/api/Scalar.md": "e8f5f3ba23531ddfd06986e845583a435188dd9b", @@ -124,7 +124,7 @@ "docs/rest/shared/_entity_lifecycle_methods.mdx": "69a119da621aa891d5525b576ca2e4f240eac4d1", "docs/rest/shared/_optimisticTransform.mdx": "ceeafd70dcf7e18448a977fdd63e533cf06fe0d1", "docs/rest/shared/_PolymorphicFeedDemo.mdx": "973ba6b30da33b373621d2f0c5118cd5b5b84536", - "docs/rest/shared/_ScalarDemo.mdx": "65b1fd201ce9e4823cfe10709aa3440e80a7fe1e", + "docs/rest/shared/_ScalarDemo.mdx": "05a1bacb97c01ef866bb769fada067e092c53d2b", "docs/rest/shared/_SortDemo.mdx": "cc3314e0f290b6044a14247ee68ebcab8796c346", "website/blog/2023-07-04-v0.2-release-announcement.md": "1156fbbc172e5c2edd6b04faa69e9605a9c87824", "website/blog/2024-01-15-v0.9-release-announcement.md": "adcf4a838aa2dc84f6009cc3743a9bd5e429922b", diff --git a/website/translate/lock/pt-BR.json b/website/translate/lock/pt-BR.json index c42f764c0f8e..36251f6b5174 100644 --- a/website/translate/lock/pt-BR.json +++ b/website/translate/lock/pt-BR.json @@ -30,12 +30,12 @@ "docs/core/api/useFetch.md": "335654729f082f685ddad8273275910d48f415a8", "docs/core/api/useLive.md": "606c0c9fa383236d43c8a2217e794d6194ee2293", "docs/core/api/useLoading.md": "30cf8f355b41c1dcd865564cd106e477d3db4690", - "docs/core/api/useQuery.md": "8a73fcd2dfffb8ec372339f29a300c965a9ecdcc", + "docs/core/api/useQuery.md": "c4565b8afa53d20001ecb26a6200dfdd576b14c7", "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "0a0845b29392e74e5e31d3530b2334f68b811857", - "docs/core/concepts/expiry-policy.md": "4337ef900fb840f6fbfc92a9894d4b429c2db50f", + "docs/core/concepts/error-policy.md": "16c7ef734d9695cce74a5aee8a2cc07cae5f2279", + "docs/core/concepts/expiry-policy.md": "00c8d118e6c3a85d8236149b0f6976ed9a14052c", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", @@ -86,7 +86,7 @@ "docs/rest/api/_EndpointLifecycle.mdx": "563727e24a78b8b6f80c6747eac028d269e6924b", "docs/rest/api/All.md": "5185dbac3cef261efeef0df24f655177a377fac6", "docs/rest/api/Array.md": "87466f305d0dad93ec23e27fc163998c88e35f37", - "docs/rest/api/Collection.md": "342d653c28ccf3acb3723334761213a12160363f", + "docs/rest/api/Collection.md": "bbb3169708399ae69e4afeb8330cbb930773dd67", "docs/rest/api/Endpoint.md": "6883b2c843ab06bcd0641fe742ff41f44a49fcb3", "docs/rest/api/Entity.md": "55a51262e065c2b996712d74c714b564868b06c7", "docs/rest/api/EntityMixin.md": "215c187fb5e9b5d8d4a617da6e268485c0566027", @@ -94,7 +94,7 @@ "docs/rest/api/Invalidate.md": "77bc709e7698bf24f4567b3f828e5e77d987d86e", "docs/rest/api/Lazy.md": "aefafedf79c7b8af2956aee40e857ced613321d8", "docs/rest/api/Object.md": "7b508c210cb217b94a03d2f76397a4b6c0cab4e7", - "docs/rest/api/Query.md": "8fad4ed189616f2b10c1e1d2a4ffa1308200aff2", + "docs/rest/api/Query.md": "9341d2b64fb21b205b7b3dbd9036f503e6c2827c", "docs/rest/api/resource.md": "9608b6eb508dd1777381e6ec198813e7455be93e", "docs/rest/api/RestEndpoint.md": "fec186be11867a0b4c5a8173b57fa5a62fd6cf5e", "docs/rest/api/Scalar.md": "e8f5f3ba23531ddfd06986e845583a435188dd9b", @@ -124,7 +124,7 @@ "docs/rest/shared/_entity_lifecycle_methods.mdx": "69a119da621aa891d5525b576ca2e4f240eac4d1", "docs/rest/shared/_optimisticTransform.mdx": "ceeafd70dcf7e18448a977fdd63e533cf06fe0d1", "docs/rest/shared/_PolymorphicFeedDemo.mdx": "973ba6b30da33b373621d2f0c5118cd5b5b84536", - "docs/rest/shared/_ScalarDemo.mdx": "65b1fd201ce9e4823cfe10709aa3440e80a7fe1e", + "docs/rest/shared/_ScalarDemo.mdx": "05a1bacb97c01ef866bb769fada067e092c53d2b", "docs/rest/shared/_SortDemo.mdx": "cc3314e0f290b6044a14247ee68ebcab8796c346", "website/blog/2023-07-04-v0.2-release-announcement.md": "1156fbbc172e5c2edd6b04faa69e9605a9c87824", "website/blog/2024-01-15-v0.9-release-announcement.md": "adcf4a838aa2dc84f6009cc3743a9bd5e429922b", diff --git a/website/translate/lock/zh-Hans.json b/website/translate/lock/zh-Hans.json index 017444a049c1..e86352baa349 100644 --- a/website/translate/lock/zh-Hans.json +++ b/website/translate/lock/zh-Hans.json @@ -30,12 +30,12 @@ "docs/core/api/useFetch.md": "335654729f082f685ddad8273275910d48f415a8", "docs/core/api/useLive.md": "606c0c9fa383236d43c8a2217e794d6194ee2293", "docs/core/api/useLoading.md": "30cf8f355b41c1dcd865564cd106e477d3db4690", - "docs/core/api/useQuery.md": "8a73fcd2dfffb8ec372339f29a300c965a9ecdcc", + "docs/core/api/useQuery.md": "c4565b8afa53d20001ecb26a6200dfdd576b14c7", "docs/core/api/useSubscription.md": "fb106dda420b71a99f324b5de8b13200a453cf22", "docs/core/api/useSuspense.md": "7cc9881e04d0d342395ebb5d4774ec260f716d29", "docs/core/concepts/atomic-mutations.md": "a2b3b26c22f0dc2f05c880b4fe02ece08c8f311e", - "docs/core/concepts/error-policy.md": "0a0845b29392e74e5e31d3530b2334f68b811857", - "docs/core/concepts/expiry-policy.md": "4337ef900fb840f6fbfc92a9894d4b429c2db50f", + "docs/core/concepts/error-policy.md": "16c7ef734d9695cce74a5aee8a2cc07cae5f2279", + "docs/core/concepts/expiry-policy.md": "00c8d118e6c3a85d8236149b0f6976ed9a14052c", "docs/core/concepts/managers.md": "e23ba140ecf5f7a9efe719891ebe07c066d8e162", "docs/core/concepts/normalization.md": "07de0abe40f327755bfded5901771a4e947edb3e", "docs/core/concepts/overview.md": "657a82b4560e469dbd49efb3f6ea8d3f8cd71197", @@ -86,7 +86,7 @@ "docs/rest/api/_EndpointLifecycle.mdx": "563727e24a78b8b6f80c6747eac028d269e6924b", "docs/rest/api/All.md": "5185dbac3cef261efeef0df24f655177a377fac6", "docs/rest/api/Array.md": "87466f305d0dad93ec23e27fc163998c88e35f37", - "docs/rest/api/Collection.md": "342d653c28ccf3acb3723334761213a12160363f", + "docs/rest/api/Collection.md": "bbb3169708399ae69e4afeb8330cbb930773dd67", "docs/rest/api/Endpoint.md": "6883b2c843ab06bcd0641fe742ff41f44a49fcb3", "docs/rest/api/Entity.md": "55a51262e065c2b996712d74c714b564868b06c7", "docs/rest/api/EntityMixin.md": "215c187fb5e9b5d8d4a617da6e268485c0566027", @@ -94,7 +94,7 @@ "docs/rest/api/Invalidate.md": "77bc709e7698bf24f4567b3f828e5e77d987d86e", "docs/rest/api/Lazy.md": "aefafedf79c7b8af2956aee40e857ced613321d8", "docs/rest/api/Object.md": "7b508c210cb217b94a03d2f76397a4b6c0cab4e7", - "docs/rest/api/Query.md": "8fad4ed189616f2b10c1e1d2a4ffa1308200aff2", + "docs/rest/api/Query.md": "9341d2b64fb21b205b7b3dbd9036f503e6c2827c", "docs/rest/api/resource.md": "9608b6eb508dd1777381e6ec198813e7455be93e", "docs/rest/api/RestEndpoint.md": "fec186be11867a0b4c5a8173b57fa5a62fd6cf5e", "docs/rest/api/Scalar.md": "e8f5f3ba23531ddfd06986e845583a435188dd9b", @@ -124,7 +124,7 @@ "docs/rest/shared/_entity_lifecycle_methods.mdx": "69a119da621aa891d5525b576ca2e4f240eac4d1", "docs/rest/shared/_optimisticTransform.mdx": "ceeafd70dcf7e18448a977fdd63e533cf06fe0d1", "docs/rest/shared/_PolymorphicFeedDemo.mdx": "973ba6b30da33b373621d2f0c5118cd5b5b84536", - "docs/rest/shared/_ScalarDemo.mdx": "65b1fd201ce9e4823cfe10709aa3440e80a7fe1e", + "docs/rest/shared/_ScalarDemo.mdx": "05a1bacb97c01ef866bb769fada067e092c53d2b", "docs/rest/shared/_SortDemo.mdx": "cc3314e0f290b6044a14247ee68ebcab8796c346", "website/blog/2023-07-04-v0.2-release-announcement.md": "1156fbbc172e5c2edd6b04faa69e9605a9c87824", "website/blog/2024-01-15-v0.9-release-announcement.md": "adcf4a838aa2dc84f6009cc3743a9bd5e429922b", From a462944fd8a6ae431e939a931539428945d387cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:37:32 +0000 Subject: [PATCH 16/66] docs: Give the playground's preview the editor's slate palette The preview's navy clashed with the editor and Store beside it. Its header now takes the bars' color and its app sheet the code's, so all three read as one family in both themes; the design system's faint text and control edges follow the slate hue at the same contrast. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 12 +++---- .../components/Playground/styles.module.css | 34 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index a4ff36017dff..d8c89fa824d3 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -44,9 +44,9 @@ a well alike */ --ds-hover: var(--pg-hover); /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ - --ds-faint: #5f6d83; + --ds-faint: hsl(229, 10%, 42%); /* 3:1 on the panel, so field and button edges stay legible */ - --ds-border-strong: #78889d; + --ds-border-strong: hsl(229, 10%, 52%); --ds-accent: #1b6fb8; --ds-accent-strong: #155b99; /* the accent's wash: a highlighted row, a changed value, a focus ring */ @@ -80,10 +80,10 @@ /* Dark: the same console at night (the panel, well, line and text follow --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { - /* 4.6:1 on the sheet */ - --ds-faint: #77869d; - /* 3.4:1 on the sheet, 3.1:1 on a well */ - --ds-border-strong: #5f7189; + /* 4.7:1 on a well, 5.4:1 on the sheet */ + --ds-faint: hsl(229, 10%, 61%); + /* 3.1:1 on a well, 3.6:1 on the sheet */ + --ds-border-strong: hsl(229, 10%, 50%); --ds-accent: #53aef0; --ds-accent-strong: #7cc1f4; --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 357004890011..3c29f1662690 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -35,13 +35,13 @@ --ds-*): the sheet the app is on (panel) and a well a step off it, a line kept for demos that draw their own, its text, and the live dot */ --pg-panel: #fff; - --pg-panel-2: #eef2f7; - --pg-line: #d8e0ea; - /* the stage the sheet sits on, showing as the header's tint: a cool tonal - ground, lighter at the top */ - --pg-stage: linear-gradient(170deg, #eef3f9, #e2e9f2); - --pg-text: #0b1220; - --pg-muted: #4c5a6e; + --pg-panel-2: hsl(229, 20%, 96%); + --pg-line: hsl(229, 16%, 88%); + /* the stage the sheet sits on, showing as the header's color: the bar's, + so the preview's header is one with the editor's and the Store's */ + --pg-stage: var(--pg-bar); + --pg-text: hsl(229, 25%, 12%); + --pg-muted: hsl(229, 10%, 38%); --pg-live: #0d8257; --pg-live-soft: color-mix(in srgb, var(--pg-live) 14%, transparent); /* a resting control's band under the pointer, on any ground */ @@ -150,19 +150,19 @@ } /* Dark mode: the frame is a muted slate of the code's indigo, so the code (and the active tab, which matches it) keeps the color. Bars sit a step - above the code, and the preview a half step, so the top row reads as one band */ + above the code, so the top row reads as one band, and the preview's app + is on the code's own color, so editor, Store and preview are one family */ :root[data-theme='dark'] .playgroundContainer:not(.endpointPlayground) { - --pg-bar: hsl(229, 9%, 21%); - --pg-tab-hover-bg: hsl(229, 9%, 26%); + --pg-bar: hsl(229, 12%, 21%); + --pg-tab-hover-bg: hsl(229, 12%, 26%); } :root[data-theme='dark'] .playgroundContainer { - /* the sheet a step above the stage, its wells a step above that */ - --pg-panel: #131b27; - --pg-panel-2: #1b2534; - --pg-line: #27334a; - --pg-stage: linear-gradient(170deg, #0c121b, #0a1620); - --pg-text: #e8eef6; - --pg-muted: #94a2b6; + /* the sheet is the code's color, its wells a step above it */ + --pg-panel: var(--monoco-code-background); + --pg-panel-2: hsl(229, 18%, 19.5%); + --pg-line: hsl(229, 14%, 27%); + --pg-text: hsl(229, 30%, 93%); + --pg-muted: hsl(229, 14%, 68%); --pg-live: #3ddc97; --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); --pg-shadow: rgba(0, 0, 0, 0.8); From dae15e747f84a271556d883c2d144aeb8fbc9160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:44:35 +0000 Subject: [PATCH 17/66] docs: Bring the playground's editor and preview saturation together Keeps today's hues (the editor's slate-indigo, the preview's navy) and replaces the slate preview from the previous commit: the preview is less saturated and the code, bars and Store more, so they read as one family with a little difference left. The preview header's gradient no longer leans green at its foot. The code color is shared, so site code blocks gain the same saturation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 12 +++---- .../components/Playground/styles.module.css | 34 +++++++++---------- website/src/css/root.css | 4 +-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index d8c89fa824d3..05f61cb88071 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -44,9 +44,9 @@ a well alike */ --ds-hover: var(--pg-hover); /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ - --ds-faint: hsl(229, 10%, 42%); + --ds-faint: hsl(217, 10%, 44%); /* 3:1 on the panel, so field and button edges stay legible */ - --ds-border-strong: hsl(229, 10%, 52%); + --ds-border-strong: hsl(214, 10%, 54%); --ds-accent: #1b6fb8; --ds-accent-strong: #155b99; /* the accent's wash: a highlighted row, a changed value, a focus ring */ @@ -80,10 +80,10 @@ /* Dark: the same console at night (the panel, well, line and text follow --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { - /* 4.7:1 on a well, 5.4:1 on the sheet */ - --ds-faint: hsl(229, 10%, 61%); - /* 3.1:1 on a well, 3.6:1 on the sheet */ - --ds-border-strong: hsl(229, 10%, 50%); + /* 4.7:1 on the sheet */ + --ds-faint: hsl(216, 10%, 54%); + /* 3.5:1 on the sheet, 3.1:1 on a well */ + --ds-border-strong: hsl(214, 11.5%, 45.5%); --ds-accent: #53aef0; --ds-accent-strong: #7cc1f4; --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 3c29f1662690..0c0fc170241a 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -35,13 +35,13 @@ --ds-*): the sheet the app is on (panel) and a well a step off it, a line kept for demos that draw their own, its text, and the live dot */ --pg-panel: #fff; - --pg-panel-2: hsl(229, 20%, 96%); - --pg-line: hsl(229, 16%, 88%); - /* the stage the sheet sits on, showing as the header's color: the bar's, - so the preview's header is one with the editor's and the Store's */ - --pg-stage: var(--pg-bar); - --pg-text: hsl(229, 25%, 12%); - --pg-muted: hsl(229, 10%, 38%); + --pg-panel-2: hsl(213, 23%, 95%); + --pg-line: hsl(213, 19%, 88%); + /* the stage the sheet sits on, showing as the header's tint: a cool tonal + ground, lighter at the top */ + --pg-stage: linear-gradient(170deg, hsl(213, 30%, 95.5%), hsl(214, 24%, 92%)); + --pg-text: #0b1220; + --pg-muted: hsl(215, 12%, 36.5%); --pg-live: #0d8257; --pg-live-soft: color-mix(in srgb, var(--pg-live) 14%, transparent); /* a resting control's band under the pointer, on any ground */ @@ -150,19 +150,19 @@ } /* Dark mode: the frame is a muted slate of the code's indigo, so the code (and the active tab, which matches it) keeps the color. Bars sit a step - above the code, so the top row reads as one band, and the preview's app - is on the code's own color, so editor, Store and preview are one family */ + above the code, and the preview a half step, so the top row reads as one band */ :root[data-theme='dark'] .playgroundContainer:not(.endpointPlayground) { - --pg-bar: hsl(229, 12%, 21%); - --pg-tab-hover-bg: hsl(229, 12%, 26%); + --pg-bar: hsl(229, 18%, 21%); + --pg-tab-hover-bg: hsl(229, 18%, 26%); } :root[data-theme='dark'] .playgroundContainer { - /* the sheet is the code's color, its wells a step above it */ - --pg-panel: var(--monoco-code-background); - --pg-panel-2: hsl(229, 18%, 19.5%); - --pg-line: hsl(229, 14%, 27%); - --pg-text: hsl(229, 30%, 93%); - --pg-muted: hsl(229, 14%, 68%); + /* the sheet a step above the stage, its wells a step above that */ + --pg-panel: hsl(216, 22%, 11.4%); + --pg-panel-2: hsl(216, 20%, 15.5%); + --pg-line: hsl(219, 20%, 22%); + --pg-stage: linear-gradient(170deg, hsl(216, 25%, 7.6%), hsl(216, 25%, 8%)); + --pg-text: hsl(214, 28%, 94%); + --pg-muted: hsl(215, 12%, 65%); --pg-live: #3ddc97; --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); --pg-shadow: rgba(0, 0, 0, 0.8); diff --git a/website/src/css/root.css b/website/src/css/root.css index 14e0ced6514c..eda5b009f78a 100644 --- a/website/src/css/root.css +++ b/website/src/css/root.css @@ -1,5 +1,5 @@ html[data-theme='dark'] { - --monoco-code-background: hsl(229, 20%, 15%); + --monoco-code-background: hsl(229, 30%, 15%); --monoco-code-lineHighlightBorder: hsl(228, 20%, 20%); --ifm-link-color: var(--ifm-color-primary-light); --docusaurus-highlighted-code-line-bg: rgb(72 77 91); @@ -12,7 +12,7 @@ html[data-theme='dark'] { --ifm-color-secondary-lightest: #fff; } html[data-theme='light'] { - --monoco-code-background: #292d3e; + --monoco-code-background: hsl(229, 30%, 20%); --monoco-code-lineHighlightBorder: hsl(228, 20%, 25%); --ifm-hero-fade-to-background-color: #fff; --ifm-link-color: var(--ifm-color-primary-dark); From 3ea9a90daaabdbdc4e02b6e9317aff6f55aed1bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:49:39 +0000 Subject: [PATCH 18/66] docs: Move the dark editor and preview palettes toward each other Replaces the saturation-only change from the previous commit. In dark mode the editor's slate and the preview's navy now each sit 40% of the way toward the other in OKLab (lightness, hue and saturation together), so they read as one family with a hint of difference. Light mode is back to how it was. The dark code color is shared, so site code blocks shift by the same small amount. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 10 +++---- .../components/Playground/styles.module.css | 29 ++++++++++--------- website/src/css/root.css | 4 +-- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 05f61cb88071..231cfb5ab075 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -44,9 +44,9 @@ a well alike */ --ds-hover: var(--pg-hover); /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ - --ds-faint: hsl(217, 10%, 44%); + --ds-faint: #5f6d83; /* 3:1 on the panel, so field and button edges stay legible */ - --ds-border-strong: hsl(214, 10%, 54%); + --ds-border-strong: #78889d; --ds-accent: #1b6fb8; --ds-accent-strong: #155b99; /* the accent's wash: a highlighted row, a changed value, a focus ring */ @@ -80,10 +80,10 @@ /* Dark: the same console at night (the panel, well, line and text follow --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { - /* 4.7:1 on the sheet */ - --ds-faint: hsl(216, 10%, 54%); + /* 4.6:1 on the sheet */ + --ds-faint: #7b879e; /* 3.5:1 on the sheet, 3.1:1 on a well */ - --ds-border-strong: hsl(214, 11.5%, 45.5%); + --ds-border-strong: #65748c; --ds-accent: #53aef0; --ds-accent-strong: #7cc1f4; --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 0c0fc170241a..15352712c6bf 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -35,13 +35,13 @@ --ds-*): the sheet the app is on (panel) and a well a step off it, a line kept for demos that draw their own, its text, and the live dot */ --pg-panel: #fff; - --pg-panel-2: hsl(213, 23%, 95%); - --pg-line: hsl(213, 19%, 88%); + --pg-panel-2: #eef2f7; + --pg-line: #d8e0ea; /* the stage the sheet sits on, showing as the header's tint: a cool tonal ground, lighter at the top */ - --pg-stage: linear-gradient(170deg, hsl(213, 30%, 95.5%), hsl(214, 24%, 92%)); + --pg-stage: linear-gradient(170deg, #eef3f9, #e2e9f2); --pg-text: #0b1220; - --pg-muted: hsl(215, 12%, 36.5%); + --pg-muted: #4c5a6e; --pg-live: #0d8257; --pg-live-soft: color-mix(in srgb, var(--pg-live) 14%, transparent); /* a resting control's band under the pointer, on any ground */ @@ -150,19 +150,22 @@ } /* Dark mode: the frame is a muted slate of the code's indigo, so the code (and the active tab, which matches it) keeps the color. Bars sit a step - above the code, and the preview a half step, so the top row reads as one band */ + above the code, and the preview a half step, so the top row reads as one + band. The editor's slate and the preview's navy each sit 40% of the way + toward the other (in OKLab), so they read as one family with a hint of + difference */ :root[data-theme='dark'] .playgroundContainer:not(.endpointPlayground) { - --pg-bar: hsl(229, 18%, 21%); - --pg-tab-hover-bg: hsl(229, 18%, 26%); + --pg-bar: #21262f; + --pg-tab-hover-bg: #2c313c; } :root[data-theme='dark'] .playgroundContainer { /* the sheet a step above the stage, its wells a step above that */ - --pg-panel: hsl(216, 22%, 11.4%); - --pg-panel-2: hsl(216, 20%, 15.5%); - --pg-line: hsl(219, 20%, 22%); - --pg-stage: linear-gradient(170deg, hsl(216, 25%, 7.6%), hsl(216, 25%, 8%)); - --pg-text: hsl(214, 28%, 94%); - --pg-muted: hsl(215, 12%, 65%); + --pg-panel: #181e2a; + --pg-panel-2: #202837; + --pg-line: #2c364d; + --pg-stage: linear-gradient(170deg, #1a1e26, #19222b); + --pg-text: #eaedf5; + --pg-muted: #96a1b5; --pg-live: #3ddc97; --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); --pg-shadow: rgba(0, 0, 0, 0.8); diff --git a/website/src/css/root.css b/website/src/css/root.css index eda5b009f78a..c874b6555757 100644 --- a/website/src/css/root.css +++ b/website/src/css/root.css @@ -1,5 +1,5 @@ html[data-theme='dark'] { - --monoco-code-background: hsl(229, 30%, 15%); + --monoco-code-background: #1a1f2b; --monoco-code-lineHighlightBorder: hsl(228, 20%, 20%); --ifm-link-color: var(--ifm-color-primary-light); --docusaurus-highlighted-code-line-bg: rgb(72 77 91); @@ -12,7 +12,7 @@ html[data-theme='dark'] { --ifm-color-secondary-lightest: #fff; } html[data-theme='light'] { - --monoco-code-background: hsl(229, 30%, 20%); + --monoco-code-background: #292d3e; --monoco-code-lineHighlightBorder: hsl(228, 20%, 25%); --ifm-hero-fade-to-background-color: #fff; --ifm-link-color: var(--ifm-color-primary-dark); From b41355798743e3bbf1aa20ffdc6c0c190da33451 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:58:34 +0000 Subject: [PATCH 19/66] docs: Keep the dark preview's depth while its palette meets the editor's The editor's slate and the preview's navy now move 20% toward each other instead of 40%, and the preview's stage stays a clear step below its sheet (about as far as the editor's bars sit above the code), so the preview keeps the strong contrast it had. Light mode is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/design-system.css | 6 ++--- .../components/Playground/styles.module.css | 27 ++++++++++--------- website/src/css/root.css | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 231cfb5ab075..2075c27d64c7 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -81,9 +81,9 @@ --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { /* 4.6:1 on the sheet */ - --ds-faint: #7b879e; - /* 3.5:1 on the sheet, 3.1:1 on a well */ - --ds-border-strong: #65748c; + --ds-faint: #78869d; + /* 3.4:1 on the sheet, 3.1:1 on a well */ + --ds-border-strong: #607189; --ds-accent: #53aef0; --ds-accent-strong: #7cc1f4; --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 15352712c6bf..e38c5a6d6c5a 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -149,23 +149,24 @@ --pg-tab-selected-bg: var(--monoco-code-background); } /* Dark mode: the frame is a muted slate of the code's indigo, so the code - (and the active tab, which matches it) keeps the color. Bars sit a step - above the code, and the preview a half step, so the top row reads as one - band. The editor's slate and the preview's navy each sit 40% of the way - toward the other (in OKLab), so they read as one family with a hint of - difference */ + (and the active tab, which matches it) keeps the color. The editor's slate + and the preview's navy each sit 20% of the way toward the other (in + OKLab), so they read as one family with a hint of difference. Each side + keeps its depth: the editor's bars a step above the code, and the + preview's stage a step below its sheet, about as far, so the sheet stands + out as clearly as the code does */ :root[data-theme='dark'] .playgroundContainer:not(.endpointPlayground) { - --pg-bar: #21262f; - --pg-tab-hover-bg: #2c313c; + --pg-bar: #292c34; + --pg-tab-hover-bg: #343842; } :root[data-theme='dark'] .playgroundContainer { /* the sheet a step above the stage, its wells a step above that */ - --pg-panel: #181e2a; - --pg-panel-2: #202837; - --pg-line: #2c364d; - --pg-stage: linear-gradient(170deg, #1a1e26, #19222b); - --pg-text: #eaedf5; - --pg-muted: #96a1b5; + --pg-panel: #151c28; + --pg-panel-2: #1d2635; + --pg-line: #2a344c; + --pg-stage: linear-gradient(170deg, #0a0e17, #08121b); + --pg-text: #e9eef6; + --pg-muted: #95a2b6; --pg-live: #3ddc97; --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); --pg-shadow: rgba(0, 0, 0, 0.8); diff --git a/website/src/css/root.css b/website/src/css/root.css index c874b6555757..f0a222bf6864 100644 --- a/website/src/css/root.css +++ b/website/src/css/root.css @@ -1,5 +1,5 @@ html[data-theme='dark'] { - --monoco-code-background: #1a1f2b; + --monoco-code-background: #1c202d; --monoco-code-lineHighlightBorder: hsl(228, 20%, 20%); --ifm-link-color: var(--ifm-color-primary-light); --docusaurus-highlighted-code-line-bg: rgb(72 77 91); From 3f290ff7024ec9197275549f2a54e640c2d4e10a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:13:31 +0000 Subject: [PATCH 20/66] docs: Simplify the playground's skeletons, busy segments and Code toggle - A busy segment in a buttonGroup keeps the pointer: the hover wash skips busy buttons instead, and the busy light's padding reads the control's border width (--ds-control-border), so one rule serves both. - Skeleton bars take their tint from --ds-hover, the design system's existing faint band. - The floating label's two-tone background lives on its own rule, not a preview override of it. - The editor turns interactive in the render that first shows it, with no layout effect. - The scalar demo's React table branches to a skeleton in the cell, as its Vue table does, so its formatters stay plain. - Drop the unused useBaseUrl jest stand-in and the old loading logo. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- docs/rest/shared/_ScalarDemo.mdx | 24 +++---- jest.config.js | 2 - .../i18n/es/docs/rest/shared/_ScalarDemo.mdx | 24 +++---- .../pt-BR/docs/rest/shared/_ScalarDemo.mdx | 24 +++---- .../zh-Hans/docs/rest/shared/_ScalarDemo.mdx | 24 +++---- .../Playground/DesignSystem/design-system.css | 49 +++++++-------- website/src/components/Playground/README.md | 6 +- website/src/components/Playground/index.tsx | 11 +--- .../components/Playground/styles.module.css | 5 +- .../testing/docusaurusUseBaseUrl.ts | 4 -- website/static/img/loading-logo.svg | 63 ------------------- 11 files changed, 78 insertions(+), 158 deletions(-) delete mode 100644 website/src/components/Playground/testing/docusaurusUseBaseUrl.ts delete mode 100644 website/static/img/loading-logo.svg diff --git a/docs/rest/shared/_ScalarDemo.mdx b/docs/rest/shared/_ScalarDemo.mdx index 05a1bacb97c0..44c68a0eb7cb 100644 --- a/docs/rest/shared/_ScalarDemo.mdx +++ b/docs/rest/shared/_ScalarDemo.mdx @@ -64,8 +64,16 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { {c.name} ${c.price.toFixed(2)} - {formatPercent(c.pct_equity)} - {formatShares(c.shares)} + + {c.pct_equity === undefined ? + + : formatPercent(c.pct_equity)} + + + {c.shares === undefined ? + + : c.shares.toLocaleString()} + ))} @@ -73,16 +81,8 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { ); } -function formatPercent(value: number | undefined) { - return value === undefined ? - - : `${(value * 100).toFixed(1)}%`; -} - -function formatShares(value: number | undefined) { - return value === undefined ? - - : value.toLocaleString(); +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } ``` diff --git a/jest.config.js b/jest.config.js index 2562be50c9b9..91360971e380 100644 --- a/jest.config.js +++ b/jest.config.js @@ -104,8 +104,6 @@ const projects = [ '/website/src/components/Playground/testing/docusaurusBrowserOnly.tsx', '^@docusaurus/useIsBrowser$': '/website/src/components/Playground/testing/docusaurusUseIsBrowser.ts', - '^@docusaurus/useBaseUrl$': - '/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts', '^@docusaurus/useIsomorphicLayoutEffect$': '/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts', '^@theme/CodeBlock$': diff --git a/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx b/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx index 05a1bacb97c0..44c68a0eb7cb 100644 --- a/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx +++ b/website/i18n/es/docs/rest/shared/_ScalarDemo.mdx @@ -64,8 +64,16 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { {c.name} ${c.price.toFixed(2)} - {formatPercent(c.pct_equity)} - {formatShares(c.shares)} + + {c.pct_equity === undefined ? + + : formatPercent(c.pct_equity)} + + + {c.shares === undefined ? + + : c.shares.toLocaleString()} + ))} @@ -73,16 +81,8 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { ); } -function formatPercent(value: number | undefined) { - return value === undefined ? - - : `${(value * 100).toFixed(1)}%`; -} - -function formatShares(value: number | undefined) { - return value === undefined ? - - : value.toLocaleString(); +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } ``` diff --git a/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx b/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx index 05a1bacb97c0..44c68a0eb7cb 100644 --- a/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx +++ b/website/i18n/pt-BR/docs/rest/shared/_ScalarDemo.mdx @@ -64,8 +64,16 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { {c.name} ${c.price.toFixed(2)} - {formatPercent(c.pct_equity)} - {formatShares(c.shares)} + + {c.pct_equity === undefined ? + + : formatPercent(c.pct_equity)} + + + {c.shares === undefined ? + + : c.shares.toLocaleString()} + ))} @@ -73,16 +81,8 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { ); } -function formatPercent(value: number | undefined) { - return value === undefined ? - - : `${(value * 100).toFixed(1)}%`; -} - -function formatShares(value: number | undefined) { - return value === undefined ? - - : value.toLocaleString(); +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } ``` diff --git a/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx b/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx index 05a1bacb97c0..44c68a0eb7cb 100644 --- a/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx +++ b/website/i18n/zh-Hans/docs/rest/shared/_ScalarDemo.mdx @@ -64,8 +64,16 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { {c.name} ${c.price.toFixed(2)} - {formatPercent(c.pct_equity)} - {formatShares(c.shares)} + + {c.pct_equity === undefined ? + + : formatPercent(c.pct_equity)} + + + {c.shares === undefined ? + + : c.shares.toLocaleString()} + ))} @@ -73,16 +81,8 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { ); } -function formatPercent(value: number | undefined) { - return value === undefined ? - - : `${(value * 100).toFixed(1)}%`; -} - -function formatShares(value: number | undefined) { - return value === undefined ? - - : value.toLocaleString(); +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } ``` diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 2075c27d64c7..9a0fbd5e6cda 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -30,6 +30,7 @@ /* a button's or a field's inset */ --ds-control-pad-y: 0.375rem; --ds-control-pad-x: 0.875rem; + --ds-control-border: 1px; /* The palette is the frame's (--pg-*, ../styles.module.css, switching with the theme there): the panel is the app's surface; a well a step off it holds a board's column or a field's inset */ @@ -91,15 +92,6 @@ html[data-theme='dark'] .playground-preview { /* fields sit in the well, a step above the panel */ --ifm-color-emphasis-0: var(--ds-surface-2); } -/* The floating label straddles the field's top edge, so it blends into both - (the panel above, the field's own background below) */ -.playground-preview .rt-TextFieldLabel { - background: linear-gradient( - var(--ds-surface) 50%, - var(--ifm-color-emphasis-0) 50% - ); -} - /* Text */ .playground-preview h1, .playground-preview h2, @@ -404,10 +396,10 @@ div.playground-preview h1:first-child { } .playground-preview form[aria-busy='true'] button[type='submit'], .playground-preview button[aria-busy='true'] { - /* 1px wider border, 1px less padding: same size, room for the light */ + /* a 2px border, out of the padding: same size, room for the light */ border: 2px solid transparent; - padding: calc(var(--ds-control-pad-y) - 1px) - calc(var(--ds-control-pad-x) - 1px); + padding: calc(var(--ds-control-pad-y) + var(--ds-control-border) - 2px) + calc(var(--ds-control-pad-x) + var(--ds-control-border) - 2px); background: linear-gradient(var(--ds-surface), var(--ds-surface)) padding-box, conic-gradient( @@ -580,26 +572,28 @@ div.playground-preview h1:first-child { overflow: hidden; } .playground-preview .buttonGroup > button { + --ds-control-border: 0px; flex: 1 1 auto; margin: 0; border: 0; border-radius: 0; } -/* opaque, so the hairlines don't show through the hover wash */ -.playground-preview .buttonGroup > button:not([class]):hover { +/* opaque, so the hairlines don't show through the hover wash (a busy + segment keeps its light) */ +.playground-preview + .buttonGroup + > button:not([class], [aria-busy='true']):hover { background: color-mix(in srgb, var(--ds-accent) 10%, var(--ds-surface)); } -.playground-preview .buttonGroup > button:not([class]):active { +.playground-preview + .buttonGroup + > button:not([class], [aria-busy='true']):active { background: color-mix(in srgb, var(--ds-accent) 18%, var(--ds-surface)); } -/* a busy segment's light runs inside the group's edge: a segment has no - border, so the light's 2px come out of its padding, and it takes no - pointer, so the hover wash can't cover the light */ +/* a busy segment's light runs inside the group's edge (outranking the + segment's border: 0) */ .playground-preview .buttonGroup > button[aria-busy='true'] { border: 2px solid transparent; - padding: calc(var(--ds-control-pad-y) - 2px) - calc(var(--ds-control-pad-x) - 2px); - pointer-events: none; } /* inside the group's edge, which clips anything outside it */ .playground-preview .buttonGroup > button:focus-visible { @@ -658,7 +652,6 @@ div.playground-preview h1:first-child { rounded band in that line, with a soft light sweeping across (still for reduced motion). */ .playground-preview .skeleton { - --ds-skeleton: color-mix(in srgb, var(--ds-text) 8%, transparent); position: relative; display: inline-block; width: 8em; @@ -677,10 +670,10 @@ div.playground-preview h1:first-child { background: linear-gradient( 100deg, transparent 35%, - color-mix(in srgb, var(--ds-text) 7%, transparent) 50%, + var(--ds-hover) 50%, transparent 65% ) - var(--ds-skeleton); + var(--ds-hover); background-size: 300% 100%; animation: ds-shimmer 1.6s ease-in-out infinite; } @@ -809,12 +802,16 @@ div.playground-preview h1:first-child { } } -/* Placeholder or label styling */ +/* The floating label straddles the field's top edge, so it blends into both + (the panel above, the field's own background below) */ .rt-TextFieldLabel { position: absolute; top: -0.75em; left: 0.75em; - background-color: var(--ifm-color-emphasis-0); + background: linear-gradient( + var(--ds-surface) 50%, + var(--ifm-color-emphasis-0) 50% + ); padding: 0 0.25em; font-size: 0.7rem; color: var(--ifm-color-emphasis-700); diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 4d77fd20f47c..b8d108d2a5b6 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -179,9 +179,9 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element header's tint with no rule under it; react-live's output `div` (`sheet`, named in `preview/PreviewBlock.tsx`, which puts the loading page on one too; an empty one hides, and the error card stands on the stage) is the - sheet, flush with the frame's sides and foot. `--pg-sheet-pad` is its inset; the header shares - it so its label lines up with the sheet's text, and `.navProgress` reads - it to run along the sheet's top edge. The frame has no ring, only + sheet, flush with the frame's sides and foot. `--pg-sheet-pad` is its + inset; the header shares it so its label lines up with the sheet's text, + and `.navProgress` reads it to run along the sheet's top edge. The frame has no ring, only shadows. `--pg-hover` is every header control's hover band. - Design system (`DesignSystem/design-system.css`, loaded with the preview): what a demo renders is styled under `.playground-preview` so it reads as a diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index ac57473d672b..4061f570345c 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -1,5 +1,4 @@ import Translate from '@docusaurus/Translate'; -import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; import clsx from 'clsx'; import React, { lazy, useCallback, useDeferredValue, useState } from 'react'; @@ -113,14 +112,8 @@ export default function Playground({ // toggle) and keep it (preserves undo / go-to-def). const codeVisible = !hidden && (!demo || codeShown); const [editorInteractive, setEditorInteractive] = useState(codeVisible); - useIsomorphicLayoutEffect(() => { - if (codeVisible) setEditorInteractive(true); - }, [codeVisible]); - // in the click's own render, not a second one from the effect - const toggleCode = useCallback(() => { - setCodeShown(shown => !shown); - setEditorInteractive(true); - }, []); + if (codeVisible && !editorInteractive) setEditorInteractive(true); + const toggleCode = useCallback(() => setCodeShown(shown => !shown), []); const fixtureContent = fixtures.length ? : undefined; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index e38c5a6d6c5a..a067e59815ef 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -678,9 +678,8 @@ div.playgroundTextEdit > .playgroundHeader, /* The preview is a stage (on the wrapper, so it tints the header) and the app one sheet filling the rest: the react-live output (.sheet; the loading page is a sheet too, and the error card stands on the stage) is - the sheet, flush - with the frame's sides and foot, its top corners notched into the - header's tint, with its own inset. The design system's surface is the + the sheet, flush with the frame's sides and foot, its top corners + notched into the header's tint, with its own inset. The design system's surface is the sheet's color, so fields and wells read against it. The inset is set on the wrapper so the header shares it (its label lines up with the sheet's text) and .navProgress reaches the sheet's edges. */ diff --git a/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts b/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts deleted file mode 100644 index 9fe0a8fa8b48..000000000000 --- a/website/src/components/Playground/testing/docusaurusUseBaseUrl.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Jest stand-in for @docusaurus/useBaseUrl: the site is served from its root -export default function useBaseUrl(url: string) { - return url; -} diff --git a/website/static/img/loading-logo.svg b/website/static/img/loading-logo.svg deleted file mode 100644 index 8c5ddca29f5a..000000000000 --- a/website/static/img/loading-logo.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - From b6388497132f1cb19494f511599793012ecddf4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:13:54 +0000 Subject: [PATCH 21/66] docs: Refresh translation locks and skill references for the scalar demo Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../data-client-schema/references/Scalar.md | 24 +++++++++---------- .../references/_ScalarDemo.md | 24 +++++++++---------- website/translate/lock/es.json | 2 +- website/translate/lock/pt-BR.json | 2 +- website/translate/lock/zh-Hans.json | 2 +- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.agents/skills/data-client-schema/references/Scalar.md b/.agents/skills/data-client-schema/references/Scalar.md index 8c4f23854710..9bc31ebf755d 100644 --- a/.agents/skills/data-client-schema/references/Scalar.md +++ b/.agents/skills/data-client-schema/references/Scalar.md @@ -83,8 +83,16 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { {c.name} ${c.price.toFixed(2)} - {formatPercent(c.pct_equity)} - {formatShares(c.shares)} + + {c.pct_equity === undefined ? + + : formatPercent(c.pct_equity)} + + + {c.shares === undefined ? + + : c.shares.toLocaleString()} + ))} @@ -92,16 +100,8 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { ); } -function formatPercent(value: number | undefined) { - return value === undefined ? - - : `${(value * 100).toFixed(1)}%`; -} - -function formatShares(value: number | undefined) { - return value === undefined ? - - : value.toLocaleString(); +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } ``` diff --git a/.agents/skills/data-client-schema/references/_ScalarDemo.md b/.agents/skills/data-client-schema/references/_ScalarDemo.md index a8b8f5e418e0..359227e6c424 100644 --- a/.agents/skills/data-client-schema/references/_ScalarDemo.md +++ b/.agents/skills/data-client-schema/references/_ScalarDemo.md @@ -59,8 +59,16 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { {c.name} ${c.price.toFixed(2)} - {formatPercent(c.pct_equity)} - {formatShares(c.shares)} + + {c.pct_equity === undefined ? + + : formatPercent(c.pct_equity)} + + + {c.shares === undefined ? + + : c.shares.toLocaleString()} + ))} @@ -68,16 +76,8 @@ export default function CompanyGrid({ companies }: { companies: Company[] }) { ); } -function formatPercent(value: number | undefined) { - return value === undefined ? - - : `${(value * 100).toFixed(1)}%`; -} - -function formatShares(value: number | undefined) { - return value === undefined ? - - : value.toLocaleString(); +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; } ``` diff --git a/website/translate/lock/es.json b/website/translate/lock/es.json index 20758c26f6b1..c66d71680213 100644 --- a/website/translate/lock/es.json +++ b/website/translate/lock/es.json @@ -124,7 +124,7 @@ "docs/rest/shared/_entity_lifecycle_methods.mdx": "69a119da621aa891d5525b576ca2e4f240eac4d1", "docs/rest/shared/_optimisticTransform.mdx": "ceeafd70dcf7e18448a977fdd63e533cf06fe0d1", "docs/rest/shared/_PolymorphicFeedDemo.mdx": "973ba6b30da33b373621d2f0c5118cd5b5b84536", - "docs/rest/shared/_ScalarDemo.mdx": "05a1bacb97c01ef866bb769fada067e092c53d2b", + "docs/rest/shared/_ScalarDemo.mdx": "44c68a0eb7cbaa52a90d844aa117972a61c5ea21", "docs/rest/shared/_SortDemo.mdx": "cc3314e0f290b6044a14247ee68ebcab8796c346", "website/blog/2023-07-04-v0.2-release-announcement.md": "1156fbbc172e5c2edd6b04faa69e9605a9c87824", "website/blog/2024-01-15-v0.9-release-announcement.md": "adcf4a838aa2dc84f6009cc3743a9bd5e429922b", diff --git a/website/translate/lock/pt-BR.json b/website/translate/lock/pt-BR.json index 36251f6b5174..39b46a604deb 100644 --- a/website/translate/lock/pt-BR.json +++ b/website/translate/lock/pt-BR.json @@ -124,7 +124,7 @@ "docs/rest/shared/_entity_lifecycle_methods.mdx": "69a119da621aa891d5525b576ca2e4f240eac4d1", "docs/rest/shared/_optimisticTransform.mdx": "ceeafd70dcf7e18448a977fdd63e533cf06fe0d1", "docs/rest/shared/_PolymorphicFeedDemo.mdx": "973ba6b30da33b373621d2f0c5118cd5b5b84536", - "docs/rest/shared/_ScalarDemo.mdx": "05a1bacb97c01ef866bb769fada067e092c53d2b", + "docs/rest/shared/_ScalarDemo.mdx": "44c68a0eb7cbaa52a90d844aa117972a61c5ea21", "docs/rest/shared/_SortDemo.mdx": "cc3314e0f290b6044a14247ee68ebcab8796c346", "website/blog/2023-07-04-v0.2-release-announcement.md": "1156fbbc172e5c2edd6b04faa69e9605a9c87824", "website/blog/2024-01-15-v0.9-release-announcement.md": "adcf4a838aa2dc84f6009cc3743a9bd5e429922b", diff --git a/website/translate/lock/zh-Hans.json b/website/translate/lock/zh-Hans.json index e86352baa349..ea2ea6cfb257 100644 --- a/website/translate/lock/zh-Hans.json +++ b/website/translate/lock/zh-Hans.json @@ -124,7 +124,7 @@ "docs/rest/shared/_entity_lifecycle_methods.mdx": "69a119da621aa891d5525b576ca2e4f240eac4d1", "docs/rest/shared/_optimisticTransform.mdx": "ceeafd70dcf7e18448a977fdd63e533cf06fe0d1", "docs/rest/shared/_PolymorphicFeedDemo.mdx": "973ba6b30da33b373621d2f0c5118cd5b5b84536", - "docs/rest/shared/_ScalarDemo.mdx": "05a1bacb97c01ef866bb769fada067e092c53d2b", + "docs/rest/shared/_ScalarDemo.mdx": "44c68a0eb7cbaa52a90d844aa117972a61c5ea21", "docs/rest/shared/_SortDemo.mdx": "cc3314e0f290b6044a14247ee68ebcab8796c346", "website/blog/2023-07-04-v0.2-release-announcement.md": "1156fbbc172e5c2edd6b04faa69e9605a9c87824", "website/blog/2024-01-15-v0.9-release-announcement.md": "adcf4a838aa2dc84f6009cc3743a9bd5e429922b", From 686dfcc8f9472d9251146d7f64e1f4b7fcce7978 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:22:40 +0000 Subject: [PATCH 22/66] docs: Tighten the Playground README's demo layout and design system notes The design system gets its own section with a class table, so an agent finds the class for a job in one read. The demo layout, preview surface and manager notes keep only what changes how code is written, and the surface note now says the editor's background is shared with every code block. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 117 +++++++++----------- 1 file changed, 53 insertions(+), 64 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index b8d108d2a5b6..c5c9b4f4a8ea 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -46,9 +46,8 @@ preview/ live execution (loaded lazily, never on the server) autorun/ the `autoRun` demo player (see Auto-run) ... editor-types/ .d.ts bundles fed to Monaco (raw-loader) -DesignSystem/ the demos' design system: design-system.css (tokens, element - styles and classes for demo content) and the components - injected into preview scope +DesignSystem/ demo styling (design-system.css) and components injected into + preview scope (see Design system) ``` ## Invariants @@ -123,24 +122,14 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - `row` layout: non-`column` files are tabs (focus switches tab); `column` files stack with collapsible headers. Stacked layout: every titled file gets a header, collapsible when there are several files or fixtures. -- `demo` layout (the `demo` prop, over `row`): the preview comes first at - the playground's full width and the code waits behind the Code toggle in - the preview's header (`CodeHeaderToggle`, `aria-pressed`), where the - label trades for "Editor" (`labelSwap`) while it shows. Pressed, the code - takes the result's place (`data-code` on the container hides the result - and shows the editor, with `row`'s tabs as `subtabs` under the header); - never both at once. The result stays mounted, so the app's state and the - Store survive, and edits apply live as usual. Use it when the point is - what happens on screen, or the app needs the room: the result and the - code share one minimum height (`--pg-demo-min`), so the app's pages and - the code trade places without moving the page. The code is in the HTML - (`StaticEditor`) as always; Monaco mounts when the code is first shown - (the `hidden` latch), so a demo never shown costs no editor. The Store - opens as when stacked: beside the result (under it when narrow), from - the side strip or the header's icon. The code and the Store never show - together: over the code, the header's Store toggle brings the preview - back with whatever it says (Show Store opens it, Hide Store closes it). - Pressing Code is reader input, so it ends an `autoRun`. +- `demo` layout (`demo` prop): for demos whose point is what happens on + screen. The preview takes the full width and the code waits behind the + header's Code toggle (`CodeHeaderToggle`); pressed, the code replaces the + result (`data-code` on the container). Never both at once, and never the + code and the Store: the Store toggle over the code brings the preview + back. The result stays mounted, so app state and the Store survive. Result + and code share one min height (`--pg-demo-min`), so toggling doesn't move + the page. Pressing Code ends an `autoRun`. - Collapsed/unselected files stay mounted (`display: none`), never unmounted: their Monaco models must exist for cross-file types, go-to-definition and to keep edits. Monaco is uncontrolled (`defaultValue`), so re-renders never @@ -174,44 +163,14 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element their built-in types (they're type-only exports). - Each playground gets its own `DataProvider` store (`MockResolver` serves `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. -- The preview is a stage and the app a sheet on it (`../styles.module.css`): - the `previewWrapper` paints the stage (`--pg-stage`), which shows as the - header's tint with no rule under it; react-live's output `div` (`sheet`, - named in `preview/PreviewBlock.tsx`, which puts the loading page on one - too; an empty one hides, and the error card stands on the stage) is the - sheet, flush with the frame's sides and foot. `--pg-sheet-pad` is its - inset; the header shares it so its label lines up with the sheet's text, - and `.navProgress` reads it to run along the sheet's top edge. The frame has no ring, only - shadows. `--pg-hover` is every header control's hover band. -- Design system (`DesignSystem/design-system.css`, loaded with the preview): - what a demo renders is styled under `.playground-preview` so it reads as a - real app, in both themes, without the demo importing anything. No - decorative lines: rows are set apart by space, groups by a label and - their inset, secondary text by color; only form controls keep an edge. - Tokens are `--ds-*` (the palette reads the frame's `--pg-*`, which switch - with the theme), with Infima's heading variables re-pointed; element - styles cover headings (an `h2` is a page's title; a `section > h3` or - `boardColumn > h4` is a group's label), `p`, `small`, links, plain - `button`s (`primary` fills one with the accent), text inputs, `nav` and - `fieldset`. Classes: `appHeader` + `appTitle` (the app's wordmark), - `card` (a group with no box of its own: the sheet is the card), `list` - (rows as rounded bands; checkbox rows light up under the pointer and fade - when checked), `listItem` (a row; `nogap`, `spaced`; a leading checkbox - in its `label` is a round box in the accent), `meta` (secondary text; - last in a `listItem`, it sits at the row's end), `toaster`/`toast` - (rendered by the `Toaster` component, which shows what the `toast` helper - sends), `buttonGroup` (actions as one segmented control; a button with - `aria-busy` is pending, like a saving form's submit), `skeleton` (one - line of text still loading, in its value's place; `Loading` draws a page - of them, and is every playground's fallback), `byline` - (avatar beside a muted name; `Avatar` takes a `size`), `author` (name - under avatar), `boardColumn` (a well holding its tasks as rows of the - sheet's color), plus the older `voteBlock`/`vote`. Prefer a - class or element style to a new component; a new scope global goes in - `preview/scope.ts`, `monaco/typeLibs.ts` and - `website/framework-docs/checkExamples.mjs`. Keep docs demos that use - these classes (grep `docs/` and `website/src/components/Demo/code`) in - mind when restyling one. +- Surfaces (`../styles.module.css`): `previewWrapper` paints the stage + (`--pg-stage`, seen as the header's tint) and react-live's output is one + `sheet` (`preview/PreviewBlock.tsx`), flush with the frame's sides and + foot; the loading fallback is a sheet too, the error card sits on the + stage. `--pg-sheet-pad` is the sheet's inset, shared by the header and + `.navProgress` so they line up. Colors are `--pg-*`, with separate light + and dark values. The editor's background is `--monoco-code-background` + (`src/css/root.css`), shared by every code block on the site. - Store inspector open state persists per `groupId` via tab storage and avoids scroll jumps. In `row` layout it slides over the code from the preview's edge (portaled into a layer `EditorSurface` stacks on the code, @@ -391,11 +350,9 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - Only editing `getManagers` or a document declaring a name it uses (e.g. its Manager class) changes the managers: the store remounts with its data, so other edits keep sockets open. Code that fails to run keeps the old ones. - - Kept managers still close over the values of the run that built them, while - the UI re-runs with fresh ones (remounting on every edit would close their - sockets), so a manager that reaches the UI does it - through something every run shares: a design system module (`toast`, - `DesignSystem/Toaster.tsx`) or the store. + - A kept manager closes over the run that built it, not the UI's latest run, + so it reaches the UI through something every run shares: the store, or a + design system module (`toast`). - A manager that throws shows a "Manager error" card; the preview runs on without that step. - Errors show in one `ErrorPanel` card (`Playground/ErrorPanel.tsx`) labeled by the stage that failed: amber @@ -406,6 +363,38 @@ DesignSystem/ the demos' design system: design-system.css (tokens, element - Third-party console noise is demoted only while previews are mounted (`usePlaygroundConsoleDemotion`); never add first-party matchers. +### Design system + +`DesignSystem/design-system.css` (scoped to `.playground-preview`, loaded with +the preview) makes demo markup read as a real app in both themes. Doc +examples use plain elements and these classes, never inline styles or +imports: styling changes go here. Space separates things, not lines: add no +borders or dividers (only form controls keep an edge). Tokens are `--ds-*`, +reading the frame's `--pg-*`. + +Elements are styled too: `h2` is a page title, plain `button`s (`primary` +fills one; `aria-busy` traces a pending light), text inputs, `nav`, +`fieldset`. + +| Class | Use for | +| ------------------------------------------ | -------------------------------------------------------------------------------- | +| `appHeader` + `appTitle` | the app's top bar and wordmark | +| `card` | a group (no box: the sheet is the card) | +| `list`, `listItem` (`nogap`, `spaced`) | rows; checkbox rows highlight and fade when done | +| `meta` | secondary text; last in a `listItem`, it sits at the row's end | +| `byline`, `author` (`Avatar` takes `size`) | who wrote something | +| `buttonGroup` | a row of actions as one segmented control | +| `skeleton` | a value still loading; ``, the default fallback, draws a page of them | +| `toaster`, `toast` | notifications: render ``, call `toast.success()` or `toast.error()` | +| `boardColumn` | a kanban column | +| `voteBlock`, `vote` | a vote count with its buttons | + +- Prefer a class or element style to a new component. A new scope global + goes in `preview/scope.ts`, `monaco/typeLibs.ts` and + `website/framework-docs/checkExamples.mjs`. +- Before restyling a class, grep `docs/` and `website/src/components/Demo/code` + for the demos using it. + ### Auto-run A playground with `autoRun` (a `Step[]`, `preview/autorun/script.ts`) plays it From 6439dae6947a4bf5df9ef69a392ea98f48f0d256 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:33:23 +0000 Subject: [PATCH 23/66] docs: Fix the endpoint playground's column and bare inputs on the new preview colors - EndpointPlayground's request/response column reused the preview's stage gradient, so its empty space below the response showed navy (dark) or pale blue (light) instead of the code's color. - Inputs written without a type (most docs demos) kept the browser's gray field, which clashed with the navy sheet; they now take the design system's field style like type="text" does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/HTTP/Wrapper.module.css | 2 +- .../Playground/DesignSystem/design-system.css | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/website/src/components/HTTP/Wrapper.module.css b/website/src/components/HTTP/Wrapper.module.css index 99e2f9fc548c..3e455f4f4734 100644 --- a/website/src/components/HTTP/Wrapper.module.css +++ b/website/src/components/HTTP/Wrapper.module.css @@ -6,7 +6,7 @@ div.fixtureCol { flex: 1 4 50%; - background-color: var(--monoco-code-background); + background: var(--monoco-code-background); } div.fixtureCol .containedCode:global(.theme-code-block) { box-shadow: none; diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 9a0fbd5e6cda..1ba98cf46f46 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -271,7 +271,7 @@ div.playground-preview h1:first-child { flex: 1 1 100%; line-height: 1.1; } -.playground-preview .listItem > label input[type='text'] { +.playground-preview .listItem > label input:is(:not([type]), [type='text']) { width: calc(100% - 21px); } /* An avatar with a name: beside each other in a row (a post's author under @@ -303,7 +303,7 @@ div.playground-preview h1:first-child { padding: 2px 6px; } .playground-preview button:where(:not([class]), .primary), -.playground-preview input[type='text']:where(:not([class])), +.playground-preview input:is(:not([type]), [type='text']):where(:not([class])), .playground-preview textarea:where(:not([class])) { border: 1px solid var(--ds-border-strong); border-radius: var(--ds-radius-sm); @@ -350,14 +350,15 @@ div.playground-preview h1:first-child { background: var(--ds-accent-strong); border-color: var(--ds-accent-strong); } -.playground-preview input[type='text']:where(:not([class])), +.playground-preview input:is(:not([type]), [type='text']):where(:not([class])), .playground-preview textarea:where(:not([class])) { padding: 0.3rem 0.625rem; transition: border-color var(--motion-snappy), box-shadow var(--motion-snappy); } -.playground-preview input[type='text']:where(:not([class])):focus-visible, +.playground-preview + input:is(:not([type]), [type='text']):where(:not([class])):focus-visible, .playground-preview textarea:where(:not([class])):focus-visible { outline: none; border-color: var(--ds-accent); @@ -434,7 +435,7 @@ div.playground-preview h1:first-child { outline: 2px solid var(--ds-accent); outline-offset: 2px; } -.playground-preview form input[type='text'], +.playground-preview form input:is(:not([type]), [type='text']), .playground-preview form textarea { width: 100%; } From 26c9f3b15c388e4b619ed711c9edf1383d794fad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 06:27:58 +0000 Subject: [PATCH 24/66] docs: Folder and window playground layout, with the Store on the preview's folder Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Demo/CodeEditor.tsx | 4 +- ...dHeaderControls.tsx => ProtocolChoice.tsx} | 6 +- website/src/components/Demo/styles.module.css | 42 ++ .../components/HTTP/EndpointPlayground.tsx | 1 - website/src/components/HooksPlayground.tsx | 2 - website/src/components/Playground/Header.tsx | 12 +- website/src/components/Playground/README.md | 43 +- website/src/components/Playground/TabList.tsx | 50 +- .../Playground/__tests__/rowLayout.test.tsx | 70 +++ .../Playground/editor/EditorSurface.tsx | 103 +--- website/src/components/Playground/index.tsx | 76 ++- .../Playground/preview/LivePreview.tsx | 4 +- .../Playground/preview/StoreInspector.tsx | 89 ++- .../Playground/preview/StoreToggle.tsx | 43 +- .../Playground/preview/store/StorePanel.tsx | 100 ++-- .../Playground/preview/store/store.module.css | 201 ++++--- .../components/Playground/styles.module.css | 551 ++++++++++++------ website/src/components/motion/tokens.ts | 3 + 18 files changed, 883 insertions(+), 517 deletions(-) rename website/src/components/Demo/{PlaygroundHeaderControls.tsx => ProtocolChoice.tsx} (67%) create mode 100644 website/src/components/Playground/__tests__/rowLayout.test.tsx diff --git a/website/src/components/Demo/CodeEditor.tsx b/website/src/components/Demo/CodeEditor.tsx index aec0e633afdf..f95b774b14c7 100644 --- a/website/src/components/Demo/CodeEditor.tsx +++ b/website/src/components/Demo/CodeEditor.tsx @@ -4,7 +4,7 @@ import type { ForwardedRef } from 'react'; import CodeProvider from './CodeProvider'; import CodeTabContext from './CodeTabContext'; import type { CodeTabValue } from './CodeTabContext'; -import PlaygroundHeaderControls from './PlaygroundHeaderControls'; +import ProtocolChoice from './ProtocolChoice'; import HooksPlayground from '../HooksPlayground'; function capitalizeFirstLetter(string: string) { @@ -25,6 +25,7 @@ const DemoPlayground = memo( return (
+ {values.map( ({ value, @@ -40,7 +41,6 @@ const DemoPlayground = memo( hidden={value !== selectedValue} fixtures={fixtures} getInitialInterceptorData={getInitialInterceptorData} - headerControls={} > {code.map(({ path, code: instanceCode, open, ssr }, i) => ( ({ key: value, label, diff --git a/website/src/components/Demo/styles.module.css b/website/src/components/Demo/styles.module.css index 5c12f89adb74..41bbddc3e434 100644 --- a/website/src/components/Demo/styles.module.css +++ b/website/src/components/Demo/styles.module.css @@ -2,3 +2,45 @@ margin-bottom: 3rem; /*margin-top: 1.2rem; add back to separate if needed */ } + +/* REST or GraphQL (ProtocolChoice): a segmented control above the + playground, in a slim row of its own */ +.segmented { + --pg-seg: var(--ifm-color-emphasis-200); + --pg-seg-on: #fff; + width: fit-content; + margin-bottom: 6px; + padding: 3px; + gap: 2px; + border-radius: 999px; + background: var(--pg-seg); +} +:root[data-theme='dark'] .segmented { + --pg-seg: #292c34; + --pg-seg-on: #3d424e; +} +.segmented > [role='tab'] { + height: 24px; + padding: 0 11px; + border-radius: 999px; + color: var(--ifm-color-emphasis-800); + font: 600 12px / 1 var(--ifm-font-family-base); + letter-spacing: 0.01em; + box-shadow: none; + transition: + background-color var(--motion-snappy), + color var(--motion-snappy); +} +.segmented > [role='tab']:hover { + background: none; + color: var(--ifm-font-color-base); +} +.segmented > [role='tab'][aria-selected='true'] { + background: var(--pg-seg-on); + color: var(--ifm-font-color-base); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); +} +.segmented > [role='tab']:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -2px; +} diff --git a/website/src/components/HTTP/EndpointPlayground.tsx b/website/src/components/HTTP/EndpointPlayground.tsx index b690f6e75fa9..7f649ba71cb8 100644 --- a/website/src/components/HTTP/EndpointPlayground.tsx +++ b/website/src/components/HTTP/EndpointPlayground.tsx @@ -23,7 +23,6 @@ export default function EndpointPlayground({ className={clsx( playgroundstyles.endpointPlayground, playgroundstyles.playgroundContainer, - playgroundstyles.row, )} > diff --git a/website/src/components/HooksPlayground.tsx b/website/src/components/HooksPlayground.tsx index 6e3b09bcfea9..eee077b1c38b 100644 --- a/website/src/components/HooksPlayground.tsx +++ b/website/src/components/HooksPlayground.tsx @@ -12,7 +12,6 @@ const HooksPlayground = ({ demo = false, fixtures = [], defaultTab, - headerControls, renderCount, actionLog, autoRun, @@ -27,7 +26,6 @@ const HooksPlayground = ({ fixtures={fixtures} getInitialInterceptorData={getInitialInterceptorData} defaultTab={defaultTab} - headerControls={headerControls} renderCount={renderCount} actionLog={actionLog} autoRun={autoRun} diff --git a/website/src/components/Playground/Header.tsx b/website/src/components/Playground/Header.tsx index 8ff5e9f72cd1..42d302ccf008 100644 --- a/website/src/components/Playground/Header.tsx +++ b/website/src/components/Playground/Header.tsx @@ -3,20 +3,13 @@ import React from 'react'; import styles from './styles.module.css'; -type OnClick = React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement ->['onClick']; - export default function Header({ children, className, onClick, small = false, -}: { - children: React.ReactNode; - className?: string; - onClick?: OnClick; + ...rest +}: React.HTMLAttributes & { small?: boolean; }) { return ( @@ -28,6 +21,7 @@ export default function Header({ { [styles.small]: small }, )} onClick={onClick} + {...rest} > {children}
diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 2dd70ae2490f..8139ade4eb0d 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -44,7 +44,8 @@ monaco/ everything Monaco-specific preview/ live execution (loaded lazily, never on the server) LivePreview.tsx react-live LiveProvider with scope + transformCode Preview.tsx DataProvider + MockResolver(fixtures) + Store inspector - StoreToggle.tsx Store toggles + open state + StoreInspector.tsx the Store on the folder (row) or beside the result + StoreToggle.tsx Store toggles (the folder's handle, header icon, side strip) + open state store/ Store inspector: StorePanel owns navigation (nav.tsx), actionLog.ts logs dispatches, actionGroups.ts groups them, attribution.ts says which manager sent or handled each (Pipeline.tsx, ManagerName.tsx) @@ -124,9 +125,13 @@ DesignSystem/ demo styling (design-system.css) and components injected int `route="…"`, `start="…"` and `layout` (see Router), `{1-3}` highlight ranges (pre-selected in Monaco); `language-*` class. Element props override metastring values (Demo passes them directly). `defaultTab` overrides `collapsed`. -- `row` layout: non-`column` files are tabs (focus switches tab); `column` - files stack with collapsible headers. Stacked layout: every titled file gets a header, - collapsible when there are several files or fixtures. +- `row` layout, "folder and window" (`styles.module.css`, `.row`): the files + are tabs on the page over the code card; beside it (under it when the + playground is narrower than 720px) the preview is a folder of the stage's + color with the Store's layer behind it (see the Store below). Non-`column` + files are tabs (focus switches tab); `column` files stack with collapsible + headers. Stacked layout: every titled file gets a header, collapsible when + there are several files or fixtures. - `demo` layout (`demo` prop): for demos whose point is what happens on screen. The preview takes the full width and the code waits behind the header's Code toggle (`CodeHeaderToggle`); pressed, the code replaces the @@ -177,19 +182,23 @@ DesignSystem/ demo styling (design-system.css) and components injected int and dark values. The editor's background is `--monoco-code-background` (`src/css/root.css`), shared by every code block on the site. - Store inspector open state persists per `groupId` via tab storage and - avoids scroll jumps. In `row` layout it slides over the code from the - preview's edge (portaled into a layer `EditorSurface` stacks on the code, - which goes `inert`), so the preview stays usable beside it; when the - layout stacks it opens upward over the code. It covers the file tabs - too, which only pick the file shown. Its toggle is `StoreBadge` in - the code's corner (rendered by `index.tsx` with the cover), which stays - put as the Store slides under it and names what it brings back (Store, - then Code); narrow, the preview header's icon takes over. Over the Store, - the header's selected control takes the Store's color so it still joins - what is under it. - It opens and closes as a drawer (`../motion`: the toggle glides, the panel - `Reveal`s); the panel's contents render a frame after it starts moving - (`useDeferredValue`). + avoids scroll jumps. In `row` layout it is on the preview's folder: + `index.tsx` renders the folder's layer (`.storeHost`, the container's + last child) with a drawer (`.storeDrawer`) that holds the handle + (`StoreTab`); `StoreInspector` portals the Store into the drawer + (`FolderPanel`, an `` that hides once the drawer has slid + shut). Pulling the handle (click; keyboard; Escape anywhere on the drawer + pushes it shut and refocuses the handle) slides the drawer out, one + `translate`, over the code and its file tabs edge to edge (they go + `inert`), the handle riding outside the Store's edge, past the + playground's. Side by side it comes from the preview's left edge by its + foot; stacked, up from the preview's top edge. The preview stays usable + either way. The Store itself is chrome on the folder (the scrubber's row, + State and Action as tabs) and content on a sheet the picked tab joins + (`store.module.css`). In the other layouts the preview header's icon + (`StoreHeaderToggle`) or the side strip (`StoreToggle`) open it beside the + result as a drawer (`../motion`: the panel `Reveal`s); its contents render + a frame after it starts moving (`useDeferredValue`). - The Store inspector logs every dispatch through `preview/store/actionLog.ts`, which documents its limits and `LogOptions` (the Playground's `actionLog` prop, over `DOCS_LOG` in `index.tsx`: a docs diff --git a/website/src/components/Playground/TabList.tsx b/website/src/components/Playground/TabList.tsx index 68ca9b688579..8b568b452a8d 100644 --- a/website/src/components/Playground/TabList.tsx +++ b/website/src/components/Playground/TabList.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import type { ReactNode } from 'react'; +import type { KeyboardEvent, ReactNode } from 'react'; import styles from './styles.module.css'; @@ -7,12 +7,55 @@ interface TabItem { key: string; label: ReactNode; selected: boolean; + /** A click, or ← → Home End from the selected tab */ onSelect: () => void; } -export default function TabList({ tabs }: { tabs: readonly TabItem[] }) { +export default function TabList({ + tabs, + className, + label, +}: { + tabs: readonly TabItem[]; + className?: string; + /** Names the list for assistive tech */ + label?: string; +}) { + // the selected tab is the one in the tab order; the arrows move from it + const onKeyDown = (event: KeyboardEvent) => { + const from = tabs.findIndex(tab => tab.selected); + const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'; + const forward = rtl ? 'ArrowLeft' : 'ArrowRight'; + const backward = rtl ? 'ArrowRight' : 'ArrowLeft'; + let to: number; + switch (event.key) { + case forward: + to = (from + 1) % tabs.length; + break; + case backward: + to = (from - 1 + tabs.length) % tabs.length; + break; + case 'Home': + to = 0; + break; + case 'End': + to = tabs.length - 1; + break; + default: + return; + } + event.preventDefault(); + tabs[to].onSelect(); + (event.currentTarget.children[to] as HTMLElement | undefined)?.focus(); + }; return ( -
+
{tabs.map(({ key, label, selected, onSelect }) => (
{label} diff --git a/website/src/components/Playground/__tests__/rowLayout.test.tsx b/website/src/components/Playground/__tests__/rowLayout.test.tsx new file mode 100644 index 000000000000..25f46389729f --- /dev/null +++ b/website/src/components/Playground/__tests__/rowLayout.test.tsx @@ -0,0 +1,70 @@ +/// +/// +/// + +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import Playground from '..'; + +// a phone: the live preview loads (not a bot), with the react-live editor +jest.mock('../userAgent', () => ({ isBot: false, isMobileOrBot: () => true })); +jest.mock('../PlaygroundEditor', () => ({ + __esModule: true, + default: ({ code }: { code: string }) => + require('react').createElement('textarea', { defaultValue: code }), +})); +// The real chunk pulls in the whole preview scope (ESM-only packages jest +// can't load); the folder and its handle are the Playground's own +jest.mock('../preview/LivePreview', () => { + const { createElement } = require('react'); + const PreviewWrapper = require('../preview/PreviewWrapper').default; + return { + __esModule: true, + default: () => createElement(PreviewWrapper, {}, 'the app'), + }; +}); + +const APP = 'render(
the app
);'; + +describe("row layout: the Store on the preview's folder", () => { + test('the handle pulls the folder out over the code, and Escape pushes it back', () => { + const { container } = render({APP}); + const handle = screen.getByRole('button', { name: 'Store' }); + const folder = document.getElementById( + handle.getAttribute('aria-controls')!, + )!; + const code = container.querySelector('textarea')!.closest('[inert]'); + expect(handle.getAttribute('aria-expanded')).toBe('false'); + expect(folder.hasAttribute('data-open')).toBe(false); + expect(code).toBeNull(); + + // the folder slides out: the code under it is inert + fireEvent.click(handle); + expect(handle.getAttribute('aria-expanded')).toBe('true'); + expect(folder.hasAttribute('data-open')).toBe(true); + expect( + container.querySelector('textarea')!.closest('[inert]'), + ).not.toBeNull(); + + // Escape on the drawer (where the Store's own keys have not taken it) + // pushes it shut and hands focus back to the handle + fireEvent.keyDown(folder.firstElementChild!, { key: 'Escape' }); + expect(handle.getAttribute('aria-expanded')).toBe('false'); + expect(folder.hasAttribute('data-open')).toBe(false); + expect(document.activeElement).toBe(handle); + expect(container.querySelector('textarea')!.closest('[inert]')).toBeNull(); + }); + + test('the server HTML has the handle, shut, on the folder after the preview', () => { + const { renderToStaticMarkup } = require('react-dom/server'); + const html: string = renderToStaticMarkup( + {APP}, + ); + const handle = html.match(/]*aria-label="Store"[^>]*>/)?.[0]; + expect(handle).toContain('aria-expanded="false"'); + expect(html.indexOf('aria-label="Store"')).toBeGreaterThan( + html.indexOf('the app'), + ); + }); +}); diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index fa411c34d353..d1ca51145962 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -6,7 +6,6 @@ import type * as Monaco from 'monaco-editor'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import Header from '../Header'; -import { editorLabel } from '../liveLabel'; import { modelPath, useModelId } from '../monaco/modelPath'; import { createMissingModels } from '../monaco/models'; import NetworkIcon from '../NetworkIcon'; @@ -25,13 +24,8 @@ export interface EditorSurfaceProps extends CodeModel { /** When false, keep static SSR-friendly editors (no Monaco). Defaults true. */ interactive?: boolean; fixtureContent?: React.ReactNode; - headerControls?: React.ReactNode; - /** Layer over the files, below the header rows (the Store drawer's host) */ - cover?: React.ReactNode; - /** Whether `cover` hides the code (which goes inert) */ + /** The Store covers the code, which goes inert */ covered?: boolean; - /** Names `cover` in the header while it hides the code */ - coverTitle?: React.ReactNode; /** A line to show, handed to its file's editor, which opens its tab and * puts the cursor there (each new object, once) */ reveal?: CodeTarget | null; @@ -44,10 +38,7 @@ export default function EditorSurface({ variant, interactive = true, fixtureContent, - headerControls, - cover, covered = false, - coverTitle, reveal, }: EditorSurfaceProps) { const id = useModelId(); @@ -104,11 +95,9 @@ export default function EditorSurface({ closedList={closedList} onClick={handleTabSwitch} compact={variant === 'standalone'} - subtabs={headerControls != null || layout === 'demo'} + subtabs={layout === 'demo'} /> : null; - // the file tabs only pick which file shows, so they go under the cover - // with the files const code = documents.map((document, index) => ( {(!row || document.col) && document.title ? @@ -142,31 +131,21 @@ export default function EditorSurface({ )); return ( -
- - {cover == null ? +
+ {!row && fixtureContent != null ? <> - {tabs} - {code} +
+ + Fixtures +
+ {fixtureContent} - :
-
- {tabs} - {code} -
- {cover} -
- } + : null} + {tabs} + {/* the files: the code card in row layout, which the Store covers */} +
+ {code} +
); } @@ -265,8 +244,8 @@ function EditorTabs({ closedList: readonly boolean[]; onClick: (index: number) => void; compact: boolean; - /** Under another header (the controls', or the preview's in the demo - * layout): a step above the bar, so the two rows read apart */ + /** Under the preview's header (the demo layout): a step above the bar, so + * the two rows read apart */ subtabs: boolean; }) { const tabs = documents @@ -283,6 +262,7 @@ function EditorTabs({ small={subtabs || compact} > ({ key: `${document.path}:${index}`, label: document.title, @@ -293,50 +273,3 @@ function EditorTabs({ ); } - -function EditorHeader({ - title = editorLabel(), - covered, - coverTitle, - fixtureContent, - controls, -}: { - title?: React.ReactNode; - /** The cover hides the code: the title names it, sliding in as it does */ - covered: boolean; - coverTitle?: React.ReactNode; - fixtureContent?: React.ReactNode; - controls?: React.ReactNode; -}) { - return ( - <> - {fixtureContent != null ? - <> -
- - Fixtures -
- {fixtureContent} - - : null} - {controls != null ? -
-
- - {title} - {coverTitle} - -
- {controls} -
- : null} - - ); -} diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 0f2a1690ac6a..285e8851bf2a 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -1,9 +1,9 @@ -import Translate from '@docusaurus/Translate'; import clsx from 'clsx'; import React, { lazy, useCallback, useDeferredValue, + useId, useMemo, useRef, useState, @@ -25,8 +25,8 @@ import PreviewWrapper from './preview/PreviewWrapper'; import type { LogOptions } from './preview/store/actionLog'; import { DemoViewContext, - StoreBadge, StoreHeaderToggle, + StoreTab, StoreToggle, useStoreOpen, } from './preview/StoreToggle'; @@ -40,11 +40,13 @@ export interface PlaygroundProps { groupId?: string; /** Store inspector open by default */ defaultOpen?: 'y' | 'n'; - /** Tabs for files + editor beside preview (when wide enough). - * Use when the code is one tall open file and the preview is short; row - * folds fixtures behind a toggle in the preview's header. Stay stacked when - * the Store opens by default (it would cover the code), or when the preview - * renders a long list (it gets tall in the narrow column). */ + /** Files as tabs over a code card, the preview a folder beside it (when + * wide enough; under it otherwise), with the Store behind the preview: its + * tab opens it over the code. Use when the code is one tall open file and + * the preview is short; row folds fixtures behind a toggle in the + * preview's header. Stay stacked when the Store opens by default (it would + * cover the code), or when the preview renders a long list (it gets tall + * in the narrow column). */ row?: boolean; /** Preview first, at full width, with room for a realistic app; the code * waits behind a Code toggle in its header (file tabs like `row`) and @@ -58,7 +60,6 @@ export interface PlaygroundProps { getInitialInterceptorData?: () => T; /** Title of the file tab to open; overrides `collapsed` metastrings */ defaultTab?: string; - headerControls?: React.ReactNode; /** Show a badge counting the preview's React commits (e.g. one notification vs N) */ renderCount?: boolean; /** How the Store's action log records (e.g. `{ recordFrom: 'open' }` for @@ -84,7 +85,6 @@ export default function Playground({ fixtures = [], getInitialInterceptorData, defaultTab, - headerControls, renderCount = false, actionLog: logOptions, autoRun, @@ -104,9 +104,26 @@ export default function Playground({ groupId, defaultOpen, ); - // Row layout: the Store slides over the code, leaving the preview usable + // Row layout: the preview is a folder with the Store behind it, which its + // handle pulls out over the code (the folder's layer, .storeHost), leaving + // the preview usable const [storeHost, setStoreHost] = useState(null); + const storeId = useId(); const codeCovered = row && storeOpen; + // the folder's handle: Escape anywhere on the folder pushes it shut + const handle = useRef(null); + const shutStore = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !storeOpen) + return; + const tab = handle.current; + if (!tab) return; + event.preventDefault(); + toggleStore({ currentTarget: tab }); + tab.focus(); + }, + [storeOpen, toggleStore], + ); // Demo layout: the code takes the result's place while shown const [codeShown, setCodeShown] = useState(false); @@ -114,7 +131,7 @@ export default function Playground({ // the preview back along with what it says (Show Store, Hide Store); with // no code shown that is a no-op const toggleStoreInPreview = useCallback( - (event: React.MouseEvent) => { + (event: { currentTarget: HTMLElement }) => { setCodeShown(false); toggleStore(event); }, @@ -168,18 +185,8 @@ export default function Playground({ layout={layout} variant="playground" fixtureContent={fixtureContent} - headerControls={headerControls} - cover={ - row ? - <> -
- - - : null - } covered={codeCovered} reveal={codeTarget} - coverTitle={Store} /> ); // the demo layout's Code toggle: in the preview's header whether or not @@ -240,14 +247,35 @@ export default function Playground({ {preview} } + {row ? +
+
+ +
+
+ : null}
); } /** SSR, crawler, hidden and loading state: empty preview frame + Store - * toggle (the side strip, unless the code's corner badge is it), and row's - * fixtures, already usable; so is a demo's Code toggle */ + * toggle (the side strip; the row layout's is the folder's handle, rendered + * with the folder above), and row's fixtures, already usable; so is a demo's + * Code toggle */ function PreviewLoading({ row, fixtureContent, @@ -259,7 +287,7 @@ function PreviewLoading({ }) { return ( } + headerControls={row ? null : } fixtures={fixtureContent} code={code} > diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 405f72939ed4..1dd597b2c004 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -99,7 +99,9 @@ export default function LivePreview({ headerControls={ <> {badge} - + {row ? null : ( + + )} } diff --git a/website/src/components/Playground/preview/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx index 8f55b16957dd..906896400c57 100644 --- a/website/src/components/Playground/preview/StoreInspector.tsx +++ b/website/src/components/Playground/preview/StoreInspector.tsx @@ -1,4 +1,11 @@ -import React, { useDeferredValue, useState, memo } from 'react'; +import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal'; +import React, { + Activity, + memo, + useDeferredValue, + useEffect, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { Reveal } from '../../motion'; @@ -19,7 +26,8 @@ function StoreInspector({ toggle: React.MouseEventHandler; registry: SchemaRegistry; history: number; - /** Slides over the code (into `host`) instead of beside the result */ + /** On the preview's folder (`host`), which opens over the code, instead + * of beside the result; its toggle is the folder's tab (../index.tsx) */ row: boolean; host: HTMLElement | null; }) { @@ -33,19 +41,70 @@ function StoreInspector({ const ready = row ? open : deferred; const [showTree, setShowTree] = useState(false); if (ready && !showTree) setShowTree(true); - const panel = ( - - {showTree ? - - : null} - + const tree = + showTree ? : null; + if (row) { + return ( + host && + createPortal( + + {tree} + , + host, + ) + ); + } + return ( + <> + + + {tree} + + ); - // over the code, its toggle is the code's corner badge (../index.tsx) - return row ? - host && createPortal(panel, host) - : <> - - {panel} - ; } export default memo(StoreInspector); + +/** The Store on the preview's folder (`host`), which sweeps open over the + * code and shut again in CSS (`data-open`, see .storeHost in + * ../styles.module.css): the tree stays shown until the sweep has shut, + * then hides (an ``, so reopening finds it as it was left) and + * leaves the layout, with the toggle that shut it held in place on screen, + * as `Reveal` does for the Store beside the result */ +function FolderPanel({ + open, + host, + children, +}: { + open: boolean; + host: HTMLElement; + children: React.ReactNode; +}) { + const [shut, setShut] = useState(!open); + if (open && shut) setShut(false); + const { blockElementScrollPositionUntilNextRender } = + useScrollPositionBlocker(); + useEffect(() => { + if (open || shut) return; + let reopened = false; + // the sweep is a transition this commit's styles started: it is running + // by the next frame (none under reduced motion: shut at once) + const frame = requestAnimationFrame(() => { + Promise.allSettled(host.getAnimations().map(a => a.finished)).then(() => { + if (reopened) return; + const held = heldToggle(); + if (held) blockElementScrollPositionUntilNextRender(held); + setShut(true); + }); + }); + return () => { + reopened = true; + cancelAnimationFrame(frame); + }; + }, [open, shut, host, blockElementScrollPositionUntilNextRender]); + return ( +
+ {children} +
+ ); +} diff --git a/website/src/components/Playground/preview/StoreToggle.tsx b/website/src/components/Playground/preview/StoreToggle.tsx index 9dce5c7a5182..27f70571e085 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -45,7 +45,7 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker(); const toggle = useCallback( - (event: React.MouseEvent) => { + (event: { currentTarget: HTMLElement }) => { clicked = event.currentTarget; blockElementScrollPositionUntilNextRender(clicked); setChoice(open ? 'n' : 'y'); @@ -193,34 +193,37 @@ function HeaderToggle({ ); } -/** The Store toggle in the code's corner, for playgrounds whose Store slides - * over the code: it names what it brings back, and stays put as the Store - * slides in under it, so a second click finds it where the first left it */ -export function StoreBadge({ +/** The folder's handle (../index.tsx renders it on the folder's drawer): a + * pull tab in the folder's color sticking out of the preview's left edge by + * its foot (stacked, out of its bottom edge). Pulled, it draws the folder's + * layer out over the code with the Store on it, and stays outside the + * Store's edge, drawn out (see .storeDrawer in ../styles.module.css) */ +export function StoreTab({ onClick, - open, + open = false, + controls, + ref, }: { - onClick: React.MouseEventHandler; - open: boolean; + onClick?: React.MouseEventHandler; + open?: boolean; + /** The folder's id */ + controls?: string; + ref?: React.Ref; }) { return ( ); } diff --git a/website/src/components/Playground/preview/store/StorePanel.tsx b/website/src/components/Playground/preview/store/StorePanel.tsx index 8fcecaaf01f2..687290f4922f 100644 --- a/website/src/components/Playground/preview/store/StorePanel.tsx +++ b/website/src/components/Playground/preview/store/StorePanel.tsx @@ -624,60 +624,62 @@ export default function StorePanel({ Action +
+
{tab === 'state' && ( - - - - - - - - + + + - - + + + - +
)} -
-
{/* both stay mounted, so each keeps the stores its levels show, their scroll, pages and filters; both read the moment's state where they show the moment's store */} diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index 3031e0760e41..c1271194a98f 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -1,5 +1,8 @@ -/* Store inspector: code-colored in both themes, like the editor */ -/* (the palette, --store-*, is the playground's: ../../styles.module.css) */ +/* Store inspector, on the preview's folder (.storePanel paints it, and sets + the palette, --store-*, for each theme: ../../styles.module.css). The + folder holds the chrome: the scrubber's row across its top, and State and + Action as tabs on it. What a tab shows sits on a sheet (.tabPanel), the + app's sheet's color and corners, that the picked tab joins */ .store { position: relative; display: flex; @@ -8,7 +11,6 @@ /* (see --store-min in ../../styles.module.css) */ min-height: var(--store-min); max-height: var(--store-cap); - background: var(--store-bg); color: var(--store-fg); font: 12px / 1.6 var(--ifm-font-family-monospace); } @@ -29,28 +31,8 @@ --store-kind-refreshed: var(--store-dim); --store-kind-expired: var(--store-stale); --store-stale-bg: color-mix(in srgb, var(--store-stale) 9%, transparent); -} - -/* Light mode: the scrubber and the lanes under it are chrome, in the - playground's header band, rather than code; their colors darken to read - on it */ -:root[data-theme='light'] :is(.scrubber, .lanes) { - --store-bg: var(--pg-bar); - --store-fg: var(--ifm-color-emphasis-900); - --store-muted: var(--ifm-color-emphasis-700); - --store-dim: var(--ifm-color-emphasis-600); - --store-key: #2f5fc4; - --store-fresh: #1d8049; - --store-stale: #a26600; - --store-null: #cc2b4b; - --store-number: #c0501c; - --store-type: #8a3fb8; - --store-line: rgba(0, 0, 0, 0.08); - --store-line-strong: rgba(0, 0, 0, 0.15); - --store-hover: rgba(0, 0, 0, 0.05); - --store-selected: rgba(47, 95, 196, 0.14); - background: var(--store-bg); - color: var(--store-muted); + /* a pressed icon button's band */ + --store-pressed: color-mix(in srgb, var(--store-fg) 12%, transparent); } /* Under the scrubber: the tab bar, and (narrow) the sheet the lanes open in @@ -63,17 +45,16 @@ flex-direction: column; } -/* The State and Action tabs and State's switches; the same band as - the other headers */ +/* The State and Action tabs: on the folder, as the files are tabs on the + page over the code (../../styles.module.css, .row .tab), the picked one + the sheet's color, running into it */ .bar { flex: none; display: flex; align-items: stretch; - min-height: 2.25rem; - padding: 0 0.375rem 0 0.25rem; - background: var(--pg-bar); - color: var(--pg-tab); - font: 500 var(--ifm-code-font-size) / 1 var(--ifm-font-family-base); + height: 2.5rem; + color: var(--store-muted); + font: 600 var(--ifm-code-font-size) / 1 var(--ifm-font-family-base); } /* The bar, the tabs' content and the peek: inert under the sheet */ .content { @@ -82,21 +63,37 @@ display: flex; flex-direction: column; } -/* The subject stack, and the peek over its right side */ +/* The sheet: the picked tab's content, to the folder's foot; what paints + over its rows as they scroll (headings, crumbs) takes its color */ .tabPanel { + --store-bg: var(--pg-panel); position: relative; flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; + border-radius: 12px 12px 0 0; + background: var(--store-bg); + overflow: clip; +} +/* the first tab picked: its edge runs straight into the sheet */ +.bar:has(.tab:first-child[aria-selected='true']) + .tabPanel { + border-top-left-radius: 0; } /* the bar's left end: the State and Action tabs, its full height */ .barStart { display: flex; - margin-left: -0.25rem; } -/* the bar's right end: Snapshot or Diff and the view switch, in State */ +/* The sheet's head (State): the view's own switches, Snapshot or Diff and + table or tree, at its top edge */ +.sheetHead { + flex: none; + display: flex; + align-items: center; + min-height: 2.25rem; + padding: 0 0.5rem; +} .barEnd { display: flex; align-items: center; @@ -111,8 +108,9 @@ .tab { display: inline-flex; align-items: center; - padding: 0 0.75rem; + padding: 0 0.85rem; border: 0; + border-radius: 8px 8px 0 0; background: none; color: inherit; font: inherit; @@ -123,16 +121,17 @@ } @media (hover: hover) { .tab:not([aria-selected='true']):hover { - background: var(--pg-tab-hover-bg); - color: var(--pg-tab-hover); + background: var(--store-hover); + color: var(--store-fg); } } -/* the selected tab joins the store below, as the editor's tabs join the - code; an inset edge, so nothing shifts */ .tab[aria-selected='true'] { - background: var(--pg-tab-selected-bg); - color: var(--pg-tab-selected); - box-shadow: inset 0 2px 0 var(--ifm-color-primary); + background: var(--pg-panel); + color: var(--store-fg); +} +.tab:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -2px; } /* The peek: the subject's actions as an overlay over the panel's right side (narrow, most of it), dropping from right under the scrubber's list @@ -149,7 +148,7 @@ background: var(--store-bg); box-shadow: inset 1px 0 0 var(--store-line-strong), - -10px 0 24px rgba(0, 0, 0, 0.45); + -10px 0 24px var(--pg-shadow); } .pane.paneNarrow { width: min(100%, 22rem); @@ -226,7 +225,7 @@ } .viewButtons button[aria-pressed='true'], .tlFit[aria-pressed='true'] { - background: var(--pg-tab-hover-bg); + background: var(--store-pressed); opacity: 1; } .viewButtons svg, @@ -278,7 +277,6 @@ min-height: 1.9rem; padding: 3px 0.75rem 3px 0.25rem; background: var(--store-bg); - box-shadow: inset 0 -1px 0 var(--store-line-strong); } .back { flex: none; @@ -351,7 +349,7 @@ padding: 0 0.4rem; border: 1px solid var(--store-line-strong); border-radius: 4px; - background: rgba(0, 0, 0, 0.22); + background: color-mix(in srgb, var(--store-fg) 6%, transparent); color: var(--store-fg); font: inherit; font-size: 11px; @@ -401,7 +399,7 @@ } .section + .section { - border-top: 1px solid var(--store-line); + margin-top: 0.35rem; } /* ~32px tall: HEADER_HEIGHT in dom.ts, what scrollToRow leaves above a row */ .sectionHeader { @@ -577,7 +575,6 @@ div.row[data-change='removed'] { z-index: 1; padding: 0.2rem 0.5rem; background: var(--store-bg); - box-shadow: inset 0 -1px 0 var(--store-line-strong); text-align: start; white-space: nowrap; overflow: hidden; @@ -590,7 +587,6 @@ div.row[data-change='removed'] { /* a row is ~28px tall: ROW_GUESS in DiveViews.tsx, until one is measured */ .table td { padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--store-line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -607,7 +603,6 @@ div.row[data-change='removed'] { .table tr.spacer > td, .table tr.spacer { padding: 0; - border: 0; } /* the +N that opens a row's other fields below it */ .table td.moreCell { @@ -655,7 +650,6 @@ div.row[data-change='removed'] { } .table tr.foot > td { padding: 0.3rem 0.75rem; - border: 0; color: var(--store-dim); font-size: 11px; } @@ -744,7 +738,7 @@ div.row[data-change='removed'] { cursor: pointer; } .seg button[aria-pressed='true'] { - background: rgba(130, 170, 255, 0.16); + background: var(--store-selected); color: var(--store-fg); } .detail > .seg { @@ -900,10 +894,10 @@ div.row[data-change='removed'] { align-items: baseline; gap: 0.3em; max-width: 100%; - padding: 0 0.35em; - border: 1px solid rgba(199, 146, 234, 0.32); + padding: 0 0.45em; + border: 0; border-radius: 4px; - background: rgba(199, 146, 234, 0.12); + background: color-mix(in srgb, var(--store-type) 14%, transparent); color: var(--store-type); font: inherit; font-size: 11px; @@ -922,19 +916,17 @@ div.row[data-change='removed'] { color: var(--store-fg); } .ref:hover { - background: rgba(199, 146, 234, 0.24); + background: color-mix(in srgb, var(--store-type) 24%, transparent); } .ref:focus-visible { outline: 2px solid var(--store-key); } .endpointRef { color: var(--store-fresh); - border-color: rgba(127, 209, 160, 0.35); - background: rgba(127, 209, 160, 0.08); + background: color-mix(in srgb, var(--store-fresh) 11%, transparent); } .countRef { color: var(--store-dim); - border-color: var(--store-line-strong); background: var(--store-hover); } .countRef:hover { @@ -942,20 +934,26 @@ div.row[data-change='removed'] { background: var(--store-line-strong); } .endpointRef:hover { - background: rgba(127, 209, 160, 0.18); + background: color-mix(in srgb, var(--store-fresh) 18%, transparent); } +/* status pills (and a manager's, below): a tint of their own color, no + edge. Under .store, to outrank the edge the composed .pill draws whatever + order the build gives the two files */ .pill { composes: pill from '../../styles.module.css'; margin-left: auto; } +.store .pill, +.store .manager { + border-color: transparent; + background: color-mix(in srgb, currentColor 12%, transparent); +} .fresh { color: var(--store-fresh); - border-color: rgba(127, 209, 160, 0.4); } .stale { color: var(--store-stale); - border-color: rgba(255, 203, 107, 0.4); } .error { composes: danger from '../../styles.module.css'; @@ -963,15 +961,9 @@ div.row[data-change='removed'] { .invalidated { margin-left: 0; color: var(--store-kind-invalidated); - border-color: color-mix( - in srgb, - var(--store-kind-invalidated) 40%, - transparent - ); } .optimistic { color: var(--store-type); - border-color: rgba(199, 146, 234, 0.4); } /* a record: inline below its row, or as its own level */ @@ -1000,9 +992,6 @@ div.row[data-change='removed'] { .actGroup { position: relative; } -.actGroup + .actGroup { - border-top: 1px solid var(--store-line); -} .actGroup[data-open] { background: var(--store-record); } @@ -1035,7 +1024,6 @@ div.gapRow { .manager { composes: pill from '../../styles.module.css'; color: var(--store-muted); - border-color: var(--store-line-strong); } /* stages an action goes through (the app, the chain's managers, the store), wrapping rather than scrolling: the pipeline over the store's @@ -1047,8 +1035,7 @@ div.gapRow { row-gap: 0.35rem; } .pipelineBar { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--store-line); + padding: 0.5rem 0.75rem 0.35rem; } /* a step's path through the managers, as plain words under its name and changes: only the managers that sent or handled it stand out */ @@ -1300,7 +1287,7 @@ div.joined { text-decoration-color: var(--store-dim); } .goneRef:hover { - background: rgba(199, 146, 234, 0.12); + background: color-mix(in srgb, var(--store-type) 12%, transparent); } /* an action at full width: what it did to the subject, then the action */ @@ -1370,7 +1357,6 @@ div.joined { flex: none; display: flex; flex-direction: column; - box-shadow: inset 0 -1px 0 var(--store-line); color: var(--store-muted); font-size: 11px; outline: none; @@ -1383,8 +1369,9 @@ div.joined { display: flex; align-items: center; gap: 0.35rem; - min-height: 1.9rem; - padding: 2px 0.375rem 2px 0.4rem; + /* the preview header's height, across the folder from it */ + min-height: var(--pg-row, 3rem); + padding: 2px 0.5rem; transition: background-color var(--motion-snappy); } /* The pointer on a track holds what it shows still (`[data-held]`, see @@ -1395,22 +1382,39 @@ div.joined { } .scrubRow > button { flex: none; - padding: 0 0.4rem; - border: 1px solid var(--store-line-strong); - border-radius: 4px; + display: grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 5px; background: none; color: var(--store-fg); font: inherit; - line-height: 1.6; + font-size: 15px; cursor: pointer; + transition: background-color var(--motion-snappy); } .scrubRow > button:disabled { opacity: 0.4; cursor: default; } +@media (hover: hover) { + .scrubRow > button:enabled:hover { + background: var(--store-hover); + } +} /* the one lane at the scale's width, scrolling sideways past what fits (as .tlBody does): every action keeps its own mark. Too short for a scrollbar; a trackpad, ‹ › and the marks move it */ +.scrubLabel { + min-width: 0; + padding: 0 0.5rem 4px 2.6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .scrubTrack { --tl-row: 18px; --tl-track: var(--tl-width); @@ -1435,14 +1439,16 @@ div.joined { display: inline-flex; align-items: center; gap: 0.3rem; - padding: 0 0.4rem; - border: 1px solid var(--store-line-strong); - border-radius: 4px; + width: auto; + height: auto; + padding: 0 0.55rem; + border: 0; + border-radius: 999px; + background: var(--store-hover); color: var(--store-muted); - font: 500 11px / 1.6 var(--ifm-font-family-base); + font: 500 11px / 1.8 var(--ifm-font-family-base); transition: background-color var(--motion-snappy), - border-color var(--motion-snappy), color var(--motion-snappy); } .liveButton::before { @@ -1463,7 +1469,6 @@ div.joined { } .scrubRow > button.liveButton:hover, .scrubRow > .liveCurrent { - border-color: color-mix(in srgb, var(--store-fresh) 40%, transparent); color: var(--store-fresh); } .scrubRow > .liveCurrent { @@ -1488,7 +1493,7 @@ div.joined { opacity: 1; } .expand[aria-pressed='true'] { - background: var(--pg-tab-hover-bg); + background: var(--store-pressed); } .expand svg { width: 14px; @@ -1503,13 +1508,6 @@ div.joined { .expand[aria-expanded='true'] svg { rotate: 180deg; } -.scrubLabel { - min-width: 0; - padding: 0 0.5rem 4px 2.6rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} /* text that is a button: the moment's action, and a manager the playground's code declares, which opens it there */ .momentAction, @@ -1557,7 +1555,7 @@ div.joined { right: 0; z-index: 5; background: var(--store-bg); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45); + box-shadow: 0 10px 24px var(--pg-shadow); } .sheetBar, .paneHead { @@ -1579,7 +1577,7 @@ div.joined { font: inherit; } .sheetBar button:hover { - background: var(--pg-tab-hover-bg); + background: var(--store-hover); opacity: 1; } .sheet .tlLanes { @@ -1597,7 +1595,6 @@ div.joined { flex: none; display: flex; max-height: 12rem; - box-shadow: inset 0 -1px 0 var(--store-line-strong); outline: none; } .tlLanes:focus-visible { @@ -1610,7 +1607,6 @@ div.joined { width: var(--tl-label); overflow-y: auto; scrollbar-width: none; - box-shadow: inset -1px 0 0 var(--store-line); } .tlLabels::-webkit-scrollbar { display: none; @@ -1866,7 +1862,7 @@ button.tlMark:hover { border-radius: 6px; box-shadow: 0 0 0 1px var(--store-line-strong), - 0 8px 24px rgba(0, 0, 0, 0.45); + 0 8px 24px var(--pg-shadow); } .previewCard > .actHead { padding: 0.35rem 0.6rem 0; @@ -1902,7 +1898,6 @@ button.tlMark:hover { } .actScroll { overflow-y: auto; - border-inline: 1px solid var(--store-line); } /* the arrow, then (vertical) what it steps to, a long name cut short */ .actStep { diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index a067e59815ef..435d6d218d72 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -38,8 +38,9 @@ --pg-panel-2: #eef2f7; --pg-line: #d8e0ea; /* the stage the sheet sits on, showing as the header's tint: a cool tonal - ground, lighter at the top */ - --pg-stage: linear-gradient(170deg, #eef3f9, #e2e9f2); + ground. One flat color: in row layout it is the preview's folder, which + the Store opens on, painted in pieces (see .row) */ + --pg-stage: #e8eef6; --pg-text: #0b1220; --pg-muted: #4c5a6e; --pg-live: #0d8257; @@ -77,21 +78,20 @@ border-bottom-right-radius: var(--ifm-global-radius); } -.playgroundContainer.row .hidden { - display: none; -} - .playgroundContainer > div { flex: 1 1 auto; min-width: 0; } -.row.playgroundContainer > div:first-child { +/* EndpointPlayground: two code columns in one frame (side by side in + @container below) */ +.endpointPlayground.playgroundContainer > div:first-child { background-color: var(--monoco-code-background); border-bottom-left-radius: 0; border-top-left-radius: calc(var(--ifm-global-radius) * 2); border-top-right-radius: var(--ifm-global-radius); } -.playgroundContainer.row .playgroundHeader.tabControls:first-of-type { +.playgroundContainer.endpointPlayground + .playgroundHeader.tabControls:first-of-type { border-top-right-radius: var(--ifm-global-radius); } @@ -164,7 +164,7 @@ --pg-panel: #151c28; --pg-panel-2: #1d2635; --pg-line: #2a344c; - --pg-stage: linear-gradient(170deg, #0a0e17, #08121b); + --pg-stage: #0a0f18; --pg-text: #e9eef6; --pg-muted: #95a2b6; --pg-live: #3ddc97; @@ -187,7 +187,7 @@ div.playgroundTextEdit > .playgroundHeader, .endpointPlayground .playgroundHeader { box-shadow: inset 0 -1px 0 var(--pg-bar-edge); } -.playgroundContainer:not(.row, .demo) +.playgroundContainer:not(.row, .demo, .endpointPlayground) > div.playgroundTextEdit > .playgroundHeader:first-child { border-top-right-radius: var(--ifm-global-radius); @@ -214,7 +214,7 @@ div.playgroundTextEdit > .playgroundHeader, .playgroundEditor :global(.prism-code) { font: 13px / 19px var(--ifm-font-family-monospace) !important; } -.playgroundContainer:not(.row, .demo) { +.playgroundContainer:not(.row, .demo, .endpointPlayground) { background-color: var(--monoco-code-background); } .closed { @@ -364,13 +364,42 @@ div.playgroundTextEdit > .playgroundHeader, inherits: true; initial-value: 0px; } -/* The Store drawer (painted before its contents render) */ +/* The Store drawer (painted before its contents render): on the preview's + stage, in its palette, as part of the preview's folder (see .storeHost) or + beside the result on the same ground. The --store-* defaults above are the + code's (the Fixtures panel keeps them); here they read on the stage */ .storePanel { --store-top: var(--ifm-navbar-height); + --store-bg: var(--pg-stage); + --store-fg: #d9e0ea; + --store-muted: var(--pg-muted); + --store-dim: #6f7c93; + --store-line: rgba(233, 238, 246, 0.08); + --store-line-strong: rgba(233, 238, 246, 0.15); + --store-hover: var(--pg-hover); + --store-tip: var(--pg-panel-2); transition: --store-top var(--ifm-transition-fast) ease; flex: 4 1 40%; min-width: 0; - background: var(--monoco-code-background); + background: var(--store-bg); +} +/* Light: a light panel on the light stage, the syntax hues darkened to read + on it */ +:root[data-theme='light'] .storePanel { + --store-fg: var(--pg-text); + --store-dim: #6b7a90; + --store-key: #2f5fc4; + --store-string: #3b7d35; + --store-number: #c0501c; + --store-null: #cc2b4b; + --store-type: #8a3fb8; + --store-fresh: #1d8049; + --store-stale: #a26600; + --store-line: rgba(11, 18, 32, 0.08); + --store-line-strong: rgba(11, 18, 32, 0.15); + --store-selected: rgba(47, 95, 196, 0.14); + --store-record: rgba(47, 95, 196, 0.06); + --store-tip: #fff; } /* beside the result, as tall as it when that is past the Store's own cap, up to the screen's height, held in view while a taller result scrolls by @@ -394,137 +423,232 @@ div.playgroundTextEdit > .playgroundHeader, transition: none; } } -[data-theme='dark'] .storePanel { - /* opaque, so the covered code never shows through; the tree inside - adds the translucent code tint on top */ - background: var(--ifm-background-color); +/* Row layout, "folder and window": no frame. The files are tabs on the page + over the code card; beside it (under it when narrow) the preview is a + folder of the stage's color: its header and the app's sheet sit on it, and + the Store waits behind it on a layer of the same color (.storeHost, the + container's last child), whose handle sticks out of the folder's left + edge by its foot (stacked, out of its top edge). Pulled, the layer slides + out over the code card and its tabs with the Store on it, the handle + leading, until it covers them edge to edge, the handle out past the + playground's edge, drawn out. The code card + stretches toward a taller preview by at most 6rem (so a tall preview + doesn't drag a short file down the page): CSS can't cap a box at its own + content plus 6rem, so it is painted in pieces, its content and a foot that + grows to fill at most 6rem, with one shadow that composes across them (no + spread, so the seam is seamless). The folder is as tall as the row, since + the Store's layer parks behind it: the app's sheet runs 6rem toward the + code's foot at most, the folder on below it */ +.playgroundContainer.row { + --pg-gap: 12px; + --pg-card-radius: 10px; + --pg-card-shadow: + 0 1px 3px rgba(15, 30, 60, 0.08), 0 14px 36px rgba(15, 30, 60, 0.12); + /* the file tabs' row, and the preview's header beside it */ + --pg-row: 48px; + /* the handle: how far it sticks out, and its length along the edge */ + --pg-tab-width: 2.5rem; + --pg-tab-length: 3rem; + /* the preview's column; the layer's pull starts at its edge */ + --pg-preview-col: max(16rem, calc((100% - var(--pg-gap)) * 0.35)); + /* the drawer's pull: a big surface a long way, deliberate */ + --pg-folder-sweep: var(--motion-drawer); + border-radius: 0; + box-shadow: none; + background: none; } - -/* Row layout: the Store slides over the code from the preview's edge, so the - preview stays usable beside it (click, watch the store change); the code's - corner badge toggles it, not the side strip */ -.playgroundTextEdit.withCover { - display: flex; - flex-direction: column; +:root[data-theme='dark'] .playgroundContainer.row { + --pg-card-shadow: + 0 1px 3px rgba(0, 0, 0, 0.45), 0 14px 36px rgba(0, 0, 0, 0.5); } -/* the files and the Store's layer share one cell; side by side the files set - its height, stacked the open Store does */ -.editorBody { - flex: 1 1 auto; - display: grid; - /* its layers (the Store over the code, its toggle over that) stay under the - fixtures floating across from the preview */ - isolation: isolate; - grid-template-columns: minmax(0, 1fr); +/* the file tabs: on the page, the open file rising as a tab from the card */ +.row .playgroundHeader.tabControls { + height: var(--pg-row); + padding: 0; + border-radius: 0; + background: none; + box-shadow: none; + overflow: visible; + font-size: var(--ifm-code-font-size); + font-weight: 600; + letter-spacing: 0; } -.editorBody > * { - grid-area: 1 / 1; +.row .tabs { + height: 100%; + min-width: 0; } -/* room to inspect even when the code is short (the Store's own minimum), - held while visible, including while it slides out pinned out of flow - (until its hides it) */ -.storeHost:has(> .storePanel[data-visible]) { - min-height: var(--store-min); +.row .tab { + gap: 0.5em; + height: 100%; + padding: 0 0.85rem; + border-radius: 8px 8px 0 0; + color: var(--ifm-color-emphasis-800); + box-shadow: none; + transition: + background-color var(--motion-snappy), + color var(--motion-snappy); +} +.row .tab:hover { + background: color-mix(in srgb, var(--ifm-font-color-base) 7%, transparent); + color: var(--ifm-font-color-base); +} +.row .tab.selected, +.row .tab.selected:hover { + background: var(--pg-tab-selected-bg); + color: var(--pg-tab-selected); + box-shadow: none; +} +.row .tab:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -2px; } -.storeHost { - /* slides up from the preview below; sideways once side by side */ +/* the code card: the file shown and its foot (see the top) */ +.row .editorDocs { display: flex; flex-direction: column; + min-width: 0; + /* the code's own layers (Monaco's) stay under the fixtures floating over it */ + isolation: isolate; +} +.row .editorDocs > .playgroundEditor, +.row .editorDocs::after { + background: var(--monoco-code-background); + box-shadow: var(--pg-card-shadow); +} +.row .editorDocs > .playgroundEditor { + padding-top: 10px; + border-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; +} +.row .editorDocs::after { + content: ''; + flex: 1 1 0; + min-height: var(--pg-card-radius); + max-height: 6rem; + border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); +} +/* the first file open: its tab's edge runs straight into the card */ +.row + .tabControls:has(.tab:first-child.selected) + + .editorDocs + > .playgroundEditor { + border-top-left-radius: 0; +} +/* the preview's folder: the row's height, its header and the app's sheet on + it; the sheet's foot runs on toward the code's by 6rem at most, its + corners the folder's where they meet */ +.row > .previewWrapper { position: relative; - /* over the code */ z-index: 2; - overflow: hidden; - pointer-events: none; - /* the code alone sets the cell's height, so the Store scrolls within it */ - contain: size; -} -/* the code's own layers (Monaco's) stay under the fixtures floating over it */ -.editorDocs { isolation: isolate; + display: flex; + flex-direction: column; + border-radius: var(--pg-card-radius); + background: var(--pg-stage); + box-shadow: var(--pg-card-shadow); } -/* under the header, the file tabs are no longer the top corner */ -.playgroundContainer .editorBody:not(:first-child) .editorDocs > .tabControls { - border-top-left-radius: 0; - border-top-right-radius: 0; +.row .playgroundHeader.previewHeader { + min-height: var(--pg-row); } -/* with no header, the Store takes the corner */ -.editorBody:first-child .storeHost { - border-top-left-radius: calc(var(--ifm-global-radius) * 2); - border-top-right-radius: var(--ifm-global-radius); +.row .playgroundResult { + flex: none; + height: auto; +} +.row .playgroundPreview { + flex: 1 1 auto; + min-width: 0; +} +.row > .previewWrapper::after { + content: ''; + flex: 1 1 0; + min-height: var(--pg-card-radius); + max-height: 6rem; + border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); } -.storeHost > .storePanel { - flex: 1 1 100%; +.row > .previewWrapper:has(.sheet:not(:empty))::after { + background: var(--pg-panel); +} +.row .sheet { + padding-bottom: calc(var(--pg-sheet-pad) - var(--pg-card-radius)); +} +/* The folder's layer: the stage's color, the whole playground's area under + the preview, clipped at the page's edge (its drawer overshoots that way, + shut) with room on the other sides for the shadow. Shut, its drawer + (../index.tsx; ./preview/StoreInspector.tsx portals the Store into it) + parks behind the preview with only its handle out; `data-open` slides it + out, one move for Store and handle. Nothing is hidden: shut, the drawer + is behind the folder and its Store leaves the tree (StoreInspector) */ +.row > .storeHost { + position: relative; + z-index: 1; + min-width: 0; min-height: 0; + /* the handle reaches past its edge (the frame's last-child rule clips) */ + overflow: visible; + pointer-events: none; +} +.row > .storeHost > .storeDrawer { + pointer-events: auto; + transition: translate var(--pg-folder-sweep); +} +.row > .storeHost .storePanel { display: flex; flex-direction: column; - pointer-events: auto; + height: 100%; + min-height: 0; + background: var(--pg-stage); + transition: box-shadow var(--pg-folder-sweep); } -/* fills the code's height instead of its usual cap */ -.storeHost > .storePanel > * { +/* fills the drawer instead of its usual cap */ +.row > .storeHost .storePanel > * { flex: 1 1 auto; min-height: 0; max-height: none; } - -.debugToggle:hover { - background-color: var(--pg-tab-hover-bg); - color: var(--pg-tab-hover); +.row > .storeHost[data-open] .storePanel { + box-shadow: var(--pg-card-shadow); } - -.hidden { - display: none; +/* room to inspect even when the code is short (the Store's own minimum), + held while shown, until it has slid shut */ +.row > .storeHost:has(.storePanel[data-visible]) { + min-height: var(--store-min); } - -/* The Store toggle in the code's corner, over the Store as it slides in - under it; its label rises out as the other rises in. */ -.storeBadge { - --swap-in: 0 110%; - --swap-out: 0 -110%; - z-index: 1; - align-self: end; - justify-self: end; - margin: 0.75rem; - height: 1.9rem; - padding: 0 0.8rem; - border-radius: 999px; - background: var(--pg-tab-hover-bg); - box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.1), - 0 6px 18px rgba(0, 0, 0, 0.35); - color: var(--pg-tab-hover); - font: 600 var(--ifm-code-font-size) / 1 var(--ifm-font-family-base); - letter-spacing: 0.06em; - text-transform: uppercase; - white-space: nowrap; +/* The handle: a pull tab of the folder's color on the drawer's edge, out + from under the preview (whose shadow falls on it) and, drawn out, still + outside the Store's edge; its own shadow clipped where it joins */ +.storeTab { + position: absolute; + display: grid; + place-items: center; + background: var(--pg-stage); + box-shadow: var(--pg-card-shadow); + color: var(--pg-muted); + font-size: 1.1rem; + cursor: pointer; + transition: color var(--motion-snappy); +} +.storeTab:disabled { + cursor: default; } -/* over the Store (z-index 2) from opening until it has slid back out */ -.storeHost:has(> .storePanel[data-visible]) + .storeBadge { - z-index: 3; +@media (hover: hover) { + .storeTab:enabled:hover { + color: var(--pg-text); + } } -.storeBadge:hover { - background: var(--pg-tab-hover-bg) - linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06)); +.storeTab[aria-expanded='true'] { + color: var(--ifm-color-primary); } -.storeBadge:focus-visible { +.storeTab:focus-visible { outline: 2px solid var(--ifm-color-primary); - outline-offset: 2px; -} -.storeBadge > * { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5em; + outline-offset: -3px; } -.storeBadgeDot { - width: 0.45rem; - height: 0.45rem; - border-radius: 50%; - color: var(--store-fresh); - background: currentColor; - box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); + +.debugToggle:hover { + background-color: var(--pg-tab-hover-bg); + color: var(--pg-tab-hover); } -.storeBadgeCode { - color: var(--ifm-color-primary); + +.hidden { + display: none; } /* tabs */ @@ -533,13 +657,9 @@ div.playgroundTextEdit > .playgroundHeader, justify-content: space-between; padding: 0; } -.playgroundHeader.tabControls .title { - padding: 0.75rem; -} -/* Two labels in one cell (so the box never resizes) trading places on the - Store drawer's spring, transform and opacity only: the second waits at - --swap-in and, `data-swapped`, takes over as the first leaves to - --swap-out */ +/* Two labels in one cell (so the box never resizes) trading places, + transform and opacity only: the second waits at --swap-in and, + `data-swapped`, takes over as the first leaves to --swap-out */ .swap { display: inline-grid; overflow: hidden; @@ -562,8 +682,7 @@ div.playgroundTextEdit > .playgroundHeader, translate: 0 0; opacity: 1; } -/* the title names what shows: the cover's slides in over the code's name - from the preview's side */ +/* the preview's label trades for "Editor" from the code's side */ .titleSwap { --swap-in: 100% 0; --swap-out: -40% 0; @@ -618,7 +737,8 @@ div.playgroundTextEdit > .playgroundHeader, } .fixturesPopover { position: absolute; - /* over the code and its Store (see .editorBody) */ + /* over the code (and, in row layout, the folder: the preview stacks + above it) */ z-index: 1; /* hangs from the preview's right edge; over the code, only as wide as a response line needs */ @@ -659,21 +779,14 @@ div.playgroundTextEdit > .playgroundHeader, translate: 0 0; } } -/* With header controls (e.g. homepage demos) the selected control joins the - file subtab row below it, and that row's selected file joins the code */ +/* Under the preview's header (the demo layout) the file tabs' row is a step + above the bar, and its selected file joins the code */ .playgroundHeader.subtabs { background: var(--pg-tab-hover-bg); } .subtabs .tab:not(.selected):hover { background-color: var(--pg-bar); } -/* in dark mode, over the Store (the cover) the selected control joins the - Store's top, code-colored like any selected tab; the light Store's top is - the bar itself, so there the control keeps its look */ -.controlTabs:not(:root[data-theme='dark'] .overCover) { - --pg-tab-selected-bg: var(--pg-tab-hover-bg); - --pg-tab-selected: var(--pg-tab-hover); -} /* The preview is a stage (on the wrapper, so it tints the header) and the app one sheet filling the rest: the react-live output (.sheet; the @@ -976,13 +1089,10 @@ div.fixtureJson + div.fixtureJson { :global(.monaco-editor) :global(.inputarea.ime-input) { background-color: var(--monoco-code-background); } -.playgroundContainer.row .playgroundEditor :global(.monaco-editor), -.playgroundContainer.row .playgroundEditor :global(.monaco-editor-background), -.playgroundContainer.row - .playgroundEditor - :global(.monaco-editor) - :global(.margin), -.playgroundContainer.row :global(.monaco-editor) :global(.inputarea.ime-input) { +.endpointPlayground .playgroundEditor :global(.monaco-editor), +.endpointPlayground .playgroundEditor :global(.monaco-editor-background), +.endpointPlayground .playgroundEditor :global(.monaco-editor) :global(.margin), +.endpointPlayground :global(.monaco-editor) :global(.inputarea.ime-input) { border-bottom-left-radius: var(--ifm-global-radius); } .playgroundEditor @@ -1004,77 +1114,150 @@ div.fixtureJson + div.fixtureJson { position: auto !important; } -/* Side-by-side when the playground itself has enough width (docs sidebar - no longer forces an incorrect stack on homepage/blog). */ +/* Side by side when the playground itself has enough width (docs sidebar + no longer forces an incorrect stack on homepage/blog): a grid, the file + tabs' row over the code card, the folder beside the card, and the folder's + layer over the card's row, its Store in the card's column */ +@property --pg-fold { + syntax: ''; + inherits: true; + initial-value: 0px; +} @container playground (min-width: 720px) { .playgroundContainer.row { + /* the preview's edge, where the drawer parks (a length, see @property, + so the drawer can move by it) */ + --pg-fold: calc(100cqw - max(16rem, calc((100cqw - var(--pg-gap)) * 0.35))); + display: grid; + grid-template-columns: minmax(0, 1fr) var(--pg-preview-col); + /* the tabs' row is the preview header's row */ + grid-template-rows: var(--pg-row) auto; + column-gap: var(--pg-gap); + /* the fixtures span the whole playground: the preview column is too thin + for a response (see .fixturesPopover) */ + position: relative; + } + .row > .playgroundTextEdit { + grid-area: 1 / 1 / 3 / 2; + display: grid; + grid-template-rows: subgrid; + } + .row > .playgroundTextEdit > .tabControls { + grid-row: 1; + } + .row .editorDocs { + grid-row: 2; + } + .row > .previewWrapper { + grid-area: 1 / 2 / 3 / 3; + /* unpositioned, so the fixtures hang from the playground's edge */ + position: static; + } + .row > .storeHost { + grid-area: 1 / 1 / 3 / 3; + clip-path: inset(-4rem 0 -4rem -4rem); + } + /* the drawer: the code's column and the gap, edge to edge, its handle + out past the playground's edge; the Store scrolls within the row's + height */ + .row > .storeHost > .storeDrawer { + position: absolute; + inset: 0 auto 0 0; + width: var(--pg-fold); + translate: var(--pg-fold) 0; + } + .row > .storeHost[data-open] > .storeDrawer { + translate: 0 0; + } + /* square along the page's edge, where it covers the file tabs' corner + and the handle joins; the Store keeps the gap from the preview lying + over its edge */ + .row > .storeHost .storePanel { + padding-right: var(--pg-gap); + border-radius: 0 var(--pg-card-radius) var(--pg-card-radius) 0; + } + .storeTab { + right: 100%; + bottom: 0; + width: var(--pg-tab-width); + height: var(--pg-tab-length); + border-radius: var(--pg-card-radius) 0 0 var(--pg-card-radius); + clip-path: inset(-4rem 0 -4rem -4rem); + } + .playgroundContainer.endpointPlayground { flex-direction: row; } - .playgroundContainer.row > :last-child { + .playgroundContainer.endpointPlayground > :last-child { border-top-right-radius: var(--ifm-global-radius); border-bottom-right-radius: var(--ifm-global-radius); border-bottom-left-radius: 0; - /* basis 0 — not auto — so preview content cannot steal editor width */ - flex: 35 1 0; + border-left: 1px solid var(--pg-bar-edge); min-width: 16rem; } - .row.playgroundContainer > div:first-child { + .endpointPlayground.playgroundContainer > div:first-child { border-bottom-left-radius: var(--ifm-global-radius); border-top-right-radius: 0; - flex: 65 1 0; min-width: 16rem; } - /* div element kept: must out-specify .row's div:first-child 65% rule */ + /* div element kept: must out-specify .endpointPlayground's div:first-child */ .playgroundContainer.endpointPlayground > div:first-child, .playgroundContainer.endpointPlayground > div:last-child { + /* basis 0 — not auto — so content cannot steal the other's width */ flex: 50 1 0; } - .playgroundContainer.endpointPlayground > div:last-child { - border-left: 1px solid var(--pg-bar-edge); - } - .playgroundContainer.row .playgroundHeader.tabControls:first-of-type { - border-top-right-radius: 0; - } - .storeHost { - flex-direction: row; - border-bottom-left-radius: var(--ifm-global-radius); - } - .editorBody:first-child .storeHost { + .playgroundContainer.endpointPlayground + .playgroundHeader.tabControls:first-of-type { border-top-right-radius: 0; } - /* the fixtures span the whole playground: the preview column is too thin - for a response. Unpositioned, the preview's clip doesn't reach them */ +} +/* Stacked row layout: the same folder turned to the stacking axis. The + tabs' row and the code card, then the folder under them with the handle + out of its top edge; pulled up, the drawer rises over the code and its + tabs, edge to edge, the handle above the Store. The gap between the two + is the handle's room */ +@container playground (max-width: 719px) { .playgroundContainer.row { + --pg-gap: calc(var(--pg-tab-width) + 6px); position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + row-gap: var(--pg-gap); } - .playgroundContainer.row > .previewWrapper { - position: static; + .row > .playgroundTextEdit { + grid-area: 1 / 1 / 2 / 2; } -} -/* Stacked row layout: the Store opens upward over the code */ -@container playground (max-width: 719px) { - /* and takes the code's place: as tall as its contents (which - `contain: size` would ignore), scrolling past a cap, not the code's height */ - .editorDocs[inert] { - max-height: 0; - overflow: hidden; + .row > .previewWrapper { + grid-area: 2 / 1 / 3 / 2; } - .storeHost { - contain: none; + /* the layer is the code's rows; the gap below it, where the handle + sits, stays in view, and nothing past that (the drawer parked behind + the folder) */ + .row > .storeHost { + grid-area: 1 / 1 / 2 / 2; + clip-path: inset(-4rem -4rem calc(-1 * var(--pg-gap)) -4rem); } - .storeHost:has(> .storePanel[data-visible]) { - min-height: 0; + .row > .storeHost > .storeDrawer { + position: absolute; + inset: 0; + translate: 0 calc(100% + var(--pg-gap)); } - .storeHost > .storePanel > * { - min-height: var(--store-min); - max-height: var(--store-cap); + .row > .storeHost[data-open] > .storeDrawer { + translate: 0 0; } - /* the Store toggle moves into the preview's header */ - .storeBadge { - display: none; + /* square across the top, where it covers the file tabs' corners */ + .row > .storeHost .storePanel { + border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); } - .playgroundContainer.row .storeHeaderToggle { - display: inline-flex; + /* up from the top edge at its right end (the thumb's side, clear of + anything laid out at the left above the preview), past the corner's + curve, so the two join */ + .storeTab { + bottom: 100%; + right: var(--pg-card-radius); + width: var(--pg-tab-length); + height: var(--pg-tab-width); + border-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; + clip-path: inset(-4rem -4rem 0 -4rem); } /* pinned, they get room to unfold a response even over a short result; the preview clips them, so they scroll within that same height */ diff --git a/website/src/components/motion/tokens.ts b/website/src/components/motion/tokens.ts index f04e4a88fe42..62889fb5bd86 100644 --- a/website/src/components/motion/tokens.ts +++ b/website/src/components/motion/tokens.ts @@ -9,4 +9,7 @@ export const springs = { snappy: { duration: 0.25, bounce: 0.15 }, /** Panels and drawers that carry content; no overshoot past their edge */ smooth: { duration: 0.4, bounce: 0 }, + /** A big surface pulled a long way, so the travel reads as deliberate: + * the Playground's Store drawer over the code (Playground/styles.module.css) */ + drawer: { duration: 0.55, bounce: 0 }, } as const satisfies Record; From 81ab734c7538a6f9734e68f9be19ce9bfcf0bf83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 06:33:10 +0000 Subject: [PATCH 25/66] docs: The Store's cover makes the file tabs inert with the code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/__tests__/rowLayout.test.tsx | 18 ++++++++++++++++++ .../Playground/editor/EditorSurface.tsx | 9 ++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/website/src/components/Playground/__tests__/rowLayout.test.tsx b/website/src/components/Playground/__tests__/rowLayout.test.tsx index 25f46389729f..f2929ecf2cfd 100644 --- a/website/src/components/Playground/__tests__/rowLayout.test.tsx +++ b/website/src/components/Playground/__tests__/rowLayout.test.tsx @@ -56,6 +56,24 @@ describe("row layout: the Store on the preview's folder", () => { expect(container.querySelector('textarea')!.closest('[inert]')).toBeNull(); }); + test('the file tabs go inert under the Store with the code', () => { + const fence = (key: string, title: string) => + React.createElement( + 'code', + { key, className: 'language-tsx', metastring: `title="${title}"` }, + `export default function ${title}() { return null; }`, + ); + const { container } = render( + + {[fence('a', 'First'), fence('b', 'Second')]} + , + ); + const tab = container.querySelector('[role="tab"]')!; + expect(tab.closest('[inert]')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Store' })); + expect(tab.closest('[inert]')).not.toBeNull(); + }); + test('the server HTML has the handle, shut, on the folder after the preview', () => { const { renderToStaticMarkup } = require('react-dom/server'); const html: string = renderToStaticMarkup( diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index d1ca51145962..60e40f27de97 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -131,7 +131,8 @@ export default function EditorSurface({ )); return ( -
+ // the Store covers the file tabs and the code card alike +
{!row && fixtureContent != null ? <>
@@ -142,10 +143,8 @@ export default function EditorSurface({ : null} {tabs} - {/* the files: the code card in row layout, which the Store covers */} -
- {code} -
+ {/* the files: the code card in row layout */} +
{code}
); } From eaa33206187a32ec751236c45c891bf73f8f7302 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:35:20 +0000 Subject: [PATCH 26/66] docs: Phone playground keeps the usual gap, the Store's tab over the code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 435d6d218d72..1d844ea67e99 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1214,10 +1214,9 @@ div.fixtureJson + div.fixtureJson { tabs' row and the code card, then the folder under them with the handle out of its top edge; pulled up, the drawer rises over the code and its tabs, edge to edge, the handle above the Store. The gap between the two - is the handle's room */ + is the usual one: shut, the handle overlaps the code card's foot */ @container playground (max-width: 719px) { .playgroundContainer.row { - --pg-gap: calc(var(--pg-tab-width) + 6px); position: relative; display: grid; grid-template-columns: minmax(0, 1fr); @@ -1229,9 +1228,8 @@ div.fixtureJson + div.fixtureJson { .row > .previewWrapper { grid-area: 2 / 1 / 3 / 2; } - /* the layer is the code's rows; the gap below it, where the handle - sits, stays in view, and nothing past that (the drawer parked behind - the folder) */ + /* the layer is the code's rows; the gap below it stays in view, and + nothing past that (the drawer parked behind the folder) */ .row > .storeHost { grid-area: 1 / 1 / 2 / 2; clip-path: inset(-4rem -4rem calc(-1 * var(--pg-gap)) -4rem); From bde71a17e5054767b6b28c1078924dbe44b96680 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:38:24 +0000 Subject: [PATCH 27/66] docs: The open file's tab curves into the code card as one sheet Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/styles.module.css | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 1d844ea67e99..58154bb43307 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -498,6 +498,43 @@ div.playgroundTextEdit > .playgroundHeader, color: var(--pg-tab-selected); box-shadow: none; } +/* the open file's tab is the card's paper rising: over the card's edge (and + its shadow) and curving out into the card's top edge either side, each + piece a pixel into the next so no hairline shows between them */ +.row .tab.selected { + position: relative; + z-index: 1; + box-shadow: 0 1px 0 var(--pg-tab-selected-bg); +} +.row .tab.selected::before, +.row .tab.selected::after { + content: ''; + position: absolute; + bottom: -1px; + width: calc(var(--pg-card-radius) + 1px); + height: calc(var(--pg-card-radius) + 1px); + pointer-events: none; +} +.row .tab.selected::before { + right: calc(100% - 1px); + background: radial-gradient( + circle at 0 0, + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-tab-selected-bg) var(--pg-card-radius) + ); +} +.row .tab.selected::after { + left: calc(100% - 1px); + background: radial-gradient( + circle at 100% 0, + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-tab-selected-bg) var(--pg-card-radius) + ); +} +/* no curve out past the card's edge */ +.row .tab.selected:first-child::before { + content: none; +} .row .tab:focus-visible { outline: 2px solid var(--ifm-color-primary); outline-offset: -2px; From ddb05bc9d43c6d140b257a6a8e7435dd9e55d263 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:42:28 +0000 Subject: [PATCH 28/66] docs: The open tab's curves stay inside the tab row, so it never scrolls Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 58154bb43307..2a3639e18bfb 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -499,20 +499,20 @@ div.playgroundTextEdit > .playgroundHeader, box-shadow: none; } /* the open file's tab is the card's paper rising: over the card's edge (and - its shadow) and curving out into the card's top edge either side, each - piece a pixel into the next so no hairline shows between them */ + its shadow) and curving out into the card's top edge either side, the + curves a pixel into the tab so no hairline shows between them (not + below it: the tab row would scroll) */ .row .tab.selected { position: relative; z-index: 1; - box-shadow: 0 1px 0 var(--pg-tab-selected-bg); } .row .tab.selected::before, .row .tab.selected::after { content: ''; position: absolute; - bottom: -1px; + bottom: 0; width: calc(var(--pg-card-radius) + 1px); - height: calc(var(--pg-card-radius) + 1px); + height: var(--pg-card-radius); pointer-events: none; } .row .tab.selected::before { From 6da1da7841c3862d6f9a1277d4278144315c80fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 12:25:29 +0000 Subject: [PATCH 29/66] docs: Store handle and open file tab are the paper they rise from The Store's handle sits on the folder's layer over the preview, curving into its edge like the open file's tab. File tabs stay on one line, scrolling the open one into view, and the card squares the corner the open tab reaches. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/TabList.tsx | 51 ++++++++++- .../components/Playground/styles.module.css | 87 +++++++++++++++---- 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/website/src/components/Playground/TabList.tsx b/website/src/components/Playground/TabList.tsx index 8b568b452a8d..e9336a605cb3 100644 --- a/website/src/components/Playground/TabList.tsx +++ b/website/src/components/Playground/TabList.tsx @@ -1,5 +1,11 @@ import clsx from 'clsx'; -import type { KeyboardEvent, ReactNode } from 'react'; +import { + useLayoutEffect, + useRef, + type KeyboardEvent, + type ReactNode, + type RefObject, +} from 'react'; import styles from './styles.module.css'; @@ -21,6 +27,9 @@ export default function TabList({ /** Names the list for assistive tech */ label?: string; }) { + const list = useRef(null); + const selected = tabs.findIndex(tab => tab.selected); + useFlushEdges(list, selected); // the selected tab is the one in the tab order; the arrows move from it const onKeyDown = (event: KeyboardEvent) => { const from = tabs.findIndex(tab => tab.selected); @@ -50,6 +59,7 @@ export default function TabList({ }; return (
); } + +/** Scrolls the selected tab into the row's view and marks the row + * `data-flush-left` / `data-flush-right` while that tab meets the header's + * edge, where what it rises from squares its corner to meet it */ +function useFlushEdges( + list: RefObject, + selected: number, +) { + useLayoutEffect(() => { + const row = list.current; + const edges = row?.parentElement; + if (!row || !edges) return; + const mark = () => { + const tab = row.children[selected]?.getBoundingClientRect(); + const box = edges.getBoundingClientRect(); + row.toggleAttribute('data-flush-left', !!tab && tab.left <= box.left + 1); + row.toggleAttribute( + 'data-flush-right', + !!tab && tab.right >= box.right - 1, + ); + }; + const tab = row.children[selected]?.getBoundingClientRect(); + const view = row.getBoundingClientRect(); + if (tab && tab.right > view.right) row.scrollLeft += tab.right - view.right; + else if (tab && tab.left < view.left) + row.scrollLeft -= view.left - tab.left; + mark(); + row.addEventListener('scroll', mark, { passive: true }); + const observer = + typeof ResizeObserver === 'undefined' ? undefined : ( + new ResizeObserver(mark) + ); + observer?.observe(edges); + return () => { + row.removeEventListener('scroll', mark); + observer?.disconnect(); + }; + }, [list, selected]); +} diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 2a3639e18bfb..463a653a6ed6 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -479,6 +479,7 @@ div.playgroundTextEdit > .playgroundHeader, } .row .tab { gap: 0.5em; + white-space: nowrap; height: 100%; padding: 0 0.85rem; border-radius: 8px 8px 0 0; @@ -531,8 +532,11 @@ div.playgroundTextEdit > .playgroundHeader, var(--pg-tab-selected-bg) var(--pg-card-radius) ); } -/* no curve out past the card's edge */ -.row .tab.selected:first-child::before { +/* at the card's edge (TabList's flush marks), no curve out past it */ +.row + .tabs:is([data-flush-left], :has(> .tab:first-child.selected)) + > .tab.selected::before, +.row .tabs[data-flush-right] > .tab.selected::after { content: none; } .row .tab:focus-visible { @@ -563,13 +567,19 @@ div.playgroundTextEdit > .playgroundHeader, max-height: 6rem; border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); } -/* the first file open: its tab's edge runs straight into the card */ +/* the open file's tab at the card's edge runs straight into it */ .row - .tabControls:has(.tab:first-child.selected) + .tabControls:has(.tabs[data-flush-left], .tab:first-child.selected) + .editorDocs > .playgroundEditor { border-top-left-radius: 0; } +.row + .tabControls:has(.tabs[data-flush-right]) + + .editorDocs + > .playgroundEditor { + border-top-right-radius: 0; +} /* the preview's folder: the row's height, its header and the app's sheet on it; the sheet's foot runs on toward the code's by 6rem at most, its corners the folder's where they meet */ @@ -616,7 +626,10 @@ div.playgroundTextEdit > .playgroundHeader, is behind the folder and its Store leaves the tree (StoreInspector) */ .row > .storeHost { position: relative; - z-index: 1; + /* over the preview, so its handle is the folder's own paper (the + preview's shadow under it); the drawer never reaches the preview + (clipped at its edge below) */ + z-index: 3; min-width: 0; min-height: 0; /* the handle reaches past its edge (the frame's last-child rule clips) */ @@ -649,15 +662,14 @@ div.playgroundTextEdit > .playgroundHeader, .row > .storeHost:has(.storePanel[data-visible]) { min-height: var(--store-min); } -/* The handle: a pull tab of the folder's color on the drawer's edge, out - from under the preview (whose shadow falls on it) and, drawn out, still - outside the Store's edge; its own shadow clipped where it joins */ +/* The handle: a pull tab of the folder's paper on the drawer's edge, out + past the preview's edge and, drawn out, the Store's; it curves into + that edge either side as the open file's tab does into its card */ .storeTab { position: absolute; display: grid; place-items: center; background: var(--pg-stage); - box-shadow: var(--pg-card-shadow); color: var(--pg-muted); font-size: 1.1rem; cursor: pointer; @@ -678,6 +690,14 @@ div.playgroundTextEdit > .playgroundHeader, outline: 2px solid var(--ifm-color-primary); outline-offset: -3px; } +.storeTab::before, +.storeTab::after { + content: ''; + position: absolute; + width: calc(var(--pg-card-radius) + 1px); + height: calc(var(--pg-card-radius) + 1px); + pointer-events: none; +} .debugToggle:hover { background-color: var(--pg-tab-hover-bg); @@ -1192,7 +1212,7 @@ div.fixtureJson + div.fixtureJson { } .row > .storeHost { grid-area: 1 / 1 / 3 / 3; - clip-path: inset(-4rem 0 -4rem -4rem); + clip-path: inset(-4rem calc(100% - var(--pg-fold)) -4rem -4rem); } /* the drawer: the code's column and the gap, edge to edge, its handle out past the playground's edge; the Store scrolls within the row's @@ -1213,13 +1233,31 @@ div.fixtureJson + div.fixtureJson { padding-right: var(--pg-gap); border-radius: 0 var(--pg-card-radius) var(--pg-card-radius) 0; } + /* by the foot, its curve clear of the corner's */ .storeTab { right: 100%; - bottom: 0; + bottom: calc(2 * var(--pg-card-radius)); width: var(--pg-tab-width); height: var(--pg-tab-length); border-radius: var(--pg-card-radius) 0 0 var(--pg-card-radius); - clip-path: inset(-4rem 0 -4rem -4rem); + } + .storeTab::before { + right: -1px; + bottom: calc(100% - 1px); + background: radial-gradient( + circle at 0 0, + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-stage) var(--pg-card-radius) + ); + } + .storeTab::after { + right: -1px; + top: calc(100% - 1px); + background: radial-gradient( + circle at 0 100%, + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-stage) var(--pg-card-radius) + ); } .playgroundContainer.endpointPlayground { flex-direction: row; @@ -1284,15 +1322,32 @@ div.fixtureJson + div.fixtureJson { border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); } /* up from the top edge at its right end (the thumb's side, clear of - anything laid out at the left above the preview), past the corner's - curve, so the two join */ + anything laid out at the left above the preview), its curve clear of + the corner's */ .storeTab { bottom: 100%; - right: var(--pg-card-radius); + right: calc(2 * var(--pg-card-radius)); width: var(--pg-tab-length); height: var(--pg-tab-width); border-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; - clip-path: inset(-4rem -4rem 0 -4rem); + } + .storeTab::before { + right: calc(100% - 1px); + bottom: -1px; + background: radial-gradient( + circle at 0 0, + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-stage) var(--pg-card-radius) + ); + } + .storeTab::after { + left: calc(100% - 1px); + bottom: -1px; + background: radial-gradient( + circle at 100% 0, + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-stage) var(--pg-card-radius) + ); } /* pinned, they get room to unfold a response even over a short result; the preview clips them, so they scroll within that same height */ From 6c280dc502efdfb1ad0237f29df46a057742f391 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 12:34:02 +0000 Subject: [PATCH 30/66] docs: Simplify the playground's Store handle and file tabs One fillet rule draws both the open file tab's and the Store handle's curves; the code card and app sheet share one foot rule and token; the drawer parks by its own width, so the fold needs no registered property; the handle is never disabled; the tab lists' names are translatable. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../src/components/Demo/ProtocolChoice.tsx | 3 +- website/src/components/Playground/Header.tsx | 12 +- website/src/components/Playground/TabList.tsx | 15 +-- .../Playground/editor/EditorSurface.tsx | 4 +- website/src/components/Playground/index.tsx | 22 ++-- .../Playground/preview/StoreToggle.tsx | 9 +- .../components/Playground/styles.module.css | 113 +++++++----------- 7 files changed, 73 insertions(+), 105 deletions(-) diff --git a/website/src/components/Demo/ProtocolChoice.tsx b/website/src/components/Demo/ProtocolChoice.tsx index c6cebb6c2561..a99d7c4a09c0 100644 --- a/website/src/components/Demo/ProtocolChoice.tsx +++ b/website/src/components/Demo/ProtocolChoice.tsx @@ -1,3 +1,4 @@ +import { translate } from '@docusaurus/Translate'; import React, { useContext } from 'react'; import CodeTabContext from './CodeTabContext'; @@ -12,7 +13,7 @@ export default function ProtocolChoice() { return ( ({ key: value, label, diff --git a/website/src/components/Playground/Header.tsx b/website/src/components/Playground/Header.tsx index 42d302ccf008..8ff5e9f72cd1 100644 --- a/website/src/components/Playground/Header.tsx +++ b/website/src/components/Playground/Header.tsx @@ -3,13 +3,20 @@ import React from 'react'; import styles from './styles.module.css'; +type OnClick = React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +>['onClick']; + export default function Header({ children, className, onClick, small = false, - ...rest -}: React.HTMLAttributes & { +}: { + children: React.ReactNode; + className?: string; + onClick?: OnClick; small?: boolean; }) { return ( @@ -21,7 +28,6 @@ export default function Header({ { [styles.small]: small }, )} onClick={onClick} - {...rest} > {children}
diff --git a/website/src/components/Playground/TabList.tsx b/website/src/components/Playground/TabList.tsx index e9336a605cb3..1ac683edfef5 100644 --- a/website/src/components/Playground/TabList.tsx +++ b/website/src/components/Playground/TabList.tsx @@ -32,18 +32,15 @@ export default function TabList({ useFlushEdges(list, selected); // the selected tab is the one in the tab order; the arrows move from it const onKeyDown = (event: KeyboardEvent) => { - const from = tabs.findIndex(tab => tab.selected); - const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'; - const forward = rtl ? 'ArrowLeft' : 'ArrowRight'; - const backward = rtl ? 'ArrowRight' : 'ArrowLeft'; let to: number; switch (event.key) { - case forward: - to = (from + 1) % tabs.length; - break; - case backward: - to = (from - 1 + tabs.length) % tabs.length; + case 'ArrowRight': + case 'ArrowLeft': { + const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'; + const step = (event.key === 'ArrowRight') !== rtl ? 1 : -1; + to = (selected + step + tabs.length) % tabs.length; break; + } case 'Home': to = 0; break; diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx index 60e40f27de97..63319a1d9f98 100644 --- a/website/src/components/Playground/editor/EditorSurface.tsx +++ b/website/src/components/Playground/editor/EditorSurface.tsx @@ -1,5 +1,5 @@ import BrowserOnly from '@docusaurus/BrowserOnly'; -import Translate from '@docusaurus/Translate'; +import Translate, { translate } from '@docusaurus/Translate'; import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; import clsx from 'clsx'; import type * as Monaco from 'monaco-editor'; @@ -261,7 +261,7 @@ function EditorTabs({ small={subtabs || compact} > ({ key: `${document.path}:${index}`, label: document.title, diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 285e8851bf2a..f29f6136f057 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -112,18 +112,14 @@ export default function Playground({ const codeCovered = row && storeOpen; // the folder's handle: Escape anywhere on the folder pushes it shut const handle = useRef(null); - const shutStore = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented || !storeOpen) - return; - const tab = handle.current; - if (!tab) return; - event.preventDefault(); - toggleStore({ currentTarget: tab }); - tab.focus(); - }, - [storeOpen, toggleStore], - ); + const shutStore = (event: React.KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !storeOpen) return; + const tab = handle.current; + if (!tab) return; + event.preventDefault(); + toggleStore({ currentTarget: tab }); + tab.focus(); + }; // Demo layout: the code takes the result's place while shown const [codeShown, setCodeShown] = useState(false); @@ -261,7 +257,7 @@ export default function Playground({
diff --git a/website/src/components/Playground/preview/StoreToggle.tsx b/website/src/components/Playground/preview/StoreToggle.tsx index 27f70571e085..8109656b50ed 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -200,14 +200,14 @@ function HeaderToggle({ * Store's edge, drawn out (see .storeDrawer in ../styles.module.css) */ export function StoreTab({ onClick, - open = false, + open, controls, ref, }: { - onClick?: React.MouseEventHandler; - open?: boolean; + onClick: React.MouseEventHandler; + open: boolean; /** The folder's id */ - controls?: string; + controls: string; ref?: React.Ref; }) { return ( @@ -219,7 +219,6 @@ export function StoreTab({ aria-label={translate({ id: 'playground.store', message: 'Store' })} aria-expanded={open} aria-controls={controls} - disabled={!onClick} onClick={onClick} data-store-toggle > diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 463a653a6ed6..207dc69480c4 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -449,10 +449,8 @@ div.playgroundTextEdit > .playgroundHeader, /* the handle: how far it sticks out, and its length along the edge */ --pg-tab-width: 2.5rem; --pg-tab-length: 3rem; - /* the preview's column; the layer's pull starts at its edge */ - --pg-preview-col: max(16rem, calc((100% - var(--pg-gap)) * 0.35)); - /* the drawer's pull: a big surface a long way, deliberate */ - --pg-folder-sweep: var(--motion-drawer); + /* how far the code card and the app's sheet reach toward the other's foot */ + --pg-card-foot: 6rem; border-radius: 0; box-shadow: none; background: none; @@ -493,44 +491,31 @@ div.playgroundTextEdit > .playgroundHeader, background: color-mix(in srgb, var(--ifm-font-color-base) 7%, transparent); color: var(--ifm-font-color-base); } -.row .tab.selected, -.row .tab.selected:hover { - background: var(--pg-tab-selected-bg); - color: var(--pg-tab-selected); - box-shadow: none; -} /* the open file's tab is the card's paper rising: over the card's edge (and its shadow) and curving out into the card's top edge either side, the curves a pixel into the tab so no hairline shows between them (not below it: the tab row would scroll) */ -.row .tab.selected { +.row .tab.selected, +.row .tab.selected:hover { position: relative; z-index: 1; + --pg-fillet-color: var(--pg-tab-selected-bg); + background: var(--pg-tab-selected-bg); + color: var(--pg-tab-selected); + box-shadow: none; } .row .tab.selected::before, .row .tab.selected::after { - content: ''; - position: absolute; bottom: 0; - width: calc(var(--pg-card-radius) + 1px); height: var(--pg-card-radius); - pointer-events: none; } .row .tab.selected::before { right: calc(100% - 1px); - background: radial-gradient( - circle at 0 0, - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-tab-selected-bg) var(--pg-card-radius) - ); + --pg-fillet-at: 0 0; } .row .tab.selected::after { left: calc(100% - 1px); - background: radial-gradient( - circle at 100% 0, - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-tab-selected-bg) var(--pg-card-radius) - ); + --pg-fillet-at: 100% 0; } /* at the card's edge (TabList's flush marks), no curve out past it */ .row @@ -560,11 +545,13 @@ div.playgroundTextEdit > .playgroundHeader, padding-top: 10px; border-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; } -.row .editorDocs::after { +/* the code card's foot, and the app sheet's on the folder (see the top) */ +.row .editorDocs::after, +.row > .previewWrapper::after { content: ''; flex: 1 1 0; min-height: var(--pg-card-radius); - max-height: 6rem; + max-height: var(--pg-card-foot); border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); } /* the open file's tab at the card's edge runs straight into it */ @@ -604,13 +591,6 @@ div.playgroundTextEdit > .playgroundHeader, flex: 1 1 auto; min-width: 0; } -.row > .previewWrapper::after { - content: ''; - flex: 1 1 0; - min-height: var(--pg-card-radius); - max-height: 6rem; - border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); -} .row > .previewWrapper:has(.sheet:not(:empty))::after { background: var(--pg-panel); } @@ -638,7 +618,7 @@ div.playgroundTextEdit > .playgroundHeader, } .row > .storeHost > .storeDrawer { pointer-events: auto; - transition: translate var(--pg-folder-sweep); + transition: translate var(--motion-drawer); } .row > .storeHost .storePanel { display: flex; @@ -646,7 +626,7 @@ div.playgroundTextEdit > .playgroundHeader, height: 100%; min-height: 0; background: var(--pg-stage); - transition: box-shadow var(--pg-folder-sweep); + transition: box-shadow var(--motion-drawer); } /* fills the drawer instead of its usual cap */ .row > .storeHost .storePanel > * { @@ -669,34 +649,44 @@ div.playgroundTextEdit > .playgroundHeader, position: absolute; display: grid; place-items: center; + --pg-fillet-color: var(--pg-stage); background: var(--pg-stage); color: var(--pg-muted); font-size: 1.1rem; cursor: pointer; transition: color var(--motion-snappy); } -.storeTab:disabled { - cursor: default; +.storeTab[aria-expanded='true'] { + color: var(--ifm-color-primary); } @media (hover: hover) { - .storeTab:enabled:hover { + .storeTab:hover { color: var(--pg-text); } } -.storeTab[aria-expanded='true'] { - color: var(--ifm-color-primary); -} .storeTab:focus-visible { outline: 2px solid var(--ifm-color-primary); outline-offset: -3px; } .storeTab::before, +.storeTab::after { + height: calc(var(--pg-card-radius) + 1px); +} +/* Concave fillets: a selected tab's or the handle's paper curving out into + the edge it rises from, at --pg-fillet-at in its own --pg-fillet-color */ +.row .tab.selected::before, +.row .tab.selected::after, +.storeTab::before, .storeTab::after { content: ''; position: absolute; width: calc(var(--pg-card-radius) + 1px); - height: calc(var(--pg-card-radius) + 1px); pointer-events: none; + background: radial-gradient( + circle at var(--pg-fillet-at), + transparent calc(var(--pg-card-radius) - 0.5px), + var(--pg-fillet-color) var(--pg-card-radius) + ); } .debugToggle:hover { @@ -1175,16 +1165,11 @@ div.fixtureJson + div.fixtureJson { no longer forces an incorrect stack on homepage/blog): a grid, the file tabs' row over the code card, the folder beside the card, and the folder's layer over the card's row, its Store in the card's column */ -@property --pg-fold { - syntax: ''; - inherits: true; - initial-value: 0px; -} @container playground (min-width: 720px) { .playgroundContainer.row { - /* the preview's edge, where the drawer parks (a length, see @property, - so the drawer can move by it) */ - --pg-fold: calc(100cqw - max(16rem, calc((100cqw - var(--pg-gap)) * 0.35))); + --pg-preview-col: max(16rem, calc((100cqw - var(--pg-gap)) * 0.35)); + /* the preview's edge, where the drawer parks */ + --pg-fold: calc(100cqw - var(--pg-preview-col)); display: grid; grid-template-columns: minmax(0, 1fr) var(--pg-preview-col); /* the tabs' row is the preview header's row */ @@ -1221,7 +1206,7 @@ div.fixtureJson + div.fixtureJson { position: absolute; inset: 0 auto 0 0; width: var(--pg-fold); - translate: var(--pg-fold) 0; + translate: 100% 0; } .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; @@ -1244,20 +1229,12 @@ div.fixtureJson + div.fixtureJson { .storeTab::before { right: -1px; bottom: calc(100% - 1px); - background: radial-gradient( - circle at 0 0, - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-stage) var(--pg-card-radius) - ); + --pg-fillet-at: 0 0; } .storeTab::after { right: -1px; top: calc(100% - 1px); - background: radial-gradient( - circle at 0 100%, - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-stage) var(--pg-card-radius) - ); + --pg-fillet-at: 0 100%; } .playgroundContainer.endpointPlayground { flex-direction: row; @@ -1334,20 +1311,12 @@ div.fixtureJson + div.fixtureJson { .storeTab::before { right: calc(100% - 1px); bottom: -1px; - background: radial-gradient( - circle at 0 0, - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-stage) var(--pg-card-radius) - ); + --pg-fillet-at: 0 0; } .storeTab::after { left: calc(100% - 1px); bottom: -1px; - background: radial-gradient( - circle at 100% 0, - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-stage) var(--pg-card-radius) - ); + --pg-fillet-at: 100% 0; } /* pinned, they get room to unfold a response even over a short result; the preview clips them, so they scroll within that same height */ From 33b07f4f3d1651b48aea02413e7bb3dc45f0ee2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:10:48 +0000 Subject: [PATCH 31/66] docs: Phone Store stops short of a long file; tab curves only square at the edge On phone the Store rises at most --store-cap from the code's foot, so a long file's top stays in view. The open file tab keeps its curve unless it is too near the card's edge for curve and corner both, whichever tab it is and however the row is scrolled (the row had clipped the last tab's curve). The drawer slides only once painted at rest, so showing a playground (the homepage's protocol switch) never slides it shut. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 6 ++++-- website/src/components/Playground/TabList.tsx | 19 +++++++++++++++---- website/src/components/Playground/index.tsx | 16 ++++++++++++++++ .../components/Playground/styles.module.css | 17 +++++++++-------- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 8139ade4eb0d..0eb4a181cfaa 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -192,8 +192,10 @@ DesignSystem/ demo styling (design-system.css) and components injected int `translate`, over the code and its file tabs edge to edge (they go `inert`), the handle riding outside the Store's edge, past the playground's. Side by side it comes from the preview's left edge by its - foot; stacked, up from the preview's top edge. The preview stays usable - either way. The Store itself is chrome on the folder (the scrubber's row, + foot; stacked, up from the preview's top edge, at most `--store-cap` + tall so a long file's top stays in view. It slides only once painted at + rest (`data-settled`), so showing a playground never animates it. The + preview stays usable either way. The Store itself is chrome on the folder (the scrubber's row, State and Action as tabs) and content on a sheet the picked tab joins (`store.module.css`). In the other layouts the preview header's icon (`StoreHeaderToggle`) or the side strip (`StoreToggle`) open it beside the diff --git a/website/src/components/Playground/TabList.tsx b/website/src/components/Playground/TabList.tsx index 1ac683edfef5..51145c57fc38 100644 --- a/website/src/components/Playground/TabList.tsx +++ b/website/src/components/Playground/TabList.tsx @@ -80,8 +80,9 @@ export default function TabList({ } /** Scrolls the selected tab into the row's view and marks the row - * `data-flush-left` / `data-flush-right` while that tab meets the header's - * edge, where what it rises from squares its corner to meet it */ + * `data-flush-left` / `data-flush-right` while that tab is too near the + * header's edge for its curve and the corner of what it rises from + * (`--pg-card-radius` each) to fit between, so that corner squares to meet it */ function useFlushEdges( list: RefObject, selected: number, @@ -90,13 +91,23 @@ function useFlushEdges( const row = list.current; const edges = row?.parentElement; if (!row || !edges) return; + const reach = Math.max( + 1, + 2 * + (parseFloat( + getComputedStyle(row).getPropertyValue('--pg-card-radius'), + ) || 0), + ); const mark = () => { const tab = row.children[selected]?.getBoundingClientRect(); const box = edges.getBoundingClientRect(); - row.toggleAttribute('data-flush-left', !!tab && tab.left <= box.left + 1); + row.toggleAttribute( + 'data-flush-left', + !!tab && tab.left < box.left + reach, + ); row.toggleAttribute( 'data-flush-right', - !!tab && tab.right >= box.right - 1, + !!tab && tab.right > box.right - reach, ); }; const tab = row.children[selected]?.getBoundingClientRect(); diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index f29f6136f057..16df2a763930 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -3,6 +3,7 @@ import React, { lazy, useCallback, useDeferredValue, + useEffect, useId, useMemo, useRef, @@ -108,6 +109,21 @@ export default function Playground({ // handle pulls out over the code (the folder's layer, .storeHost), leaving // the preview usable const [storeHost, setStoreHost] = useState(null); + // the drawer slides only once it has been painted where it rests: shown + // (the protocol switch, a page load), a browser may resolve the container + // query that parks it a frame late, and slide it there (Safari) + useEffect(() => { + if (!storeHost || hidden) return; + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => + storeHost.toggleAttribute('data-settled', true), + ); + }); + return () => { + cancelAnimationFrame(frame); + storeHost.removeAttribute('data-settled'); + }; + }, [storeHost, hidden]); const storeId = useId(); const codeCovered = row && storeOpen; // the folder's handle: Escape anywhere on the folder pushes it shut diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 207dc69480c4..f97ec2b0415f 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -474,6 +474,8 @@ div.playgroundTextEdit > .playgroundHeader, .row .tabs { height: 100%; min-width: 0; + /* room for the last tab's curve; at the card's edges the row clips them */ + padding-inline-end: var(--pg-card-radius); } .row .tab { gap: 0.5em; @@ -517,13 +519,6 @@ div.playgroundTextEdit > .playgroundHeader, left: calc(100% - 1px); --pg-fillet-at: 100% 0; } -/* at the card's edge (TabList's flush marks), no curve out past it */ -.row - .tabs:is([data-flush-left], :has(> .tab:first-child.selected)) - > .tab.selected::before, -.row .tabs[data-flush-right] > .tab.selected::after { - content: none; -} .row .tab:focus-visible { outline: 2px solid var(--ifm-color-primary); outline-offset: -2px; @@ -618,6 +613,9 @@ div.playgroundTextEdit > .playgroundHeader, } .row > .storeHost > .storeDrawer { pointer-events: auto; +} +/* once painted at rest (../index.tsx) */ +.row > .storeHost > .storeDrawer[data-settled] { transition: translate var(--motion-drawer); } .row > .storeHost .storePanel { @@ -1286,9 +1284,12 @@ div.fixtureJson + div.fixtureJson { grid-area: 1 / 1 / 2 / 2; clip-path: inset(-4rem -4rem calc(-1 * var(--pg-gap)) -4rem); } + /* from the code's foot up, at most the Store's cap: a long file's top + stays in view above it */ .row > .storeHost > .storeDrawer { position: absolute; - inset: 0; + inset: auto 0 0; + height: min(100%, var(--store-cap)); translate: 0 calc(100% + var(--pg-gap)); } .row > .storeHost[data-open] > .storeDrawer { From 22813b0a6790b12945b86b684c1af993ed98ed19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:23:43 +0000 Subject: [PATCH 32/66] docs: The file tab row scrolls no further than the card's edge Fully scrolled, the last tab sits flush on the card's right edge: near the edge the open tab's curve is hidden (the corner squares to meet it), so it no longer adds scroll past the edge; the row fills the header, so a short row still has room for the last tab's curve. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index f97ec2b0415f..9ca53a67c1b2 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -473,9 +473,9 @@ div.playgroundTextEdit > .playgroundHeader, } .row .tabs { height: 100%; + /* the header's width, so a short row has room for the last tab's curve */ + flex: 1 1 auto; min-width: 0; - /* room for the last tab's curve; at the card's edges the row clips them */ - padding-inline-end: var(--pg-card-radius); } .row .tab { gap: 0.5em; @@ -519,6 +519,15 @@ div.playgroundTextEdit > .playgroundHeader, left: calc(100% - 1px); --pg-fillet-at: 100% 0; } +/* too near the card's edge for its curve and the card's corner both + (TabList's flush marks): straight down into the squared corner. Hidden, + the last tab's curve no longer lets the row scroll past the edge */ +.row + .tabs:is([data-flush-left], :has(> .tab:first-child.selected)) + > .tab.selected::before, +.row .tabs[data-flush-right] > .tab.selected::after { + content: none; +} .row .tab:focus-visible { outline: 2px solid var(--ifm-color-primary); outline-offset: -2px; From dc63fff3906633f534e6523deef3325ef77dd27a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:30:07 +0000 Subject: [PATCH 33/66] docs: On phone the open Store's paper runs on into the preview's header Stacked, the Store's folder layer reaches down to the preview and joins its header (the preview's top corners square while the Store shows), so the only gap between them is under the Store's sheet, whose foot is now rounded. Its shadow comes from a box that stops short of that seam, so no line shows where the two meet. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/store/store.module.css | 7 ++-- .../components/Playground/styles.module.css | 40 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index c1271194a98f..e4c26ff0fee8 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -63,8 +63,9 @@ display: flex; flex-direction: column; } -/* The sheet: the picked tab's content, to the folder's foot; what paints - over its rows as they scroll (headings, crumbs) takes its color */ +/* The sheet: the picked tab's content, to the folder's foot (rounded by + --store-sheet-foot where the folder runs on past it); what paints over its + rows as they scroll (headings, crumbs) takes its color */ .tabPanel { --store-bg: var(--pg-panel); position: relative; @@ -73,7 +74,7 @@ min-height: 0; display: flex; flex-direction: column; - border-radius: 12px 12px 0 0; + border-radius: 12px 12px var(--store-sheet-foot, 0) var(--store-sheet-foot, 0); background: var(--store-bg); overflow: clip; } diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 9ca53a67c1b2..ec411624cb6a 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1293,20 +1293,46 @@ div.fixtureJson + div.fixtureJson { grid-area: 1 / 1 / 2 / 2; clip-path: inset(-4rem -4rem calc(-1 * var(--pg-gap)) -4rem); } - /* from the code's foot up, at most the Store's cap: a long file's top - stays in view above it */ + /* from the preview's top edge up, at most the Store's cap (a long + file's top stays in view above it): the folder's paper runs on + unbroken into the preview's header, only the Store's sheet keeping + the gap from it */ .row > .storeHost > .storeDrawer { position: absolute; - inset: auto 0 0; - height: min(100%, var(--store-cap)); - translate: 0 calc(100% + var(--pg-gap)); + inset: auto 0 calc(-1 * var(--pg-gap)); + height: min(100% + var(--pg-gap), var(--store-cap)); + translate: 0 100%; } .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } - /* square across the top, where it covers the file tabs' corners */ + /* square all round: across the top it covers the file tabs' corners, + and its foot is the preview's top edge */ .row > .storeHost .storePanel { - border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); + --store-sheet-foot: 12px; + padding-bottom: var(--pg-gap); + } + .row > .storeHost[data-open] .storePanel { + box-shadow: none; + } + /* its shadow, from a box that stops short of the foot, so none of it + falls past the seam onto the preview (the Store paints over the rest) */ + .row > .storeHost > .storeDrawer::before { + content: ''; + position: absolute; + inset: 0 0 4rem; + z-index: -1; + box-shadow: var(--pg-card-shadow); + opacity: 0; + transition: opacity var(--motion-drawer); + } + .row > .storeHost[data-open] > .storeDrawer::before { + opacity: 1; + } + /* the folder's top edge joins the Store's, while it shows */ + .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { + border-top-left-radius: 0; + border-top-right-radius: 0; } /* up from the top edge at its right end (the thumb's side, clear of anything laid out at the left above the preview), its curve clear of From 9e148f0c9f269750dabdb64bec0fbf8c4279ae1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:16:51 +0000 Subject: [PATCH 34/66] docs: Round the Store sheet's foot on wide playgrounds too Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index ec411624cb6a..004c74c73061 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -627,11 +627,14 @@ div.playgroundTextEdit > .playgroundHeader, .row > .storeHost > .storeDrawer[data-settled] { transition: translate var(--motion-drawer); } +/* its sheet ends short of the foot, rounded, as the code card does */ .row > .storeHost .storePanel { + --store-sheet-foot: 12px; display: flex; flex-direction: column; height: 100%; min-height: 0; + padding-bottom: var(--pg-gap); background: var(--pg-stage); transition: box-shadow var(--motion-drawer); } @@ -1306,12 +1309,6 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } - /* square all round: across the top it covers the file tabs' corners, - and its foot is the preview's top edge */ - .row > .storeHost .storePanel { - --store-sheet-foot: 12px; - padding-bottom: var(--pg-gap); - } .row > .storeHost[data-open] .storePanel { box-shadow: none; } From bc4ed5d6bcf89b85ca1292a4a0e2e9a030b5c8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:22:15 +0000 Subject: [PATCH 35/66] docs: On wide playgrounds the open Store's paper runs on into the preview's, under one shadow Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/styles.module.css | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 004c74c73061..9d7f6ef71d86 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -636,7 +636,6 @@ div.playgroundTextEdit > .playgroundHeader, min-height: 0; padding-bottom: var(--pg-gap); background: var(--pg-stage); - transition: box-shadow var(--motion-drawer); } /* fills the drawer instead of its usual cap */ .row > .storeHost .storePanel > * { @@ -644,9 +643,6 @@ div.playgroundTextEdit > .playgroundHeader, min-height: 0; max-height: none; } -.row > .storeHost[data-open] .storePanel { - box-shadow: var(--pg-card-shadow); -} /* room to inspect even when the code is short (the Store's own minimum), held while shown, until it has slid shut */ .row > .storeHost:has(.storePanel[data-visible]) { @@ -1204,6 +1200,38 @@ div.fixtureJson + div.fixtureJson { grid-area: 1 / 2 / 3 / 3; /* unpositioned, so the fixtures hang from the playground's edge */ position: static; + box-shadow: none; + } + /* its shadow, from a box that runs out over the Store with the drawer, + so the open Store and the preview are one paper with one shadow */ + .row > .previewWrapper::before { + content: ''; + position: absolute; + inset: 0 0 0 var(--pg-fold); + z-index: -1; + border-radius: var(--pg-card-radius); + box-shadow: var(--pg-card-shadow); + pointer-events: none; + } + .row:has(> .storeHost[data-open]) > .previewWrapper::before { + left: 0; + } + .row:has(> .storeHost > .storeDrawer[data-settled]) + > .previewWrapper::before { + transition: left var(--motion-drawer); + } + .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + /* under it, the code card's own shadow would deepen the Store's */ + .row .editorDocs > .playgroundEditor, + .row .editorDocs::after { + transition: box-shadow var(--motion-drawer); + } + .row:has(> .storeHost[data-open]) .editorDocs > .playgroundEditor, + .row:has(> .storeHost[data-open]) .editorDocs::after { + box-shadow: none; } .row > .storeHost { grid-area: 1 / 1 / 3 / 3; @@ -1221,12 +1249,11 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } - /* square along the page's edge, where it covers the file tabs' corner - and the handle joins; the Store keeps the gap from the preview lying - over its edge */ + /* its paper runs on into the preview's, square where they meet; the + Store's sheet keeps the gap from the preview's */ .row > .storeHost .storePanel { padding-right: var(--pg-gap); - border-radius: 0 var(--pg-card-radius) var(--pg-card-radius) 0; + border-radius: var(--pg-card-radius) 0 0 var(--pg-card-radius); } /* by the foot, its curve clear of the corner's */ .storeTab { @@ -1309,9 +1336,6 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } - .row > .storeHost[data-open] .storePanel { - box-shadow: none; - } /* its shadow, from a box that stops short of the foot, so none of it falls past the seam onto the preview (the Store paints over the rest) */ .row > .storeHost > .storeDrawer::before { From 86256873534a8848d2b8ac43a33f0c32447da8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:23:05 +0000 Subject: [PATCH 36/66] docs: Switch a file tab's paper at once, so its curves never lag its fill Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 9d7f6ef71d86..6eb85caec3f2 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -485,9 +485,8 @@ div.playgroundTextEdit > .playgroundHeader, border-radius: 8px 8px 0 0; color: var(--ifm-color-emphasis-800); box-shadow: none; - transition: - background-color var(--motion-snappy), - color var(--motion-snappy); + /* its paper (background and curves) comes and goes at once, together */ + transition: color var(--motion-snappy); } .row .tab:hover { background: color-mix(in srgb, var(--ifm-font-color-base) 7%, transparent); From 7bcb966a6440db5fff3cfdac7ed3c7b2b5ad7c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:30:31 +0000 Subject: [PATCH 37/66] docs: On wide playgrounds the Store's sheet runs to the foot, as the preview's does Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 6eb85caec3f2..8a707f2f60f3 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -626,14 +626,11 @@ div.playgroundTextEdit > .playgroundHeader, .row > .storeHost > .storeDrawer[data-settled] { transition: translate var(--motion-drawer); } -/* its sheet ends short of the foot, rounded, as the code card does */ .row > .storeHost .storePanel { - --store-sheet-foot: 12px; display: flex; flex-direction: column; height: 100%; min-height: 0; - padding-bottom: var(--pg-gap); background: var(--pg-stage); } /* fills the drawer instead of its usual cap */ @@ -1251,6 +1248,8 @@ div.fixtureJson + div.fixtureJson { /* its paper runs on into the preview's, square where they meet; the Store's sheet keeps the gap from the preview's */ .row > .storeHost .storePanel { + /* its sheet to the foot, as the preview's is, rounded with the folder */ + --store-sheet-foot: var(--pg-card-radius); padding-right: var(--pg-gap); border-radius: var(--pg-card-radius) 0 0 var(--pg-card-radius); } @@ -1335,6 +1334,11 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } + /* its sheet ends short of the foot, rounded, as the code card does */ + .row > .storeHost .storePanel { + --store-sheet-foot: 12px; + padding-bottom: var(--pg-gap); + } /* its shadow, from a box that stops short of the foot, so none of it falls past the seam onto the preview (the Store paints over the rest) */ .row > .storeHost > .storeDrawer::before { From 02b03a530bda1326c0cea068e33fbcf5cc64523c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:51:16 +0000 Subject: [PATCH 38/66] docs: End the wide preview's folder at its sheet's foot, the open Store curving on below it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 7 +- .../Playground/preview/PreviewWrapper.tsx | 42 ++++++++++- .../components/Playground/styles.module.css | 74 ++++++++++++++++--- 3 files changed, 107 insertions(+), 16 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 0eb4a181cfaa..d15d83741cc2 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -191,8 +191,11 @@ DesignSystem/ demo styling (design-system.css) and components injected int pushes it shut and refocuses the handle) slides the drawer out, one `translate`, over the code and its file tabs edge to edge (they go `inert`), the handle riding outside the Store's edge, past the - playground's. Side by side it comes from the preview's left edge by its - foot; stacked, up from the preview's top edge, at most `--store-cap` + playground's. Side by side the preview's folder ends at its sheet's foot + (`PreviewWrapper` measures how far the row runs on below, as + `--pg-preview-gap`); the drawer comes from its left edge with the handle + level with that foot, and runs on below it with the preview's foot curving + into its edge; stacked, up from the preview's top edge, at most `--store-cap` tall so a long file's top stays in view. It slides only once painted at rest (`data-settled`), so showing a playground never animates it. The preview stays usable either way. The Store itself is chrome on the folder (the scrubber's row, diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index 9dc88ec3e1be..53171355a507 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -1,6 +1,6 @@ import { translate } from '@docusaurus/Translate'; import clsx from 'clsx'; -import React, { useId, useState } from 'react'; +import React, { useId, useLayoutEffect, useState } from 'react'; import Header from '../Header'; import { editorLabel, liveLabel } from '../liveLabel'; @@ -20,6 +20,8 @@ export default function PreviewWrapper({ }: Props) { const [routerSlot, setRouterSlot] = useState(null); const [fixturesOpen, setFixturesOpen] = useState(false); + const [result, setResult] = useState(null); + usePreviewGap(result); const fixturesId = useId(); const fixturesLabel = translate({ id: 'playground.fixtures', @@ -81,11 +83,47 @@ export default function PreviewWrapper({
: null} -
{children}
+
+ {children} +
); } +/** Row layout: how far the row runs on below the app's sheet with its foot + * (the wrapper's ::after) at full reach, as `--pg-preview-gap` on the row: + * the preview's folder ends there, and the open Store runs on past it. + * Measured against the row, which that end never moves */ +function usePreviewGap(result: HTMLElement | null) { + useLayoutEffect(() => { + const wrapper = result?.parentElement; + const row = wrapper?.parentElement; + if ( + !result || + !wrapper || + !row?.classList.contains(styles.row) || + typeof ResizeObserver === 'undefined' + ) + return; + const measure = () => { + const foot = + parseFloat(getComputedStyle(wrapper, '::after').maxHeight) || 0; + const gap = + row.getBoundingClientRect().bottom - + result.getBoundingClientRect().bottom - + foot; + row.style.setProperty('--pg-preview-gap', `${Math.max(0, gap)}px`); + }; + const observer = new ResizeObserver(measure); + observer.observe(row); + observer.observe(result); + return () => { + observer.disconnect(); + row.style.removeProperty('--pg-preview-gap'); + }; + }, [result]); +} + interface Props { children: React.ReactNode; headerControls?: React.ReactNode; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 8a707f2f60f3..30e333529378 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1169,6 +1169,9 @@ div.fixtureJson + div.fixtureJson { layer over the card's row, its Store in the card's column */ @container playground (min-width: 720px) { .playgroundContainer.row { + /* where the Store runs on below the preview: its corner and the + preview's foot curving into it, no deeper than that run */ + --pg-foot-curve: min(var(--pg-card-radius), var(--pg-preview-gap, 0px)); --pg-preview-col: max(16rem, calc((100cqw - var(--pg-gap)) * 0.35)); /* the preview's edge, where the drawer parks */ --pg-fold: calc(100cqw - var(--pg-preview-col)); @@ -1196,29 +1199,74 @@ div.fixtureJson + div.fixtureJson { grid-area: 1 / 2 / 3 / 3; /* unpositioned, so the fixtures hang from the playground's edge */ position: static; + /* ends at its sheet's foot: --pg-preview-gap is how far the row runs on + below that (./preview/PreviewWrapper.tsx) */ + max-height: calc(100% - var(--pg-preview-gap, 0px)); box-shadow: none; } - /* its shadow, from a box that runs out over the Store with the drawer, - so the open Store and the preview are one paper with one shadow */ - .row > .previewWrapper::before { + /* The folder's shadow, from boxes that run out over the Store with the + drawer, so the open Store and the preview are one paper with one + shadow: the preview's height, edge to edge (here), and the Store's + length below it (.row::before) */ + .row > .previewWrapper::before, + .row::before { content: ''; position: absolute; - inset: 0 0 0 var(--pg-fold); - z-index: -1; - border-radius: var(--pg-card-radius); + left: var(--pg-fold); box-shadow: var(--pg-card-shadow); pointer-events: none; } - .row:has(> .storeHost[data-open]) > .previewWrapper::before { + .row > .previewWrapper::before { + inset-block: 0 var(--pg-preview-gap, 0px); + right: 0; + z-index: -1; + border-radius: var(--pg-card-radius); + } + .row::before { + top: calc(100% - var(--pg-preview-gap, 0px)); + bottom: 0; + right: var(--pg-preview-col); + border-radius: 0 0 var(--pg-foot-curve) var(--pg-card-radius); + } + .row:has(> .storeHost[data-open]) > .previewWrapper::before, + .row:has(> .storeHost[data-open])::before { left: 0; } - .row:has(> .storeHost > .storeDrawer[data-settled]) - > .previewWrapper::before { + .row:has(> .storeHost > .storeDrawer[data-settled]) > .previewWrapper::before, + .row:has(> .storeHost > .storeDrawer[data-settled])::before { transition: left var(--motion-drawer); } .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { border-top-left-radius: 0; border-bottom-left-radius: 0; + /* its paper a pixel on under the Store's edge, so no seam shows */ + box-shadow: -1px 0 var(--pg-stage); + } + /* the Store running on below the preview: its corner there squares + where the two boxes meet */ + .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper::before { + border-bottom-left-radius: calc( + var(--pg-card-radius) - var(--pg-foot-curve) + ); + } + /* and the preview's foot curves into the Store's edge, as a tab does + into its card */ + .row:has(> .storeHost .storePanel[data-visible])::after { + content: ''; + position: absolute; + /* a pixel on under the Store's edge too */ + left: calc(var(--pg-fold) - 1px); + top: calc(100% - var(--pg-preview-gap, 0px)); + /* over the preview's shadow (a grid item, it stacks by its z-index) */ + z-index: 2; + width: calc(var(--pg-foot-curve) + 1px); + height: var(--pg-foot-curve); + pointer-events: none; + background: radial-gradient( + circle at 100% 100%, + transparent calc(var(--pg-foot-curve) - 0.5px), + var(--pg-stage) var(--pg-foot-curve) + ); } /* under it, the code card's own shadow would deepen the Store's */ .row .editorDocs > .playgroundEditor, @@ -1251,12 +1299,14 @@ div.fixtureJson + div.fixtureJson { /* its sheet to the foot, as the preview's is, rounded with the folder */ --store-sheet-foot: var(--pg-card-radius); padding-right: var(--pg-gap); - border-radius: var(--pg-card-radius) 0 0 var(--pg-card-radius); + border-radius: var(--pg-card-radius) 0 var(--pg-foot-curve) + var(--pg-card-radius); } - /* by the foot, its curve clear of the corner's */ + /* by the preview's foot, its curve clear of the corner's: open, it + stays level with that foot, however far the Store runs on below */ .storeTab { right: 100%; - bottom: calc(2 * var(--pg-card-radius)); + bottom: calc(var(--pg-preview-gap, 0px) + 2 * var(--pg-card-radius)); width: var(--pg-tab-width); height: var(--pg-tab-length); border-radius: var(--pg-card-radius) 0 0 var(--pg-card-radius); From 08b0a9e185f658c0f492d6fed08234e0b72227b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:26:20 +0000 Subject: [PATCH 39/66] docs: Inset the playground folder's header from its content, curving out into it The Live Preview header, and the open Store's header with it, is now a little narrower than the content below it, flaring out into the content's edge in one S-curve at the card radius. Side by side, the strip of folder between the Store's sheet and the preview's ends at the preview's foot, so only the Store's sheet runs on below it. In dark mode the closed file tabs' names are muted so the open one stands out. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 9 +- .../Playground/preview/store/store.module.css | 60 +++++- .../components/Playground/styles.module.css | 180 ++++++++++++++---- 3 files changed, 208 insertions(+), 41 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index d15d83741cc2..51f498455c07 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -194,13 +194,16 @@ DesignSystem/ demo styling (design-system.css) and components injected int playground's. Side by side the preview's folder ends at its sheet's foot (`PreviewWrapper` measures how far the row runs on below, as `--pg-preview-gap`); the drawer comes from its left edge with the handle - level with that foot, and runs on below it with the preview's foot curving - into its edge; stacked, up from the preview's top edge, at most `--store-cap` + level with that foot, and only its sheet runs on below it, the strip of + folder between the two sheets curving into its edge; stacked, up from the preview's top edge, at most `--store-cap` tall so a long file's top stays in view. It slides only once painted at rest (`data-settled`), so showing a playground never animates it. The preview stays usable either way. The Store itself is chrome on the folder (the scrubber's row, State and Action as tabs) and content on a sheet the picked tab joins - (`store.module.css`). In the other layouts the preview header's icon + (`store.module.css`). A folder's header is inset from its content + (`--pg-head-inset`) and curves out into it, so the folder's paper is + painted in parts (header, content's top edge, the rest), not by one box; + open side by side, the Store's header and the preview's run on as one. In the other layouts the preview header's icon (`StoreHeaderToggle`) or the side strip (`StoreToggle`) open it beside the result as a drawer (`../motion`: the panel `Reveal`s); its contents render a frame after it starts moving (`useDeferredValue`). diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index e4c26ff0fee8..6e9468dd6c82 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -35,6 +35,57 @@ --store-pressed: color-mix(in srgb, var(--store-fg) 12%, transparent); } +/* The folder's paper, the panel's unless laid out as the preview's folder + (the row layout, ../../styles.module.css): then it is the scrubber's (the + header) and the body's (the content), so the header can be narrower, + inset (--pg-head-inset) at the sides --store-head-start and + --store-head-end mark (1), flaring out into the content's top edge as the + open file's tab does into its card, the content's corner turning down + from there. The body's paper stops --store-folder-end short of its foot, + so only the sheet runs on past that. Where the header runs on into the + preview's instead (the end side), --store-head-run takes it on under + that edge */ +.scrubber { + --store-head-in-start: calc( + var(--store-head-start, 0) * var(--pg-head-inset, 0px) + ); + --store-head-in-end: calc( + var(--store-head-end, 0) * var(--pg-head-inset, 0px) - + var(--store-head-run, 0px) + ); + position: relative; + margin: 0 var(--store-head-in-end) 0 var(--store-head-in-start); + border-radius: calc(var(--store-head-start, 0) * var(--pg-card-radius, 0px)) + calc(var(--store-head-end, 0) * var(--pg-card-radius, 0px)) 0 0; + background: var(--store-bg); + box-shadow: var(--pg-card-shadow, none); +} +.scrubber::before, +.scrubber::after { + content: ''; + position: absolute; + bottom: 0; + height: var(--pg-head-flare, 0px); + pointer-events: none; +} +.scrubber::before { + right: calc(100% - 1px); + width: calc(var(--store-head-start, 0) * (var(--pg-head-flare, 0px) + 1px)); + background: radial-gradient( + circle at 0 0, + transparent calc(var(--pg-head-flare, 0px) - 0.5px), + var(--store-bg) var(--pg-head-flare, 0px) + ); +} +.scrubber::after { + left: calc(100% - 1px); + width: calc(var(--store-head-end, 0) * (var(--pg-head-flare, 0px) + 1px)); + background: radial-gradient( + circle at 100% 0, + transparent calc(var(--pg-head-flare, 0px) - 0.5px), + var(--store-bg) var(--pg-head-flare, 0px) + ); +} /* Under the scrubber: the tab bar, and (narrow) the sheet the lanes open in over what follows */ .body { @@ -43,6 +94,12 @@ min-height: 0; display: flex; flex-direction: column; + padding: var(--store-body-pad, 0); + border-radius: calc(var(--store-head-start, 0) * var(--pg-body-radius, 0px)) + calc(var(--store-head-end, 0) * var(--pg-body-radius, 0px)) 0 + var(--store-folder-foot, 0); + background: linear-gradient(var(--store-bg) 0 0) top / 100% + calc(100% - var(--store-folder-end, 0px)) no-repeat; } /* The State and Action tabs: on the folder, as the files are tabs on the @@ -111,7 +168,8 @@ align-items: center; padding: 0 0.85rem; border: 0; - border-radius: 8px 8px 0 0; + /* the first tab sits in the body's corner (the row layout): one radius */ + border-radius: var(--pg-body-radius, 8px) var(--pg-body-radius, 8px) 0 0; background: none; color: inherit; font: inherit; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 30e333529378..36b95ef1eb74 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -451,6 +451,14 @@ div.playgroundTextEdit > .playgroundHeader, --pg-tab-length: 3rem; /* how far the code card and the app's sheet reach toward the other's foot */ --pg-card-foot: 6rem; + /* the folder's header is narrower than its content: inset by this at + each free side, its paper flaring out into the content's top edge + (concave, --pg-head-flare) and the content's corner turning down from + there (convex, the rest). Both the card's radius, so the two make one + even S, the curve every tab here makes into its card */ + --pg-head-inset: 20px; + --pg-head-flare: var(--pg-card-radius); + --pg-body-radius: calc(var(--pg-head-inset) - var(--pg-head-flare)); border-radius: 0; box-shadow: none; background: none; @@ -492,6 +500,11 @@ div.playgroundTextEdit > .playgroundHeader, background: color-mix(in srgb, var(--ifm-font-color-base) 7%, transparent); color: var(--ifm-font-color-base); } +/* dark, the open file's tab is near the page's shade: the others' names + step back to the muted text so the bright one reads as open */ +:root[data-theme='dark'] .row .tab:not(.selected, :hover) { + color: var(--pg-muted); +} /* the open file's tab is the card's paper rising: over the card's edge (and its shadow) and curving out into the card's top edge either side, the curves a pixel into the tab so no hairline shows between them (not @@ -572,7 +585,11 @@ div.playgroundTextEdit > .playgroundHeader, } /* the preview's folder: the row's height, its header and the app's sheet on it; the sheet's foot runs on toward the code's by 6rem at most, its - corners the folder's where they meet */ + corners the folder's where they meet. Its paper comes in parts, so the + header can be narrower than the content: the header's own, the content's + top edge (.playgroundResult, its corners turning down) and the folder's + below that; its shadow likewise, the header's own and the content's box + (::before) */ .row > .previewWrapper { position: relative; z-index: 2; @@ -580,15 +597,56 @@ div.playgroundTextEdit > .playgroundHeader, display: flex; flex-direction: column; border-radius: var(--pg-card-radius); - background: var(--pg-stage); + /* room for its header's shadow */ + overflow-clip-margin: 3rem; + background: linear-gradient(var(--pg-stage) 0 0) 0 + calc(var(--pg-row) + var(--pg-body-radius)) / 100% 100% no-repeat; +} +.row > .previewWrapper::before { + content: ''; + position: absolute; + inset: var(--pg-row) 0 0; + z-index: -1; + border-radius: var(--pg-body-radius) var(--pg-body-radius) + var(--pg-card-radius) var(--pg-card-radius); box-shadow: var(--pg-card-shadow); + pointer-events: none; } +/* inset at each side the Store's header doesn't run on from + (--pg-head-start, --pg-head-end), its label in line with the sheet's + text where the inset leaves room for that, else a header's usual + 0.75rem in from its edge */ .row .playgroundHeader.previewHeader { + --pg-head-start: var(--pg-head-inset); + --pg-head-end: var(--pg-head-inset); + position: relative; min-height: var(--pg-row); + margin: 0 var(--pg-head-end) 0 var(--pg-head-start); + padding: 0 max(0.75rem, calc(var(--pg-sheet-pad) - var(--pg-head-end))) 0 + max(0.75rem, calc(var(--pg-sheet-pad) - var(--pg-head-start))); + border-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; + background: var(--pg-stage); + box-shadow: var(--pg-card-shadow); +} +.row .previewHeader::before, +.row .previewHeader::after { + bottom: 0; + height: var(--pg-head-flare); + --pg-fillet-r: var(--pg-head-flare); +} +.row .previewHeader::before { + right: calc(100% - 1px); + --pg-fillet-at: 0 0; +} +.row .previewHeader::after { + left: calc(100% - 1px); + --pg-fillet-at: 100% 0; } .row .playgroundResult { flex: none; height: auto; + border-radius: var(--pg-body-radius) var(--pg-body-radius) 0 0; + background: var(--pg-stage); } .row .playgroundPreview { flex: 1 1 auto; @@ -599,6 +657,7 @@ div.playgroundTextEdit > .playgroundHeader, } .row .sheet { padding-bottom: calc(var(--pg-sheet-pad) - var(--pg-card-radius)); + border-radius: var(--pg-body-radius) var(--pg-body-radius) 0 0; } /* The folder's layer: the stage's color, the whole playground's area under the preview, clipped at the page's edge (its drawer overshoots that way, @@ -626,12 +685,15 @@ div.playgroundTextEdit > .playgroundHeader, .row > .storeHost > .storeDrawer[data-settled] { transition: translate var(--motion-drawer); } +/* its paper is its header's and its body's (./preview/store), the header + inset at the drawer's leading edge as the preview's is */ .row > .storeHost .storePanel { + --store-head-start: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; - background: var(--pg-stage); + background: none; } /* fills the drawer instead of its usual cap */ .row > .storeHost .storePanel > * { @@ -674,20 +736,24 @@ div.playgroundTextEdit > .playgroundHeader, .storeTab::after { height: calc(var(--pg-card-radius) + 1px); } -/* Concave fillets: a selected tab's or the handle's paper curving out into - the edge it rises from, at --pg-fillet-at in its own --pg-fillet-color */ +/* Concave fillets: a selected tab's, the handle's or a header's paper + curving out into the edge it rises from, at --pg-fillet-at in its own + --pg-fillet-color, --pg-fillet-r round */ .row .tab.selected::before, .row .tab.selected::after, .storeTab::before, -.storeTab::after { +.storeTab::after, +.row .previewHeader::before, +.row .previewHeader::after { content: ''; position: absolute; - width: calc(var(--pg-card-radius) + 1px); + width: calc(var(--pg-fillet-r, var(--pg-card-radius)) + 1px); pointer-events: none; background: radial-gradient( circle at var(--pg-fillet-at), - transparent calc(var(--pg-card-radius) - 0.5px), - var(--pg-fillet-color) var(--pg-card-radius) + transparent calc(var(--pg-fillet-r, var(--pg-card-radius)) - 0.5px), + var(--pg-fillet-color, var(--pg-stage)) + var(--pg-fillet-r, var(--pg-card-radius)) ); } @@ -1202,12 +1268,11 @@ div.fixtureJson + div.fixtureJson { /* ends at its sheet's foot: --pg-preview-gap is how far the row runs on below that (./preview/PreviewWrapper.tsx) */ max-height: calc(100% - var(--pg-preview-gap, 0px)); - box-shadow: none; } - /* The folder's shadow, from boxes that run out over the Store with the + /* The content's shadow, from boxes that run out over the Store with the drawer, so the open Store and the preview are one paper with one - shadow: the preview's height, edge to edge (here), and the Store's - length below it (.row::before) */ + shadow (the headers cast their own): the preview's content, edge to + edge (here), and the Store's sheet below it (.row::before) */ .row > .previewWrapper::before, .row::before { content: ''; @@ -1217,16 +1282,14 @@ div.fixtureJson + div.fixtureJson { pointer-events: none; } .row > .previewWrapper::before { - inset-block: 0 var(--pg-preview-gap, 0px); + inset-block: var(--pg-row) var(--pg-preview-gap, 0px); right: 0; - z-index: -1; - border-radius: var(--pg-card-radius); } .row::before { top: calc(100% - var(--pg-preview-gap, 0px)); bottom: 0; - right: var(--pg-preview-col); - border-radius: 0 0 var(--pg-foot-curve) var(--pg-card-radius); + right: calc(var(--pg-preview-col) + var(--pg-gap)); + border-radius: 0 0 var(--pg-card-radius) var(--pg-card-radius); } .row:has(> .storeHost[data-open]) > .previewWrapper::before, .row:has(> .storeHost[data-open])::before { @@ -1242,6 +1305,20 @@ div.fixtureJson + div.fixtureJson { /* its paper a pixel on under the Store's edge, so no seam shows */ box-shadow: -1px 0 var(--pg-stage); } + /* its header runs on from the Store's (a pixel on under its edge, or its + shadow shows through the drawer's clipped edge as a hairline at the + fold), and its content from the Store's strip, the sheet's corner + notched into it */ + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader { + --pg-head-start: -1px; + border-top-left-radius: 0; + } + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before { + content: none; + } + .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult { + border-top-left-radius: 0; + } /* the Store running on below the preview: its corner there squares where the two boxes meet */ .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper::before { @@ -1249,13 +1326,13 @@ div.fixtureJson + div.fixtureJson { var(--pg-card-radius) - var(--pg-foot-curve) ); } - /* and the preview's foot curves into the Store's edge, as a tab does - into its card */ + /* and the strip between the two sheets, ending at the preview's foot, + curves into the Store's sheet's edge, as a tab does into its card */ .row:has(> .storeHost .storePanel[data-visible])::after { content: ''; position: absolute; - /* a pixel on under the Store's edge too */ - left: calc(var(--pg-fold) - 1px); + /* a pixel on under the sheet's edge */ + left: calc(var(--pg-fold) - var(--pg-gap) - 1px); top: calc(100% - var(--pg-preview-gap, 0px)); /* over the preview's shadow (a grid item, it stacks by its z-index) */ z-index: 2; @@ -1268,15 +1345,22 @@ div.fixtureJson + div.fixtureJson { var(--pg-stage) var(--pg-foot-curve) ); } - /* under it, the code card's own shadow would deepen the Store's */ + /* under it, the code card's own shadow would deepen the Store's, and the + file tabs would show past the Store header's inset */ .row .editorDocs > .playgroundEditor, .row .editorDocs::after { transition: box-shadow var(--motion-drawer); } + .row .tabControls { + transition: opacity var(--motion-drawer); + } .row:has(> .storeHost[data-open]) .editorDocs > .playgroundEditor, .row:has(> .storeHost[data-open]) .editorDocs::after { box-shadow: none; } + .row:has(> .storeHost[data-open]) .tabControls { + opacity: 0; + } .row > .storeHost { grid-area: 1 / 1 / 3 / 3; clip-path: inset(-4rem calc(100% - var(--pg-fold)) -4rem -4rem); @@ -1294,13 +1378,18 @@ div.fixtureJson + div.fixtureJson { translate: 0 0; } /* its paper runs on into the preview's, square where they meet; the - Store's sheet keeps the gap from the preview's */ + Store's sheet keeps the gap from the preview's, a strip of the folder + that ends with the preview's foot: below that, only the sheet runs on */ .row > .storeHost .storePanel { /* its sheet to the foot, as the preview's is, rounded with the folder */ --store-sheet-foot: var(--pg-card-radius); - padding-right: var(--pg-gap); - border-radius: var(--pg-card-radius) 0 var(--pg-foot-curve) - var(--pg-card-radius); + --store-folder-foot: var(--pg-card-radius); + --store-folder-end: var(--pg-preview-gap, 0px); + --store-body-pad: 0 var(--pg-gap) 0 0; + /* its header a pixel on past the fold, under the clip: painted boxes + snap to pixels and the clip doesn't, so at rest its shadow would + show through the sliver between as a hairline on the preview's */ + --store-head-run: 1px; } /* by the preview's foot, its curve clear of the corner's: open, it stays level with that foot, however far the Store runs on below */ @@ -1384,17 +1473,21 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } - /* its sheet ends short of the foot, rounded, as the code card does */ + /* its header edge to edge: its top edge lands on the code, which inset + corners would show through; its sheet ends short of the foot, rounded, + as the code card does */ .row > .storeHost .storePanel { + --store-head-start: 0; --store-sheet-foot: 12px; - padding-bottom: var(--pg-gap); + --store-body-pad: 0 0 var(--pg-gap); } - /* its shadow, from a box that stops short of the foot, so none of it - falls past the seam onto the preview (the Store paints over the rest) */ + /* its content's shadow (its header casts its own), from a box that stops + short of the foot, so none of it falls past the seam onto the preview + (the Store paints over the rest) */ .row > .storeHost > .storeDrawer::before { content: ''; position: absolute; - inset: 0 0 4rem; + inset: var(--pg-row) 0 4rem; z-index: -1; box-shadow: var(--pg-card-shadow); opacity: 0; @@ -1403,17 +1496,30 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer::before { opacity: 1; } - /* the folder's top edge joins the Store's, while it shows */ + /* the folder's top edge joins the Store's, while it shows: its header + the paper between the two sheets, edge to edge */ .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { border-top-left-radius: 0; border-top-right-radius: 0; } - /* up from the top edge at its right end (the thumb's side, clear of - anything laid out at the left above the preview), its curve clear of - the corner's */ + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader { + --pg-head-start: 0px; + --pg-head-end: 0px; + border-radius: 0; + } + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before, + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::after { + content: none; + } + .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult { + border-radius: 0; + } + /* up from the header's top edge at its right end (the thumb's side, + clear of anything laid out at the left above the preview), past the + header's inset, its curve clear of the corner's */ .storeTab { bottom: 100%; - right: calc(2 * var(--pg-card-radius)); + right: calc(var(--pg-head-inset) + 2 * var(--pg-card-radius)); width: var(--pg-tab-length); height: var(--pg-tab-width); border-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; From ab9a993e6c57ddb3dd7884b046be242a7fdd7641 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:38:58 +0000 Subject: [PATCH 40/66] docs: Make the playground folder's header inset subtler 12px instead of 20px, its two curves 6px each. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 36b95ef1eb74..c20495281fc9 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -454,10 +454,10 @@ div.playgroundTextEdit > .playgroundHeader, /* the folder's header is narrower than its content: inset by this at each free side, its paper flaring out into the content's top edge (concave, --pg-head-flare) and the content's corner turning down from - there (convex, the rest). Both the card's radius, so the two make one - even S, the curve every tab here makes into its card */ - --pg-head-inset: 20px; - --pg-head-flare: var(--pg-card-radius); + there (convex, the rest). The two halves equal, so they make one even + S; a subtle one, short of the card's radius */ + --pg-head-inset: 12px; + --pg-head-flare: 6px; --pg-body-radius: calc(var(--pg-head-inset) - var(--pg-head-flare)); border-radius: 0; box-shadow: none; From 9eae950ffdc983b9eafe1914407be4026024feb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:51:59 +0000 Subject: [PATCH 41/66] docs: Sweep the playground header's foot into its content, and land the Store's header on the preview's as it shuts The header's flare now runs twice as deep as it is wide, so its foot sweeps into the content in one long curve. Wide, the preview's header stays inset while the Store is open: the Store's header runs past the fold to meet it, so the drawer shuts corner on corner instead of the preview header snapping back to its inset. The Store header's content fades as it slides shut. On a phone the header eases back into its inset after the Store hides. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 4 +- .../Playground/preview/store/store.module.css | 29 +++-- .../components/Playground/styles.module.css | 110 +++++++++++++----- 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 51f498455c07..f775f2063fe5 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -203,7 +203,9 @@ DesignSystem/ demo styling (design-system.css) and components injected int (`store.module.css`). A folder's header is inset from its content (`--pg-head-inset`) and curves out into it, so the folder's paper is painted in parts (header, content's top edge, the rest), not by one box; - open side by side, the Store's header and the preview's run on as one. In the other layouts the preview header's icon + open side by side, the Store's header and the preview's run on as one: the + Store's runs past the fold (`--store-head-run`, the host's clip lets it) to + the preview's, which stays inset, so shutting lands corner on corner. In the other layouts the preview header's icon (`StoreHeaderToggle`) or the side strip (`StoreToggle`) open it beside the result as a drawer (`../motion`: the panel `Reveal`s); its contents render a frame after it starts moving (`useDeferredValue`). diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index 6e9468dd6c82..334d0ab66791 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -55,35 +55,43 @@ ); position: relative; margin: 0 var(--store-head-in-end) 0 var(--store-head-in-start); + /* what it holds stays clear of the run */ + padding-right: var(--store-head-run, 0px); border-radius: calc(var(--store-head-start, 0) * var(--pg-card-radius, 0px)) calc(var(--store-head-end, 0) * var(--pg-card-radius, 0px)) 0 0; background: var(--store-bg); box-shadow: var(--pg-card-shadow, none); } +/* (--store-head-ink: the row layout fades what it holds as the drawer + slides shut) */ +.scrubber > * { + opacity: var(--store-head-ink, 1); + transition: opacity var(--motion-snappy); +} .scrubber::before, .scrubber::after { content: ''; position: absolute; bottom: 0; - height: var(--pg-head-flare, 0px); + height: var(--pg-head-flare-depth, 0px); pointer-events: none; } .scrubber::before { right: calc(100% - 1px); width: calc(var(--store-head-start, 0) * (var(--pg-head-flare, 0px) + 1px)); background: radial-gradient( - circle at 0 0, - transparent calc(var(--pg-head-flare, 0px) - 0.5px), - var(--store-bg) var(--pg-head-flare, 0px) + var(--pg-head-flare, 0px) var(--pg-head-flare-depth, 0px) at 0 0, + transparent calc(100% - 0.5px), + var(--store-bg) 100% ); } .scrubber::after { left: calc(100% - 1px); width: calc(var(--store-head-end, 0) * (var(--pg-head-flare, 0px) + 1px)); background: radial-gradient( - circle at 100% 0, - transparent calc(var(--pg-head-flare, 0px) - 0.5px), - var(--store-bg) var(--pg-head-flare, 0px) + var(--pg-head-flare, 0px) var(--pg-head-flare-depth, 0px) at 100% 0, + transparent calc(100% - 0.5px), + var(--store-bg) 100% ); } /* Under the scrubber: the tab bar, and (narrow) the sheet the lanes open in @@ -143,6 +151,10 @@ .barStart { display: flex; } +/* the first sits in the body's corner (the row layout): its radius */ +.barStart > .tab:first-child { + border-top-left-radius: var(--pg-body-radius, 8px); +} /* The sheet's head (State): the view's own switches, Snapshot or Diff and table or tree, at its top edge */ .sheetHead { @@ -168,8 +180,7 @@ align-items: center; padding: 0 0.85rem; border: 0; - /* the first tab sits in the body's corner (the row layout): one radius */ - border-radius: var(--pg-body-radius, 8px) var(--pg-body-radius, 8px) 0 0; + border-radius: 8px 8px 0 0; background: none; color: inherit; font: inherit; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index c20495281fc9..0a7cb51edff6 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -454,10 +454,12 @@ div.playgroundTextEdit > .playgroundHeader, /* the folder's header is narrower than its content: inset by this at each free side, its paper flaring out into the content's top edge (concave, --pg-head-flare) and the content's corner turning down from - there (convex, the rest). The two halves equal, so they make one even - S; a subtle one, short of the card's radius */ + there (convex, the rest). The flare runs deeper than it is wide + (--pg-head-flare-depth), so the header's foot sweeps out into the + content in one long curve */ --pg-head-inset: 12px; - --pg-head-flare: 6px; + --pg-head-flare: 8px; + --pg-head-flare-depth: 16px; --pg-body-radius: calc(var(--pg-head-inset) - var(--pg-head-flare)); border-radius: 0; box-shadow: none; @@ -630,17 +632,28 @@ div.playgroundTextEdit > .playgroundHeader, } .row .previewHeader::before, .row .previewHeader::after { + content: ''; + position: absolute; bottom: 0; - height: var(--pg-head-flare); - --pg-fillet-r: var(--pg-head-flare); + width: calc(var(--pg-head-flare) + 1px); + height: var(--pg-head-flare-depth); + pointer-events: none; } .row .previewHeader::before { right: calc(100% - 1px); - --pg-fillet-at: 0 0; + background: radial-gradient( + var(--pg-head-flare) var(--pg-head-flare-depth) at 0 0, + transparent calc(100% - 0.5px), + var(--pg-stage) 100% + ); } .row .previewHeader::after { left: calc(100% - 1px); - --pg-fillet-at: 100% 0; + background: radial-gradient( + var(--pg-head-flare) var(--pg-head-flare-depth) at 100% 0, + transparent calc(100% - 0.5px), + var(--pg-stage) 100% + ); } .row .playgroundResult { flex: none; @@ -736,15 +749,13 @@ div.playgroundTextEdit > .playgroundHeader, .storeTab::after { height: calc(var(--pg-card-radius) + 1px); } -/* Concave fillets: a selected tab's, the handle's or a header's paper - curving out into the edge it rises from, at --pg-fillet-at in its own - --pg-fillet-color, --pg-fillet-r round */ +/* Concave fillets: a selected tab's or the handle's paper curving out into + the edge it rises from, at --pg-fillet-at in its own --pg-fillet-color, + --pg-fillet-r round */ .row .tab.selected::before, .row .tab.selected::after, .storeTab::before, -.storeTab::after, -.row .previewHeader::before, -.row .previewHeader::after { +.storeTab::after { content: ''; position: absolute; width: calc(var(--pg-fillet-r, var(--pg-card-radius)) + 1px); @@ -1302,19 +1313,18 @@ div.fixtureJson + div.fixtureJson { .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { border-top-left-radius: 0; border-bottom-left-radius: 0; - /* its paper a pixel on under the Store's edge, so no seam shows */ - box-shadow: -1px 0 var(--pg-stage); } - /* its header runs on from the Store's (a pixel on under its edge, or its - shadow shows through the drawer's clipped edge as a hairline at the - fold), and its content from the Store's strip, the sheet's corner - notched into it */ - .row:has(> .storeHost .storePanel[data-visible]) .previewHeader { - --pg-head-start: -1px; - border-top-left-radius: 0; + /* its content's paper a pixel on under the Store's edge, so no seam + shows (not its header's: inset, the Store's header covers it) */ + .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult, + .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper::after { + box-shadow: -1px 0 var(--pg-stage); } - .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before { - content: none; + /* its content runs on from the Store's strip, the sheet's corner notched + into it (its header stays inset: the Store's covers that, see + .storeHost) */ + .row .playgroundResult { + transition: border-top-left-radius var(--motion-snappy); } .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult { border-top-left-radius: 0; @@ -1361,9 +1371,23 @@ div.fixtureJson + div.fixtureJson { .row:has(> .storeHost[data-open]) .tabControls { opacity: 0; } + /* clipped at the fold, but for the header's row, where the Store's + header runs on over the preview's inset corner (--store-head-run): as + the drawer slides shut, the Store's header corner travels across that + inset and lands on the preview's, the same shape */ .row > .storeHost { grid-area: 1 / 1 / 3 / 3; - clip-path: inset(-4rem calc(100% - var(--pg-fold)) -4rem -4rem); + --pg-fold-head: calc( + var(--pg-fold) + var(--pg-head-inset) + var(--pg-card-radius) + 1px + ); + clip-path: polygon( + -4rem -4rem, + var(--pg-fold-head) -4rem, + var(--pg-fold-head) var(--pg-row), + var(--pg-fold) var(--pg-row), + var(--pg-fold) calc(100% + 4rem), + -4rem calc(100% + 4rem) + ); } /* the drawer: the code's column and the gap, edge to edge, its handle out past the playground's edge; the Store scrolls within the row's @@ -1386,10 +1410,16 @@ div.fixtureJson + div.fixtureJson { --store-folder-foot: var(--pg-card-radius); --store-folder-end: var(--pg-preview-gap, 0px); --store-body-pad: 0 var(--pg-gap) 0 0; - /* its header a pixel on past the fold, under the clip: painted boxes - snap to pixels and the clip doesn't, so at rest its shadow would - show through the sliver between as a hairline on the preview's */ - --store-head-run: 1px; + /* its header on past the fold over the preview's inset and rounded + corner, and a pixel into the preview's header: painted boxes snap to + pixels and the clip doesn't, so short of it its shadow shows as a + hairline */ + --store-head-run: calc(var(--pg-head-inset) + var(--pg-card-radius) + 1px); + } + /* sliding shut, what its header holds would pass that run, beside the + preview's label: it fades as the drawer goes */ + .row > .storeHost:not([data-open]) .storePanel { + --store-head-ink: 0; } /* by the preview's foot, its curve clear of the corner's: open, it stays level with that foot, however far the Store runs on below */ @@ -1509,11 +1539,31 @@ div.fixtureJson + div.fixtureJson { } .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before, .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::after { - content: none; + opacity: 0; } .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult { border-radius: 0; } + /* once the Store has slid shut, the header eases back into its inset + (margin and padding together, so its label holds still), its curves + fading in as it lands */ + .row .previewHeader { + transition: + margin var(--motion-snappy), + padding var(--motion-snappy), + border-radius var(--motion-snappy); + } + .row .previewHeader::before, + .row .previewHeader::after { + transition: opacity 120ms ease-out 180ms; + } + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before, + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::after { + transition: none; + } + .row .playgroundResult { + transition: border-radius var(--motion-snappy); + } /* up from the header's top edge at its right end (the thumb's side, clear of anything laid out at the left above the preview), past the header's inset, its curve clear of the corner's */ From ebebbdbe7c3f533cf44666b31964c78484371e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:55:14 +0000 Subject: [PATCH 42/66] docs: Color the homepage's REST/GraphQL switch with the playground's palette The switch used the page's neutral grays, so it read as separate from the playground under it. It now takes the preview's stage for its track and a sheet for the pick, with the preview's text colors, in both themes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Demo/styles.module.css | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/website/src/components/Demo/styles.module.css b/website/src/components/Demo/styles.module.css index 41bbddc3e434..c5412daa7565 100644 --- a/website/src/components/Demo/styles.module.css +++ b/website/src/components/Demo/styles.module.css @@ -4,10 +4,14 @@ } /* REST or GraphQL (ProtocolChoice): a segmented control above the - playground, in a slim row of its own */ + playground, in a slim row of its own. It takes the preview's colors (its + stage, a sheet for the pick, and their text), so it reads as part of the + playground rather than the page's grays */ .segmented { - --pg-seg: var(--ifm-color-emphasis-200); + --pg-seg: #e8eef6; --pg-seg-on: #fff; + --pg-seg-text: #4c5a6e; + --pg-seg-text-on: #0b1220; width: fit-content; margin-bottom: 6px; padding: 3px; @@ -16,14 +20,16 @@ background: var(--pg-seg); } :root[data-theme='dark'] .segmented { - --pg-seg: #292c34; - --pg-seg-on: #3d424e; + --pg-seg: #0a0f18; + --pg-seg-on: #151c28; + --pg-seg-text: #95a2b6; + --pg-seg-text-on: #e9eef6; } .segmented > [role='tab'] { height: 24px; padding: 0 11px; border-radius: 999px; - color: var(--ifm-color-emphasis-800); + color: var(--pg-seg-text); font: 600 12px / 1 var(--ifm-font-family-base); letter-spacing: 0.01em; box-shadow: none; @@ -33,12 +39,12 @@ } .segmented > [role='tab']:hover { background: none; - color: var(--ifm-font-color-base); + color: var(--pg-seg-text-on); } .segmented > [role='tab'][aria-selected='true'] { background: var(--pg-seg-on); - color: var(--ifm-font-color-base); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); + color: var(--pg-seg-text-on); + box-shadow: 0 1px 2px rgba(15, 30, 60, 0.16); } .segmented > [role='tab']:focus-visible { outline: 2px solid var(--ifm-color-primary); From 3a19f363cf9212f17d4de8b3758c6e6dccc0f69f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:57:26 +0000 Subject: [PATCH 43/66] docs: Share the preview's palette tokens with the homepage's protocol switch The switch sits outside the playground, so it copied the preview's colors as literals. The preview's sheet, stage and text tokens now live on :root, and the switch reads them, so a palette change reaches both. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Demo/styles.module.css | 20 +++--------- .../components/Playground/styles.module.css | 31 ++++++++++++------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/website/src/components/Demo/styles.module.css b/website/src/components/Demo/styles.module.css index c5412daa7565..066401a6e8c5 100644 --- a/website/src/components/Demo/styles.module.css +++ b/website/src/components/Demo/styles.module.css @@ -8,28 +8,18 @@ stage, a sheet for the pick, and their text), so it reads as part of the playground rather than the page's grays */ .segmented { - --pg-seg: #e8eef6; - --pg-seg-on: #fff; - --pg-seg-text: #4c5a6e; - --pg-seg-text-on: #0b1220; width: fit-content; margin-bottom: 6px; padding: 3px; gap: 2px; border-radius: 999px; - background: var(--pg-seg); -} -:root[data-theme='dark'] .segmented { - --pg-seg: #0a0f18; - --pg-seg-on: #151c28; - --pg-seg-text: #95a2b6; - --pg-seg-text-on: #e9eef6; + background: var(--pg-stage); } .segmented > [role='tab'] { height: 24px; padding: 0 11px; border-radius: 999px; - color: var(--pg-seg-text); + color: var(--pg-muted); font: 600 12px / 1 var(--ifm-font-family-base); letter-spacing: 0.01em; box-shadow: none; @@ -39,11 +29,11 @@ } .segmented > [role='tab']:hover { background: none; - color: var(--pg-seg-text-on); + color: var(--pg-text); } .segmented > [role='tab'][aria-selected='true'] { - background: var(--pg-seg-on); - color: var(--pg-seg-text-on); + background: var(--pg-panel); + color: var(--pg-text); box-shadow: 0 1px 2px rgba(15, 30, 60, 0.16); } .segmented > [role='tab']:focus-visible { diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 0a7cb51edff6..223dce04c123 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -34,15 +34,8 @@ /* The preview's palette (DesignSystem/design-system.css reads it into --ds-*): the sheet the app is on (panel) and a well a step off it, a line kept for demos that draw their own, its text, and the live dot */ - --pg-panel: #fff; --pg-panel-2: #eef2f7; --pg-line: #d8e0ea; - /* the stage the sheet sits on, showing as the header's tint: a cool tonal - ground. One flat color: in row layout it is the preview's folder, which - the Store opens on, painted in pieces (see .row) */ - --pg-stage: #e8eef6; - --pg-text: #0b1220; - --pg-muted: #4c5a6e; --pg-live: #0d8257; --pg-live-soft: color-mix(in srgb, var(--pg-live) 14%, transparent); /* a resting control's band under the pointer, on any ground */ @@ -137,6 +130,24 @@ padding: 0.75rem var(--ifm-pre-padding); } +/* The preview's sheet (panel), the stage it sits on, and their text, on + :root so controls beside a playground (the homepage's protocol switch) + share them. The stage shows as the header's tint: a cool tonal ground, + one flat color; in row layout it is the preview's folder, which the Store + opens on, painted in pieces (see .row). Dark: the sheet a step above the + stage */ +:root { + --pg-panel: #fff; + --pg-stage: #e8eef6; + --pg-text: #0b1220; + --pg-muted: #4c5a6e; +} +:root[data-theme='dark'] { + --pg-panel: #151c28; + --pg-stage: #0a0f18; + --pg-text: #e9eef6; + --pg-muted: #95a2b6; +} /* Playground chrome. Every header (editor tabs, Live Preview, Store) shares one neutral bar color in both themes so the top row reads as one band. */ :root { @@ -160,13 +171,9 @@ --pg-tab-hover-bg: #343842; } :root[data-theme='dark'] .playgroundContainer { - /* the sheet a step above the stage, its wells a step above that */ - --pg-panel: #151c28; + /* the sheet's wells a step above it */ --pg-panel-2: #1d2635; --pg-line: #2a344c; - --pg-stage: #0a0f18; - --pg-text: #e9eef6; - --pg-muted: #95a2b6; --pg-live: #3ddc97; --pg-live-soft: color-mix(in srgb, var(--pg-live) 16%, transparent); --pg-shadow: rgba(0, 0, 0, 0.8); From 59c59c3889952b58d96a3c238916d2f2d89275b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:05:34 +0000 Subject: [PATCH 44/66] docs: Make the protocol switch's pick clear in dark mode The preview's stage and sheet sit close in dark mode, so the picked option barely stood out. It now lifts toward the text color, and the other recedes toward the stage. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Demo/styles.module.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/website/src/components/Demo/styles.module.css b/website/src/components/Demo/styles.module.css index 066401a6e8c5..8c1bb2c4f756 100644 --- a/website/src/components/Demo/styles.module.css +++ b/website/src/components/Demo/styles.module.css @@ -36,6 +36,18 @@ color: var(--pg-text); box-shadow: 0 1px 2px rgba(15, 30, 60, 0.16); } +/* dark: the stage and sheet sit close, so the pick lifts toward the text + and the rest recede toward the stage */ +:root[data-theme='dark'] .segmented > [role='tab'] { + color: color-mix(in oklab, var(--pg-muted), var(--pg-stage) 30%); +} +:root[data-theme='dark'] .segmented > [role='tab']:hover { + color: var(--pg-text); +} +:root[data-theme='dark'] .segmented > [role='tab'][aria-selected='true'] { + background: color-mix(in oklab, var(--pg-panel), var(--pg-text) 12%); + color: var(--pg-text); +} .segmented > [role='tab']:focus-visible { outline: 2px solid var(--ifm-color-primary); outline-offset: -2px; From 232557c5b39c5c2ebaf58ab45af228469f6cc0c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:16:47 +0000 Subject: [PATCH 45/66] docs: Round the preview's content like the code card, and keep the Store's sheet flush with it as it slides shut The content's top corners were 4px beside the code card's 10px, so they looked sharp. They now match the card, and the header's inset grows to fit its flare plus that corner. Sliding shut, the Store's sheet and the app's now meet square, reading as one bar until the Store has slid behind, instead of a sliver of the folder's tint curving down at the fold. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/styles.module.css | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 223dce04c123..48c7df6b92d4 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -461,13 +461,13 @@ div.playgroundTextEdit > .playgroundHeader, /* the folder's header is narrower than its content: inset by this at each free side, its paper flaring out into the content's top edge (concave, --pg-head-flare) and the content's corner turning down from - there (convex, the rest). The flare runs deeper than it is wide - (--pg-head-flare-depth), so the header's foot sweeps out into the - content in one long curve */ - --pg-head-inset: 12px; + there (convex, as round as the code card's). The flare runs deeper than + it is wide (--pg-head-flare-depth), so the header's foot sweeps out into + the content in one long curve */ --pg-head-flare: 8px; --pg-head-flare-depth: 16px; - --pg-body-radius: calc(var(--pg-head-inset) - var(--pg-head-flare)); + --pg-body-radius: var(--pg-card-radius); + --pg-head-inset: calc(var(--pg-head-flare) + var(--pg-body-radius)); border-radius: 0; box-shadow: none; background: none; @@ -1321,21 +1321,25 @@ div.fixtureJson + div.fixtureJson { border-top-left-radius: 0; border-bottom-left-radius: 0; } - /* its content's paper a pixel on under the Store's edge, so no seam - shows (not its header's: inset, the Store's header covers it) */ - .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult, + /* its foot's paper a pixel on under the Store's edge, so no seam shows + (not its content's: the Store's sheet meets it, square) */ .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper::after { box-shadow: -1px 0 var(--pg-stage); } - /* its content runs on from the Store's strip, the sheet's corner notched - into it (its header stays inset: the Store's covers that, see - .storeHost) */ - .row .playgroundResult { + /* its content runs on from the Store's strip (its header stays inset: + the Store's covers that, see .storeHost) */ + .row .playgroundResult, + .row .sheet { transition: border-top-left-radius var(--motion-snappy); } .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult { border-top-left-radius: 0; } + /* sliding shut, the Store's sheet meets the app's at the fold: square, + the two read as one bar until it has slid behind */ + .row:has(> .storeHost:not([data-open]) .storePanel[data-visible]) .sheet { + border-top-left-radius: 0; + } /* the Store running on below the preview: its corner there squares where the two boxes meet */ .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper::before { From 8f0ce27e7408eb2fe0e67357afa119acbd384317 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:20:07 +0000 Subject: [PATCH 46/66] docs: Make the fixtures popup a card like the code's In the row layout the fixtures hung from the preview's edge as a square dark strip with a hairline border. They now float as a rounded card like the code card, a step under the preview's header and in line with its inset, and the Store's handle no longer cuts into them while they show. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/styles.module.css | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 48c7df6b92d4..3b7339abbadc 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -901,6 +901,32 @@ div.playgroundTextEdit > .playgroundHeader, opacity: 1; translate: 0 0; } +/* row: a card like the code's, lifted off the folder a step under its + header, in line with the header's end; while it shows, the preview + stacks over the Store's layer, so the handle doesn't cut into it */ +.row > .previewWrapper:has(.fixturesPopover[data-open]) { + z-index: 4; +} +@media (hover: hover) { + .row > .previewWrapper:has(.fixturesToggle:hover, .fixturesPopover:hover) { + z-index: 4; + } +} +.row .fixturesPopover { + right: var(--pg-head-inset); + width: min(100% - 2 * var(--pg-head-inset), 36rem); + margin-top: 6px; + border: 0; + border-radius: var(--pg-card-radius); + box-shadow: + 0 2px 6px rgba(15, 30, 60, 0.18), + 0 18px 40px -8px rgba(15, 30, 60, 0.4); +} +:root[data-theme='dark'] .row .fixturesPopover { + box-shadow: + 0 0 0 1px var(--store-line-strong), + 0 18px 40px -8px rgba(0, 0, 0, 0.75); +} @media (hover: hover) { /* a preview while the pointer is on the toggle or the fixtures */ .previewHeader:has(.fixturesToggle:hover) From 11e6dc115432d9cfc2504a788826ae44f4fd9c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:27:07 +0000 Subject: [PATCH 47/66] docs: Join the fixtures card to its toggle as a tab The fixtures card floated a step under the preview's header, apart from the toggle that opened it. It now sits on the header's foot, and the toggle turns the card's color and runs down into it as a tab, the way the open file's tab rises from the code. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../components/Playground/styles.module.css | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 3b7339abbadc..29ed7f8e15ae 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -901,9 +901,10 @@ div.playgroundTextEdit > .playgroundHeader, opacity: 1; translate: 0 0; } -/* row: a card like the code's, lifted off the folder a step under its - header, in line with the header's end; while it shows, the preview - stacks over the Store's layer, so the handle doesn't cut into it */ +/* row: a card like the code's under the preview's header, in line with + its inset, its toggle a tab rising from it into the header (as the open + file's tab rises from the code); while it shows, the preview stacks over + the Store's layer, so the handle doesn't cut into it */ .row > .previewWrapper:has(.fixturesPopover[data-open]) { z-index: 4; } @@ -915,17 +916,73 @@ div.playgroundTextEdit > .playgroundHeader, .row .fixturesPopover { right: var(--pg-head-inset); width: min(100% - 2 * var(--pg-head-inset), 36rem); - margin-top: 6px; border: 0; border-radius: var(--pg-card-radius); + /* it fades in where it rests, joined to its tab */ + translate: 0 0; + /* nothing reaches above its top edge, where the tab joins it */ box-shadow: - 0 2px 6px rgba(15, 30, 60, 0.18), - 0 18px 40px -8px rgba(15, 30, 60, 0.4); + 0 4px 8px -2px rgba(15, 30, 60, 0.25), + 0 22px 40px -10px rgba(15, 30, 60, 0.45); } :root[data-theme='dark'] .row .fixturesPopover { - box-shadow: - 0 0 0 1px var(--store-line-strong), - 0 18px 40px -8px rgba(0, 0, 0, 0.75); + box-shadow: 0 22px 40px -10px rgba(0, 0, 0, 0.8); +} +/* the tab's foot, down to the header's bottom edge, curving out into the + card's top edge */ +.row .fixturesToggle { + --fixtures-tab: var(--pg-tab-selected-bg); + --fixtures-foot: calc((var(--pg-row) - 2rem) / 2); + position: relative; +} +.row .fixturesToggle::after { + content: ''; + position: absolute; + top: 100%; + left: calc(-1 * var(--fixtures-foot)); + right: calc(-1 * var(--fixtures-foot)); + height: var(--fixtures-foot); + pointer-events: none; + opacity: 0; + background: + radial-gradient( + var(--fixtures-foot) var(--fixtures-foot) at 0 0, + transparent calc(100% - 0.5px), + var(--fixtures-tab) 100% + ) + left / var(--fixtures-foot) 100% no-repeat, + radial-gradient( + var(--fixtures-foot) var(--fixtures-foot) at 100% 0, + transparent calc(100% - 0.5px), + var(--fixtures-tab) 100% + ) + right / var(--fixtures-foot) 100% no-repeat, + linear-gradient(var(--fixtures-tab) 0 0) center / + calc(100% - 2 * var(--fixtures-foot)) 100% no-repeat; +} +.row .headerButton.fixturesToggle[aria-expanded='true'], +.row + .previewHeader:has(+ .fixturesAnchor > .fixturesPopover:hover) + .headerButton.fixturesToggle { + border-radius: 8px 8px 0 0; + background: var(--fixtures-tab); + color: var(--pg-tab-selected); +} +.row .headerButton.fixturesToggle[aria-expanded='true']::after, +.row + .previewHeader:has(+ .fixturesAnchor > .fixturesPopover:hover) + .headerButton.fixturesToggle::after { + opacity: 1; +} +@media (hover: hover) { + .row .headerButton.fixturesToggle:hover { + border-radius: 8px 8px 0 0; + background: var(--fixtures-tab); + color: var(--pg-tab-selected); + } + .row .headerButton.fixturesToggle:hover::after { + opacity: 1; + } } @media (hover: hover) { /* a preview while the pointer is on the toggle or the fixtures */ From 5541622e7faab4ef3d76dae4c17b037238dc6f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:29:24 +0000 Subject: [PATCH 48/66] docs: Meet the folder header's flare partway round the content's corner The 10px content corners had pushed the header's inset to 18px, since the flare ended where the corner began. The inset is back to 12px: the flare now meets the corner 30 degrees round it, sharing a tangent there, so the header's side runs into the content's in one S. The Store's header does the same. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/store/store.module.css | 9 +++-- .../components/Playground/styles.module.css | 40 ++++++++++++++----- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index 334d0ab66791..cd02a582e1c7 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -72,7 +72,8 @@ .scrubber::after { content: ''; position: absolute; - bottom: 0; + /* under the body (it stacks above), meeting its corner partway round */ + bottom: calc(-1 * var(--pg-head-flare-drop, 0px)); height: var(--pg-head-flare-depth, 0px); pointer-events: none; } @@ -80,7 +81,8 @@ right: calc(100% - 1px); width: calc(var(--store-head-start, 0) * (var(--pg-head-flare, 0px) + 1px)); background: radial-gradient( - var(--pg-head-flare, 0px) var(--pg-head-flare-depth, 0px) at 0 0, + circle var(--pg-head-flare-radius, 0px) at + calc(100% - 1px - var(--pg-head-flare-radius, 0px)) 0, transparent calc(100% - 0.5px), var(--store-bg) 100% ); @@ -89,7 +91,8 @@ left: calc(100% - 1px); width: calc(var(--store-head-end, 0) * (var(--pg-head-flare, 0px) + 1px)); background: radial-gradient( - var(--pg-head-flare, 0px) var(--pg-head-flare-depth, 0px) at 100% 0, + circle var(--pg-head-flare-radius, 0px) at + calc(1px + var(--pg-head-flare-radius, 0px)) 0, transparent calc(100% - 0.5px), var(--store-bg) 100% ); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 29ed7f8e15ae..f8085fc34032 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -459,15 +459,29 @@ div.playgroundTextEdit > .playgroundHeader, /* how far the code card and the app's sheet reach toward the other's foot */ --pg-card-foot: 6rem; /* the folder's header is narrower than its content: inset by this at - each free side, its paper flaring out into the content's top edge - (concave, --pg-head-flare) and the content's corner turning down from - there (convex, as round as the code card's). The flare runs deeper than - it is wide (--pg-head-flare-depth), so the header's foot sweeps out into - the content in one long curve */ - --pg-head-flare: 8px; - --pg-head-flare-depth: 16px; + each free side, its paper flaring out (concave, a circle's arc) to meet + the content's corner (convex, as round as the code card's) partway + round it, --pg-head-meet from its top, where the two curves share a + tangent: one S from the header's side to the content's. The flare runs + --pg-head-flare across, --pg-head-flare-depth up the header's side, + and --pg-head-flare-drop below the header's foot, where it meets the + corner */ + --pg-head-inset: 12px; --pg-body-radius: var(--pg-card-radius); - --pg-head-inset: calc(var(--pg-head-flare) + var(--pg-body-radius)); + --pg-head-meet: 30deg; + --pg-head-flare: calc( + var(--pg-head-inset) - var(--pg-body-radius) * + (1 - sin(var(--pg-head-meet))) + ); + --pg-head-flare-radius: calc( + var(--pg-head-flare) / (1 - sin(var(--pg-head-meet))) + ); + --pg-head-flare-depth: calc( + var(--pg-head-flare-radius) * cos(var(--pg-head-meet)) + ); + --pg-head-flare-drop: calc( + var(--pg-body-radius) * (1 - cos(var(--pg-head-meet))) + ); border-radius: 0; box-shadow: none; background: none; @@ -637,11 +651,13 @@ div.playgroundTextEdit > .playgroundHeader, background: var(--pg-stage); box-shadow: var(--pg-card-shadow); } +/* the flare: under the content (.playgroundResult stacks above it), so it + shows only beside the content's corner */ .row .previewHeader::before, .row .previewHeader::after { content: ''; position: absolute; - bottom: 0; + bottom: calc(-1 * var(--pg-head-flare-drop)); width: calc(var(--pg-head-flare) + 1px); height: var(--pg-head-flare-depth); pointer-events: none; @@ -649,7 +665,8 @@ div.playgroundTextEdit > .playgroundHeader, .row .previewHeader::before { right: calc(100% - 1px); background: radial-gradient( - var(--pg-head-flare) var(--pg-head-flare-depth) at 0 0, + circle var(--pg-head-flare-radius) at + calc(100% - 1px - var(--pg-head-flare-radius)) 0, transparent calc(100% - 0.5px), var(--pg-stage) 100% ); @@ -657,7 +674,8 @@ div.playgroundTextEdit > .playgroundHeader, .row .previewHeader::after { left: calc(100% - 1px); background: radial-gradient( - var(--pg-head-flare) var(--pg-head-flare-depth) at 100% 0, + circle var(--pg-head-flare-radius) at + calc(1px + var(--pg-head-flare-radius)) 0, transparent calc(100% - 0.5px), var(--pg-stage) 100% ); From 1de94994c8ebc8cbfe638e37bc78c35658bacb81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:29:24 +0000 Subject: [PATCH 49/66] docs: Give the protocol switch's pick the preview sheet's color in dark mode Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Demo/styles.module.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/components/Demo/styles.module.css b/website/src/components/Demo/styles.module.css index 8c1bb2c4f756..7e1dfd8de711 100644 --- a/website/src/components/Demo/styles.module.css +++ b/website/src/components/Demo/styles.module.css @@ -36,8 +36,8 @@ color: var(--pg-text); box-shadow: 0 1px 2px rgba(15, 30, 60, 0.16); } -/* dark: the stage and sheet sit close, so the pick lifts toward the text - and the rest recede toward the stage */ +/* dark: the stage and sheet sit close, so the rest recede toward the + stage while the pick takes the sheet's color, as in light */ :root[data-theme='dark'] .segmented > [role='tab'] { color: color-mix(in oklab, var(--pg-muted), var(--pg-stage) 30%); } @@ -45,7 +45,6 @@ color: var(--pg-text); } :root[data-theme='dark'] .segmented > [role='tab'][aria-selected='true'] { - background: color-mix(in oklab, var(--pg-panel), var(--pg-text) 12%); color: var(--pg-text); } .segmented > [role='tab']:focus-visible { From e0005d877cd5f93fdea2b290faff3d4b09a74803 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:40:54 +0000 Subject: [PATCH 50/66] docs: Hold the preview's sheet and reset button while its code loads A hard refresh showed the preview's stage color where the app's sheet goes, then the sheet and the header's reset button popped in. The loading state now draws a blank sheet and a disabled reset in their final places, so only the app's content arrives. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- eslint.config.mjs | 2 + website/src/components/Playground/README.md | 6 +-- website/src/components/Playground/index.tsx | 21 ++++++--- .../Playground/preview/LivePreview.tsx | 2 +- .../Playground/preview/PendingSheet.tsx | 9 ++++ .../components/Playground/preview/Preview.tsx | 5 ++- .../Playground/preview/PreviewError.tsx | 40 +---------------- .../Playground/preview/ResetButton.tsx | 44 +++++++++++++++++++ .../preview/__tests__/PreviewError.test.tsx | 3 +- .../components/Playground/styles.module.css | 5 ++- 10 files changed, 84 insertions(+), 53 deletions(-) create mode 100644 website/src/components/Playground/preview/PendingSheet.tsx create mode 100644 website/src/components/Playground/preview/ResetButton.tsx diff --git a/eslint.config.mjs b/eslint.config.mjs index 42c80c3f3740..8cc848480d58 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,9 @@ import globals from 'globals'; // may be imported. const EAGER_PREVIEW = [ 'FixturePreview', + 'PendingSheet', 'PreviewWrapper', + 'ResetButton', 'routerSlot', 'StoreToggle', ]; diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index f775f2063fe5..0e3970e818da 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -91,9 +91,9 @@ DesignSystem/ demo styling (design-system.css) and components injected int `PreviewBlock` a nested lazy chunk (preloaded with its parent). The Store inspector (`preview/StoreInspector.tsx`, `preview/store/`) lives in it. ESLint treats all of `preview/` as lazy: from outside it, only `import()`, - `import type`, or an allowlisted eager file (`StoreToggle`, - `PreviewWrapper`, `FixturePreview`, which render in SSR and loading states) - may be imported, and those files may not statically import the rest. + `import type`, or an eager file (`EAGER_PREVIEW` in `eslint.config.mjs`; + they render in SSR and loading states) may be imported, and those files + may not statically import the rest. `website/scripts/check-lazy-chunks.mjs` (run by the `website` CI job) backs this up on the build: the Store UI must ship only in `PreviewWithScope`. - Docusaurus only shares modules used by half of all pages, so diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 16df2a763930..319f2b44a796 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -22,7 +22,9 @@ import { import type { AutoRunScript } from './preview/autorun/script'; import FixturePreview from './preview/FixturePreview'; import type LivePreviewType from './preview/LivePreview'; +import PendingSheet from './preview/PendingSheet'; import PreviewWrapper from './preview/PreviewWrapper'; +import ResetButton from './preview/ResetButton'; import type { LogOptions } from './preview/store/actionLog'; import { DemoViewContext, @@ -284,10 +286,10 @@ export default function Playground({ ); } -/** SSR, crawler, hidden and loading state: empty preview frame + Store - * toggle (the side strip; the row layout's is the folder's handle, rendered - * with the folder above), and row's fixtures, already usable; so is a demo's - * Code toggle */ +/** SSR, crawler, hidden and loading state: the preview's frame with a blank + * sheet + Store toggle (the side strip; the row layout's is the folder's + * handle, rendered with the folder above), and row's fixtures, already + * usable; so is a demo's Code toggle. The reset holds its place, disabled */ function PreviewLoading({ row, fixtureContent, @@ -299,11 +301,18 @@ function PreviewLoading({ }) { return ( } + headerControls={ + <> + {row ? null : } + + + } fixtures={fixtureContent} code={code} > -
+
+ +
{row ? null : } ); diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 1dd597b2c004..edcbd12c3eb1 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -5,9 +5,9 @@ import DemoLabel from './autorun/DemoLabel'; import type { AutoRunScript } from './autorun/script'; import { useAutoRun } from './autorun/useAutoRun'; import Preview from './Preview'; -import { ResetButton } from './PreviewError'; import PreviewWrapper from './PreviewWrapper'; import { useRenderCount } from './RenderCount'; +import ResetButton from './ResetButton'; import { routerTrailer } from './router'; import type { CodeDocument } from '../editor/codeModel'; import type { CodeControl, PreviewProps } from '../types'; diff --git a/website/src/components/Playground/preview/PendingSheet.tsx b/website/src/components/Playground/preview/PendingSheet.tsx new file mode 100644 index 000000000000..a0f554255122 --- /dev/null +++ b/website/src/components/Playground/preview/PendingSheet.tsx @@ -0,0 +1,9 @@ +import styles from '../styles.module.css'; + +/** The app's sheet before its code has run (the page's first paint, and + * while the preview's code loads): blank, at the size an app that fits the + * preview takes, so the preview holds its final shape and only its content + * arrives */ +export default function PendingSheet() { + return
; +} diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index cfd8137703e4..c9012b8c94a9 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -11,10 +11,11 @@ import { MotionGroup } from '../../motion'; import Boundary from '../Boundary'; import type ManagerHost from './managers'; import { ManagerError, ManagersSync } from './ManagersSync'; +import PendingSheet from './PendingSheet'; import type { PreviewErrorProps } from './PreviewError'; +import styles from '../styles.module.css'; import SchemaRegistry from './store/schemaRegistry'; import StoreInspector from './StoreInspector'; -import styles from '../styles.module.css'; import type { PreviewProps } from '../types'; function Preview({ @@ -79,7 +80,7 @@ function Preview({ onPointerDownCapture={onInteract} onKeyDownCapture={onInteract} > - + }> diff --git a/website/src/components/Playground/preview/PreviewError.tsx b/website/src/components/Playground/preview/PreviewError.tsx index 492706be81b0..385ec725dbc1 100644 --- a/website/src/components/Playground/preview/PreviewError.tsx +++ b/website/src/components/Playground/preview/PreviewError.tsx @@ -1,9 +1,9 @@ import { useController } from '@data-client/react'; -import Translate, { translate } from '@docusaurus/Translate'; -import clsx from 'clsx'; +import Translate from '@docusaurus/Translate'; import React, { useContext, useEffect } from 'react'; import { LiveContext } from 'react-live'; +import { ResetIcon } from './ResetButton'; import { takeSnapshot, type PreviewSnapshot } from './usePreviewReset'; import ErrorPanel from '../ErrorPanel'; import styles from '../styles.module.css'; @@ -107,39 +107,3 @@ function splitError(error: string) { } return { name, message, location }; } - -export function ResetButton({ onClick }: { onClick: () => void }) { - return ( - - ); -} - -const resetLabel = () => - translate({ id: 'playground.resetPreview', message: 'Reset preview' }); - -function ResetIcon() { - return ( - - ); -} diff --git a/website/src/components/Playground/preview/ResetButton.tsx b/website/src/components/Playground/preview/ResetButton.tsx new file mode 100644 index 000000000000..b613c3d647cf --- /dev/null +++ b/website/src/components/Playground/preview/ResetButton.tsx @@ -0,0 +1,44 @@ +import { translate } from '@docusaurus/Translate'; +import clsx from 'clsx'; +import React from 'react'; + +import styles from '../styles.module.css'; + +/** The preview header's reset; without `onClick` (the preview still + * loading) it holds its place, disabled */ +export default function ResetButton({ onClick }: { onClick?: () => void }) { + return ( + + ); +} + +const resetLabel = () => + translate({ id: 'playground.resetPreview', message: 'Reset preview' }); + +export function ResetIcon() { + return ( + + ); +} diff --git a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx index 7433a2743c36..f8449ddedd5d 100644 --- a/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx +++ b/website/src/components/Playground/preview/__tests__/PreviewError.test.tsx @@ -7,7 +7,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { LivePreview, LiveProvider } from 'react-live'; -import PreviewError, { ResetButton } from '../PreviewError'; +import PreviewError from '../PreviewError'; +import ResetButton from '../ResetButton'; // Guards PreviewError's reliance on react-live's undocumented `newCode`. function renderPreview(code: string) { diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index f8085fc34032..82690a6cb89d 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -690,7 +690,7 @@ div.playgroundTextEdit > .playgroundHeader, flex: 1 1 auto; min-width: 0; } -.row > .previewWrapper:has(.sheet:not(:empty))::after { +.row > .previewWrapper:has(.sheet:not(:empty), .sheet[data-pending])::after { background: var(--pg-panel); } .row .sheet { @@ -1048,7 +1048,8 @@ div.playgroundTextEdit > .playgroundHeader, /* the progress bar's corners stay inside the radius */ overflow: clip; } -.sheet:empty { +/* an app that renders nothing has no sheet; one still loading has one */ +.sheet:empty:not([data-pending]) { display: none; } /* The router's navigation indicator (preview/router.tsx): a 3px bar along From 3124198dad1c342d62f096651a4bde788a8f1493 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:59:03 +0000 Subject: [PATCH 51/66] docs: Animate the Store only when the reader or a demo moves it Switching docs tabs could replay the Store's slide: a playground shown again resolved its layout a frame late and the drawer transitioned there. Now every Store-linked transition takes its duration from tokens that are 0s at rest, and only a reader's toggle or a demo's step turns them on (data-store-moving) until the Store settles; the stacked Store's glide keys on the same moves. Tab switches, hydration, resizes and other Stores in the group closing this one all just land. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 8 ++- website/src/components/Playground/index.tsx | 30 +++------ .../Playground/preview/LivePreview.tsx | 2 + .../components/Playground/preview/Preview.tsx | 3 +- .../Playground/preview/StoreToggle.tsx | 66 +++++++++++++++++- .../preview/__tests__/StoreMotion.test.tsx | 67 +++++++++++++++++++ .../preview/__tests__/managers.test.tsx | 1 + .../Playground/preview/store/store.module.css | 2 +- .../components/Playground/styles.module.css | 44 +++++++----- website/src/components/Playground/types.ts | 3 + 10 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 0e3970e818da..4546032f8b75 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -196,8 +196,12 @@ DesignSystem/ demo styling (design-system.css) and components injected int `--pg-preview-gap`); the drawer comes from its left edge with the handle level with that foot, and only its sheet runs on below it, the strip of folder between the two sheets curving into its edge; stacked, up from the preview's top edge, at most `--store-cap` - tall so a long file's top stays in view. It slides only once painted at - rest (`data-settled`), so showing a playground never animates it. The + tall so a long file's top stays in view. It animates only while the reader or a demo moves it + (`useStoreMotion` sets `data-store-moving`, which gives every + Store-linked transition its duration, `--pg-store-*`; the stacked + Store's `MotionGroup` keys on the same moves), so a tab showing the + playground, hydration, a resize or another Store in its group closing + it just lands. The preview stays usable either way. The Store itself is chrome on the folder (the scrubber's row, State and Action as tabs) and content on a sheet the picked tab joins (`store.module.css`). A folder's header is inset from its content diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 319f2b44a796..0108d7b96e51 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -3,7 +3,6 @@ import React, { lazy, useCallback, useDeferredValue, - useEffect, useId, useMemo, useRef, @@ -31,6 +30,7 @@ import { StoreHeaderToggle, StoreTab, StoreToggle, + useStoreMotion, useStoreOpen, } from './preview/StoreToggle'; import styles from './styles.module.css'; @@ -103,29 +103,16 @@ export default function Playground({ : rowLayout ? 'row' : 'stacked'; const row = layout === 'row'; - const [storeOpen, toggleStore, showStore, demoView] = useStoreOpen( - groupId, - defaultOpen, - ); + const [storeOpen, toggleStore, showStore, demoView, storeMoves] = + useStoreOpen(groupId, defaultOpen); + // the Store animates only while the reader or a demo moves it, never as + // the playground shows or its layout changes + const [container, setContainer] = useState(null); + const storeMoving = useStoreMotion(container, storeMoves, storeOpen); // Row layout: the preview is a folder with the Store behind it, which its // handle pulls out over the code (the folder's layer, .storeHost), leaving // the preview usable const [storeHost, setStoreHost] = useState(null); - // the drawer slides only once it has been painted where it rests: shown - // (the protocol switch, a page load), a browser may resolve the container - // query that parks it a frame late, and slide it there (Safari) - useEffect(() => { - if (!storeHost || hidden) return; - let frame = requestAnimationFrame(() => { - frame = requestAnimationFrame(() => - storeHost.toggleAttribute('data-settled', true), - ); - }); - return () => { - cancelAnimationFrame(frame); - storeHost.removeAttribute('data-settled'); - }; - }, [storeHost, hidden]); const storeId = useId(); const codeCovered = row && storeOpen; // the folder's handle: Escape anywhere on the folder pushes it shut @@ -229,6 +216,7 @@ export default function Playground({ actionLog={actionLog} autoRun={autoRun} showStore={showStore} + storeMoves={storeMoves} code={code} /> @@ -245,11 +233,13 @@ export default function Playground({ })} >
{demo ? <> diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index edcbd12c3eb1..7836f581a3f2 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -36,6 +36,7 @@ export default function LivePreview({ documents, fixtureContent, storeOpen, + storeMoves, toggleStore, row, storeHost, @@ -109,6 +110,7 @@ export default function LivePreview({ ({ storeOpen, + storeMoves, toggleStore, row, storeHost, @@ -74,7 +75,7 @@ function Preview({ getInitialInterceptorData={getInitialInterceptorData} > - +
{ + latestOpen.current = open; + }); const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker(); const toggle = useCallback( @@ -50,6 +66,7 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { blockElementScrollPositionUntilNextRender(clicked); setChoice(open ? 'n' : 'y'); show(null); + setMoves(n => n + 1); }, [blockElementScrollPositionUntilNextRender, open, setChoice], ); @@ -57,6 +74,7 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { // doesn't pin a toggle the reader clicked earlier const showStore = useCallback((demoOpen, view) => { clicked = undefined; + if (demoOpen !== latestOpen.current) setMoves(n => n + 1); show(prev => { const next = demoOpen ? (view ?? prev?.view) : undefined; // unchanged: the playground's children don't re-render @@ -72,7 +90,51 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { ); const view = shown?.view; const demoView = useMemo(() => ({ view, drop }), [view, drop]); - return [open, toggle, showStore, demoView] as const; + return [open, toggle, showStore, demoView, moves] as const; +} + +/** Whether the Store is moving for one of `moves` (`useStoreOpen`), as + * `data-store-moving` on the playground (`container`), which gives its + * motion its durations (styles.module.css): until it is at rest, open or + * shut, with nothing left sliding or fading. Any other change, from the + * layout or the page (a tab showing the playground, a resize, hydration), + * lands at once */ +export function useStoreMotion( + container: HTMLElement | null, + moves: number, + open: boolean, +) { + const [rested, setRested] = useState(moves); + const moving = moves !== rested; + useEffect(() => { + if (!container || !moving) return; + let frame = 0; + const check = () => { + // shown once open, hidden once shut (none before the preview loads) + const panel = container.querySelector(`.${styles.storePanel}`); + const resting = !panel || open === panel.hasAttribute('data-visible'); + // the Store's own motion, not the app's or what the Store shows (a + // row's flash as data arrives), which would hold the playground moving + const app = container.querySelector(`.${styles.playgroundPreview}`); + const unrelated = (target: Element) => + app?.contains(target) || (target !== panel && panel?.contains(target)); + // (no Web Animations, e.g. jsdom: nothing runs) + const running = container + .getAnimations?.({ subtree: true }) + .some( + a => + a.playState === 'running' && + a.effect?.getTiming().iterations !== Infinity && + !unrelated((a.effect as KeyframeEffect).target!), + ); + if (resting && !running) setRested(moves); + else frame = requestAnimationFrame(check); + }; + // what this move started is running by the next frame + frame = requestAnimationFrame(check); + return () => cancelAnimationFrame(frame); + }, [container, moving, moves, open]); + return moving; } /** Toggle row; also rendered (inert) by the preview loading fallback in ../index.tsx */ diff --git a/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx b/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx new file mode 100644 index 000000000000..4ea68f4eb435 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx @@ -0,0 +1,67 @@ +/// +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { useStoreMotion, useStoreOpen } from '../StoreToggle'; + +// one stored choice for the group, as the page's storage slot is +let mockStored: string | null = null; +const mockListeners = new Set<() => void>(); +const mockSetStored = (value: string) => { + mockStored = value; + mockListeners.forEach(listener => listener()); +}; +jest.mock('../../../../utils/tabStorage', () => ({ + useTabStorage: () => { + const { useSyncExternalStore } = require('react'); + const value = useSyncExternalStore( + (listener: () => void) => { + mockListeners.add(listener); + return () => mockListeners.delete(listener); + }, + () => mockStored, + ); + return [value, mockSetStored]; + }, +})); + +let showStore: ReturnType[2] = () => {}; +function Store() { + const [open, toggle, show, , moves] = useStoreOpen('test', 'n'); + showStore = show; + const [container, setContainer] = React.useState(null); + const moving = useStoreMotion(container, moves, open); + return ( +
+ +
+ ); +} +const button = () => screen.getByRole('button'); +const moving = () => button().parentElement!.hasAttribute('data-store-moving'); + +beforeEach(() => { + mockStored = null; +}); + +it('moves only for the reader or a demo', async () => { + render(); + // the stored choice arriving, or another Store in the group, just lands + act(() => mockSetStored('y')); + expect(button().textContent).toBe('open'); + expect(moving()).toBe(false); + + fireEvent.click(button()); + expect(button().textContent).toBe('shut'); + expect(moving()).toBe(true); + // at rest by the next frame with nothing running (no Web Animations here) + await act(() => new Promise(requestAnimationFrame)); + expect(moving()).toBe(false); + + // a demo step that changes nothing doesn't move it + act(() => showStore(false)); + expect(moving()).toBe(false); + act(() => showStore(true)); + expect(button().textContent).toBe('open'); + expect(moving()).toBe(true); +}); diff --git a/website/src/components/Playground/preview/__tests__/managers.test.tsx b/website/src/components/Playground/preview/__tests__/managers.test.tsx index 5b91236d9ce7..a7b840187a27 100644 --- a/website/src/components/Playground/preview/__tests__/managers.test.tsx +++ b/website/src/components/Playground/preview/__tests__/managers.test.tsx @@ -42,6 +42,7 @@ function renderPlayground(documents: string[]) { groupId: 'test', documents: docs.map(value => ({ value }) as any), storeOpen: false, + storeMoves: 0, toggleStore: () => {}, storeHost: null, row: false, diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index cd02a582e1c7..3364933ffb9e 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -66,7 +66,7 @@ slides shut) */ .scrubber > * { opacity: var(--store-head-ink, 1); - transition: opacity var(--motion-snappy); + transition: opacity var(--pg-store-snappy); } .scrubber::before, .scrubber::after { diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 82690a6cb89d..6214086f77dc 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -6,6 +6,15 @@ margin-bottom: var(--ifm-leading); } .playgroundContainer { + /* The Store's motion (its slide and everything that moves with it) runs + only while the reader or a demo moves it (`data-store-moving`, + ./preview/StoreToggle.tsx `useStoreMotion`): at rest these durations + are 0, so nothing that changes the Store's layout instead (a tab showing + the playground, a container query resolving late, a resize) animates. + Every Store-linked transition uses them */ + --pg-store-drawer: 0s; + --pg-store-snappy: 0s; + --pg-store-fade: 0s; /* the Store's size, for it and every layout that holds it: room to inspect whatever the code's or the preview's height, and the most it takes when its own content sets the height */ @@ -54,6 +63,12 @@ flex-direction: column; background-color: var(--ifm-background-color); } +.playgroundContainer[data-store-moving] { + --pg-store-drawer: var(--motion-drawer); + --pg-store-snappy: var(--motion-snappy); + /* the preview header's curves fade in as it lands */ + --pg-store-fade: 120ms ease-out 180ms; +} .playgroundContainer.standaloneEditor { --ifm-code-font-size: 14.4px; } @@ -718,10 +733,7 @@ div.playgroundTextEdit > .playgroundHeader, } .row > .storeHost > .storeDrawer { pointer-events: auto; -} -/* once painted at rest (../index.tsx) */ -.row > .storeHost > .storeDrawer[data-settled] { - transition: translate var(--motion-drawer); + transition: translate var(--pg-store-drawer); } /* its paper is its header's and its body's (./preview/store), the header inset at the drawer's leading edge as the preview's is */ @@ -1415,9 +1427,9 @@ div.fixtureJson + div.fixtureJson { .row:has(> .storeHost[data-open])::before { left: 0; } - .row:has(> .storeHost > .storeDrawer[data-settled]) > .previewWrapper::before, - .row:has(> .storeHost > .storeDrawer[data-settled])::before { - transition: left var(--motion-drawer); + .row > .previewWrapper::before, + .row::before { + transition: left var(--pg-store-drawer); } .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { border-top-left-radius: 0; @@ -1432,7 +1444,7 @@ div.fixtureJson + div.fixtureJson { the Store's covers that, see .storeHost) */ .row .playgroundResult, .row .sheet { - transition: border-top-left-radius var(--motion-snappy); + transition: border-top-left-radius var(--pg-store-snappy); } .row:has(> .storeHost .storePanel[data-visible]) .playgroundResult { border-top-left-radius: 0; @@ -1472,10 +1484,10 @@ div.fixtureJson + div.fixtureJson { file tabs would show past the Store header's inset */ .row .editorDocs > .playgroundEditor, .row .editorDocs::after { - transition: box-shadow var(--motion-drawer); + transition: box-shadow var(--pg-store-drawer); } .row .tabControls { - transition: opacity var(--motion-drawer); + transition: opacity var(--pg-store-drawer); } .row:has(> .storeHost[data-open]) .editorDocs > .playgroundEditor, .row:has(> .storeHost[data-open]) .editorDocs::after { @@ -1634,7 +1646,7 @@ div.fixtureJson + div.fixtureJson { z-index: -1; box-shadow: var(--pg-card-shadow); opacity: 0; - transition: opacity var(--motion-drawer); + transition: opacity var(--pg-store-drawer); } .row > .storeHost[data-open] > .storeDrawer::before { opacity: 1; @@ -1662,20 +1674,20 @@ div.fixtureJson + div.fixtureJson { fading in as it lands */ .row .previewHeader { transition: - margin var(--motion-snappy), - padding var(--motion-snappy), - border-radius var(--motion-snappy); + margin var(--pg-store-snappy), + padding var(--pg-store-snappy), + border-radius var(--pg-store-snappy); } .row .previewHeader::before, .row .previewHeader::after { - transition: opacity 120ms ease-out 180ms; + transition: opacity var(--pg-store-fade); } .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before, .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::after { transition: none; } .row .playgroundResult { - transition: border-radius var(--motion-snappy); + transition: border-radius var(--pg-store-snappy); } /* up from the header's top edge at its right end (the thumb's side, clear of anything laid out at the left above the preview), past the diff --git a/website/src/components/Playground/types.ts b/website/src/components/Playground/types.ts index c8a93411a2c4..0a2d1b4d6807 100644 --- a/website/src/components/Playground/types.ts +++ b/website/src/components/Playground/types.ts @@ -13,6 +13,9 @@ export interface CodeControl { export interface PreviewProps { storeOpen: boolean; + /** Counts the reader's and demos' Store moves (`useStoreOpen`): the Store + * animates only for these */ + storeMoves: number; toggleStore: MouseEventHandler; row: boolean; /** Row layout: the layer over the code the Store slides into */ From 73fabf5b328e692c141de1fe8c05977c9efbbf63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:08:14 +0000 Subject: [PATCH 52/66] docs: Show one loading page until the preview's app is ready A hard refresh drew the page's placeholder, then a blank sheet, then the app's skeleton, then the app. The skeleton CSS loaded with the lazy design system, and react-live's first transpile rendered an empty sheet. Now the tokens and skeletons load with the Playground (DesignSystem/base.css), and every step before the app renders draws the same PendingSheet, so a hard refresh goes from loading to loaded like client-side navigation does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- __tests__/css-modules.d.ts | 1 + .../Playground/DesignSystem/Loading.tsx | 2 +- .../Playground/DesignSystem/base.css | 137 ++++++++++++++++++ .../Playground/DesignSystem/design-system.css | 135 +---------------- website/src/components/Playground/README.md | 4 +- website/src/components/Playground/index.tsx | 2 +- .../Playground/preview/PendingSheet.tsx | 15 +- .../Playground/preview/PreviewBlock.tsx | 27 ++-- .../components/Playground/styles.module.css | 5 +- 9 files changed, 173 insertions(+), 155 deletions(-) create mode 100644 website/src/components/Playground/DesignSystem/base.css diff --git a/__tests__/css-modules.d.ts b/__tests__/css-modules.d.ts index b9562ea2ffa5..a7f418d233f4 100644 --- a/__tests__/css-modules.d.ts +++ b/__tests__/css-modules.d.ts @@ -4,3 +4,4 @@ declare module '*.module.css' { const classes: { readonly [key: string]: string }; export default classes; } +declare module '*.css'; diff --git a/website/src/components/Playground/DesignSystem/Loading.tsx b/website/src/components/Playground/DesignSystem/Loading.tsx index 58cd61db8015..32be2e179b7c 100644 --- a/website/src/components/Playground/DesignSystem/Loading.tsx +++ b/website/src/components/Playground/DesignSystem/Loading.tsx @@ -1,5 +1,5 @@ /** A page while it loads: its title and a paragraph drawn as skeletons - * (design-system.css), standing where the content will land */ + * (base.css), standing where the content will land */ export function Loading() { return (
diff --git a/website/src/components/Playground/DesignSystem/base.css b/website/src/components/Playground/DesignSystem/base.css new file mode 100644 index 000000000000..728146c1d73d --- /dev/null +++ b/website/src/components/Playground/DesignSystem/base.css @@ -0,0 +1,137 @@ +/* The design system's tokens and its skeletons (./design-system.css has the + rest), loaded with the Playground itself (../preview/PendingSheet.tsx) + rather than with the preview: the page's first paint shows the same + loading page the app shows while its data loads, so a hard refresh goes + from loading to loaded as a client-side navigation does. */ +.playground-preview { + /* type: one step down from the docs, like an app beside its prose */ + --ds-text-sm: 0.8125rem; + --ds-text-base: 0.9375rem; + --ds-text-lg: 1.125rem; + --ds-text-xl: 1.375rem; + --ds-space-1: 0.25rem; + --ds-space-2: 0.5rem; + --ds-space-3: 0.75rem; + --ds-space-4: 1rem; + --ds-space-5: 1.5rem; + /* a well (a board's column) */ + --ds-radius: 12px; + --ds-radius-sm: 8px; + --ds-radius-xs: 4px; + /* a button's or a field's inset */ + --ds-control-pad-y: 0.375rem; + --ds-control-pad-x: 0.875rem; + --ds-control-border: 1px; + /* The palette is the frame's (--pg-*, ../styles.module.css, switching with + the theme there): the panel is the app's surface; a well a step off it + holds a board's column or a field's inset */ + --ds-text: var(--pg-text); + --ds-text-muted: var(--pg-muted); + --ds-surface: var(--pg-panel); + --ds-surface-2: var(--pg-panel-2); + /* a faint line: the busy submit button's trace, and demos that draw their + own rule */ + --ds-border: var(--pg-line); + /* an interactive row's band under the pointer: reads on the panel and on + a well alike */ + --ds-hover: var(--pg-hover); + /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ + --ds-faint: #5f6d83; + /* 3:1 on the panel, so field and button edges stay legible */ + --ds-border-strong: #78889d; + --ds-accent: #1b6fb8; + --ds-accent-strong: #155b99; + /* the accent's wash: a highlighted row, a changed value, a focus ring */ + --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 10%, transparent); + --ds-on-accent: #fff; + /* Infima's own vars, re-pointed so what reads them (the rt-* fields, links, + the error panel's text) follows this palette in both themes */ + --ifm-color-emphasis-0: var(--ds-surface); + --ifm-color-emphasis-100: var(--ds-surface-2); + --ifm-color-emphasis-400: var(--ds-border-strong); + --ifm-color-emphasis-500: var(--ds-border-strong); + --ifm-color-emphasis-700: var(--ds-text-muted); + --ifm-color-content: var(--ds-text); + --ifm-font-color-base: var(--ds-text); + /* links keep the accent under the pointer */ + --ifm-link-color: var(--ds-accent); + --ifm-link-hover-color: var(--ds-accent); + --ifm-color-formfield-active: var(--ds-accent); + /* Infima's headings read these (h1 is set below: theme-classic sizes a + first-child h1 as a page title) */ + --ifm-h2-font-size: 1.25rem; + --ifm-h3-font-size: var(--ds-text-lg); + --ifm-h4-font-size: var(--ds-text-base); + --ifm-heading-font-weight: 600; + --ifm-heading-margin-bottom: var(--ds-space-1); + --ifm-paragraph-margin-bottom: var(--ds-space-3); + font-size: var(--ds-text-base); + line-height: 1.5; + color: var(--ds-text); +} + +/* Skeletons: what is loading, drawn where it will land in the shape it + will take, so nothing moves when it arrives. A `skeleton` is one line of + text: beside a row's label it holds the value's place (a clock's "API + time"), in a table cell a number's. (./Loading.tsx) draws a + page of them: a title and a paragraph. The zero-width space gives it a + real line, so it keeps the row's height and baseline; the bar is a + rounded band in that line, with a soft light sweeping across (still for + reduced motion). */ +.playground-preview .skeleton { + position: relative; + display: inline-block; + width: 8em; + max-width: 100%; +} +.playground-preview .skeleton::before { + content: '\200b'; +} +.playground-preview .skeleton::after { + content: ''; + position: absolute; + inset: 0; + height: 0.7em; + margin-block: auto; + border-radius: var(--ds-radius-xs); + background: linear-gradient( + 100deg, + transparent 35%, + var(--ds-hover) 50%, + transparent 65% + ) + var(--ds-hover); + background-size: 300% 100%; + animation: ds-shimmer 1.6s ease-in-out infinite; +} +@keyframes ds-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: 0 0; + } +} +@media (prefers-reduced-motion: reduce) { + .playground-preview .skeleton::after { + animation: none; + } +} +.playground-preview td > .skeleton { + width: 3.5em; +} +/* a page: its title at the h2's size, then the paragraph's lines, the last + one short like a paragraph's */ +.playground-preview .skeletonPage > .skeleton { + display: block; + width: 100%; +} +.playground-preview .skeletonPage > .skeleton:first-child { + width: 55%; + margin-bottom: var(--ds-space-3); + font-size: var(--ifm-h2-font-size); + line-height: 1.3; +} +.playground-preview .skeletonPage > .skeleton:last-child { + width: 62%; +} diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 1ba98cf46f46..1bf81a64bad6 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -11,73 +11,8 @@ The look is quiet: no decorative lines anywhere. Rows are set apart by space, groups by a label in weight and by their inset, and secondary text by a muted color; one blue serves links, primary actions and focus. Only - form controls keep an edge (it is what makes them controls). */ -.playground-preview { - /* type: one step down from the docs, like an app beside its prose */ - --ds-text-sm: 0.8125rem; - --ds-text-base: 0.9375rem; - --ds-text-lg: 1.125rem; - --ds-text-xl: 1.375rem; - --ds-space-1: 0.25rem; - --ds-space-2: 0.5rem; - --ds-space-3: 0.75rem; - --ds-space-4: 1rem; - --ds-space-5: 1.5rem; - /* a well (a board's column) */ - --ds-radius: 12px; - --ds-radius-sm: 8px; - --ds-radius-xs: 4px; - /* a button's or a field's inset */ - --ds-control-pad-y: 0.375rem; - --ds-control-pad-x: 0.875rem; - --ds-control-border: 1px; - /* The palette is the frame's (--pg-*, ../styles.module.css, switching with - the theme there): the panel is the app's surface; a well a step off it - holds a board's column or a field's inset */ - --ds-text: var(--pg-text); - --ds-text-muted: var(--pg-muted); - --ds-surface: var(--pg-panel); - --ds-surface-2: var(--pg-panel-2); - /* a faint line: the busy submit button's trace, and demos that draw their - own rule */ - --ds-border: var(--pg-line); - /* an interactive row's band under the pointer: reads on the panel and on - a well alike */ - --ds-hover: var(--pg-hover); - /* labels and hints: the quietest text that still reads (4.5:1 on the panel and the well) */ - --ds-faint: #5f6d83; - /* 3:1 on the panel, so field and button edges stay legible */ - --ds-border-strong: #78889d; - --ds-accent: #1b6fb8; - --ds-accent-strong: #155b99; - /* the accent's wash: a highlighted row, a changed value, a focus ring */ - --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 10%, transparent); - --ds-on-accent: #fff; - /* Infima's own vars, re-pointed so what reads them (the rt-* fields, links, - the error panel's text) follows this palette in both themes */ - --ifm-color-emphasis-0: var(--ds-surface); - --ifm-color-emphasis-100: var(--ds-surface-2); - --ifm-color-emphasis-400: var(--ds-border-strong); - --ifm-color-emphasis-500: var(--ds-border-strong); - --ifm-color-emphasis-700: var(--ds-text-muted); - --ifm-color-content: var(--ds-text); - --ifm-font-color-base: var(--ds-text); - /* links keep the accent under the pointer */ - --ifm-link-color: var(--ds-accent); - --ifm-link-hover-color: var(--ds-accent); - --ifm-color-formfield-active: var(--ds-accent); - /* Infima's headings read these (h1 is set below: theme-classic sizes a - first-child h1 as a page title) */ - --ifm-h2-font-size: 1.25rem; - --ifm-h3-font-size: var(--ds-text-lg); - --ifm-h4-font-size: var(--ds-text-base); - --ifm-heading-font-weight: 600; - --ifm-heading-margin-bottom: var(--ds-space-1); - --ifm-paragraph-margin-bottom: var(--ds-space-3); - font-size: var(--ds-text-base); - line-height: 1.5; - color: var(--ds-text); -} + form controls keep an edge (it is what makes them controls). The tokens + and the skeletons are in ./base.css, which loads with the Playground. */ /* Dark: the same console at night (the panel, well, line and text follow --pg-* above); what's left is the design system's own */ html[data-theme='dark'] .playground-preview { @@ -644,72 +579,6 @@ div.playground-preview h1:first-child { visibility: hidden; } -/* Skeletons: what is loading, drawn where it will land in the shape it - will take, so nothing moves when it arrives. A `skeleton` is one line of - text: beside a row's label it holds the value's place (a clock's "API - time"), in a table cell a number's. (./Loading.tsx) draws a - page of them: a title and a paragraph. The zero-width space gives it a - real line, so it keeps the row's height and baseline; the bar is a - rounded band in that line, with a soft light sweeping across (still for - reduced motion). */ -.playground-preview .skeleton { - position: relative; - display: inline-block; - width: 8em; - max-width: 100%; -} -.playground-preview .skeleton::before { - content: '\200b'; -} -.playground-preview .skeleton::after { - content: ''; - position: absolute; - inset: 0; - height: 0.7em; - margin-block: auto; - border-radius: var(--ds-radius-xs); - background: linear-gradient( - 100deg, - transparent 35%, - var(--ds-hover) 50%, - transparent 65% - ) - var(--ds-hover); - background-size: 300% 100%; - animation: ds-shimmer 1.6s ease-in-out infinite; -} -@keyframes ds-shimmer { - from { - background-position: 100% 0; - } - to { - background-position: 0 0; - } -} -@media (prefers-reduced-motion: reduce) { - .playground-preview .skeleton::after { - animation: none; - } -} -.playground-preview td > .skeleton { - width: 3.5em; -} -/* a page: its title at the h2's size, then the paragraph's lines, the last - one short like a paragraph's */ -.playground-preview .skeletonPage > .skeleton { - display: block; - width: 100%; -} -.playground-preview .skeletonPage > .skeleton:first-child { - width: 55%; - margin-bottom: var(--ds-space-3); - font-size: var(--ifm-h2-font-size); - line-height: 1.3; -} -.playground-preview .skeletonPage > .skeleton:last-child { - width: 62%; -} - /* Toasts (, ./Toaster.tsx): stacked under the app, each a tinted row with an edge bar in its outcome's color: the live green for success, red for an error */ diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 4546032f8b75..9d4aa6ef64c2 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -337,7 +337,9 @@ DesignSystem/ demo styling (design-system.css) and components injected int ### Design system `DesignSystem/design-system.css` (scoped to `.playground-preview`, loaded with -the preview) makes demo markup read as a real app in both themes. Doc +the preview) makes demo markup read as a real app in both themes; its tokens +and skeletons (`DesignSystem/base.css`) load with the Playground, so the +first paint shows the app's own loading page (`preview/PendingSheet.tsx`). Doc examples use plain elements and these classes, never inline styles or imports: styling changes go here. Space separates things, not lines: add no borders or dividers (only form controls keep an edge). Tokens are `--ds-*`, diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index 0108d7b96e51..f9bccd34e4fa 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -300,7 +300,7 @@ function PreviewLoading({ fixtures={fixtureContent} code={code} > -
+
{row ? null : } diff --git a/website/src/components/Playground/preview/PendingSheet.tsx b/website/src/components/Playground/preview/PendingSheet.tsx index a0f554255122..15a5bbe9763b 100644 --- a/website/src/components/Playground/preview/PendingSheet.tsx +++ b/website/src/components/Playground/preview/PendingSheet.tsx @@ -1,9 +1,14 @@ +import '../DesignSystem/base.css'; +import { Loading } from '../DesignSystem/Loading'; import styles from '../styles.module.css'; -/** The app's sheet before its code has run (the page's first paint, and - * while the preview's code loads): blank, at the size an app that fits the - * preview takes, so the preview holds its final shape and only its content - * arrives */ +/** The app's sheet while it loads: the page's first paint, the preview's + * code loading, and the app waiting on its data all draw this one loading + * page, so the preview goes from loading to loaded and nothing between */ export default function PendingSheet() { - return
; + return ( +
+ +
+ ); } diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index 42692f20f941..457f986fd551 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -1,25 +1,30 @@ -import React, { memo, Profiler, type ProfilerOnRenderCallback } from 'react'; -import { LivePreview } from 'react-live'; +import React, { + memo, + Profiler, + useContext, + type ProfilerOnRenderCallback, +} from 'react'; +import { LiveContext, LivePreview } from 'react-live'; import Boundary from '../Boundary'; +import PendingSheet from './PendingSheet'; import PreviewError, { type PreviewErrorProps } from './PreviewError'; -import { Loading } from '../DesignSystem/Loading'; import styles from '../styles.module.css'; function PreviewBlock({ onCommit, ...errorProps }: PreviewErrorProps & { onCommit?: ProfilerOnRenderCallback }) { - const preview = ; + // react-live runs the code only after its first render: until it has, the + // app is still loading (not an app that renders nothing) + const { newCode, error } = useContext(LiveContext); + const preview = + newCode === undefined && !error ? + + : ; return ( <> - - -
- } - > + }> {onCommit ? {preview} diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 6214086f77dc..441af0c5c829 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -705,7 +705,7 @@ div.playgroundTextEdit > .playgroundHeader, flex: 1 1 auto; min-width: 0; } -.row > .previewWrapper:has(.sheet:not(:empty), .sheet[data-pending])::after { +.row > .previewWrapper:has(.sheet:not(:empty))::after { background: var(--pg-panel); } .row .sheet { @@ -1060,8 +1060,7 @@ div.playgroundTextEdit > .playgroundHeader, /* the progress bar's corners stay inside the radius */ overflow: clip; } -/* an app that renders nothing has no sheet; one still loading has one */ -.sheet:empty:not([data-pending]) { +.sheet:empty { display: none; } /* The router's navigation indicator (preview/router.tsx): a 3px bar along From 44c6c18dd8590e341865585700955d144fad2e7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:16:16 +0000 Subject: [PATCH 53/66] docs: Round the phone Store's top and float its fixtures over the page On a phone the open Store's header ran square across the top of the code, and opening the fixtures made the preview grow to hold them, pushing the page down. The header now rounds its top corners as the code card does, and the fixtures hang past the preview's foot over the page, so the preview keeps its size. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/store/store.module.css | 9 ++++++--- .../components/Playground/styles.module.css | 18 ++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index 3364933ffb9e..b29a059449f0 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -44,7 +44,7 @@ from there. The body's paper stops --store-folder-end short of its foot, so only the sheet runs on past that. Where the header runs on into the preview's instead (the end side), --store-head-run takes it on under - that edge */ + that edge. --store-head-radius rounds a header that isn't inset */ .scrubber { --store-head-in-start: calc( var(--store-head-start, 0) * var(--pg-head-inset, 0px) @@ -57,8 +57,11 @@ margin: 0 var(--store-head-in-end) 0 var(--store-head-in-start); /* what it holds stays clear of the run */ padding-right: var(--store-head-run, 0px); - border-radius: calc(var(--store-head-start, 0) * var(--pg-card-radius, 0px)) - calc(var(--store-head-end, 0) * var(--pg-card-radius, 0px)) 0 0; + border-radius: var( + --store-head-radius, + calc(var(--store-head-start, 0) * var(--pg-card-radius, 0px)) + calc(var(--store-head-end, 0) * var(--pg-card-radius, 0px)) 0 0 + ); background: var(--store-bg); box-shadow: var(--pg-card-shadow, none); } diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 441af0c5c829..09e1978b288c 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1627,11 +1627,12 @@ div.fixtureJson + div.fixtureJson { .row > .storeHost[data-open] > .storeDrawer { translate: 0 0; } - /* its header edge to edge: its top edge lands on the code, which inset - corners would show through; its sheet ends short of the foot, rounded, - as the code card does */ + /* its header edge to edge, rounded as the code card under it is: an + inset would show the code through its sides; its sheet ends short of + the foot, rounded, as the code card does */ .row > .storeHost .storePanel { --store-head-start: 0; + --store-head-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; --store-sheet-foot: 12px; --store-body-pad: 0 0 var(--pg-gap); } @@ -1708,13 +1709,10 @@ div.fixtureJson + div.fixtureJson { bottom: -1px; --pg-fillet-at: 100% 0; } - /* pinned, they get room to unfold a response even over a short result; - the preview clips them, so they scroll within that same height */ - .fixturesAnchor:has(> [data-open]) + .playgroundResult { - min-height: min(14rem, 50svh); - } - .fixturesPopover { - max-height: min(14rem, 50svh); + /* the fixtures hang on past the preview's foot, over the page below + (it stacks above that while they show), so the preview never moves */ + .row > .previewWrapper { + overflow: visible; } } From edf4b63402fa4029cb3f4d37f942a6057db7f05f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:24:29 +0000 Subject: [PATCH 54/66] docs: Settle the Store's motion on its animations finishing Simplify pass: the motion gate waits on the running animations' finished promises instead of scanning the playground every frame, react-live's context is read by its own small component so PreviewBlock's memo holds, showStore reads the open state directly, and the narrow preview's overflow joins its existing rule. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/PreviewBlock.tsx | 17 ++-- .../Playground/preview/StoreToggle.tsx | 92 +++++++++++-------- .../components/Playground/styles.module.css | 8 +- 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index 457f986fd551..c2865cf3f56c 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -15,13 +15,7 @@ function PreviewBlock({ onCommit, ...errorProps }: PreviewErrorProps & { onCommit?: ProfilerOnRenderCallback }) { - // react-live runs the code only after its first render: until it has, the - // app is still loading (not an app that renders nothing) - const { newCode, error } = useContext(LiveContext); - const preview = - newCode === undefined && !error ? - - : ; + const preview = ; return ( <> }> @@ -35,6 +29,15 @@ function PreviewBlock({ ); } +/** The app's sheet: react-live runs the code only after its first render, so + * until it has, the app is still loading (not an app that renders nothing). + * Its own component, so react-live's context changes don't reach the memo */ +function LiveSheet() { + const { newCode, error } = useContext(LiveContext); + return newCode === undefined && !error ? + + : ; +} // memo: Store inspector toggles re-render Preview; they must not reach the // profiled tree (or bump the render badge). export default memo(PreviewBlock); diff --git a/website/src/components/Playground/preview/StoreToggle.tsx b/website/src/components/Playground/preview/StoreToggle.tsx index 117a69374778..f7fa8b6bea87 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -8,9 +8,7 @@ import React, { createContext, useCallback, useEffect, - useLayoutEffect, useMemo, - useRef, useState, } from 'react'; @@ -54,10 +52,6 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { // (`useStoreMotion`); the stored choice arriving, or another Store in the // group closing this one, just lands const [moves, setMoves] = useState(0); - const latestOpen = useRef(open); - useLayoutEffect(() => { - latestOpen.current = open; - }); const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker(); const toggle = useCallback( @@ -72,17 +66,20 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { ); // a demo's open or close is no reader's click: forget the last one, so it // doesn't pin a toggle the reader clicked earlier - const showStore = useCallback((demoOpen, view) => { - clicked = undefined; - if (demoOpen !== latestOpen.current) setMoves(n => n + 1); - show(prev => { - const next = demoOpen ? (view ?? prev?.view) : undefined; - // unchanged: the playground's children don't re-render - return prev?.open === demoOpen && prev.view === next ? - prev - : { open: demoOpen, view: next }; - }); - }, []); + const showStore = useCallback( + (demoOpen, view) => { + clicked = undefined; + if (demoOpen !== open) setMoves(n => n + 1); + show(prev => { + const next = demoOpen ? (view ?? prev?.view) : undefined; + // unchanged: the playground's children don't re-render + return prev?.open === demoOpen && prev.view === next ? + prev + : { open: demoOpen, view: next }; + }); + }, + [open], + ); // the reader picked a view: the demo's goes, its open stays const drop = useCallback( () => show(prev => (prev?.view ? { open: prev.open } : prev)), @@ -109,30 +106,45 @@ export function useStoreMotion( useEffect(() => { if (!container || !moving) return; let frame = 0; - const check = () => { - // shown once open, hidden once shut (none before the preview loads) - const panel = container.querySelector(`.${styles.storePanel}`); - const resting = !panel || open === panel.hasAttribute('data-visible'); - // the Store's own motion, not the app's or what the Store shows (a - // row's flash as data arrives), which would hold the playground moving - const app = container.querySelector(`.${styles.playgroundPreview}`); - const unrelated = (target: Element) => - app?.contains(target) || (target !== panel && panel?.contains(target)); - // (no Web Animations, e.g. jsdom: nothing runs) - const running = container - .getAnimations?.({ subtree: true }) - .some( - a => - a.playState === 'running' && - a.effect?.getTiming().iterations !== Infinity && - !unrelated((a.effect as KeyframeEffect).target!), - ); - if (resting && !running) setRested(moves); - else frame = requestAnimationFrame(check); + let cancelled = false; + const nextFrame = () => + new Promise(resolve => (frame = requestAnimationFrame(resolve))); + const settle = async () => { + // what this move started is running by the next frame + await nextFrame(); + while (!cancelled) { + // shown once open, hidden once shut (none before the preview loads) + const panel = container.querySelector(`.${styles.storePanel}`); + // the Store's own motion, not the app's or what the Store shows (a + // row's flash as data arrives), which would hold the playground moving + const app = container.querySelector(`.${styles.playgroundPreview}`); + const related = (target: Element) => + !app?.contains(target) && + (target === panel || !panel?.contains(target)); + // (no Web Animations, e.g. jsdom: nothing runs) + const running = (container.getAnimations?.({ subtree: true }) ?? []) + .filter( + a => + a.playState === 'running' && + a.effect?.getTiming().iterations !== Infinity && + related((a.effect as KeyframeEffect).target!), + ) + .map(a => a.finished); + const resting = !panel || open === panel.hasAttribute('data-visible'); + if (resting && !running.length) { + setRested(moves); + return; + } + // then check again: a retargeted motion, or the panel shown or hidden + await Promise.allSettled(running); + await nextFrame(); + } + }; + settle(); + return () => { + cancelled = true; + cancelAnimationFrame(frame); }; - // what this move started is running by the next frame - frame = requestAnimationFrame(check); - return () => cancelAnimationFrame(frame); }, [container, moving, moves, open]); return moving; } diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 09e1978b288c..622385b3f1a1 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1605,8 +1605,11 @@ div.fixtureJson + div.fixtureJson { .row > .playgroundTextEdit { grid-area: 1 / 1 / 2 / 2; } + /* the fixtures hang on past its foot, over the page below (it stacks + above that while they show), so the preview never moves */ .row > .previewWrapper { grid-area: 2 / 1 / 3 / 2; + overflow: visible; } /* the layer is the code's rows; the gap below it stays in view, and nothing past that (the drawer parked behind the folder) */ @@ -1709,11 +1712,6 @@ div.fixtureJson + div.fixtureJson { bottom: -1px; --pg-fillet-at: 100% 0; } - /* the fixtures hang on past the preview's foot, over the page below - (it stacks above that while they show), so the preview never moves */ - .row > .previewWrapper { - overflow: visible; - } } /* Demo layout: the preview first, at the playground's full width, with room From 9d6622b1c76598d8f19725a6119e0bf765cbb8de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:27:16 +0000 Subject: [PATCH 55/66] docs: Read the Store's current state in a demo's showStore A demo's run holds the showStore it started with, so closing over `open` compared each step to the state at the start: after the demo opened the Store, its close looked like no change and the Store snapped shut. Read the latest state through a ref again. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/StoreToggle.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/website/src/components/Playground/preview/StoreToggle.tsx b/website/src/components/Playground/preview/StoreToggle.tsx index f7fa8b6bea87..b9758b8b4a56 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -8,7 +8,9 @@ import React, { createContext, useCallback, useEffect, + useLayoutEffect, useMemo, + useRef, useState, } from 'react'; @@ -52,6 +54,12 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { // (`useStoreMotion`); the stored choice arriving, or another Store in the // group closing this one, just lands const [moves, setMoves] = useState(0); + // a demo's run holds one showStore from its start (./autorun/stage.ts), so + // it reads the state as it is now + const latestOpen = useRef(open); + useLayoutEffect(() => { + latestOpen.current = open; + }); const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker(); const toggle = useCallback( @@ -66,20 +74,17 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { ); // a demo's open or close is no reader's click: forget the last one, so it // doesn't pin a toggle the reader clicked earlier - const showStore = useCallback( - (demoOpen, view) => { - clicked = undefined; - if (demoOpen !== open) setMoves(n => n + 1); - show(prev => { - const next = demoOpen ? (view ?? prev?.view) : undefined; - // unchanged: the playground's children don't re-render - return prev?.open === demoOpen && prev.view === next ? - prev - : { open: demoOpen, view: next }; - }); - }, - [open], - ); + const showStore = useCallback((demoOpen, view) => { + clicked = undefined; + if (demoOpen !== latestOpen.current) setMoves(n => n + 1); + show(prev => { + const next = demoOpen ? (view ?? prev?.view) : undefined; + // unchanged: the playground's children don't re-render + return prev?.open === demoOpen && prev.view === next ? + prev + : { open: demoOpen, view: next }; + }); + }, []); // the reader picked a view: the demo's goes, its open stays const drop = useCallback( () => show(prev => (prev?.view ? { open: prev.open } : prev)), From 39e3ccf20f9c9318d8fd0f4b2460cc43995d007f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:34:32 +0000 Subject: [PATCH 56/66] docs: Join the Store's handle to its paper without a seam The open Store's header cast its shadow up over the handle rising from it, so on a phone a darker band split the handle from the header. The handle now sits above that shadow, so the two read as one piece of paper. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/styles.module.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 622385b3f1a1..989fbbf5e442 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -758,9 +758,11 @@ div.playgroundTextEdit > .playgroundHeader, } /* The handle: a pull tab of the folder's paper on the drawer's edge, out past the preview's edge and, drawn out, the Store's; it curves into - that edge either side as the open file's tab does into its card */ + that edge either side as the open file's tab does into its card; over + that edge's shadow, so the two read as one paper */ .storeTab { position: absolute; + z-index: 1; display: grid; place-items: center; --pg-fillet-color: var(--pg-stage); From 464a09a2c2e9486f74f5948716988309c29b81ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:38:32 +0000 Subject: [PATCH 57/66] docs: Fill the preview with its loading page before the app sizes it In the side-by-side row layout the preview ends where its app's sheet does, measured once the page hydrates. Until then (and while the app loads), the preview ran to the code's foot with only a short loading sheet on it, so the preview's tinted backing showed below. The loading sheet now fills the preview, the same before and after hydration. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/preview/PendingSheet.tsx | 5 +++-- website/src/components/Playground/styles.module.css | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/website/src/components/Playground/preview/PendingSheet.tsx b/website/src/components/Playground/preview/PendingSheet.tsx index 15a5bbe9763b..ebdcf682f07d 100644 --- a/website/src/components/Playground/preview/PendingSheet.tsx +++ b/website/src/components/Playground/preview/PendingSheet.tsx @@ -4,10 +4,11 @@ import styles from '../styles.module.css'; /** The app's sheet while it loads: the page's first paint, the preview's * code loading, and the app waiting on its data all draw this one loading - * page, so the preview goes from loading to loaded and nothing between */ + * page, so the preview goes from loading to loaded and nothing between. + * Busy, it fills the row layout's preview (styles.module.css) */ export default function PendingSheet() { return ( -
+
); diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 989fbbf5e442..4c5a78817c5e 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -705,6 +705,12 @@ div.playgroundTextEdit > .playgroundHeader, flex: 1 1 auto; min-width: 0; } +/* while the app loads, its sheet runs to the folder's foot: where the app's + would end is unknown until it renders (and --pg-preview-gap until the + page hydrates), so the folder never shows under the loading page */ +.row .playgroundResult:has(.sheet[aria-busy]) { + flex: 1 1 auto; +} .row > .previewWrapper:has(.sheet:not(:empty))::after { background: var(--pg-panel); } From 95f7836a54e711bd2fa5ef819227ccdfac550845 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:39:28 +0000 Subject: [PATCH 58/66] docs: Test a demo's held showStore across two Store moves Guards the stale-state regression Bugbot and Staff caught: the test fails when showStore compares against the open state it closed over. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../preview/__tests__/StoreMotion.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx b/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx index 4ea68f4eb435..432bf0a2b987 100644 --- a/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx +++ b/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx @@ -65,3 +65,16 @@ it('moves only for the reader or a demo', async () => { expect(button().textContent).toBe('open'); expect(moving()).toBe(true); }); + +it("moves for each of a demo's steps through the showStore it started with", async () => { + render(); + // a demo's run holds the showStore it started with (../autorun/stage.ts) + const held = showStore; + act(() => held(true)); + expect(moving()).toBe(true); + await act(() => new Promise(requestAnimationFrame)); + expect(moving()).toBe(false); + act(() => held(false)); + expect(button().textContent).toBe('shut'); + expect(moving()).toBe(true); +}); From 4c342cde6339ad08d41ead0f766e95172bf6c536 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:01:06 +0000 Subject: [PATCH 59/66] fix(website): Loading folder ends at its sheet's foot before hydration The row's loading page is one height, so the folder's end (and the Store tab beside it) is known before the page hydrates and measures it, instead of the loading sheet stretching to the full row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/PendingSheet.tsx | 3 +-- .../components/Playground/styles.module.css | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/website/src/components/Playground/preview/PendingSheet.tsx b/website/src/components/Playground/preview/PendingSheet.tsx index ebdcf682f07d..115f78f6717e 100644 --- a/website/src/components/Playground/preview/PendingSheet.tsx +++ b/website/src/components/Playground/preview/PendingSheet.tsx @@ -4,8 +4,7 @@ import styles from '../styles.module.css'; /** The app's sheet while it loads: the page's first paint, the preview's * code loading, and the app waiting on its data all draw this one loading - * page, so the preview goes from loading to loaded and nothing between. - * Busy, it fills the row layout's preview (styles.module.css) */ + * page, so the preview goes from loading to loaded and nothing between */ export default function PendingSheet() { return (
diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 4c5a78817c5e..5b0fefc5ef4b 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -471,6 +471,8 @@ div.playgroundTextEdit > .playgroundHeader, /* the handle: how far it sticks out, and its length along the edge */ --pg-tab-width: 2.5rem; --pg-tab-length: 3rem; + /* the row's loading page: its skeleton title and paragraph */ + --pg-pending-sheet: 8.5rem; /* how far the code card and the app's sheet reach toward the other's foot */ --pg-card-foot: 6rem; /* the folder's header is narrower than its content: inset by this at @@ -705,12 +707,6 @@ div.playgroundTextEdit > .playgroundHeader, flex: 1 1 auto; min-width: 0; } -/* while the app loads, its sheet runs to the folder's foot: where the app's - would end is unknown until it renders (and --pg-preview-gap until the - page hydrates), so the folder never shows under the loading page */ -.row .playgroundResult:has(.sheet[aria-busy]) { - flex: 1 1 auto; -} .row > .previewWrapper:has(.sheet:not(:empty))::after { background: var(--pg-panel); } @@ -1408,6 +1404,17 @@ div.fixtureJson + div.fixtureJson { below that (./preview/PreviewWrapper.tsx) */ max-height: calc(100% - var(--pg-preview-gap, 0px)); } + /* the loading page is one height, so its folder's end is known before + the page hydrates and measures it (the same, measured) */ + .row:has(.sheet[aria-busy]) { + --pg-preview-gap: max( + 0px, + 100% - var(--pg-row) - var(--pg-pending-sheet) - var(--pg-card-foot) + ); + } + .row .sheet[aria-busy] { + height: var(--pg-pending-sheet); + } /* The content's shadow, from boxes that run out over the Store with the drawer, so the open Store and the preview are one paper with one shadow (the headers cast their own): the preview's content, edge to From 309c86af87788381226dcb485cd23b42060aa9c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:01:51 +0000 Subject: [PATCH 60/66] docs(website): Tie the loading folder's derived gap to its measurement Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../src/components/Playground/preview/PreviewWrapper.tsx | 4 +++- website/src/components/Playground/styles.module.css | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index 53171355a507..098e416ece1a 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -93,7 +93,9 @@ export default function PreviewWrapper({ /** Row layout: how far the row runs on below the app's sheet with its foot * (the wrapper's ::after) at full reach, as `--pg-preview-gap` on the row: * the preview's folder ends there, and the open Store runs on past it. - * Measured against the row, which that end never moves */ + * Measured against the row, which that end never moves. While the loading + * page shows, styles.module.css derives the same from its fixed height, so + * a server-rendered page has it before it hydrates */ function usePreviewGap(result: HTMLElement | null) { useLayoutEffect(() => { const wrapper = result?.parentElement; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 5b0fefc5ef4b..40e6e3441c1d 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -471,7 +471,7 @@ div.playgroundTextEdit > .playgroundHeader, /* the handle: how far it sticks out, and its length along the edge */ --pg-tab-width: 2.5rem; --pg-tab-length: 3rem; - /* the row's loading page: its skeleton title and paragraph */ + /* the height of the row's loading page (a skeleton title and paragraph) */ --pg-pending-sheet: 8.5rem; /* how far the code card and the app's sheet reach toward the other's foot */ --pg-card-foot: 6rem; @@ -1404,8 +1404,8 @@ div.fixtureJson + div.fixtureJson { below that (./preview/PreviewWrapper.tsx) */ max-height: calc(100% - var(--pg-preview-gap, 0px)); } - /* the loading page is one height, so its folder's end is known before - the page hydrates and measures it (the same, measured) */ + /* the loading page is one height, so where its folder ends is known + before the page hydrates: what PreviewWrapper.tsx then measures */ .row:has(.sheet[aria-busy]) { --pg-preview-gap: max( 0px, From 01c99b054977fbb0c535a9ae8d8d8e834b4f5305 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:04:10 +0000 Subject: [PATCH 61/66] fix(website): Loading folder's end comes from tokens alone While the loading page shows, the stylesheet's --pg-preview-gap wins over the measured one, so a change to the measurement can't make the folder move when the page hydrates. The skeleton page notes the fixed sheet it must fit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/DesignSystem/base.css | 3 ++- .../src/components/Playground/preview/PreviewWrapper.tsx | 4 ++-- website/src/components/Playground/styles.module.css | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/website/src/components/Playground/DesignSystem/base.css b/website/src/components/Playground/DesignSystem/base.css index 728146c1d73d..2a4d5d11d447 100644 --- a/website/src/components/Playground/DesignSystem/base.css +++ b/website/src/components/Playground/DesignSystem/base.css @@ -121,7 +121,8 @@ width: 3.5em; } /* a page: its title at the h2's size, then the paragraph's lines, the last - one short like a paragraph's */ + one short like a paragraph's. The side-by-side playground's loading sheet + is fixed at --pg-pending-sheet (../styles.module.css) to fit it */ .playground-preview .skeletonPage > .skeleton { display: block; width: 100%; diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index 098e416ece1a..35db4f249286 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -94,8 +94,8 @@ export default function PreviewWrapper({ * (the wrapper's ::after) at full reach, as `--pg-preview-gap` on the row: * the preview's folder ends there, and the open Store runs on past it. * Measured against the row, which that end never moves. While the loading - * page shows, styles.module.css derives the same from its fixed height, so - * a server-rendered page has it before it hydrates */ + * page shows, styles.module.css sets it from tokens instead, so the page + * has it before it hydrates */ function usePreviewGap(result: HTMLElement | null) { useLayoutEffect(() => { const wrapper = result?.parentElement; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index 40e6e3441c1d..cca99756b055 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1404,13 +1404,14 @@ div.fixtureJson + div.fixtureJson { below that (./preview/PreviewWrapper.tsx) */ max-height: calc(100% - var(--pg-preview-gap, 0px)); } - /* the loading page is one height, so where its folder ends is known - before the page hydrates: what PreviewWrapper.tsx then measures */ + /* the loading page is one height, so where its folder ends follows from + the tokens alone: the same before the page hydrates as after, over + what PreviewWrapper.tsx measures (inline, so this must be important) */ .row:has(.sheet[aria-busy]) { --pg-preview-gap: max( 0px, 100% - var(--pg-row) - var(--pg-pending-sheet) - var(--pg-card-foot) - ); + ) !important; } .row .sheet[aria-busy] { height: var(--pg-pending-sheet); From 0eab7336c926dac06e7eb4c2754745a9e3abd32d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:09:17 +0000 Subject: [PATCH 62/66] fix(website): Measured gap takes over from the loading page's again The loading page's derived --pg-preview-gap is in row heights (100%), which the Store's rules resolve against their own boxes; marked important, it outlived hydration and misdrew an open Store's paper while the app loaded. It now applies only until the page measures the same value. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../src/components/Playground/preview/PreviewWrapper.tsx | 5 ++--- website/src/components/Playground/styles.module.css | 9 +++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index 35db4f249286..53f1dc0dbd6f 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -93,9 +93,8 @@ export default function PreviewWrapper({ /** Row layout: how far the row runs on below the app's sheet with its foot * (the wrapper's ::after) at full reach, as `--pg-preview-gap` on the row: * the preview's folder ends there, and the open Store runs on past it. - * Measured against the row, which that end never moves. While the loading - * page shows, styles.module.css sets it from tokens instead, so the page - * has it before it hydrates */ + * Measured against the row, which that end never moves. Before the page + * hydrates, styles.module.css derives it for the loading page */ function usePreviewGap(result: HTMLElement | null) { useLayoutEffect(() => { const wrapper = result?.parentElement; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index cca99756b055..e4a00f508a0a 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -1404,14 +1404,15 @@ div.fixtureJson + div.fixtureJson { below that (./preview/PreviewWrapper.tsx) */ max-height: calc(100% - var(--pg-preview-gap, 0px)); } - /* the loading page is one height, so where its folder ends follows from - the tokens alone: the same before the page hydrates as after, over - what PreviewWrapper.tsx measures (inline, so this must be important) */ + /* the loading page is one height, so where its folder ends is known + before the page hydrates and PreviewWrapper.tsx measures the same; in + row heights (100%), so only the folder's own rules may read it before + then, never the Store's (it shows only once hydrated) */ .row:has(.sheet[aria-busy]) { --pg-preview-gap: max( 0px, 100% - var(--pg-row) - var(--pg-pending-sheet) - var(--pg-card-foot) - ) !important; + ); } .row .sheet[aria-busy] { height: var(--pg-pending-sheet); From 8324c7e4431487f66e1288c7568b21d20319740f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:16:06 +0000 Subject: [PATCH 63/66] refactor(website): One owner for the Playground's shared shapes and tints The Store's lines and selection tints mix from the text and key colors they matched by hand; the sheet's corners, a tab's shape and the Store header's run past the fold each live in one token. Same computed values. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/preview/store/store.module.css | 7 +-- .../components/Playground/styles.module.css | 44 ++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css index b29a059449f0..7472d5558c4a 100644 --- a/website/src/components/Playground/preview/store/store.module.css +++ b/website/src/components/Playground/preview/store/store.module.css @@ -145,7 +145,8 @@ min-height: 0; display: flex; flex-direction: column; - border-radius: 12px 12px var(--store-sheet-foot, 0) var(--store-sheet-foot, 0); + border-radius: var(--pg-sheet-radius) var(--pg-sheet-radius) + var(--store-sheet-foot, 0) var(--store-sheet-foot, 0); background: var(--store-bg); overflow: clip; } @@ -184,9 +185,9 @@ .tab { display: inline-flex; align-items: center; - padding: 0 0.85rem; + padding: var(--pg-tab-pad); border: 0; - border-radius: 8px 8px 0 0; + border-radius: var(--pg-tab-radius); background: none; color: inherit; font: inherit; diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index e4a00f508a0a..f25dd77e1506 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -20,6 +20,11 @@ takes when its own content sets the height */ --store-min: min(24rem, 70svh); --store-cap: min(32rem, 70svh); + /* the app's sheet's corners, which the Store's sheet shares */ + --pg-sheet-radius: 12px; + /* a tab rising from its card: the files', the Store's and the fixtures' */ + --pg-tab-radius: 8px 8px 0 0; + --pg-tab-pad: 0 0.85rem; /* The Store's palette, shared with the Fixtures panel (both code-colored in either theme); the light timeline re-declares it (store.module.css) */ --store-fg: rgb(191, 199, 213); @@ -396,8 +401,8 @@ div.playgroundTextEdit > .playgroundHeader, --store-fg: #d9e0ea; --store-muted: var(--pg-muted); --store-dim: #6f7c93; - --store-line: rgba(233, 238, 246, 0.08); - --store-line-strong: rgba(233, 238, 246, 0.15); + --store-line: color-mix(in srgb, var(--pg-text) 8%, transparent); + --store-line-strong: color-mix(in srgb, var(--pg-text) 15%, transparent); --store-hover: var(--pg-hover); --store-tip: var(--pg-panel-2); transition: --store-top var(--ifm-transition-fast) ease; @@ -417,10 +422,8 @@ div.playgroundTextEdit > .playgroundHeader, --store-type: #8a3fb8; --store-fresh: #1d8049; --store-stale: #a26600; - --store-line: rgba(11, 18, 32, 0.08); - --store-line-strong: rgba(11, 18, 32, 0.15); - --store-selected: rgba(47, 95, 196, 0.14); - --store-record: rgba(47, 95, 196, 0.06); + --store-selected: color-mix(in srgb, var(--store-key) 14%, transparent); + --store-record: color-mix(in srgb, var(--store-key) 6%, transparent); --store-tip: #fff; } /* beside the result, as tall as it when that is past the Store's own cap, @@ -529,8 +532,8 @@ div.playgroundTextEdit > .playgroundHeader, gap: 0.5em; white-space: nowrap; height: 100%; - padding: 0 0.85rem; - border-radius: 8px 8px 0 0; + padding: var(--pg-tab-pad); + border-radius: var(--pg-tab-radius); color: var(--ifm-color-emphasis-800); box-shadow: none; /* its paper (background and curves) comes and goes at once, together */ @@ -998,7 +1001,7 @@ div.playgroundTextEdit > .playgroundHeader, .row .previewHeader:has(+ .fixturesAnchor > .fixturesPopover:hover) .headerButton.fixturesToggle { - border-radius: 8px 8px 0 0; + border-radius: var(--pg-tab-radius); background: var(--fixtures-tab); color: var(--pg-tab-selected); } @@ -1010,7 +1013,7 @@ div.playgroundTextEdit > .playgroundHeader, } @media (hover: hover) { .row .headerButton.fixturesToggle:hover { - border-radius: 8px 8px 0 0; + border-radius: var(--pg-tab-radius); background: var(--fixtures-tab); color: var(--pg-tab-selected); } @@ -1059,7 +1062,7 @@ div.playgroundTextEdit > .playgroundHeader, .sheet { flex: 1 0 auto; padding: var(--pg-sheet-pad); - border-radius: 12px 12px 0 0; + border-radius: var(--pg-sheet-radius) var(--pg-sheet-radius) 0 0; background: var(--pg-panel); /* the progress bar's corners stay inside the radius */ overflow: clip; @@ -1376,6 +1379,11 @@ div.fixtureJson + div.fixtureJson { --pg-preview-col: max(16rem, calc((100cqw - var(--pg-gap)) * 0.35)); /* the preview's edge, where the drawer parks */ --pg-fold: calc(100cqw - var(--pg-preview-col)); + /* how far the Store's header runs on past the fold: over the preview's + inset and rounded corner, and a pixel into the preview's header + (painted boxes snap to pixels and the clip doesn't, so short of it + its shadow shows as a hairline) */ + --pg-head-run: calc(var(--pg-head-inset) + var(--pg-card-radius) + 1px); display: grid; grid-template-columns: minmax(0, 1fr) var(--pg-preview-col); /* the tabs' row is the preview header's row */ @@ -1518,9 +1526,7 @@ div.fixtureJson + div.fixtureJson { inset and lands on the preview's, the same shape */ .row > .storeHost { grid-area: 1 / 1 / 3 / 3; - --pg-fold-head: calc( - var(--pg-fold) + var(--pg-head-inset) + var(--pg-card-radius) + 1px - ); + --pg-fold-head: calc(var(--pg-fold) + var(--pg-head-run)); clip-path: polygon( -4rem -4rem, var(--pg-fold-head) -4rem, @@ -1551,11 +1557,7 @@ div.fixtureJson + div.fixtureJson { --store-folder-foot: var(--pg-card-radius); --store-folder-end: var(--pg-preview-gap, 0px); --store-body-pad: 0 var(--pg-gap) 0 0; - /* its header on past the fold over the preview's inset and rounded - corner, and a pixel into the preview's header: painted boxes snap to - pixels and the clip doesn't, so short of it its shadow shows as a - hairline */ - --store-head-run: calc(var(--pg-head-inset) + var(--pg-card-radius) + 1px); + --store-head-run: var(--pg-head-run); } /* sliding shut, what its header holds would pass that run, beside the preview's label: it fades as the drawer goes */ @@ -1649,11 +1651,11 @@ div.fixtureJson + div.fixtureJson { } /* its header edge to edge, rounded as the code card under it is: an inset would show the code through its sides; its sheet ends short of - the foot, rounded, as the code card does */ + the foot, rounded as its top is */ .row > .storeHost .storePanel { --store-head-start: 0; --store-head-radius: var(--pg-card-radius) var(--pg-card-radius) 0 0; - --store-sheet-foot: 12px; + --store-sheet-foot: var(--pg-sheet-radius); --store-body-pad: 0 0 var(--pg-gap); } /* its content's shadow (its header casts its own), from a box that stops From 9adbbe57274524fb85a4f9742ca098373ae30cff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:17:34 +0000 Subject: [PATCH 64/66] refactor(website): One owner for the demo design system's washes and shared sizes The accent wash and press strengths, a row's inset (which its remove button reaches into) and the h2's leading (which the loading title holds) are tokens, and the dark palette sits with the light one in base.css. A grouped button's hover now takes the dark wash (14%) like a plain one. Tests share the editor stand-in and the toast duration. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- .../Playground/DesignSystem/Toaster.tsx | 2 +- .../DesignSystem/__tests__/Toaster.test.tsx | 6 +-- .../Playground/DesignSystem/base.css | 31 ++++++++++++-- .../Playground/DesignSystem/design-system.css | 41 +++++++++---------- .../Playground/__tests__/demoLayout.test.tsx | 6 +-- .../__tests__/demoLayoutLive.test.tsx | 6 +-- .../Playground/__tests__/rowLayout.test.tsx | 6 +-- .../Playground/testing/playgroundEditor.tsx | 8 ++++ 8 files changed, 63 insertions(+), 43 deletions(-) create mode 100644 website/src/components/Playground/testing/playgroundEditor.tsx diff --git a/website/src/components/Playground/DesignSystem/Toaster.tsx b/website/src/components/Playground/DesignSystem/Toaster.tsx index cc0cbb7fa35f..54ff74e1bc4d 100644 --- a/website/src/components/Playground/DesignSystem/Toaster.tsx +++ b/website/src/components/Playground/DesignSystem/Toaster.tsx @@ -7,7 +7,7 @@ interface Toast { } /** How long a toast stays on screen */ -const SHOW_MS = 4000; +export const SHOW_MS = 4000; // Module state, like a toast library's: the preview's code re-runs on every // edit, but this module doesn't, so code kept from an earlier run (a Manager) diff --git a/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx b/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx index 48f4789417f5..469db310ab62 100644 --- a/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx +++ b/website/src/components/Playground/DesignSystem/__tests__/Toaster.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen } from '@testing-library/react'; import React from 'react'; -import { toast, Toaster } from '../Toaster'; +import { SHOW_MS, toast, Toaster } from '../Toaster'; describe('Toaster', () => { beforeEach(() => jest.useFakeTimers()); @@ -17,7 +17,7 @@ describe('Toaster', () => { expect(screen.getByText('Saved').dataset.kind).toBe('success'); expect(screen.getByText('Failed').dataset.kind).toBe('error'); - act(() => jest.advanceTimersByTime(4000)); + act(() => jest.advanceTimersByTime(SHOW_MS)); expect(screen.queryByText('Saved')).toBeNull(); expect(screen.queryByText('Failed')).toBeNull(); }); @@ -26,6 +26,6 @@ describe('Toaster', () => { toast.success('Early'); render(); expect(screen.queryByText('Early')).not.toBeNull(); - act(() => jest.advanceTimersByTime(4000)); + act(() => jest.advanceTimersByTime(SHOW_MS)); }); }); diff --git a/website/src/components/Playground/DesignSystem/base.css b/website/src/components/Playground/DesignSystem/base.css index 2a4d5d11d447..915975e07554 100644 --- a/website/src/components/Playground/DesignSystem/base.css +++ b/website/src/components/Playground/DesignSystem/base.css @@ -18,6 +18,8 @@ --ds-radius: 12px; --ds-radius-sm: 8px; --ds-radius-xs: 4px; + /* a row's inset above and below (a row's button reaches into it) */ + --ds-row-pad: var(--ds-space-2); /* a button's or a field's inset */ --ds-control-pad-y: 0.375rem; --ds-control-pad-x: 0.875rem; @@ -41,8 +43,15 @@ --ds-border-strong: #78889d; --ds-accent: #1b6fb8; --ds-accent-strong: #155b99; - /* the accent's wash: a highlighted row, a changed value, a focus ring */ - --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 10%, transparent); + /* the accent's wash: a highlighted row, a changed value, a focus ring, + an accent button under the pointer; pressed, it deepens */ + --ds-accent-wash: 10%; + --ds-accent-press: 18%; + --ds-accent-soft: color-mix( + in srgb, + var(--ds-accent) var(--ds-accent-wash), + transparent + ); --ds-on-accent: #fff; /* Infima's own vars, re-pointed so what reads them (the rt-* fields, links, the error panel's text) follows this palette in both themes */ @@ -60,6 +69,8 @@ /* Infima's headings read these (h1 is set below: theme-classic sizes a first-child h1 as a page title) */ --ifm-h2-font-size: 1.25rem; + /* the h2's leading, which the loading page's title holds too */ + --ds-h2-leading: 1.3; --ifm-h3-font-size: var(--ds-text-lg); --ifm-h4-font-size: var(--ds-text-base); --ifm-heading-font-weight: 600; @@ -69,6 +80,20 @@ line-height: 1.5; color: var(--ds-text); } +/* Dark: the same console at night (the panel, well, line and text follow + --pg-*); what's left is the design system's own */ +html[data-theme='dark'] .playground-preview { + /* 4.6:1 on the sheet */ + --ds-faint: #78869d; + /* 3.4:1 on the sheet, 3.1:1 on a well */ + --ds-border-strong: #607189; + --ds-accent: #53aef0; + --ds-accent-strong: #7cc1f4; + --ds-accent-wash: 14%; + --ds-on-accent: #04121f; + /* fields sit in the well, a step above the panel */ + --ifm-color-emphasis-0: var(--ds-surface-2); +} /* Skeletons: what is loading, drawn where it will land in the shape it will take, so nothing moves when it arrives. A `skeleton` is one line of @@ -131,7 +156,7 @@ width: 55%; margin-bottom: var(--ds-space-3); font-size: var(--ifm-h2-font-size); - line-height: 1.3; + line-height: var(--ds-h2-leading); } .playground-preview .skeletonPage > .skeleton:last-child { width: 62%; diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 1bf81a64bad6..9995f15f6359 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -13,20 +13,6 @@ by a muted color; one blue serves links, primary actions and focus. Only form controls keep an edge (it is what makes them controls). The tokens and the skeletons are in ./base.css, which loads with the Playground. */ -/* Dark: the same console at night (the panel, well, line and text follow - --pg-* above); what's left is the design system's own */ -html[data-theme='dark'] .playground-preview { - /* 4.6:1 on the sheet */ - --ds-faint: #78869d; - /* 3.4:1 on the sheet, 3.1:1 on a well */ - --ds-border-strong: #607189; - --ds-accent: #53aef0; - --ds-accent-strong: #7cc1f4; - --ds-accent-soft: color-mix(in srgb, var(--ds-accent) 14%, transparent); - --ds-on-accent: #04121f; - /* fields sit in the well, a step above the panel */ - --ifm-color-emphasis-0: var(--ds-surface-2); -} /* Text */ .playground-preview h1, .playground-preview h2, @@ -36,7 +22,7 @@ html[data-theme='dark'] .playground-preview { /* A page's title (a post's): a step up from the body, with the byline close under it */ .playground-preview h2 { - line-height: 1.3; + line-height: var(--ds-h2-leading); margin-bottom: 2px; } /* an app's h1 is no page title: outranks theme-classic's @@ -186,7 +172,7 @@ div.playground-preview h1:first-child { display: flex; align-items: flex-start; gap: var(--ds-space-3); - padding: 8px 0; + padding: var(--ds-row-pad) 0; } .playground-preview .listItem > * { min-width: 0; @@ -264,7 +250,11 @@ div.playground-preview h1:first-child { color: var(--ds-accent); } .playground-preview button:where(:not([class])):active { - background: color-mix(in srgb, var(--ds-accent) 18%, var(--ds-surface)); + background: color-mix( + in srgb, + var(--ds-accent) var(--ds-accent-press), + var(--ds-surface) + ); } .playground-preview button:where(:not([class]), .primary):focus-visible { outline: 2px solid var(--ds-accent); @@ -519,12 +509,20 @@ div.playground-preview h1:first-child { .playground-preview .buttonGroup > button:not([class], [aria-busy='true']):hover { - background: color-mix(in srgb, var(--ds-accent) 10%, var(--ds-surface)); + background: color-mix( + in srgb, + var(--ds-accent) var(--ds-accent-wash), + var(--ds-surface) + ); } .playground-preview .buttonGroup > button:not([class], [aria-busy='true']):active { - background: color-mix(in srgb, var(--ds-accent) 18%, var(--ds-surface)); + background: color-mix( + in srgb, + var(--ds-accent) var(--ds-accent-press), + var(--ds-surface) + ); } /* a busy segment's light runs inside the group's edge (outranking the segment's border: 0) */ @@ -553,8 +551,9 @@ div.playground-preview h1:first-child { 32px hit area reaches into the row's inset, so the row keeps its height */ .playground-preview button.cancel { flex: none; - margin: -8px -8px -8px auto; - padding: 8px; + margin: calc(-1 * var(--ds-row-pad)) calc(-1 * var(--ds-row-pad)) + calc(-1 * var(--ds-row-pad)) auto; + padding: var(--ds-row-pad); border: 0; border-radius: var(--ds-radius-sm); background: none; diff --git a/website/src/components/Playground/__tests__/demoLayout.test.tsx b/website/src/components/Playground/__tests__/demoLayout.test.tsx index 488e2187f1a9..0d501590161e 100644 --- a/website/src/components/Playground/__tests__/demoLayout.test.tsx +++ b/website/src/components/Playground/__tests__/demoLayout.test.tsx @@ -11,11 +11,7 @@ import Playground from '..'; // a crawler: the live preview never loads, so the preview stays its loading // frame (whose Code toggle already works), and no Monaco jest.mock('../userAgent', () => ({ isBot: true, isMobileOrBot: () => true })); -jest.mock('../PlaygroundEditor', () => ({ - __esModule: true, - default: ({ code }: { code: string }) => - require('react').createElement('textarea', { defaultValue: code }), -})); +jest.mock('../PlaygroundEditor', () => require('../testing/playgroundEditor')); // one Prism token each, so the HTML and the DOM's text can be searched for them const LIST = diff --git a/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx index 4c45d75fd328..8262fdb8ef14 100644 --- a/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx +++ b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx @@ -9,11 +9,7 @@ import Playground from '..'; // a phone: the live preview loads (not a bot), with the react-live editor jest.mock('../userAgent', () => ({ isBot: false, isMobileOrBot: () => true })); -jest.mock('../PlaygroundEditor', () => ({ - __esModule: true, - default: ({ code }: { code: string }) => - require('react').createElement('textarea', { defaultValue: code }), -})); +jest.mock('../PlaygroundEditor', () => require('../testing/playgroundEditor')); // The real chunk pulls in the whole preview scope (ESM-only packages jest // can't load); what the demo layout needs of it is the header it renders // (PreviewWrapper with its Store toggle) and the toggleStore it is handed. diff --git a/website/src/components/Playground/__tests__/rowLayout.test.tsx b/website/src/components/Playground/__tests__/rowLayout.test.tsx index f2929ecf2cfd..bb2b5cd910d6 100644 --- a/website/src/components/Playground/__tests__/rowLayout.test.tsx +++ b/website/src/components/Playground/__tests__/rowLayout.test.tsx @@ -9,11 +9,7 @@ import Playground from '..'; // a phone: the live preview loads (not a bot), with the react-live editor jest.mock('../userAgent', () => ({ isBot: false, isMobileOrBot: () => true })); -jest.mock('../PlaygroundEditor', () => ({ - __esModule: true, - default: ({ code }: { code: string }) => - require('react').createElement('textarea', { defaultValue: code }), -})); +jest.mock('../PlaygroundEditor', () => require('../testing/playgroundEditor')); // The real chunk pulls in the whole preview scope (ESM-only packages jest // can't load); the folder and its handle are the Playground's own jest.mock('../preview/LivePreview', () => { diff --git a/website/src/components/Playground/testing/playgroundEditor.tsx b/website/src/components/Playground/testing/playgroundEditor.tsx new file mode 100644 index 000000000000..8cad618be794 --- /dev/null +++ b/website/src/components/Playground/testing/playgroundEditor.tsx @@ -0,0 +1,8 @@ +import { createElement } from 'react'; + +/** The code editor as a plain textarea holding the code, for tests that + * render a whole Playground: `jest.mock('../PlaygroundEditor', () => + * require('../testing/playgroundEditor'))` */ +export default function PlaygroundEditor({ code }: { code: string }) { + return createElement('textarea', { defaultValue: code }); +} From 8ad5c69f1366f26c792a4330d6a8b0e85b955779 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:20:01 +0000 Subject: [PATCH 65/66] refactor(website): Preview header controls and surface class have one owner The loading frame and the live preview render the same header controls and app surface class from PreviewWrapper, so the header can't jump when the preview loads because one copy changed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/index.tsx | 16 ++++----- .../Playground/preview/LivePreview.tsx | 18 +++++----- .../components/Playground/preview/Preview.tsx | 4 +-- .../Playground/preview/PreviewWrapper.tsx | 35 ++++++++++++++++++- 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx index f9bccd34e4fa..ee975fcd9f97 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -22,12 +22,13 @@ import type { AutoRunScript } from './preview/autorun/script'; import FixturePreview from './preview/FixturePreview'; import type LivePreviewType from './preview/LivePreview'; import PendingSheet from './preview/PendingSheet'; -import PreviewWrapper from './preview/PreviewWrapper'; -import ResetButton from './preview/ResetButton'; +import PreviewWrapper, { + PreviewHeaderControls, + previewSurfaceClassName, +} from './preview/PreviewWrapper'; import type { LogOptions } from './preview/store/actionLog'; import { DemoViewContext, - StoreHeaderToggle, StoreTab, StoreToggle, useStoreMotion, @@ -291,16 +292,11 @@ function PreviewLoading({ }) { return ( - {row ? null : } - - - } + headerControls={} fixtures={fixtureContent} code={code} > -
+
{row ? null : } diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 7836f581a3f2..f4edfb9789fb 100644 --- a/website/src/components/Playground/preview/LivePreview.tsx +++ b/website/src/components/Playground/preview/LivePreview.tsx @@ -5,16 +5,14 @@ import DemoLabel from './autorun/DemoLabel'; import type { AutoRunScript } from './autorun/script'; import { useAutoRun } from './autorun/useAutoRun'; import Preview from './Preview'; -import PreviewWrapper from './PreviewWrapper'; +import PreviewWrapper, { PreviewHeaderControls } from './PreviewWrapper'; import { useRenderCount } from './RenderCount'; -import ResetButton from './ResetButton'; import { routerTrailer } from './router'; import type { CodeDocument } from '../editor/codeModel'; import type { CodeControl, PreviewProps } from '../types'; import type { LogOptions } from './store/actionLog'; import SchemaRegistry from './store/schemaRegistry'; import type { ShowStore } from './store/storeView'; -import { StoreHeaderToggle } from './StoreToggle'; import { useCodeManagers } from './useCodeManagers'; import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion'; import { usePreviewReset } from './usePreviewReset'; @@ -98,13 +96,13 @@ export default function LivePreview({ fixtures={fixtureContent} code={codeControl} headerControls={ - <> - {badge} - {row ? null : ( - - )} - - + } > ({
diff --git a/website/src/components/Playground/preview/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index 53f1dc0dbd6f..15b0c1f54865 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -7,8 +7,9 @@ import { editorLabel, liveLabel } from '../liveLabel'; import NetworkIcon from '../NetworkIcon'; import styles from '../styles.module.css'; import type { CodeControl } from '../types'; +import ResetButton from './ResetButton'; import { RouterControlsSlot } from './routerSlot'; -import { CodeHeaderToggle } from './StoreToggle'; +import { CodeHeaderToggle, StoreHeaderToggle } from './StoreToggle'; export default function PreviewWrapper({ children, @@ -90,6 +91,38 @@ export default function PreviewWrapper({
); } +/** The app's surface in the frame: also the design system's scope + * (../DesignSystem/base.css), so the loading page is styled as the app is */ +export const previewSurfaceClassName = `playground-preview ${styles.playgroundPreview}`; + +/** The header's controls, live (./LivePreview.tsx) or held by the loading + * frame (../index.tsx) so the header doesn't move as the preview loads: + * without handlers each holds its place, disabled. Row layout has no Store + * toggle here (the folder's handle is it) */ +export function PreviewHeaderControls({ + row, + badge, + storeOpen, + toggleStore, + reset, +}: { + row: boolean; + badge?: React.ReactNode; + storeOpen?: boolean; + toggleStore?: React.MouseEventHandler; + reset?: () => void; +}) { + return ( + <> + {badge} + {row ? null : ( + + )} + + + ); +} + /** Row layout: how far the row runs on below the app's sheet with its foot * (the wrapper's ::after) at full reach, as `--pg-preview-gap` on the row: * the preview's folder ends there, and the open Store runs on past it. From a09daf9fd4c2cef5aa886521cad0479418b646a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:20:37 +0000 Subject: [PATCH 66/66] docs(website): Tighten the Playground README and check it against the code Routes a task to its files instead of listing them, keeps the invariants and gotchas, fixes stale claims (design-system scope globals, a reset's start page, ssr as an element prop), adds the loading invariant and the checks CI runs. 433 to 329 lines. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018FerSYMdRyzxaKQdv64mGf --- website/src/components/Playground/README.md | 672 +++++++++----------- 1 file changed, 284 insertions(+), 388 deletions(-) diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 9d4aa6ef64c2..7bb990d4ff91 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -1,353 +1,262 @@ # Playground -Live, editable code examples for the docs, blog and homepage. Read this before -changing anything here: most of the complexity exists to keep pages fast, -indexable and hydration-safe, and those behaviors are easy to drop silently. - -## Entry points - -| Component | Used by | What it renders | -| ------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------- | -| `HooksPlayground` (`../HooksPlayground.tsx`) → `Playground` (`index.tsx`) | MDX docs/blog, homepage `Demo` | Editor + live preview + Store inspector | -| `TypeScriptEditor` (`../TypeScriptEditor.tsx`) | MDX | Editor only (`variant="standalone"`) | -| `EndpointPlayground` (`../HTTP/EndpointPlayground.tsx`) | MDX | Editor + static HTTP request/response | -| `DiffEditor` (`../DiffEditor.tsx` → `../DiffEditorChooser.tsx` → `../DiffEditorMonaco.tsx`) | MDX | Read-only Monaco diff of two fences | -| `Demo/CodeEditor` (`../Demo/CodeEditor.tsx`) | Homepage | One `HooksPlayground` per protocol tab | - -## Layout - -``` -index.tsx Playground: code model, editor/preview ordering (row, stacked, demo), hidden handling -userAgent.ts isBot / isMobileOrBot gates -PlaygroundEditor.tsx editor entry used by EditorSurface (aliases editor/InteractiveEditor) -PlaygroundLiveEditor.tsx editable react-live editor (mobile/bot fallback) -resources/ sample resources (currently unreferenced; kept for future demos) -Boundary.tsx BrowserOnly + Suspense with one fallback for SSR and loading -Header.tsx, TabList.tsx, styles.module.css shared chrome (also used by HTTP/, Demo/) -liveLabel.ts "Live Preview" label text (PreviewWrapper, autorun/DemoLabel) -editor/ code model + editor UI - codeModel.ts parse MDX fences/strings into documents; edits - EditorSurface.tsx headers, file tabs, collapse state, per-file TextEditTab - StaticEditor.tsx SSR/crawler/loading markup (Prism, not editable) - InteractiveEditor.tsx client-only: Monaco, or editable react-live on mobile/bots - declarations.ts top-level names each document declares (managers' reload, Store links) - openCode.ts CodeOpenerContext: what the Store can open in the code -monaco/ everything Monaco-specific - setup.ts side-effect init: CDN loader, hints, compiler options, theme, types - typeLibs.ts raw .d.ts chunks from editor-types/ → addExtraLib - navigation.ts import completions - useDefinitionOpener.ts cross-tab go-to-definition, and `reveal` for Store links, into this editor - modelPath.ts `//` model URI scheme - models.ts creates a surface's models together so cross-tab imports type-check - preloadManifest.ts GENERATED by website/scripts/generateMonacoPreloads.cjs - resourceHints.ts, theme.ts, options.ts, language.ts, useAutoHeight.ts, highlightSelections.ts -preview/ live execution (loaded lazily, never on the server) - LivePreview.tsx react-live LiveProvider with scope + transformCode - Preview.tsx DataProvider + MockResolver(fixtures) + Store inspector - StoreInspector.tsx the Store on the folder (row) or beside the result - StoreToggle.tsx Store toggles (the folder's handle, header icon, side strip) + open state - store/ Store inspector: StorePanel owns navigation (nav.tsx), actionLog.ts - logs dispatches, actionGroups.ts groups them, attribution.ts - says which manager sent or handled each (Pipeline.tsx, ManagerName.tsx) - autorun/ the `autoRun` demo player (see Auto-run) - ... -editor-types/ .d.ts bundles fed to Monaco (raw-loader) -DesignSystem/ demo styling (design-system.css) and components injected into - preview scope (see Design system) -``` +Live, editable code examples for the docs, blog and homepage. Most of the +complexity here keeps pages fast, indexable and hydration-safe, and each of +those breaks silently: nothing fails in dev when the Store ships in every +docs page chunk or a crawler gets an empty frame. Read this before changing +code under this directory, then run the checks under Verify. + +## Where to change what + +`../HooksPlayground.tsx` (MDX, and the homepage's `../Demo/CodeEditor.tsx`) +renders `index.tsx`. `../TypeScriptEditor.tsx` and +`../HTTP/EndpointPlayground.tsx` reuse `editor/EditorSurface.tsx` +(`variant="standalone"`); `../DiffEditor.tsx` reuses `editor/codeModel.ts` +and `editor/callouts.ts`. + +| Task | Where | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Props, layouts, Store placement, hidden handling | `index.tsx` (`PlaygroundProps` documents each prop) | +| Fence metastring, file list | `editor/codeModel.ts` | +| Editor chrome: tabs, headers, collapse, SSR markup | `editor/EditorSurface.tsx`, `editor/StaticEditor.tsx`, `editor/InteractiveEditor.tsx` | +| Monaco: types, completions, go-to-definition, theme | `monaco/`; `editor-types/` and `monaco/preloadManifest.ts` are generated (see JS delivery) | +| Running the code, scope globals | `preview/LivePreview.tsx`, `preview/useCodeManagers.ts`, `preview/transformCode.ts`, `preview/scope.ts` | +| Preview frame, Store open state and motion | `preview/PreviewWrapper.tsx`, `preview/StoreToggle.tsx`, `preview/StoreInspector.tsx`, `styles.module.css` | +| Store inspector UI | `preview/store/`: `StorePanel.tsx` owns navigation (`nav.tsx`), `actionLog.ts` records, `actionGroups.ts` groups, `attribution.ts` names the manager behind each action | +| Managers the code declares | `preview/managers.ts`; [data flow](./managers-data-flow.png) (source `managers-data-flow.svg`) | +| Multi-page demos | `preview/router.tsx` | +| Demo markup's look | `DesignSystem/design-system.css` (tokens and skeletons in `base.css`) | +| Self-playing demos | `preview/autorun/` | +| Mobile and crawler gates | `userAgent.ts` | +| Docusaurus stubs for tests | `testing/` (mapped in the root `jest.config.js`) | + +`resources/` is referenced by nothing. ## Invariants -### SSR / SSG and crawlers +### Hydration and crawlers - Open files' source is in the static HTML: `TextEditTab` renders - `StaticEditor` on the server and during hydration (BrowserOnly fallback). - Collapsed/unselected files render nothing until opened, to keep the HTML - small, unless the document sets `ssr`: then its plain text is in the HTML, - hidden by CSS, for crawlers. -- The preview renders `PreviewLoading` (empty frame + Store toggle) on the - server, while loading, for bots, and while `hidden`. -- Fixtures render server-side as JSON `CodeBlock`s; function sources and - titles wait for `useIsBrowser` (a stringified function differs between - server and client bundles). -- Never branch on `navigator`/user agent during render outside `BrowserOnly` - (hydration mismatch). `isBot` is module-level and only read in the - `lazy()` factory (and in StackBlitz after intersection); `isMobileOrBot()` - is only called inside BrowserOnly or in `monaco/setup.ts`. - -### Incremental JS delivery - -- `monaco/setup.ts` runs once when the editor chunk evaluates: injects - preload/prefetch ``s for the version-matched CDN files, starts - `loader.init()` and the type-lib chunk downloads in parallel. - `preloadManifest.ts` is generated from the installed `monaco-editor` on - install and every docusaurus config load, so it is gitignored and can't go - stale. -- Type libs: one webpack chunk per third-party `.d.ts` (`reactDTS`, …) and a - single `dataClientDTS` chunk holding exactly the `DATA_CLIENT_LIBS` entries - (check-only editor types such as `vue/test` stay out). Failed fetches - degrade to empty libs. -- Preview code is a lazy chunk (`PreviewWithScope`, prefetched) and - `PreviewBlock` a nested lazy chunk (preloaded with its parent). The Store - inspector (`preview/StoreInspector.tsx`, `preview/store/`) lives in it. - ESLint treats all of `preview/` as lazy: from outside it, only `import()`, - `import type`, or an eager file (`EAGER_PREVIEW` in `eslint.config.mjs`; - they render in SSR and loading states) may be imported, and those files - may not statically import the rest. - `website/scripts/check-lazy-chunks.mjs` (run by the `website` CI job) - backs this up on the build: the Store UI must ship only in `PreviewWithScope`. -- Docusaurus only shares modules used by half of all pages, so - `website/chunks-plugin.js` splits site source used by 2+ pages (the eagerly - loaded Playground modules among it) into shared chunks instead of a copy - per docs page. -- Mobile and bots download none of Monaco or the type libs. A `demo` - playground mounts its editor only once its code is first shown. + `StaticEditor` on the server and as the `BrowserOnly` fallback. Collapsed + files render nothing (HTML size), unless the element has `ssr` (an element + prop, not a metastring; Demo passes it): then their plain text is in the + HTML, hidden by CSS, for crawlers. +- The preview renders `PreviewLoading` (frame, loading page, Store toggle) on + the server, while loading, for bots and while `hidden`. +- Loading goes straight to loaded. The page's first paint, the chunk + loading and the app waiting on data all draw `preview/PendingSheet.tsx`, + and `PreviewHeaderControls` gives the loading and live headers the same + controls, so nothing moves in between. In `row`, the loading page has a + fixed height (`--pg-pending-sheet`), so CSS knows where the folder ends + before hydration; keep it equal to what `usePreviewGap` then measures. +- Never branch on `navigator` or the user agent during render outside + `BrowserOnly`: both gates are false on the server, so the markup would + mismatch. `isBot` is read only in the `lazy()` factory (and by StackBlitz + after intersection); `isMobileOrBot()` only inside `BrowserOnly` or in + `monaco/setup.ts`. +- Fixture function sources wait for `useIsBrowser`: a stringified function + differs between the server and browser bundles. + +### JS delivery + +- `preview/` is the lazy `PreviewWithScope` chunk (`PreviewBlock` nested in + it, preloaded with its parent). From outside it, import only with + `import()`, `import type`, or from a file listed in `EAGER_PREVIEW` + (`eslint.config.mjs`); those render in SSR and loading states and may not + statically import the rest of `preview/`. ESLint can't see MDX or a + `splitChunks` change, so `website/scripts/check-lazy-chunks.mjs` (the + `website` GitHub job, after the build) fails when the Store UI ships + outside that chunk. +- `website/chunks-plugin.js` shares site modules used by 2+ pages; Docusaurus + alone shares only modules used by half of all pages, so the eager + Playground modules would be copied into every docs page chunk. +- `monaco/setup.ts` runs once when the editor chunk evaluates (resource + hints, `loader.init()`, type libs in parallel) and does nothing for mobile + or bots, so they download none of Monaco. `monaco/preloadManifest.ts` is + generated from the installed `monaco-editor` on install and on every + docusaurus config load, and gitignored. +- Type libs: one chunk per third-party `.d.ts`, one `dataClientDTS` chunk + holding exactly `DATA_CLIENT_LIBS` (check-only types like `vue/test` stay + out). A failed fetch degrades to an empty lib. +- `editor-types/` is generated by `yarn copy:websitetypes`; the `regenerate` + workflow fails when it is stale. Don't hand-edit it. ### Mobile and bots -- Mobile: editable react-live editor instead of Monaco; live preview still runs. -- Bots: same editor fallback; preview stays `PreviewLoading` (never loads - `LivePreview`); StackBlitz embeds never load. -- `DiffEditor` shows a two-`CodeBlock` grid instead of Monaco, with callout - markers appended as trailing comments; caption and callout legend are - outside the editor, so they render for everyone. - -### Hidden playgrounds (homepage Demo protocol tabs) - -- All protocols stay mounted so every protocol's source is in the HTML. -- `hidden` keeps `StaticEditor` (no Monaco) until first shown, then latches - interactive so undo history and go-to-definition survive tab switches. -- The live preview unmounts while hidden (its store resets). +- Mobile: an editable react-live editor instead of Monaco; the preview runs. +- Bots: the same editor; the preview stays `PreviewLoading` and StackBlitz + embeds never load. +- `DiffEditor` falls back to two `CodeBlock`s with callout markers appended + as comments; caption and callout legend render outside the editor so they + are in the HTML whether or not Monaco loads. + +### Hidden playgrounds (homepage protocol tabs) + +All tabs stay mounted so every protocol's source is in the HTML. `hidden` +keeps `StaticEditor` until first shown, then latches interactive so undo +history and go-to-definition survive tab switches. The live preview unmounts +while hidden, so its store resets. + +### Layouts + +`index.tsx` picks `demo`, else `row`, else stacked. Tests: +`__tests__/rowLayout.test.tsx`, `__tests__/demoLayout*.test.tsx`. + +- Fence metastring: `title`, `path` (quotes optional), `collapsed`, `column`, + `route`, `start`, `layout`, `{1-3}` (pre-selected in Monaco) and the + `language-*` class. Element props override metastring values (Demo passes + them); `defaultTab` overrides `collapsed`. +- Stacked: every titled file has a header, collapsible when there are several + files or fixtures; fixtures list above the editor (the other layouts fold + them into the preview header). +- `row`: files are tabs over the code card (`column` files stack with + headers); the preview is a folder beside it (under it below a 720px + container width) with the Store on a layer behind it (`.storeHost` > + `.storeDrawer`, `StoreTab` as the handle). Open, the drawer slides over the + code and its file tabs, which go `inert`. `PreviewWrapper` measures + `--pg-preview-gap` so the folder ends at the sheet's foot. +- `demo`: preview first, full width; the code waits behind the header's Code + toggle and takes the result's place (`data-code` on the container). Never + code and result together, and never code and Store: the Store toggle over + the code brings the result back. The result stays mounted so app state + survives; both share `--pg-demo-min` so toggling doesn't move the page. + The Code toggle counts as reader input, so it ends an `autoRun`. ### Editor -- Fence metastring: `title="…"`, `path="…"` (quotes optional), `collapsed`, `column`, - `route="…"`, `start="…"` and `layout` (see Router), `{1-3}` highlight ranges (pre-selected in Monaco); `language-*` class. Element props - override metastring values (Demo passes them directly). `defaultTab` - overrides `collapsed`. -- `row` layout, "folder and window" (`styles.module.css`, `.row`): the files - are tabs on the page over the code card; beside it (under it when the - playground is narrower than 720px) the preview is a folder of the stage's - color with the Store's layer behind it (see the Store below). Non-`column` - files are tabs (focus switches tab); `column` files stack with collapsible - headers. Stacked layout: every titled file gets a header, collapsible when - there are several files or fixtures. -- `demo` layout (`demo` prop): for demos whose point is what happens on - screen. The preview takes the full width and the code waits behind the - header's Code toggle (`CodeHeaderToggle`); pressed, the code replaces the - result (`data-code` on the container). Never both at once, and never the - code and the Store: the Store toggle over the code brings the preview - back. The result stays mounted, so app state and the Store survive. Result - and code share one min height (`--pg-demo-min`), so toggling doesn't move - the page. Pressing Code ends an `autoRun`. -- Collapsed/unselected files stay mounted (`display: none`), never unmounted: - their Monaco models must exist for cross-file types, go-to-definition and - to keep edits. Monaco is uncontrolled (`defaultValue`), so re-renders never - reset the buffer. -- After a `monaco-editor` bump, open a multi-file playground after page load - (e.g. a second homepage demo tab) whose importing file comes before the - file it imports, and check it shows no TS2307 (Cannot find module) marker. - `monaco/models.ts` relies on the TS worker answering requests in order, - which no test runs against real Monaco. -- Fixtures are listed above the editor in stacked layout only. -- Go-to-definition across files: each editor registers an opener for its own - model (`monaco/useDefinitionOpener.ts`) that shows its tab, then selects and - focuses once the tab is visible (a `display: none` editor can't take focus). -- Relative import completions list only the other files of the same - playground (same model id). -- Handlers passed to `InteractiveEditor` are referentially stable so unedited - tabs skip re-rendering on keystrokes. -- Height follows Monaco content size and re-measures when a tab is shown. - -### Preview - -- All documents are concatenated with a `;` between them (so a half-typed - statement can't absorb the next document), deferred with `useDeferredValue`, - stripped of imports/exports by `transformCode`, and run `noInline` - (`render()`) with `preview/scope.ts` as globals, inside one block so - a demo may declare its own `Link` or `Avatar` (react-live passes the scope - as parameters, which a `const` or `class` can't redeclare). The documents - share that block, so two of them can't declare the same name. Keep - `monaco/typeLibs.ts` declarations in sync with the scope: `globalScopeLib()` - aliases library exports into `declare global`, and `Array`/`Object` keep - their built-in types (they're type-only exports). -- Each playground gets its own `DataProvider` store (`MockResolver` serves - `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. -- Surfaces (`../styles.module.css`): `previewWrapper` paints the stage - (`--pg-stage`, seen as the header's tint) and react-live's output is one - `sheet` (`preview/PreviewBlock.tsx`), flush with the frame's sides and - foot; the loading fallback is a sheet too, the error card sits on the - stage. `--pg-sheet-pad` is the sheet's inset, shared by the header and - `.navProgress` so they line up. Colors are `--pg-*`, with separate light - and dark values. The editor's background is `--monoco-code-background` - (`src/css/root.css`), shared by every code block on the site. -- Store inspector open state persists per `groupId` via tab storage and - avoids scroll jumps. In `row` layout it is on the preview's folder: - `index.tsx` renders the folder's layer (`.storeHost`, the container's - last child) with a drawer (`.storeDrawer`) that holds the handle - (`StoreTab`); `StoreInspector` portals the Store into the drawer - (`FolderPanel`, an `` that hides once the drawer has slid - shut). Pulling the handle (click; keyboard; Escape anywhere on the drawer - pushes it shut and refocuses the handle) slides the drawer out, one - `translate`, over the code and its file tabs edge to edge (they go - `inert`), the handle riding outside the Store's edge, past the - playground's. Side by side the preview's folder ends at its sheet's foot - (`PreviewWrapper` measures how far the row runs on below, as - `--pg-preview-gap`); the drawer comes from its left edge with the handle - level with that foot, and only its sheet runs on below it, the strip of - folder between the two sheets curving into its edge; stacked, up from the preview's top edge, at most `--store-cap` - tall so a long file's top stays in view. It animates only while the reader or a demo moves it - (`useStoreMotion` sets `data-store-moving`, which gives every - Store-linked transition its duration, `--pg-store-*`; the stacked - Store's `MotionGroup` keys on the same moves), so a tab showing the - playground, hydration, a resize or another Store in its group closing - it just lands. The - preview stays usable either way. The Store itself is chrome on the folder (the scrubber's row, - State and Action as tabs) and content on a sheet the picked tab joins - (`store.module.css`). A folder's header is inset from its content - (`--pg-head-inset`) and curves out into it, so the folder's paper is - painted in parts (header, content's top edge, the rest), not by one box; - open side by side, the Store's header and the preview's run on as one: the - Store's runs past the fold (`--store-head-run`, the host's clip lets it) to - the preview's, which stays inset, so shutting lands corner on corner. In the other layouts the preview header's icon - (`StoreHeaderToggle`) or the side strip (`StoreToggle`) open it beside the - result as a drawer (`../motion`: the panel `Reveal`s); its contents render - a frame after it starts moving (`useDeferredValue`). -- The Store inspector logs every dispatch through - `preview/store/actionLog.ts`, which documents its limits and `LogOptions` - (the Playground's `actionLog` prop, over `DOCS_LOG` in `index.tsx`: a docs - page holds several playgrounds, so each keeps a shorter history than the - log's defaults, which suit a devtool for a whole site). Preview.tsx gives each store mount its - own `registry.log.connect(history, replacedHistory, skipLogging)` managers. Keep - `head` first and `tail` last when adding managers: the tail must see exactly - what reaches the store. `usePreviewReset` owns the `history` id: a fresh - store (see Reset) starts a new history, a store the retry restores continues - its own, and the panel shows only the current one. An action's chips open the - store as that action left it (see `Moment` in `preview/store/nav.tsx`). -- Store inspector navigation (`preview/store/nav.tsx`, all state owned by - `StorePanel`): - - Two coordinates. The **moment** is a `Step` (`seq` plus `scope`) or - `null` for live; `NavStateContext` carries it down with `earlier`, - `later` and `current` (the seqs it stands for, which the timeline, list - and levels all mark), and children move it only through `set`, `step` - and `show`. The **subject** is a stack of levels (table, record, list) - with breadcrumbs, Back and Escape. - - `scope` says what a moment spans: `request` (the default, and live: the - newest request), `group` (a row of several actions was picked) or - `action` (a step was picked, shown a level deeper). `unitOf` in - `actionGroups.ts` is the one owner of that; `momentEntries` (what Diff - shows as one effect, so an optimistic update and its response merge) - and `shownGroup` (what names it) build on it. - - Every way of moving goes through two pure functions at the moment's - depth: `stepAt` (`actionLog.ts`) for ‹ ›, ← →, Before and After, and - `pickAt` for a mark picked on a timeline. The surface steps by request - (`requestsOf`: each poll of a subscription its own); a level deeper - steps every listed action. Live is a place past the last request. - - Tabs: **State** (Snapshot, or Diff via `diffModel` and `mergeChanges`, - which leaves out rows other actions changed in between) and **Action** - (`ActionDetail` over `ActionsLevel`, so Back lists the subject's - actions). Both stacks stay mounted while hidden, keeping scroll, pages - and filters. The tab and Snapshot/Diff are each Playground's own; table - vs tree is shared (`useTabStorage`). - - The Action tab's Before/After columns (`ActionStep`) take no height and - only the action between them scrolls, so the wheel over the action - scrolls it. Stepping moves the action level with the moment - (`followMoment`), keeping an action alone a level under its request or - row. - - A level a chip opened at an action shows that action's store until the - moment is set on purpose (`clearAt`). - - The action list (`ActionList`, also the scrubber's hover peek - `ActionsPane`) shows what `subjectFilter` says touched the subject. Rows - render again only when their own group changes: keep `GroupRow` - memoized, `subjectFilter`'s result stable and the chips' `Nav` reading - the model through a ref. ↑ ↓ read a row's moment from its `data-seq` - and `data-scope`. - - ← → reach the panel only when the focused element has no use for them - (`usesArrows`, or a widget calling `preventDefault`). - - Timeline marks are placed as fractions of the track (`--tl-f`), never - measured. Under `NARROW_WIDTH` the timeline opens as a sheet over the - content. Where no level can open a view (tree view, the peek's chips), - `StorePanel` opens it over the top level in the table view. - -- Router (`preview/router.tsx`): a demo with several pages gives each page's - fence a `route` (a path whose `:name` segments are params, `/posts/:id`) and writes no - navigation of its own. The page is that file's named default export, and - the path's params are its props. When any file has one, `routerTrailer` - appends the `render()` (so no file calls it): a router starting at `/` with - Back, Home and the current path in the preview's header (so pages need no - back links; `PreviewWrapper` gives the header slot through - `RouterControlsSlot`, and the router portals into it). Pages move with - `` and `useNavigate()`, scope globals that stand in for the app's own router, like - the design system; Vue pages use `RouterLink` and `vue-router`'s - `useRouter()`, and keep the same `route` fences only to name their path - (Vue examples don't run). - A `layout` fence's default export wraps every page as its `children` and - stays mounted through navigation (links shared by every page, a clock to - compare against); `` marks the one to the current path - `aria-current="page"`. A `start="/time/1"` on a page's fence opens the router - there (and Home returns there) instead of `/`. - Navigation is a transition: the page stays until the next one is ready, - with a progress bar along the preview's top edge meanwhile, so a page needs - no `startTransition` of its own. Pages render in a Suspense boundary whose - fallback shows only before the first page is ready. - A reset (fresh store) starts again at `/`. -- `renderCount` wraps the live result in a `` and shows its commit - count in the preview header (written to the DOM, so counting adds no commits). - `website/profiling-plugin.js` replaces `react-dom/client` with React's - profiling build because production builds never call `onRender`; the badge - stays hidden if that ever stops working. `website/profiling-loader.js` pins - the build's DevTools check off, so only `` subtrees are ever timed - (otherwise DevTools users would profile every page). -- Reset: the header icon and the button under a render error remount the - preview with a fresh store (`usePreviewReset`). When a render error follows an - edit (e.g. a changed `Entity.key` leaves old data unreadable), the preview - retries once with a fresh store and restores the old store if the error - persists. It retries again only after the preview has rendered cleanly for a - second, so typing through a typo costs at most one retry. Compile and - evaluation errors never reset. -- Managers: code defining `getManagers()` (as passed to ``) - sets the store's managers; otherwise `getDefaultManagers()` without DevTools. - `ManagerHost` (`preview/managers.ts`) builds and guards them; - `useCodeManagers` is LivePreview's only hook into it. - [Data flow diagram](./managers-data-flow.png) (source: `managers-data-flow.svg`). - - Only editing `getManagers` or a document declaring a name it uses (e.g. its - Manager class) changes the managers: the store remounts with its data, so - other edits keep sockets open. Code that fails to run keeps the old ones. - - A kept manager closes over the run that built it, not the UI's latest run, - so it reaches the UI through something every run shares: the store, or a - design system module (`toast`). - - A manager that throws shows a "Manager error" card; the preview runs on - without that step. - - The Store shows which manager sent or handled each action - (`preview/store/attribution.ts`). Library managers are matched by exact - class, so a playground's subclass counts as its own code. - - A manager the code declares links to its declaration (`ManagerName`). - Opening one closes a Store covering the code in the same render as it - hands the line to the editor (`openCode` in `index.tsx`): an `inert` - editor can't take focus, and Monaco may still be loading, so `reveal` - waits for both. -- Errors show in one `ErrorPanel` card (`Playground/ErrorPanel.tsx`) labeled by the stage that failed: amber - "Compile error" for react-live's `SyntaxError`s (the code never ran) and red - "Runtime error" for anything thrown while running. Demos rely on the reset - above to recover rather than encoding their own (e.g. a store-clearing error - fallback); say so in the prose next to a demo that needs it. -- Third-party console noise is demoted only while previews are mounted - (`usePlaygroundConsoleDemotion`); never add first-party matchers. +- Collapsed files stay mounted (`display: none`), never unmounted: their + Monaco models carry cross-file types, go-to-definition and edits. Monaco + is uncontrolled (`defaultValue`), so re-renders never reset the buffer. +- Handlers passed to `InteractiveEditor` are referentially stable + (`EditorSurface`), which is what lets `memo` skip unedited tabs on + keystrokes; `useDefinitionOpener` reads `onOpen` once. +- `monaco/models.ts` creates a surface's models in one synchronous pass and + relies on the TS worker answering in order. No test runs against real + Monaco, so after a `monaco-editor` bump open a multi-file playground after + page load (a second homepage tab) whose importing file comes before the + file it imports, and check for no TS2307 (Cannot find module) marker. +- Go-to-definition and Store links (`reveal`) only ask for the tab; + selection and focus wait until it is visible, since a `display: none` + editor can't take focus. For the same reason `openCode` (`index.tsx`) + closes a covering Store in the same render as it hands the line over. + +### Running the code + +- Documents are joined with `\n;\n` (a half-typed statement can't absorb the + next file), deferred with `useDeferredValue`, stripped of imports and + exports (`transformCode`), wrapped in one `{}` block (so a demo can declare + its own `Link` or `Avatar` with `const` or `class`, which a scope parameter + would forbid) and run `noInline` with `render()`. Files share the + block, so two can't declare the same name. +- Scope globals and their types live apart. A design-system export is picked + up from `DesignSystem/index.ts` by `preview/scope.ts` and by + `website/framework-docs/checkExamples.mjs`; declare its type by hand in + `monaco/typeLibs.ts` (`PREVIEW_SCOPE_DECLARATIONS`). Any other global goes + in all three. `globalScopeLib()` aliases library exports into + `declare global`, skipping `Array` and `Object` so the built-ins keep + their types. +- Each playground has its own `DataProvider` with `MockResolver(fixtures)`; + `memo(Preview)` keeps it from re-rendering on code edits, so what reaches + it must be stable. + +### Store inspector + +- Open state persists per `groupId` (tab storage); closing one Store closes + every playground's in the group, and toggling blocks the page's scroll + position. +- Motion invariant: the Store animates only while the reader or a demo moves + it. `useStoreOpen` counts those moves, `useStoreMotion` turns them into + `data-store-moving` on the container, which alone gives `--pg-store-*` + their durations, and the stacked `MotionGroup` keys on the same count. + Everything else (the saved choice arriving at hydration, a tab showing the + playground, a resize, another Store in the group closing this one) must + land without animating. `preview/__tests__/StoreMotion.test.tsx` checks it. +- Row: `StoreInspector` portals into the drawer (`FolderPanel`, an + `` hidden once the sweep has shut, so reopening finds the Store + as it was left). Other layouts: a `Reveal` beside the result (see + `../motion/README.md`); the tree renders a frame after the drawer starts + moving (`useDeferredValue`). +- Log: `Preview.tsx` connects `registry.log` once per store mount with + `log.head` first and `log.tail` last. Keep that order when adding + managers: the head credits each action to the manager that sent it, the + tail must see exactly what reaches the store. `usePreviewReset` owns the + `history` id: a fresh store starts a new one, a store the retry restores + continues its own. `DOCS_LOG` (`index.tsx`) shortens the log's defaults + because a page holds several playgrounds; the `actionLog` prop overrides + it per playground. +- Navigation (`preview/store/nav.tsx`, all state owned by `StorePanel`): the + moment (a `Step`, or `null` for live) moves only through `stepAt` + (`actionLog.ts`) and `pickAt` (`actionGroups.ts`); `unitOf` is the one + owner of what a `scope` spans. Rows render again only when their own group + changes: keep `GroupRow` memoized and `subjectFilter`'s result stable. + Timeline marks are fractions of the track (`--tl-f`), never measured. + ← → reach the panel only when the focused element has no use for them + (`usesArrows`). + +### Multi-page demos + +A fence with `route="/posts/:id"` is a page: its default export, the path's +params as props. When any file has one, `routerTrailer` appends the +`render()`, so no file calls it. A `layout` fence wraps every page and stays +mounted; `start` opens the router somewhere other than `/`. Pages move with +the scope's `Link` and `useNavigate()`; navigation is already a transition +with a progress bar, so pages need no `startTransition`. A reset starts over +at `start`. Vue examples don't run; `checkExamples.mjs` declares +`RouterLink` and `RouterView` for them. + +### Reset and errors + +- A render error right after an edit retries once with a fresh store (a + changed `Entity.key` leaves old data unreadable); if it persists the old + store comes back, and no retry runs until a clean render has held for + `HEALTHY_AFTER_MS`, so typing through a typo costs at most one retry. + Compile and evaluation errors never reset. +- `ErrorPanel` names the failed stage: compile (react-live `SyntaxError`, the + code never ran), runtime, or manager (the preview runs on without that + manager). Demos rely on this reset instead of their own store-clearing + fallbacks; say so in the prose beside a demo that needs it. +- `usePlaygroundConsoleDemotion` demotes only React's recreate-tree report + while previews are mounted. Never add matchers for `@data-client/*` or + first-party code: fix the source. + +### Managers + +- `getManagers()` in the code sets the store's managers (otherwise + `getDefaultManagers({ devToolsManager: null })`). Only editing the file + that defines it, or a file declaring a name it uses, remounts the store + with its data; other edits keep sockets open. Code that fails to run keeps + the old managers. +- A kept manager closes over the run that built it, not the UI's latest, so + it reaches the UI only through what every run shares: the store, or a + design-system module such as `toast`. +- Attribution matches library managers by exact class, so a playground's + subclass counts as its own code and links to its declaration + (`ManagerName`). + +### renderCount + +The badge is a `` commit count written straight to the DOM, so +counting adds no commits. It depends on `website/profiling-plugin.js` +swapping `react-dom/client` for React's profiling build (production never +calls `onRender`) and `website/profiling-loader.js` pinning the DevTools +check off (otherwise visitors with DevTools would profile every page). The +badge stays hidden while `onRender` never fires, so a broken swap shows +nothing rather than a wrong count. ### Design system -`DesignSystem/design-system.css` (scoped to `.playground-preview`, loaded with -the preview) makes demo markup read as a real app in both themes; its tokens -and skeletons (`DesignSystem/base.css`) load with the Playground, so the -first paint shows the app's own loading page (`preview/PendingSheet.tsx`). Doc -examples use plain elements and these classes, never inline styles or -imports: styling changes go here. Space separates things, not lines: add no -borders or dividers (only form controls keep an edge). Tokens are `--ds-*`, -reading the frame's `--pg-*`. +`DesignSystem/design-system.css` is scoped to `.playground-preview` and loads +with the preview; its tokens (`--ds-*`, reading the frame's `--pg-*`) and +skeletons (`base.css`) load with the Playground, so the first paint shows the +app's own loading page (`preview/PendingSheet.tsx`). Doc examples use plain +elements and these classes, never inline styles or style imports, so a look +changes in one place. Space separates things, not lines: add no borders or +dividers (only form controls keep an edge). Before restyling a class, grep +`docs/` and `website/src/components/Demo/code` for the demos using it. Prefer +a class or element style to a new component. -Elements are styled too: `h2` is a page title, plain `button`s (`primary` -fills one; `aria-busy` traces a pending light), text inputs, `nav`, -`fieldset`. +Elements are styled: `h2` is a page title; plain `button`s (`primary` fills +one; `aria-busy` traces a pending light), text inputs, `nav`, `fieldset`. | Class | Use for | | ------------------------------------------ | -------------------------------------------------------------------------------- | @@ -357,77 +266,64 @@ fills one; `aria-busy` traces a pending light), text inputs, `nav`, | `meta` | secondary text; last in a `listItem`, it sits at the row's end | | `byline`, `author` (`Avatar` takes `size`) | who wrote something | | `buttonGroup` | a row of actions as one segmented control | +| `cancel` (``) | a row's remove button | | `skeleton` | a value still loading; ``, the default fallback, draws a page of them | | `toaster`, `toast` | notifications: render ``, call `toast.success()` or `toast.error()` | | `boardColumn` | a kanban column | | `voteBlock`, `vote` | a vote count with its buttons | -- Prefer a class or element style to a new component. A new scope global - goes in `preview/scope.ts`, `monaco/typeLibs.ts` and - `website/framework-docs/checkExamples.mjs`. -- Before restyling a class, grep `docs/` and `website/src/components/Demo/code` - for the demos using it. - ### Auto-run -A playground with `autoRun` (a `Step[]`, `preview/autorun/script.ts`) plays it -by itself, like a video, the first time 60% of its preview (or of the -viewport, for a taller preview) is in view; never by itself under reduced -motion. The "Live Preview" label becomes its control (`DemoLabel`): pressing -it replays the demo from a fresh store and scrolls the preview into view, and -its red dot pulses while the demo plays. +A playground with `autoRun` (a `Step[]`, `preview/autorun/script.ts`) plays +it the first time 60% of its preview (or of the viewport, for a taller +preview) is in view, never under reduced motion. The "Live Preview" label +becomes its control (`DemoLabel`): pressing it replays from a fresh store. +`player.ts` owns timing, `stage.ts` the DOM (locators, untrusted events, the +pointer), `useAutoRun.ts` connects them to the preview's ref. Writing a script: -- No captions: the change on screen as the pointer acts is what draws the - eye, so pick steps whose effect shows and `wait` while it sinks in. - Declare it as a module-level `export const` (like `raceDemo` in - `docs/rest/shared/_optimisticTransform.mdx`). "Starts by itself once per - page load" is tracked by array identity, so an inline `autoRun={[…]}` - plays by itself again whenever the page's framework is switched away and - back. -- Target Store UI with `in: 'store'`; the default scope is the preview. + `docs/rest/shared/_optimisticTransform.mdx`): "plays by itself once per + page load" is keyed by array identity, so an inline `autoRun={[…]}` plays + again whenever the page's framework is switched away and back. +- Target Store UI with `in: 'store'` (the default scope is the preview). Match `aria-label`s and `data-*` attributes over visible text, which copy edits change. -- The Store opens in the reader's saved view (table or tree), so a script - whose targets only one view shows names it: `{ store: 'open', view: 'table' }` - for `[data-table]` rows. Like the open itself, it isn't saved, and it holds - while the Store stays open, until the reader picks a view - (`useStoreOpen`, `store/storeView.ts`): once the demo or the reader closes it, and on - replay, the Store shows the reader's view again. -- A target that doesn't appear within `FIND_TIMEOUT` ends the run, and only - the console says so (`Playground demo:`). Play every new or edited script - through in a browser, at a phone width too, where the Store opens above the - preview. -- Step counts follow the fixtures: `raceDemo` presses Next once per change - its clicks make, so changing the fixture means recounting. - -Behavior to keep when changing the code: - -- The reader's own input (`isTrusted`) anywhere in the playground, a press, a - tap or a key, ends the run for good, since the rest of the script assumes a - state the reader just changed; the label starts it over. Scrolling, - hovering, a touch that starts a scroll and the demo's own controls - (`data-autorun-control`) don't count. +- The Store opens in the reader's saved view, so a script whose targets only + one view shows names it: `{ store: 'open', view: 'table' }` for + `[data-table]` rows. Neither the open nor the view is saved. +- A target missing after `FIND_TIMEOUT` ends the run, and only the console + says so (`Playground demo:`). Play every new or edited script in a + browser, at a phone width too, where the Store opens above the preview. +- Step counts follow the fixtures (`raceDemo` presses Next once per change + its clicks make), so changing a fixture means recounting. + +Behavior to keep when changing the player: + +- The reader's own input (`isTrusted`) anywhere in the playground ends the + run for good, since the rest of the script assumes a state they just + changed. Scrolling, hovering, a touch that starts a scroll and the demo's + own controls (`data-autorun-control`) don't count. - It pauses while neither the preview nor the playground is mostly in view, or the tab is in the background, so it never scrolls the page back to a reader who scrolled past. -- A `store` step opens or closes the Store through `useStoreOpen`'s `showStore`, - without saving it: tab storage keeps the reader's own choice, shared by - every playground with the page's `groupId`. - It never calls `focus()`, which would take the page's focus from the - reader. -- The player's state reaches React only in `DemoLabel`, so a run never + reader, and its state reaches React only in `DemoLabel`, so a run never re-renders the playground. -`player.ts` owns timing: a pausable clock, one step at a time, each looking -its target up again before it acts (renders replace elements). `stage.ts` owns -the DOM: locators, untrusted events and the pointer. `useAutoRun.ts` connects -them to the preview's ref. - -## Tests - -`yarn test --selectProjects ReactDOM --testPathPatterns website/src/components/Playground` -(CI persists only this directory of the website for these tests, without the -website's dependencies. `react-live` is also a root devDependency so the -`PreviewError` test can pin the react-live behavior it relies on.) +## Verify + +- Unit tests: + `yarn test --selectProjects ReactDOM --testPathPatterns website/src/components/Playground`. + CI persists only `Playground/`, `motion/` and `utils/` of the website for + these, without the website's dependencies; `react-live` is a root + devDependency so `PreviewError.test.tsx` can pin the react-live behavior + it relies on. +- Lazy-import rule: `yarn lint website/src/components/Playground`. +- Chunk check, as the `website` job runs it: + `yarn workspace rdc-website build && node website/scripts/check-lazy-chunks.mjs`. +- Docs examples still type-check against the scope: + `yarn workspace rdc-website check:doc-examples`. +- In a browser: the TS2307 check after a `monaco-editor` bump (Editor), and + any changed auto-run script at desktop and phone widths.