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 087f5be6f386..36624c11639e 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,10 +72,17 @@ function ShowTime() { }; return (
- }> + + 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 010ff0fafd55..5b9667c35538 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,12 +82,17 @@ export const lastUpdated = new RestEndpoint({ ``` @@ -379,8 +341,9 @@ export const lastUpdated = new RestEndpoint({ ``` @@ -398,13 +361,17 @@ export const lastUpdated = new RestEndpoint({
- + - - +
+ + +
``` @@ -443,8 +410,8 @@ export const lastUpdated = new RestEndpoint({ @@ -474,19 +441,26 @@ export const lastUpdated = new RestEndpoint({
- + - - - +
+ + + +
``` diff --git a/.agents/skills/data-client-schema/references/Collection.md b/.agents/skills/data-client-schema/references/Collection.md index df01b0fa6429..2b887165c680 100644 --- a/.agents/skills/data-client-schema/references/Collection.md +++ b/.agents/skills/data-client-schema/references/Collection.md @@ -381,7 +381,9 @@ export default function PostListLayout({ Clara - +
); 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..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,12 +100,8 @@ export default function CompanyGrid({ companies }: { companies: 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)}%`; } ``` 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..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,12 +76,8 @@ export default function CompanyGrid({ companies }: { companies: 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)}%`; } ``` 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(); ``` @@ -834,11 +862,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,31 +891,41 @@ function ShowTime() { ); return (
- loading...
}> + + API time for 1 + +
+ } + > -
- Current Time: +
+ Current time + +
+
+ + +
- - -
); } @@ -910,8 +946,8 @@ render(); @@ -941,19 +977,26 @@ render();
- + - - - +
+ + + +
``` diff --git a/docs/rest/api/Collection.md b/docs/rest/api/Collection.md index 342d653c28cc..bbb316970839 100644 --- a/docs/rest/api/Collection.md +++ b/docs/rest/api/Collection.md @@ -520,7 +520,9 @@ export default function PostListLayout({ Clara - +
); @@ -603,7 +605,7 @@ render(); Clara - +
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..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,12 +81,8 @@ export default function CompanyGrid({ companies }: { companies: 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)}%`; } ``` @@ -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/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/jest.config.js b/jest.config.js index 47cec4d704cf..91360971e380 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/useIsomorphicLayoutEffect$': + '/website/src/components/Playground/testing/docusaurusUseIsomorphicLayoutEffect.ts', '^@theme/CodeBlock$': '/website/src/components/Playground/testing/themeCodeBlock.tsx', }, diff --git a/website/framework-docs/README.md b/website/framework-docs/README.md index a1042fa3b11d..b5746be9d252 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..0a16b754d90b 100644 --- a/website/framework-docs/checkExamples.mjs +++ b/website/framework-docs/checkExamples.mjs @@ -33,15 +33,21 @@ 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); + // 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)) + .map(name => ` const ${name}: any;`) + .join('\n'); const titleOf = block => block.meta?.match(/title="([^"]+)"/)?.[1]; /** A single file component, titled or not */ @@ -66,6 +72,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 +91,9 @@ declare module 'vue' { ${[...PLACEHOLDERS, 'RouterLink', 'RouterView'].map(name => ` ${name}: DefineComponent;`).join('\n')} } } +declare global { +${HELPER_DECLARATIONS} +} `, }, }; diff --git a/website/i18n/es/code.json b/website/i18n/es/code.json index 2d30384682c1..66807253dafb 100644 --- a/website/i18n/es/code.json +++ b/website/i18n/es/code.json @@ -808,6 +808,9 @@ "playground.errorMessage.reset": { "message": "Reiniciar" }, + "playground.cancelButton.label": { + "message": "Eliminar" + }, "playground.error.manager": { "message": "Error del Manager" }, @@ -848,6 +851,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/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(); ``` @@ -834,11 +862,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,31 +891,41 @@ function ShowTime() { ); return (
- loading...
}> + + API time for 1 + +
+ } + > -
- Current Time: +
+ Current time + +
+
+ + +
- - -
); } @@ -910,8 +946,8 @@ render(); @@ -941,19 +977,26 @@ render();
- + - - - +
+ + + +
``` diff --git a/website/i18n/es/docs/rest/api/Collection.md b/website/i18n/es/docs/rest/api/Collection.md index cd671d113744..fb9f367c5a5b 100644 --- a/website/i18n/es/docs/rest/api/Collection.md +++ b/website/i18n/es/docs/rest/api/Collection.md @@ -520,7 +520,9 @@ export default function PostListLayout({ Clara - +
); @@ -603,7 +605,7 @@ render(); Clara - +
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..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,12 +81,8 @@ export default function CompanyGrid({ companies }: { companies: 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)}%`; } ``` @@ -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/code.json b/website/i18n/pt-BR/code.json index 1c8eddaa8e3b..fa5ae7d30fab 100644 --- a/website/i18n/pt-BR/code.json +++ b/website/i18n/pt-BR/code.json @@ -808,6 +808,9 @@ "playground.errorMessage.reset": { "message": "Redefinir" }, + "playground.cancelButton.label": { + "message": "Remover" + }, "playground.error.manager": { "message": "Erro do Manager" }, @@ -848,6 +851,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/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(); ``` @@ -834,11 +862,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,31 +891,41 @@ function ShowTime() { ); return (
- loading...
}> + + API time for 1 + +
+ } + > -
- Current Time: +
+ Current time + +
+
+ + +
- - -
); } @@ -910,8 +946,8 @@ render(); @@ -941,19 +977,26 @@ render();
- + - - - +
+ + + +
``` diff --git a/website/i18n/pt-BR/docs/rest/api/Collection.md b/website/i18n/pt-BR/docs/rest/api/Collection.md index 8dceabc64130..a213765a4e90 100644 --- a/website/i18n/pt-BR/docs/rest/api/Collection.md +++ b/website/i18n/pt-BR/docs/rest/api/Collection.md @@ -520,7 +520,9 @@ export default function PostListLayout({ Clara - +
); @@ -603,7 +605,7 @@ render(); Clara - + 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..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,12 +81,8 @@ export default function CompanyGrid({ companies }: { companies: 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)}%`; } ``` @@ -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/code.json b/website/i18n/zh-Hans/code.json index 256b417f3223..7c23e96aa33d 100644 --- a/website/i18n/zh-Hans/code.json +++ b/website/i18n/zh-Hans/code.json @@ -808,6 +808,9 @@ "playground.errorMessage.reset": { "message": "重置" }, + "playground.cancelButton.label": { + "message": "移除" + }, "playground.error.manager": { "message": "Manager 错误" }, @@ -848,6 +851,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/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(); ``` @@ -834,11 +862,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,31 +891,41 @@ function ShowTime() { ); return (
- loading...
}> + + API time for 1 + +
+ } + > -
- Current Time: +
+ Current time + +
+
+ + +
- - -
); } @@ -910,8 +946,8 @@ render(); @@ -941,19 +977,26 @@ render();
- + - - - +
+ + + +
``` diff --git a/website/i18n/zh-Hans/docs/rest/api/Collection.md b/website/i18n/zh-Hans/docs/rest/api/Collection.md index 4ee8a808ad56..d3826e73a11a 100644 --- a/website/i18n/zh-Hans/docs/rest/api/Collection.md +++ b/website/i18n/zh-Hans/docs/rest/api/Collection.md @@ -519,7 +519,9 @@ export default function PostListLayout({ Clara - +
); @@ -602,7 +604,7 @@ render(); Clara - + 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..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,12 +81,8 @@ export default function CompanyGrid({ companies }: { companies: 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)}%`; } ``` @@ -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/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..7e1dfd8de711 100644 --- a/website/src/components/Demo/styles.module.css +++ b/website/src/components/Demo/styles.module.css @@ -2,3 +2,52 @@ 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. 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 { + width: fit-content; + margin-bottom: 6px; + padding: 3px; + gap: 2px; + border-radius: 999px; + background: var(--pg-stage); +} +.segmented > [role='tab'] { + height: 24px; + padding: 0 11px; + border-radius: 999px; + color: var(--pg-muted); + 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(--pg-text); +} +.segmented > [role='tab'][aria-selected='true'] { + background: var(--pg-panel); + color: var(--pg-text); + box-shadow: 0 1px 2px rgba(15, 30, 60, 0.16); +} +/* 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%); +} +:root[data-theme='dark'] .segmented > [role='tab']:hover { + color: var(--pg-text); +} +:root[data-theme='dark'] .segmented > [role='tab'][aria-selected='true'] { + color: var(--pg-text); +} +.segmented > [role='tab']:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -2px; +} 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/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/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/HooksPlayground.tsx b/website/src/components/HooksPlayground.tsx index 401545daa207..eee077b1c38b 100644 --- a/website/src/components/HooksPlayground.tsx +++ b/website/src/components/HooksPlayground.tsx @@ -9,9 +9,9 @@ const HooksPlayground = ({ hidden = false, defaultOpen = 'n', row = false, + demo = false, fixtures = [], defaultTab, - headerControls, renderCount, actionLog, autoRun, @@ -21,11 +21,11 @@ const HooksPlayground = ({ groupId={groupId} defaultOpen={defaultOpen} row={row} + demo={demo} hidden={hidden} fixtures={fixtures} getInitialInterceptorData={getInitialInterceptorData} defaultTab={defaultTab} - headerControls={headerControls} renderCount={renderCount} actionLog={actionLog} autoRun={autoRun} diff --git a/website/src/components/Playground/DesignSystem/Avatar.tsx b/website/src/components/Playground/DesignSystem/Avatar.tsx index 378da65bd0b3..32c5421c7ad8 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` to match the text beside it (smaller in a byline or a comment) */ +export function Avatar({ src, size = 32 }: { src: string; size?: number }) { return ( void }) { const cancelSrc = useBaseUrl('/img/cancel.png'); return ( -
-
- {onClick ? - - :
-   -
- } -
-
+ + ); } 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..32be2e179b7c 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 + * (base.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/Toaster.tsx b/website/src/components/Playground/DesignSystem/Toaster.tsx new file mode 100644 index 000000000000..54ff74e1bc4d --- /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 */ +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) +// 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..469db310ab62 --- /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 { SHOW_MS, 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(SHOW_MS)); + 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(SHOW_MS)); + }); +}); diff --git a/website/src/components/Playground/DesignSystem/base.css b/website/src/components/Playground/DesignSystem/base.css new file mode 100644 index 000000000000..915975e07554 --- /dev/null +++ b/website/src/components/Playground/DesignSystem/base.css @@ -0,0 +1,163 @@ +/* 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 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; + --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, + 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 */ + --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; + /* 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; + --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: 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 + 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. 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%; +} +.playground-preview .skeletonPage > .skeleton:first-child { + width: 55%; + margin-bottom: var(--ds-space-3); + font-size: var(--ifm-h2-font-size); + 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 1bf33df7c7fe..9995f15f6359 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -1,10 +1,619 @@ -html[data-theme='light'] .playground-preview { - --ifm-color-formfield-active: var(--ifm-link-color); +/* The demos' design system: what a demo app is made of, so it reads as a + real app inside the preview (`.playground-preview`, ../preview/Preview.tsx) + rather than as bare markup. Loaded with the preview, so only demo content + is styled here; the preview's frame is in ../styles.module.css. + + 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, bylines). Add a component + (./index.ts) only for markup every demo would repeat. + + 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). The tokens + and the skeletons are in ./base.css, which loads with the Playground. */ +/* Text */ +.playground-preview h1, +.playground-preview h2, +.playground-preview h3 { + letter-spacing: -0.01em; +} +/* A page's title (a post's): a step up from the body, with the byline + close under it */ +.playground-preview h2 { + line-height: var(--ds-h2-leading); + 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) */ +.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 { + font-size: var(--ds-text-sm); + color: var(--ds-text-muted); +} +.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); +} +.playground-preview section:last-child { + margin-bottom: 0; +} +/* A group's label over its rows (a section's "Comments", a board column's + 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 6px; + font: 600 var(--ds-text-sm) / 1.4 var(--ifm-font-family-base); + letter-spacing: 0; + color: var(--ds-text-muted); +} +/* 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 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; + text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); + text-underline-offset: 0.15em; +} +.playground-preview :is(h1, h2, h3, h4) a:hover, +.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 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: 1.25rem; +} +/* through .appHeader to outrank Infima's `.markdown h1:first-child` margin */ +.playground-preview .appHeader .appTitle { + margin: 0; + 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; +} +.playground-preview .appHeader button { + margin: 0; +} + +/* 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: 0; + background: none; + box-shadow: none; +} +.playground-preview .card > :last-child { + margin-bottom: 0; +} + +/* 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-2) var(--ds-space-3); + border-radius: var(--ds-radius-sm); + transition: background-color var(--motion-snappy); +} +/* touch screens skip it: a tapped row would stay lit */ +@media (hover: hover) { + .playground-preview .list > .listItem:has(> label):hover { + background: var(--ds-hover); + } +} +.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), set apart by + 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: var(--ds-row-pad) 0; +} +.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 a gap 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: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 + 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: var(--ds-space-4); + font-size: var(--ds-text-sm); + color: var(--ds-text-muted); +} +.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: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); + background: var(--ds-surface); + color: var(--ds-text); + font: inherit; +} +.playground-preview button:where(:not([class]), .primary) { + padding: var(--ds-control-pad-y) var(--ds-control-pad-x); + 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); +} +/* 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) var(--ds-accent-press), + 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:active { + background: var(--ds-accent-strong); + border-color: var(--ds-accent-strong); +} +.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: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); + 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])) { + 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; 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 button[aria-busy='true'] { + /* a 2px border, out of the padding: same size, room for the light */ + border: 2px solid transparent; + 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( + from var(--pg-trace-angle), + var(--ds-border) 0 45%, + var(--ds-accent) 75%, + var(--ds-border) 76% + ) + border-box; + box-shadow: 0 0 0 3px var(--ds-accent-soft); + color: var(--ds-accent); + animation: ds-trace 1.2s linear infinite; } -html[data-theme='dark'] .playground-preview { - --ifm-color-formfield-active: var(--ifm-link-color); +@keyframes ds-trace { + to { + --pg-trace-angle: 360deg; + } +} +@media (prefers-reduced-motion: reduce) { + .playground-preview form[aria-busy='true'] button[type='submit'], + .playground-preview button[aria-busy='true'] { + animation: none; + } +} +.playground-preview input[type='checkbox'] { + width: 1rem; + height: 1rem; + 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:is(:not([type]), [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(--ds-accent); + 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 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); + padding: 6px; + margin-bottom: 6px; +} +.playground-preview .boardColumn > h4 { + padding: 0 var(--ds-space-1); +} +.playground-preview .boardColumn .listItem { + align-items: center; + background: var(--ds-surface); + border-radius: var(--ds-radius-sm); + padding: 7px 10px; + margin-bottom: var(--ds-space-1); +} +.playground-preview .boardColumn .listItem:last-child { + margin-bottom: 0; +} +.playground-preview .boardColumn button { + margin: 0; + padding: 0 4px; + line-height: 1; + background: none; + border: none; + border-radius: var(--ds-radius-xs); + color: var(--ds-text-muted); +} +.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; + /* 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; + 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; +} +/* 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: 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 { + --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 (a busy + segment keeps its light) */ +.playground-preview + .buttonGroup + > button:not([class], [aria-busy='true']):hover { + 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) var(--ds-accent-press), + var(--ds-surface) + ); +} +/* 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; +} +/* 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 { + 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: 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; + 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, + red for an error */ +.playground-preview .toaster { + display: flex; + flex-direction: column; + gap: 6px; + 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-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; @@ -13,14 +622,13 @@ html[data-theme='dark'] .playground-preview { 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) */ @@ -63,14 +671,18 @@ html[data-theme='dark'] .rt-TextAreaRoot { } } -/* 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.70rem; + font-size: 0.7rem; color: var(--ifm-color-emphasis-700); pointer-events: none; } @@ -82,9 +694,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; } @@ -99,38 +711,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 { @@ -152,9 +756,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) { @@ -167,11 +768,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 */ @@ -180,6 +783,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; @@ -189,7 +793,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); @@ -197,4 +800,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/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 dd60e4a548a8..7bb990d4ff91 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -1,355 +1,329 @@ # 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, 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 - StoreToggle.tsx Store toggles + 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/ components injected into preview scope -``` +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 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. - `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. + `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. +- 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) -### Hidden playgrounds (homepage Demo 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. -- 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). +### 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: 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. -- 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. -- 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`). -- 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 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` 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: `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 | +| `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 | ### 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 +## Verify -`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.) +- 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. diff --git a/website/src/components/Playground/TabList.tsx b/website/src/components/Playground/TabList.tsx index 68ca9b688579..51145c57fc38 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 { ReactNode } from 'react'; +import { + useLayoutEffect, + useRef, + type KeyboardEvent, + type ReactNode, + type RefObject, +} from 'react'; import styles from './styles.module.css'; @@ -7,12 +13,56 @@ 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; +}) { + 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) => { + let to: number; + switch (event.key) { + 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; + 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} @@ -29,3 +78,53 @@ export default function TabList({ tabs }: { tabs: readonly TabItem[] }) {
); } + +/** Scrolls the selected tab into the row's view and marks the row + * `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, +) { + useLayoutEffect(() => { + 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 + reach, + ); + row.toggleAttribute( + 'data-flush-right', + !!tab && tab.right > box.right - reach, + ); + }; + 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/__tests__/demoLayout.test.tsx b/website/src/components/Playground/__tests__/demoLayout.test.tsx new file mode 100644 index 000000000000..0d501590161e --- /dev/null +++ b/website/src/components/Playground/__tests__/demoLayout.test.tsx @@ -0,0 +1,76 @@ +/// +/// +/// + +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', () => require('../testing/playgroundEditor')); + +// 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'); + 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'), + ); + }); + + 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/__tests__/demoLayoutLive.test.tsx b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx new file mode 100644 index 000000000000..8262fdb8ef14 --- /dev/null +++ b/website/src/components/Playground/__tests__/demoLayoutLive.test.tsx @@ -0,0 +1,70 @@ +/// +/// +/// + +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', () => 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. +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/__tests__/rowLayout.test.tsx b/website/src/components/Playground/__tests__/rowLayout.test.tsx new file mode 100644 index 000000000000..bb2b5cd910d6 --- /dev/null +++ b/website/src/components/Playground/__tests__/rowLayout.test.tsx @@ -0,0 +1,84 @@ +/// +/// +/// + +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', () => 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', () => { + 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 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( + {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 16420212600a..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'; @@ -18,18 +18,14 @@ import type { CodeTarget } from './openCode'; 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; 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; @@ -42,14 +38,11 @@ export default function EditorSurface({ variant, interactive = true, fixtureContent, - headerControls, - cover, covered = false, - coverTitle, reveal, }: EditorSurfaceProps) { const id = useModelId(); - const row = layout === 'row'; + const row = layout !== 'stacked'; const [closedList, setClosed] = useState(() => documents.map(({ collapsed }) => collapsed), ); @@ -102,11 +95,9 @@ export default function EditorSurface({ closedList={closedList} onClick={handleTabSwitch} compact={variant === 'standalone'} - hasHeaderControls={headerControls != null} + 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 ? @@ -140,31 +131,20 @@ export default function EditorSurface({ )); return ( -
- - {cover == null ? + // the Store covers the file tabs and the code card alike +
+ {!row && fixtureContent != null ? <> - {tabs} - {code} +
+ + Fixtures +
+ {fixtureContent} - :
-
- {tabs} - {code} -
- {cover} -
- } + : null} + {tabs} + {/* the files: the code card in row layout */} +
{code}
); } @@ -257,13 +237,15 @@ function EditorTabs({ closedList, onClick, compact, - hasHeaderControls, + subtabs, }: { documents: readonly CodeDocument[]; closedList: readonly boolean[]; onClick: (index: number) => void; compact: boolean; - hasHeaderControls: boolean; + /** Under the preview's header (the demo layout): a step above the bar, so + * the two rows read apart */ + subtabs: boolean; }) { const tabs = documents .map((document, index) => ({ document, index })) @@ -272,13 +254,14 @@ function EditorTabs({ return (
({ key: `${document.path}:${index}`, label: document.title, @@ -289,57 +272,3 @@ function EditorTabs({
); } - -function EditorHeader({ - title = ( - - Editor - - ), - 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 836821cdb3ef..ee975fcd9f97 100644 --- a/website/src/components/Playground/index.tsx +++ b/website/src/components/Playground/index.tsx @@ -1,10 +1,9 @@ -import Translate from '@docusaurus/Translate'; -import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; import clsx from 'clsx'; import React, { lazy, useCallback, useDeferredValue, + useId, useMemo, useRef, useState, @@ -22,17 +21,21 @@ import { import type { AutoRunScript } from './preview/autorun/script'; import FixturePreview from './preview/FixturePreview'; import type LivePreviewType from './preview/LivePreview'; -import PreviewWrapper from './preview/PreviewWrapper'; +import PendingSheet from './preview/PendingSheet'; +import PreviewWrapper, { + PreviewHeaderControls, + previewSurfaceClassName, +} from './preview/PreviewWrapper'; import type { LogOptions } from './preview/store/actionLog'; import { DemoViewContext, - StoreBadge, - StoreHeaderToggle, + StoreTab, StoreToggle, + useStoreMotion, 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 { @@ -41,19 +44,26 @@ 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 + * 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[]; 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 @@ -73,12 +83,12 @@ export default function Playground({ children, groupId = 'playground', defaultOpen = 'n', - row = false, + row: rowLayout = false, + demo = false, hidden = false, fixtures = [], getInitialInterceptorData, defaultTab, - headerControls, renderCount = false, actionLog: logOptions, autoRun, @@ -89,22 +99,56 @@ export default function Playground({ // Defer preview transpilation so editor input remains responsive. const documents = useDeferredValue(model.documents); - const [storeOpen, toggleStore, showStore, demoView] = useStoreOpen( - groupId, - defaultOpen, - ); - // Row layout: the Store slides over the code, leaving the preview usable + const layout = + demo ? 'demo' + : rowLayout ? 'row' + : 'stacked'; + const row = layout === 'row'; + 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); + 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 = (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); + // 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: { currentTarget: HTMLElement }) => { + setCodeShown(false); + toggleStore(event); + }, + [toggleStore], + ); // the line the Store last asked to show: a new object per ask, so asking // for the same line again shows it again - const [codeShown, showCode] = useState(null); + const [codeTarget, showCode] = useState(null); const openCode = useCallback( (target: CodeTarget) => { // in the same render, so the code is no longer inert by the time its // editor takes focus if (codeCovered) showStore(false); + setCodeShown(true); showCode({ ...target }); }, [codeCovered, showStore], @@ -125,39 +169,36 @@ export default function Playground({ [declared, openCode], ); - // Hydrate Monaco on first show and keep it (preserves undo / go-to-def). - const [editorInteractive, setEditorInteractive] = useState(!hidden); - useIsomorphicLayoutEffect(() => { - if (!hidden) setEditorInteractive(true); - }, [hidden]); + // 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); + if (codeVisible && !editorInteractive) setEditorInteractive(true); + const toggleCode = useCallback(() => setCodeShown(shown => !shown), []); const fixtureContent = fixtures.length ? : undefined; - const previewFixtures = row ? fixtureContent : undefined; + const previewFixtures = layout === 'stacked' ? undefined : fixtureContent; const editor = ( -
- - - : null - } covered={codeCovered} - reveal={codeShown} - coverTitle={Store} + reveal={codeTarget} /> ); + // 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: toggleCode } : undefined; // Live preview only while visible — unmounts when hidden (resets store). - const loading = ; + const loading = ( + + ); const preview = hidden ? loading : ( @@ -167,7 +208,7 @@ export default function Playground({ documents={documents} fixtureContent={previewFixtures} storeOpen={storeOpen} - toggleStore={toggleStore} + toggleStore={toggleStoreInPreview} row={row} storeHost={storeHost} fixtures={fixtures} @@ -176,6 +217,8 @@ export default function Playground({ actionLog={actionLog} autoRun={autoRun} showStore={showStore} + storeMoves={storeMoves} + code={code} /> @@ -191,33 +234,71 @@ export default function Playground({ })} >
- {editor} - {preview} + {demo ? + <> + {preview} + {editor} + + : <> + {editor} + {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 */ +/** 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, + code, }: { row: boolean; fixtureContent?: React.ReactNode; + code?: CodeControl; }) { return ( } + headerControls={} fixtures={fixtureContent} + 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/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts index 31d69f822e28..d7f279837081 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; @@ -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; diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx index 420e3e1aaaed..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 { ResetButton } from './PreviewError'; -import PreviewWrapper from './PreviewWrapper'; +import PreviewWrapper, { PreviewHeaderControls } 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 type { ShowStore } from './store/storeView'; -import { StoreHeaderToggle } from './StoreToggle'; import { useCodeManagers } from './useCodeManagers'; import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion'; import { usePreviewReset } from './usePreviewReset'; @@ -28,12 +26,15 @@ export interface LivePreviewProps extends PreviewProps { autoRun?: AutoRunScript; /** Opens or closes the Store for the demo, without saving it */ showStore: ShowStore; + /** Demo layout: the code in the result's place, and its toggle (../index.tsx) */ + code?: CodeControl; } export default function LivePreview({ documents, fixtureContent, storeOpen, + storeMoves, toggleStore, row, storeHost, @@ -43,6 +44,7 @@ export default function LivePreview({ actionLog, autoRun, showStore, + code: codeControl, }: LivePreviewProps) { usePlaygroundConsoleDemotion(); const { onCommit, badge } = useRenderCount(renderCount); @@ -92,17 +94,21 @@ export default function LivePreview({ : undefined } fixtures={fixtureContent} + code={codeControl} headerControls={ - <> - {badge} - - - + } > + +
+ ); +} diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index cfd8137703e4..fcc1aa50688b 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -11,14 +11,16 @@ 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 { previewSurfaceClassName } from './PreviewWrapper'; import SchemaRegistry from './store/schemaRegistry'; import StoreInspector from './StoreInspector'; -import styles from '../styles.module.css'; import type { PreviewProps } from '../types'; function Preview({ storeOpen, + storeMoves, toggleStore, row, storeHost, @@ -73,13 +75,13 @@ function Preview({ getInitialInterceptorData={getInitialInterceptorData} > - +
- + }> diff --git a/website/src/components/Playground/preview/PreviewBlock.tsx b/website/src/components/Playground/preview/PreviewBlock.tsx index 65e07e84c6b0..c2865cf3f56c 100644 --- a/website/src/components/Playground/preview/PreviewBlock.tsx +++ b/website/src/components/Playground/preview/PreviewBlock.tsx @@ -1,27 +1,43 @@ -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 = ; return ( <> - }> + }> {onCommit ? - + {preview} - : } + : preview} ); } +/** 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/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/PreviewWrapper.tsx b/website/src/components/Playground/preview/PreviewWrapper.tsx index c145e31508ce..15b0c1f54865 100644 --- a/website/src/components/Playground/preview/PreviewWrapper.tsx +++ b/website/src/components/Playground/preview/PreviewWrapper.tsx @@ -1,35 +1,52 @@ 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 { liveLabel } from '../liveLabel'; +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, StoreHeaderToggle } from './StoreToggle'; export default function PreviewWrapper({ children, headerControls, label, fixtures, + code, ref, }: 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', message: 'Fixtures', }); + const liveLabelNode = label ?? ( + + {liveLabel()} + + ); return (
- {label ?? ( - - {liveLabel()} + {code ? + + + {liveLabelNode} + + {editorLabel()} - )} + : liveLabelNode} {fixtures != null ? @@ -49,6 +66,9 @@ export default function PreviewWrapper({ : null} + {code ? + + : null} {headerControls}
@@ -64,11 +84,80 @@ export default function PreviewWrapper({
: null} -
{children}
+
+ {children} +
); } +/** 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. + * 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; + 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; @@ -77,5 +166,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 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/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/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 b9acec7381be..b9758b8b4a56 100644 --- a/website/src/components/Playground/preview/StoreToggle.tsx +++ b/website/src/components/Playground/preview/StoreToggle.tsx @@ -4,7 +4,15 @@ import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal'; import Translate, { translate } from '@docusaurus/Translate'; import clsx from 'clsx'; -import React, { createContext, useCallback, useMemo, useState } from 'react'; +import React, { + createContext, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ShowStore, StoreView } from './store/storeView'; import { useTabStorage } from '../../../utils/tabStorage'; @@ -42,14 +50,25 @@ export function useStoreOpen(groupId: string, defaultOpen: 'y' | 'n') { const open = shown?.open ?? (choice === 'y' || choice === 'n' ? choice : defaultOpen) === 'y'; + // moves made here, by the reader or a demo: only these animate the Store + // (`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( - (event: React.MouseEvent) => { + (event: { currentTarget: HTMLElement }) => { clicked = event.currentTarget; blockElementScrollPositionUntilNextRender(clicked); setChoice(open ? 'n' : 'y'); show(null); + setMoves(n => n + 1); }, [blockElementScrollPositionUntilNextRender, open, setChoice], ); @@ -57,6 +76,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 +92,66 @@ 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; + 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); + }; + }, [container, moving, moves, open]); + return moving; } /** Toggle row; also rendered (inert) by the preview loading fallback in ../index.tsx */ @@ -114,55 +193,115 @@ 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 ( ); } -/** 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, + controls, + ref, }: { onClick: React.MouseEventHandler; open: boolean; + /** The folder's id */ + controls: string; + ref?: React.Ref; }) { return ( ); } @@ -173,10 +312,10 @@ const toggleTitle = (open: boolean) => : translate({ id: 'playground.store.show', message: 'Show Store' }); /** 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
+
, + ); + // 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( + '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/preview/__tests__/StoreMotion.test.tsx b/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx new file mode 100644 index 000000000000..432bf0a2b987 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StoreMotion.test.tsx @@ -0,0 +1,80 @@ +/// +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); +}); + +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); +}); 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/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..7472d5558c4a 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,30 +31,75 @@ --store-kind-refreshed: var(--store-dim); --store-kind-expired: var(--store-stale); --store-stale-bg: color-mix(in srgb, var(--store-stale) 9%, transparent); + /* a pressed icon button's band */ + --store-pressed: color-mix(in srgb, var(--store-fg) 12%, 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); +/* 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. --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) + ); + --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); + /* what it holds stays clear of the run */ + padding-right: var(--store-head-run, 0px); + 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); - color: var(--store-muted); + 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(--pg-store-snappy); +} +.scrubber::before, +.scrubber::after { + content: ''; + position: absolute; + /* 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; +} +.scrubber::before { + right: calc(100% - 1px); + width: calc(var(--store-head-start, 0) * (var(--pg-head-flare, 0px) + 1px)); + background: radial-gradient( + 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% + ); +} +.scrubber::after { + left: calc(100% - 1px); + width: calc(var(--store-head-end, 0) * (var(--pg-head-flare, 0px) + 1px)); + background: radial-gradient( + 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% + ); } - /* Under the scrubber: the tab bar, and (narrow) the sheet the lanes open in over what follows */ .body { @@ -61,19 +108,24 @@ 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 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 +134,43 @@ 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 (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; flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; + 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; +} +/* 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 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 { + flex: none; + display: flex; + align-items: center; + min-height: 2.25rem; + padding: 0 0.5rem; +} .barEnd { display: flex; align-items: center; @@ -111,8 +185,9 @@ .tab { display: inline-flex; align-items: center; - padding: 0 0.75rem; + padding: var(--pg-tab-pad); border: 0; + border-radius: var(--pg-tab-radius); background: none; color: inherit; font: inherit; @@ -123,16 +198,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 +225,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 +302,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 +354,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 +426,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 +476,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 +652,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 +664,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 +680,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 +727,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 +815,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 +971,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 +993,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 +1011,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 +1038,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 +1069,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 +1101,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 +1112,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 +1364,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 +1434,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 +1446,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 +1459,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 +1516,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 +1546,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 +1570,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 +1585,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 +1632,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 +1654,7 @@ div.joined { font: inherit; } .sheetBar button:hover { - background: var(--pg-tab-hover-bg); + background: var(--store-hover); opacity: 1; } .sheet .tlLanes { @@ -1597,7 +1672,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 +1684,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 +1939,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 +1975,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 6fe3e1bd82fd..f25dd77e1506 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -6,11 +6,25 @@ 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 */ --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); @@ -31,14 +45,35 @@ --store-record: rgba(130, 170, 255, 0.07); --store-tip: #3a4057; --store-bg: var(--monoco-code-background); + /* 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-2: #eef2f7; + --pg-line: #d8e0ea; + --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); - box-shadow: var(--ifm-global-shadow-lw); + /* 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 2px 8px -2px var(--pg-shadow-edge), + 0 24px 60px -30px var(--pg-shadow); overflow: visible; display: flex; align-items: stretch; 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; } @@ -56,21 +91,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); } @@ -116,6 +150,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 { @@ -128,11 +180,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 */ + (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: hsl(229, 9%, 21%); - --pg-tab-hover-bg: hsl(229, 9%, 26%); + --pg-bar: #292c34; + --pg-tab-hover-bg: #343842; +} +:root[data-theme='dark'] .playgroundContainer { + /* the sheet's wells a step above it */ + --pg-panel-2: #1d2635; + --pg-line: #2a344c; + --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 { @@ -149,7 +214,7 @@ div.playgroundTextEdit > .playgroundHeader, .endpointPlayground .playgroundHeader { box-shadow: inset 0 -1px 0 var(--pg-bar-edge); } -.playgroundContainer:not(.row) +.playgroundContainer:not(.row, .demo, .endpointPlayground) > div.playgroundTextEdit > .playgroundHeader:first-child { border-top-right-radius: var(--ifm-global-radius); @@ -176,7 +241,7 @@ div.playgroundTextEdit > .playgroundHeader, .playgroundEditor :global(.prism-code) { font: 13px / 19px var(--ifm-font-family-monospace) !important; } -.playgroundContainer:not(.row) { +.playgroundContainer:not(.row, .demo, .endpointPlayground) { background-color: var(--monoco-code-background); } .closed { @@ -211,12 +276,7 @@ div.playgroundTextEdit > .playgroundHeader, } :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); - background-color: var(--ifm-pre-background); 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). @@ -314,7 +374,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; @@ -330,13 +391,40 @@ 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: 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; 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-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, up to the screen's height, held in view while a taller result scrolls by @@ -360,137 +448,377 @@ 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 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; + /* the folder's header is narrower than its content: inset by this at + 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-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; } - -/* 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 { +/* 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; +} +.row .tabs { + height: 100%; + /* the header's width, so a short row has room for the last tab's curve */ 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); + min-width: 0; } -.editorBody > * { - grid-area: 1 / 1; +.row .tab { + gap: 0.5em; + white-space: nowrap; + height: 100%; + 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 */ + transition: 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); } -/* 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); +/* 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 + below it: the tab row would scroll) */ +.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 { + bottom: 0; + height: var(--pg-card-radius); +} +.row .tab.selected::before { + right: calc(100% - 1px); + --pg-fillet-at: 0 0; +} +.row .tab.selected::after { + 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; } -.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; +} +/* 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: 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 */ +.row + .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. 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; - /* over the code */ z-index: 2; - overflow: hidden; + isolation: isolate; + display: flex; + flex-direction: column; + border-radius: var(--pg-card-radius); + /* 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; - /* 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; +/* 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); +} +/* 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: calc(-1 * var(--pg-head-flare-drop)); + width: calc(var(--pg-head-flare) + 1px); + height: var(--pg-head-flare-depth); + pointer-events: none; } -/* 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 .previewHeader::before { + right: calc(100% - 1px); + background: radial-gradient( + 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% + ); } -/* 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 .previewHeader::after { + left: calc(100% - 1px); + background: radial-gradient( + circle var(--pg-head-flare-radius) at + calc(1px + var(--pg-head-flare-radius)) 0, + transparent calc(100% - 0.5px), + var(--pg-stage) 100% + ); } -.storeHost > .storePanel { - flex: 1 1 100%; +.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; + min-width: 0; +} +.row > .previewWrapper:has(.sheet:not(:empty))::after { + background: var(--pg-panel); +} +.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, + 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; + /* 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) */ + overflow: visible; + pointer-events: none; +} +.row > .storeHost > .storeDrawer { + pointer-events: auto; + 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 */ +.row > .storeHost .storePanel { + --store-head-start: 1; display: flex; flex-direction: column; - pointer-events: auto; + height: 100%; + min-height: 0; + background: none; } -/* 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); -} - -.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%; +/* 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; over + that edge's shadow, so the two read as one paper */ +.storeTab { + position: absolute; 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; + 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); } -/* over the Store (z-index 2) from opening until it has slid back out */ -.storeHost:has(> .storePanel[data-visible]) + .storeBadge { - z-index: 3; +.storeTab[aria-expanded='true'] { + color: var(--ifm-color-primary); } -.storeBadge:hover { - background: var(--pg-tab-hover-bg) - linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06)); +@media (hover: hover) { + .storeTab:hover { + color: var(--pg-text); + } } -.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; +} +.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, + --pg-fillet-r round */ +.row .tab.selected::before, +.row .tab.selected::after, +.storeTab::before, +.storeTab::after { + content: ''; + position: absolute; + 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-fillet-r, var(--pg-card-radius)) - 0.5px), + var(--pg-fillet-color, var(--pg-stage)) + var(--pg-fillet-r, var(--pg-card-radius)) + ); } -.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 */ @@ -499,13 +827,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; @@ -528,12 +852,17 @@ 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; } +/* 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) { @@ -578,7 +907,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 */ @@ -608,6 +938,89 @@ div.playgroundTextEdit > .playgroundHeader, opacity: 1; translate: 0 0; } +/* 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; +} +@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); + 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 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 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: var(--pg-tab-radius); + 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: var(--pg-tab-radius); + 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 */ .previewHeader:has(.fixturesToggle:hover) @@ -619,45 +1032,44 @@ 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); -} -:root[data-theme='dark'] .playgroundPreview { - /* The demo's content is quieter than the frame: a hint of the code's hue */ - --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% - ); +/* 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 + 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); } -:root .playgroundPreview { - --ifm-heading-margin-bottom: 0.3ex; - --ifm-heading-font-weight: 500; - --ifm-heading-line-height: 1.2; +.playgroundPreview { + /* the sheet fills the preview's height */ + display: flex; + flex-direction: column; overflow: auto; } +.sheet { + flex: 1 0 auto; + padding: var(--pg-sheet-pad); + 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; +} +.sheet:empty { + display: none; +} /* 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 @@ -670,7 +1082,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); @@ -682,7 +1094,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; @@ -737,10 +1149,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; @@ -750,218 +1160,14 @@ 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 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 */ @@ -1136,13 +1342,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 @@ -1164,88 +1367,406 @@ 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 */ @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)); + /* 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 */ + 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; + /* 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)); + } + /* 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) + ); + } + .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 + edge (here), and the Store's sheet below it (.row::before) */ + .row > .previewWrapper::before, + .row::before { + content: ''; + position: absolute; + left: var(--pg-fold); + box-shadow: var(--pg-card-shadow); + pointer-events: none; + } + .row > .previewWrapper::before { + inset-block: var(--pg-row) var(--pg-preview-gap, 0px); + right: 0; + } + .row::before { + top: calc(100% - var(--pg-preview-gap, 0px)); + bottom: 0; + 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 { + left: 0; + } + .row > .previewWrapper::before, + .row::before { + transition: left var(--pg-store-drawer); + } + .row:has(> .storeHost .storePanel[data-visible]) > .previewWrapper { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + /* 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 (its header stays inset: + the Store's covers that, see .storeHost) */ + .row .playgroundResult, + .row .sheet { + transition: border-top-left-radius var(--pg-store-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 { + border-bottom-left-radius: calc( + var(--pg-card-radius) - var(--pg-foot-curve) + ); + } + /* 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 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; + 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, and the + file tabs would show past the Store header's inset */ + .row .editorDocs > .playgroundEditor, + .row .editorDocs::after { + transition: box-shadow var(--pg-store-drawer); + } + .row .tabControls { + transition: opacity var(--pg-store-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; + } + /* 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; + --pg-fold-head: calc(var(--pg-fold) + var(--pg-head-run)); + 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 + height */ + .row > .storeHost > .storeDrawer { + position: absolute; + inset: 0 auto 0 0; + width: var(--pg-fold); + translate: 100% 0; + } + .row > .storeHost[data-open] > .storeDrawer { + 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, 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); + --store-folder-foot: var(--pg-card-radius); + --store-folder-end: var(--pg-preview-gap, 0px); + --store-body-pad: 0 var(--pg-gap) 0 0; + --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 */ + .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 */ + .storeTab { + right: 100%; + 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); + } + .storeTab::before { + right: -1px; + bottom: calc(100% - 1px); + --pg-fillet-at: 0 0; + } + .storeTab::after { + right: -1px; + top: calc(100% - 1px); + --pg-fillet-at: 0 100%; + } + .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 { + .playgroundContainer.endpointPlayground + .playgroundHeader.tabControls:first-of-type { border-top-right-radius: 0; } - .storeHost { - flex-direction: row; - border-bottom-left-radius: var(--ifm-global-radius); +} +/* 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 usual one: shut, the handle overlaps the code card's foot */ +@container playground (max-width: 719px) { + .playgroundContainer.row { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + row-gap: var(--pg-gap); + } + .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) */ + .row > .storeHost { + grid-area: 1 / 1 / 2 / 2; + clip-path: inset(-4rem -4rem calc(-1 * var(--pg-gap)) -4rem); + } + /* 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 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; + } + /* 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 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: var(--pg-sheet-radius); + --store-body-pad: 0 0 var(--pg-gap); } - .editorBody:first-child .storeHost { + /* 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: var(--pg-row) 0 4rem; + z-index: -1; + box-shadow: var(--pg-card-shadow); + opacity: 0; + transition: opacity var(--pg-store-drawer); + } + .row > .storeHost[data-open] > .storeDrawer::before { + opacity: 1; + } + /* 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; } - /* the fixtures span the whole playground: the preview column is too thin - for a response. Unpositioned, the preview's clip doesn't reach them */ - .playgroundContainer.row { - position: relative; + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader { + --pg-head-start: 0px; + --pg-head-end: 0px; + border-radius: 0; } - .playgroundContainer.row > .previewWrapper { - position: static; + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before, + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::after { + opacity: 0; } -} -/* 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:has(> .storeHost .storePanel[data-visible]) .playgroundResult { + border-radius: 0; } - .storeHost { - contain: none; + /* 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(--pg-store-snappy), + padding var(--pg-store-snappy), + border-radius var(--pg-store-snappy); } - .storeHost:has(> .storePanel[data-visible]) { - min-height: 0; + .row .previewHeader::before, + .row .previewHeader::after { + transition: opacity var(--pg-store-fade); } - .storeHost > .storePanel > * { - min-height: var(--store-min); - max-height: var(--store-cap); + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::before, + .row:has(> .storeHost .storePanel[data-visible]) .previewHeader::after { + transition: none; } - /* the Store toggle moves into the preview's header */ - .storeBadge { - display: none; + .row .playgroundResult { + transition: border-radius var(--pg-store-snappy); } - .playgroundContainer.row .storeHeaderToggle { - display: inline-flex; + /* 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(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; } - /* 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); + .storeTab::before { + right: calc(100% - 1px); + bottom: -1px; + --pg-fillet-at: 0 0; } - .fixturesPopover { - max-height: min(14rem, 50svh); + .storeTab::after { + left: calc(100% - 1px); + bottom: -1px; + --pg-fillet-at: 100% 0; } } +/* 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; +} +.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 { + min-height: var(--pg-demo-min); +} +.demo[data-code] .playgroundResult { + display: none; +} +.demo:not([data-code]) > .playgroundTextEdit { + display: none; +} +.demo > .playgroundTextEdit { + min-height: var(--pg-demo-min); + background-color: var(--monoco-code-background); +} + /* EndpointPlayground: left tab bar and right fixture headers share one height */ .endpointPlayground .playgroundHeader { display: flex; @@ -1283,22 +1804,42 @@ div.fixtureJson + div.fixtureJson { left: 0.75rem; } -/* Live Preview label: a drawn dot renders the same on every OS, unlike 🔴 */ +/* 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: 0.6em; + gap: 10px; } .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: 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 { + /* on the stage */ + --pg-bar: transparent; + --pg-tab: var(--pg-muted); + --pg-tab-hover-bg: var(--pg-hover); + --pg-tab-hover: var(--pg-text); + min-height: 44px; + /* 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 label's dot (still named for screen readers), so the path gets the room */ @@ -1321,7 +1862,6 @@ div.fixtureJson + div.fixtureJson { font: inherit; letter-spacing: inherit; text-transform: inherit; - color: inherit; cursor: pointer; } .demoLabel:focus-visible { @@ -1329,11 +1869,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); } @@ -1342,7 +1886,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; } } @@ -1350,7 +1894,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); } } @@ -1361,16 +1905,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; - 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: 0 0.7em; + border: 0; + border-radius: 999px; + background: var(--pg-hover); color: inherit; cursor: pointer; + font-variant-numeric: tabular-nums; } .previewControls { display: flex; @@ -1406,8 +1950,15 @@ div.fixtureJson + div.fixtureJson { .headerButton.storeHeaderToggle { display: none; } -/* the fixtures toggle (row) is lit the same while they show */ +/* over a demo's code the side strip hides with the preview, so the header's + toggle stands in for it */ +.demo[data-code] .headerButton.storeHeaderToggle { + display: inline-flex; +} +/* 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); @@ -1447,8 +1998,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; } 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'; 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 }); +} diff --git a/website/src/components/Playground/types.ts b/website/src/components/Playground/types.ts index 5bc60f6ad4c0..0a2d1b4d6807 100644 --- a/website/src/components/Playground/types.ts +++ b/website/src/components/Playground/types.ts @@ -3,8 +3,19 @@ import type { MouseEventHandler } 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; + /** Shows or hides it (the header's Code toggle) */ + toggle: () => void; +} + 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 */ 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; diff --git a/website/src/css/root.css b/website/src/css/root.css index 14e0ced6514c..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: hsl(229, 20%, 15%); + --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); 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 @@ - - - - - - - - - - - - - - - diff --git a/website/translate/lock/es.json b/website/translate/lock/es.json index a372cea562b4..c66d71680213 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": "ac04c4154461fc41535d4ff7d810d1b165209b08", - "docs/core/concepts/expiry-policy.md": "d274674f9ee59f3b598c991a2a14ed0ba37431b2", + "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": "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", @@ -410,7 +410,10 @@ "agentPrompt.copyFor": "Copy prompt for {agent}", "agentPrompt.copy": "Copy prompt for your coding agent", "theme.CodeBlock.terminal": "Run in your terminal", - "theme.CodeBlock.terminalTitle": "Terminal" + "theme.CodeBlock.terminalTitle": "Terminal", + "playground.code.hide": "Hide 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 0fd0e80b0d2c..39b46a604deb 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": "ac04c4154461fc41535d4ff7d810d1b165209b08", - "docs/core/concepts/expiry-policy.md": "d274674f9ee59f3b598c991a2a14ed0ba37431b2", + "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": "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", @@ -410,7 +410,10 @@ "agentPrompt.copyFor": "Copy prompt for {agent}", "agentPrompt.copy": "Copy prompt for your coding agent", "theme.CodeBlock.terminal": "Run in your terminal", - "theme.CodeBlock.terminalTitle": "Terminal" + "theme.CodeBlock.terminalTitle": "Terminal", + "playground.code.hide": "Hide 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 46fbb4ad2ca0..ea2ea6cfb257 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": "ac04c4154461fc41535d4ff7d810d1b165209b08", - "docs/core/concepts/expiry-policy.md": "d274674f9ee59f3b598c991a2a14ed0ba37431b2", + "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": "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", @@ -410,7 +410,10 @@ "agentPrompt.copyFor": "Copy prompt for {agent}", "agentPrompt.copy": "Copy prompt for your coding agent", "theme.CodeBlock.terminalTitle": "Terminal", - "theme.CodeBlock.terminal": "Run in your terminal" + "theme.CodeBlock.terminal": "Run in your terminal", + "playground.code.hide": "Hide code", + "playground.code.show": "Show code", + "playground.cancelButton.label": "Remove" }, "docusaurus-plugin-content-blog/options.json": { "title": "Blog",