diff --git a/.claude/skills/add-feature-demo/SKILL.md b/.claude/skills/add-feature-demo/SKILL.md index 344e9e623..4b21b8396 100644 --- a/.claude/skills/add-feature-demo/SKILL.md +++ b/.claude/skills/add-feature-demo/SKILL.md @@ -6,8 +6,8 @@ description: Use this skill when adding a new feature to the engine. # Add a Demo `documentation-site/src/pages/demos//` holds interactive, in-browser -demos of engine features, each rendered through -`documentation-site/src/components/Demo.tsx`. Read `AGENTS.md`'s +demos of engine features, each rendered through `DemoPage` +(`documentation-site/src/components/demo-page/`). Read `AGENTS.md`'s "Documentation Site Demos" section first for the `file:..`/`dist` gotcha that governs how demos are verified — it applies to every demo, new or existing. @@ -57,8 +57,8 @@ Follow the existing file split, every demo uses the same shape: `_create-boundaries.ts` from `_spawn-shapes.ts`, not into one monolithic file — each file also becomes its own tab in the demo's code viewer (see step 3). -- `index.tsx` — the page itself, a thin wrapper around `` (see step - 3). +- `index.tsx` — the page itself, a thin wrapper around `` (see + step 3). Demo-only components/systems (input handling for interaction, camera follow, reset-on-key, etc.) still follow the engine's own component/system @@ -71,59 +71,93 @@ instead of `/src` because they're not part of the public API. import React, { JSX } from 'react'; import { createGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import someOtherCode from '!!raw-loader!./_some-other-file'; +import thingSystemCode from '!!raw-loader!./_thing.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function (): JSX.Element { return ( - Demo', - description: 'A demo showcasing .', - }} - header="" - blurb="<1-3 sentences: what the demo shows, what the engine feature - does, and how to interact with it (click/drag/keys) if applicable.>" + Game} - codeFiles={[ - { name: 'game.ts', content: gameCode }, - { name: 'some-other-file.ts', content: someOtherCode }, + controls={[ + { inputs: ['←', 'A'], action: 'Move left' }, + { inputs: [{ device: 'mouse', label: 'Click' }], action: 'Select' }, + ]} + highlights={[ + { + text: '', + file: 'thing.system.ts', + }, + ]} + docLinks={[{ label: '', to: '/docs/docs//' }]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: '', + content: gameCode, + }, + ], + }, + { + title: '', + files: [ + { + name: 'thing.system.ts', + summary: '', + content: thingSystemCode, + }, + ], + }, ]} /> ); } ``` -Each `codeFiles` entry drives one tab in the demo's live code viewer, so -list every demo-source file the reader would need to fully understand the -scene, in the order that makes it easiest to follow (usually `game.ts` -first, since it's the entry point). Import each via `!!raw-loader!` exactly -like `gameCode` — this is what pulls the raw source text in at build time, -not the compiled/imported binding. - -If the demo needs live interaction controls beyond canvas input (a slider, -a toggle button), pass a React node via `interactions` — see -`space-shooter/index.tsx` and `_BloomControls.tsx` for the pattern of a -small controls component alongside the demo files. - -## 4. Wire it into the navbar - -Add an entry to the `Demos` dropdown in -`documentation-site/docusaurus.config.ts` (currently around line 114), -alphabetical-ish grouping isn't strictly enforced but new entries are -generally added near thematically similar ones (physics demos are grouped -together, etc.): - -```typescript -{ - to: 'demos/', - label: '', -}, -``` - -This is the only place demos are registered/linked — there's no separate -index page or sidebar to update. +The page's title and one-line summary come from the demo's entry in +`documentation-site/src/data/demos.ts` (step 4), so the catalogue card and +the page always agree. Write the rest for a reader who has never seen the +feature: + +- `controls`: one row per action, with alternative inputs in the same + row. Check each binding against the demo's input code. Leave it out for + a demo you only watch. +- `highlights`: 3-5 "How it works" points, one short sentence each, each + linked to the file that implements it. Clicking the link opens that file + in the code explorer. +- `docLinks`: the guides under `documentation-site/docs/docs` that cover + the feature (`/docs/docs/`, a folder's `index.md` is + `/docs/docs/`). +- `fileGroups`: every demo-source file a reader needs, grouped by purpose, + with `create-game.ts` alone under "Start here". Name each after its + source file without the leading underscore, and keep the + `.component.ts`/`.system.ts` suffixes: the explorer's type icons and + filter come from them. Import each via `!!raw-loader!` exactly like + `gameCode`, which pulls in the raw source text at build time. +- **Never list React or other framework code** (`.tsx`/`.jsx`) in + `fileGroups`. The explorer shows engine code a reader can reuse in their + own game; a settings panel that only exists to drive this site's page + isn't that. `DemoPage` throws if a `.tsx`/`.jsx` file is listed. + +If the demo needs something else beside the game, such as live settings (a +slider, a toggle) or a legend for colors in the scene, pass it as `panels`, +wrapped in `DemoPanel` (with `DemoLegend` for a legend). Build live +settings from `DemoSettings`, `DemoSlider`, `DemoToggle`, `DemoChoice` and +`DemoTextField`. Keep the game-side logic the panel changes in a plain +`.ts` file (shown in the explorer) and the React panel itself out of the +explorer. See `text/index.tsx` and `easing-functions/index.tsx`. + +## 4. Add it to the demo catalogue + +Add an entry to `documentation-site/src/data/demos.ts` with the demo's +`slug` (its directory name), `title`, a one-sentence `description` (what +you see or do, and which feature it shows; it's the page's summary too) and +the category slugs it belongs to. See AGENTS.md's "Demo Catalogue and +Categories" section. ## 5. Verify @@ -138,23 +172,14 @@ verification checklist: already-built, unchanged API). 2. From `documentation-site/`: `npm run typecheck`, then `npm run build` (`docusaurus build`) — this is what actually catches a broken import - against the published package surface or an MDX/broken-link error from - the navbar entry. + against the published package surface or a broken `docLinks` entry. 3. `npm run start` in `documentation-site/` (or reuse a running dev server), open `demos/` in a browser with a full page reload (fast refresh doesn't guarantee a clean re-init), and confirm it - renders and behaves correctly — click through any interactions the - `blurb` describes. -4. **Also click the entry from the actual "Demos" navbar dropdown** - don't - only visit the URL directly. Adding the array entry in step 4 above is - necessary but not sufficient: the dropdown has grown to ~30 entries and - its `.dropdown__menu` is capped to the viewport height with internal - scrolling (`documentation-site/src/css/custom.css`) specifically so - every entry stays reachable regardless of list length - confirm your new - entry actually scrolls into view and is clickable in a real browser, - since a syntactically-correct array entry can still end up unreachable - if that CSS ever regresses or a future redesign changes the dropdown - markup. + renders and behaves correctly — try every control the page lists and + open each highlight's file link. +4. Open the demo from its card on `/demos` too, to check the catalogue + entry. If this was prompted by a `/src` change (not a brand-new demo for existing functionality), also re-run the full root-level `CLAUDE.md` verification diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index 05e559eab..7538c4616 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -394,3 +394,13 @@ Zoltan rrggbb rrggbbaa zstandard +sublist +orbitron +mcinerney +inerney +rasmus +andersson +oxanium +overcrafted +squaremeapixel +manu diff --git a/AGENTS.md b/AGENTS.md index c852ea38b..cd12289ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -673,7 +673,14 @@ e2e/playwright-report` opens the HTML report, which links each test's `documentation-site/src/pages/demos//` holds interactive, in-browser demos of engine features (`physics`, `ecs`, `particles`, `rendering`, ...), -each rendered through `documentation-site/src/components/Demo.tsx`. +each rendered through `DemoPage` (`documentation-site/src/components/demo-page/`): +the running game with its controls and a short "How it works" list beside +it, and a code explorer for the demo's source below. Its title and summary +come from the demo's entry in `documentation-site/src/data/demos.ts`. The +`add-feature-demo` skill shows how to write a page. The code explorer only +shows engine code a reader can reuse: never list React or other framework +code (`.tsx`/`.jsx`) in it, such as a settings panel that only drives the +site's page. **Critical gotcha**: these demos import the engine as a published package (e.g. `import { addRigidBodyComponent } from '@forge-game-engine/forge/physics'`), @@ -721,8 +728,9 @@ wrapper around the shared `CategoryDemosPage` component. The catalogue's data lives separately from the demo pages themselves: - `documentation-site/src/data/demo-categories.ts` - the fixed list of - categories (slug, title, description, card image under - `static/img/demos/categories/`). + categories (slug, title, description, and the line icon its card shows, + from `src/components/ModuleIcon.tsx`, the same set the homepage's feature + grid uses). - `documentation-site/src/data/demos.ts` - one entry per demo (slug, title, short description, and the category slugs it belongs to). A demo can belong to more than one category - e.g. the car demo is tagged both @@ -731,8 +739,8 @@ The catalogue's data lives separately from the demo pages themselves: **When adding a new demo**, also add an entry to `demos.ts` with the category slugs it belongs to (adding a new category itself means adding to -`demo-categories.ts` and a matching SVG under -`static/img/demos/categories/`, plus a new +`demo-categories.ts` with a `ModuleIcon` name, drawing a new icon in +`ModuleIcon.tsx` in the same style if none fits, plus a new `src/pages/demos/category//index.tsx` wrapper). A demo that combines many engine features into a complete mini-game (like the space shooter, brick breaker and car demos) belongs in the `games` category regardless of diff --git a/documentation-site/docs/credits.md b/documentation-site/docs/credits.md index 3d29e30be..176e0dd8d 100644 --- a/documentation-site/docs/credits.md +++ b/documentation-site/docs/credits.md @@ -2,24 +2,82 @@ title: Credits and attributions --- -## Icons +Forge's demos, documentation and website use art, audio and fonts made by +the people below. Thank you to all of them. + +## Sprites and textures -Scroll down icons created by [Smashicons](https://www.flaticon.com/free-icons/scroll-down) from [Flaticon](https://www.flaticon.com/) +- [Kenney](https://kenney.nl), all licensed + [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/): + - [UI Pack](https://kenney.nl/assets/ui-pack): the buttons, sliders, text + inputs and panels in the UI demos. + - [Fantasy UI Borders](https://kenney.nl/assets/fantasy-ui-borders): the + frames in the nine-slice and masks demos. + - [Particle Pack](https://kenney.nl/assets/particle-pack): the particle + textures in the particles demo. + - [Pattern Pack](https://kenney.nl/assets/pattern-pack): the backgrounds in + the physics demos. + - [Pirate Pack](https://kenney.nl/assets/pirate-pack): the sprites in the + pirate battle demo. + - [Puzzle Pack 2](https://kenney.nl/assets/puzzle-pack-2): the sprites in + the brick breaker demo. + - [Rolling Ball Assets](https://kenney.nl/assets/rolling-ball-assets): the + balls and blocks in the physics demos. + - [Simple Space](https://kenney.nl/assets/simple-space): ships, meteors and + stars in the space demos. +- [Screaming Brain Studios](https://screamingbrainstudios.com/dl-seamless-space-backgrounds/): + the nebula space background. +- [Wenrexa](https://opengameart.org/content/assets-free-laser-bullets-pack-2020): + the laser bullets in the space shooter demo. +- [Squaremeapixel](https://squaremeapixel.itch.io/space-pack): the + spaceships and asteroids in the space shooter demo. +- [CodeManu](https://codemanu.itch.io/vfx-free-pack): the explosion + animation in the space shooter demo. +- [Overcrafted](https://overcrafted.itch.io/free-paid-vehicle-car-sprites-): + the car body and wheels in the car demo. +- [Deep-Fold's Pixel Planet Generator](https://deep-fold.itch.io/pixel-planet-generator): + the pixel-art planet in the rendering demos and blog. +- [OboroPixel](https://oboropixel.itch.io/characters-animations-asset-pack): + the soldier sprite sheets in the sprite animation docs. -## Sprites +## Music and sound effects -- Some assets used in the demos and documentation have been provided by [Screaming Brain Studios - OpenGameArt.org](https://opengameart.org/content/seamless-space-backgrounds) -- Some assets used in the demos and documentation have been provided by [Wenrexa - OpenGameArt.org](https://opengameart.org/content/assets-free-laser-bullets-pack-2020) -- Some assets used in the demos and documentation have been provided by [OboroPixel - itch.io](https://oboropixel.itch.io/characters-animations-asset-pack) +- Music by + [GerardDEP](https://pixabay.com/users/gerarddep-48160304/?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=363808) + from + [Pixabay](https://pixabay.com/?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=363808). +- Sound effects by + [LoLa Joy](https://pixabay.com/users/u_xg7ssi08yr-48039129/?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=381976) + from + [Pixabay](https://pixabay.com/?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=381976). ## Fonts -- The engine's shipped default font atlas, and the text rendering demo's - font, both use [Liberation Sans](https://github.com/liberationfonts) - (Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation), - licensed under the [SIL Open Font License, Version 1.1](https://scripts.sil.org/OFL). +All licensed under the +[SIL Open Font License, Version 1.1](https://openfontlicense.org): + +- [Liberation Sans](https://github.com/liberationfonts) (Copyright (c) 2012 + Red Hat, Inc. with Reserved Font Name Liberation): the engine's default + font atlas, used by the text demo. +- [Orbitron](https://github.com/theleagueof/orbitron) by Matt McInerney: + the website's headings. +- [Inter](https://github.com/rsms/inter) by Rasmus Andersson: the website's + body text. +- [JetBrains Mono](https://github.com/JetBrains/JetBrainsMono) by JetBrains: + the website's code. + +## Icons + +- [Font Awesome Free](https://fontawesome.com) icons, licensed + [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). + +## Galactic Journey -## Music +The gameplay video on the home page is from +[Galactic Journey](https://galactic-journey-6517e.web.app), a game made with +Forge. Besides the art credited above, it uses: -- Some music used in the demos and documentation have been provided by [GerardDEP](https://pixabay.com/users/gerarddep-48160304/?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=363808) from [Pixabay](https://pixabay.com//?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=363808) -- Some sound effects used in the demos and documentation have been provided by [LoLa Joy](https://pixabay.com/users/u_xg7ssi08yr-48039129/?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=381976) from [Pixabay](https://pixabay.com//?utm_source=link-attribution&utm_medium=referral&utm_campaign=music&utm_content=381976) +- The sci-fi UI kit by [Wenrexa](https://wenrexa.itch.io). +- [Oxanium](https://github.com/sevmeyer/oxanium) (Copyright 2019 The Oxanium + Project Authors), licensed under the + [SIL Open Font License, Version 1.1](https://openfontlicense.org). diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index 36876486f..9d9740c3a 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -56,7 +56,16 @@ const config: Config = { onUntruncatedBlogPosts: 'warn', }, theme: { - customCss: './src/css/custom.css', + // Self-hosted fonts: Orbitron for headings, Inter for body text and + // JetBrains Mono for code and small eyebrow labels. + customCss: [ + require.resolve('@fontsource/orbitron/600.css'), + require.resolve('@fontsource/orbitron/700.css'), + require.resolve('@fontsource-variable/inter/index.css'), + require.resolve('@fontsource/jetbrains-mono/400.css'), + require.resolve('@fontsource/jetbrains-mono/500.css'), + './src/css/custom.css', + ], }, } satisfies Preset.Options, ], @@ -95,6 +104,7 @@ const config: Config = { // Replace with your project's social card image: 'img/forge-social-card.jpg', colorMode: { + defaultMode: 'dark', respectPrefersColorScheme: true, }, navbar: { diff --git a/documentation-site/package-lock.json b/documentation-site/package-lock.json index c5ee2dc0a..82e96e23e 100644 --- a/documentation-site/package-lock.json +++ b/documentation-site/package-lock.json @@ -12,6 +12,9 @@ "@docusaurus/faster": "^3.10.2", "@docusaurus/preset-classic": "^3.10.1", "@docusaurus/theme-mermaid": "^3.10.1", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/orbitron": "^5.3.0", "@forge-game-engine/forge": "file:..", "@mdx-js/react": "^3.1.1", "clsx": "^2.0.0", @@ -36,7 +39,7 @@ }, "..": { "name": "@forge-game-engine/forge", - "version": "0.25.8", + "version": "0.26.1", "license": "MIT", "dependencies": { "@types/imurmurhash": "^0.1.4", @@ -4288,6 +4291,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/orbitron": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/orbitron/-/orbitron-5.3.0.tgz", + "integrity": "sha512-+BhPsbUAo/AF7sRcf+TEexm+EWWkgTiR9Zwb7AhYIPkc5oplfvw0C45Oc2xlKPhwVEmxG8hHuA6/Ansbl+Bc6w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@forge-game-engine/forge": { "resolved": "..", "link": true diff --git a/documentation-site/package.json b/documentation-site/package.json index eecf2b174..d25284e00 100644 --- a/documentation-site/package.json +++ b/documentation-site/package.json @@ -22,6 +22,9 @@ "@docusaurus/faster": "^3.10.2", "@docusaurus/preset-classic": "^3.10.1", "@docusaurus/theme-mermaid": "^3.10.1", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/orbitron": "^5.3.0", "@forge-game-engine/forge": "file:..", "@mdx-js/react": "^3.1.1", "clsx": "^2.0.0", diff --git a/documentation-site/src/components/CategoryDemosPage.tsx b/documentation-site/src/components/CategoryDemosPage.tsx index 28eedce5c..3990c67c9 100644 --- a/documentation-site/src/components/CategoryDemosPage.tsx +++ b/documentation-site/src/components/CategoryDemosPage.tsx @@ -34,8 +34,9 @@ export const CategoryDemosPage: FC = ({ ← All categories +

Demos

{category.title}

-

{category.description}

+

{category.description}

{categoryDemos.map((demo) => ( = ({ - metaData, - blurb, - interactions, - header, - createGame, - codeFiles, -}) => { - const demoBoxRef = useRef(null); - const { isFullscreen, toggleFullscreen } = useFullscreen(demoBoxRef); - - // `Game` keeps its render context sized to its container itself (via a - // `ResizeObserver`), so entering/exiting fullscreen resizes the running - // game in place instead of needing a restart. - useGame(createGame); - - // The category page a demo was navigated from is threaded through as a - // `from` query param (see `DemoCard`), so the back button returns to the - // specific category the user was browsing rather than always the full - // demo catalogue. - const location = useLocation(); - const fromCategory = demoCategories.find( - (category) => - category.slug === new URLSearchParams(location.search).get('from'), - ); - const backLink = fromCategory - ? { - to: `/demos/category/${fromCategory.slug}`, - label: `← Back to ${fromCategory.title}`, - } - : { to: '/demos', label: '← All demos' }; - - return ( - -
- - {backLink.label} - -

{header}

- {interactions} -
-
- -
- -
-

{blurb}

-
-
- ); -}; diff --git a/documentation-site/src/components/DemoCategoryCard.tsx b/documentation-site/src/components/DemoCategoryCard.tsx index d30c04d35..87ddaaf6b 100644 --- a/documentation-site/src/components/DemoCategoryCard.tsx +++ b/documentation-site/src/components/DemoCategoryCard.tsx @@ -1,7 +1,8 @@ import React, { FC } from 'react'; import Link from '@docusaurus/Link'; -import useBaseUrl from '@docusaurus/useBaseUrl'; import { DemoCategory } from '@site/src/data/demo-categories'; +import { demos } from '@site/src/data/demos'; +import { ModuleIcon } from './ModuleIcon'; import styles from './_DemoCategoryCard.module.css'; interface DemoCategoryCardProps { @@ -9,15 +10,22 @@ interface DemoCategoryCardProps { } export const DemoCategoryCard: FC = ({ category }) => { - const imageUrl = useBaseUrl(category.image); + const demoCount = demos.filter((demo) => + demo.categories.includes(category.slug), + ).length; return ( - -
-

{category.title}

-

{category.description}

+
+ + + + + {demoCount} {demoCount === 1 ? 'demo' : 'demos'} +
+

{category.title}

+

{category.description}

); }; diff --git a/documentation-site/src/components/ModuleIcon.tsx b/documentation-site/src/components/ModuleIcon.tsx new file mode 100644 index 000000000..3ec7f54d4 --- /dev/null +++ b/documentation-site/src/components/ModuleIcon.tsx @@ -0,0 +1,159 @@ +import React, { FC, ReactNode } from 'react'; +import clsx from 'clsx'; +import styles from './_ModuleIcon.module.css'; + +/** + * Line icons for the engine's modules, used on the homepage and the demo + * categories: teal strokes (`currentColor`) with + * one amber accent each, drawn on a 24x24 grid. + */ +const icons = { + ecs: ( + <> + + + + + + ), + rendering: ( + <> + + + + ), + postProcessing: ( + <> + + + + ), + physics: ( + <> + + + + ), + ui: ( + <> + + + + + ), + text: , + input: ( + <> + + + + + + ), + audio: ( + <> + + + + ), + animations: ( + <> + + + + + + + ), + particles: ( + <> + + + + + + + + + ), + transforms: ( + <> + + + + + + ), + assetLoading: , + states: ( + <> + + + + + ), + events: , + timers: ( + <> + + + + + ), + gameLoop: , + stateMachines: ( + <> + + + + + + ), + lifetimes: ( + <> + + + + ), + storage: ( + <> + + + + ), + math: ( + <> + + + + ), +} satisfies Record; + +export type ModuleIconName = keyof typeof icons; + +interface ModuleIconProps { + name: ModuleIconName; + className?: string; +} + +export const ModuleIcon: FC = ({ name, className }) => ( + +); diff --git a/documentation-site/src/components/_CategoryDemosPage.module.css b/documentation-site/src/components/_CategoryDemosPage.module.css index 14b22ae37..44a1b8351 100644 --- a/documentation-site/src/components/_CategoryDemosPage.module.css +++ b/documentation-site/src/components/_CategoryDemosPage.module.css @@ -1,5 +1,14 @@ .container { - margin: 50px; + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +.intro { + max-width: 48rem; + color: var(--text-muted-color); + font-size: 1.15rem; } .back { diff --git a/documentation-site/src/components/_CodeSelector.module.css b/documentation-site/src/components/_CodeSelector.module.css deleted file mode 100644 index f40783976..000000000 --- a/documentation-site/src/components/_CodeSelector.module.css +++ /dev/null @@ -1,116 +0,0 @@ -.container { - height: 600px; - margin-left: 20px; - width: 40%; - min-width: 600px; - display: flex; - flex-direction: column; - color: var(--text-color); -} - -.fileNameToolbar { - display: flex; - background-color: var(--card-background-color); - flex-direction: row; - align-items: center; -} - -.fileTypeIcon { - margin-left: 20px; -} - -.fileNameToolbar button { - margin: 5px; - background-color: rgb(50, 50, 50); - border-radius: 5px; - margin-left: auto; - border: none; - text-align: center; - padding: 10px; - border-bottom: 2px solid transparent; - cursor: pointer; - margin-right: 5px; - text-wrap: nowrap; -} - -.fileNameToolbar .folder { - display: inline-flex; - align-items: center; - justify-content: center; - color: white; -} - -.fileNameToolbar .folder:hover { - background-color: var(--card-highlight-color); - cursor: pointer; -} - -.fileNameToolbar .fileName { - font-weight: 500; - margin-left: 20px; -} - -.fileNameToolbar button:hover { - background-color: var(--card-highlight-color); -} - -.fileNameToolbar button.selected { - background-color: var(--card-highlight-color); - border-bottom: 2px solid var(--ifm-color-primary); - font-weight: bold; -} - -.codeBlock { - margin: 0; - overflow-y: auto; - border-radius: 0; -} - -.codeSelector { - display: flex; - background-color: var(--card-background-color); - flex-direction: column; - height: 100%; - overflow-y: auto; -} - -.codeSelector button { - width: 100%; - padding: 10px 0; - text-align: left; - background-color: var(--card-background-color); - border: none; - cursor: pointer; -} - -.codeSelector button span { - margin-left: 50px; -} - -.codeSelector button:hover { - background-color: var(--card-highlight-color); -} - -.codeSelector i { - margin-left: 20px; - margin-right: 10px; -} - -.fileTypeGroup { - border-top: 1px solid rgb(80, 80, 80); - padding-top: 10px; -} - -.fileIcon { - margin-left: 20px; - margin-right: 10px; -} - -.fileButtonName { - margin-left: 50px; -} - -.selectedFile { - background-color: var(--card-highlight-color); - font-weight: bold; -} diff --git a/documentation-site/src/components/_CodeSelector.tsx b/documentation-site/src/components/_CodeSelector.tsx deleted file mode 100644 index f8c23e386..000000000 --- a/documentation-site/src/components/_CodeSelector.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { FC, useState } from 'react'; -import CodeBlock from '@theme/CodeBlock'; -import { cleanCodeSnippet } from '../utils/clean-code-snippet'; -import styles from './_CodeSelector.module.css'; -import { CodeFile, CodeSelectorProps } from './_CodeSelector.types'; -import { fileTypeIconLookup, getFileTypeIcon } from './_CodeSelector.utils'; -import { FileToolbar } from './_FileToolbar'; -import { FileSelectorDropdown } from './_FileSelectorDropdown'; - -export type { CodeFile, CodeSelectorProps }; - -export const CodeSelector: FC = ({ - codeFiles, - selectedFileName, -}) => { - const [selectedFile, setSelectedFile] = useState( - codeFiles.find((file) => file.name === selectedFileName) || codeFiles[0], - ); - - const [selectingFile, setSelectingFile] = useState(false); - - const fileType = getFileTypeIcon(selectedFile.name); - const fileTypeIcon = fileTypeIconLookup[fileType]; - - const handleSelectFile = (file: CodeFile): void => { - setSelectedFile(file); - setSelectingFile(false); - }; - - const toggleSelector = (): void => { - setSelectingFile(!selectingFile); - }; - - return ( -
- - {selectingFile ? ( - - ) : ( - - {cleanCodeSnippet(selectedFile.content)} - - )} -
- ); -}; diff --git a/documentation-site/src/components/_CodeSelector.types.ts b/documentation-site/src/components/_CodeSelector.types.ts deleted file mode 100644 index 03607667c..000000000 --- a/documentation-site/src/components/_CodeSelector.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface CodeFile { - name: string; - content: string; -} - -export interface CodeSelectorProps { - codeFiles: CodeFile[]; - selectedFileName?: string; -} diff --git a/documentation-site/src/components/_CodeSelector.utils.ts b/documentation-site/src/components/_CodeSelector.utils.ts deleted file mode 100644 index 2cb5e856b..000000000 --- a/documentation-site/src/components/_CodeSelector.utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { CodeFile } from './_CodeSelector.types'; - -export type FileType = 'Game' | 'Components' | 'Systems' | 'Shaders' | 'Others'; - -export const fileTypeIconLookup: Record = { - Game: 'fa-earth-africa', - Components: 'fa-cube', - Systems: 'fa-gears', - Shaders: 'fa-palette', - Others: 'fa-code', -}; - -export const FILE_TYPES: readonly FileType[] = [ - 'Game', - 'Components', - 'Systems', - 'Shaders', - 'Others', -]; - -export const getFileTypeIcon = (fileName: string): FileType => { - if (fileName.includes('.component')) { - return 'Components'; - } - - if (fileName.includes('.system')) { - return 'Systems'; - } - - if (fileName.includes('game')) { - return 'Game'; - } - - if (fileName.includes('.shader')) { - return 'Shaders'; - } - - return 'Others'; -}; - -export const groupByFileType = ( - codeFiles: CodeFile[], -): Record => { - const record = { - Game: [], - Components: [], - Systems: [], - Shaders: [], - Others: [], - } as Record; - - const groups = codeFiles.reduce((acc, file) => { - const fileType = getFileTypeIcon(file.name); - acc[fileType].push(file); - - return acc; - }, record); - - return groups; -}; diff --git a/documentation-site/src/components/_Demo.module.css b/documentation-site/src/components/_Demo.module.css deleted file mode 100644 index 6dc437047..000000000 --- a/documentation-site/src/components/_Demo.module.css +++ /dev/null @@ -1,60 +0,0 @@ -.demoContainer { - display: flex; - justify-content: center; - align-items: center; - flex-direction: row; - margin-top: 8px; -} - -.demoBox { - position: relative; - height: 600px; - width: 60%; - background-color: black; -} - -.demoBox canvas { - position: absolute; -} - -.demoBox:fullscreen, -.demoBox:-webkit-full-screen { - width: 100%; - height: 100%; -} - -.fullscreenButton { - position: absolute; - top: 10px; - right: 10px; - z-index: 1; - background-color: rgb(50, 50, 50); - border: none; - border-radius: 5px; - color: white; - padding: 8px 10px; - cursor: pointer; -} - -.fullscreenButton:hover { - background-color: var(--card-highlight-color); -} - -.demoContainer h1 { - width: 100%; - text-align: center; - margin-bottom: 20px; -} - -.container { - margin: 50px; -} - -.back { - display: inline-block; - margin-bottom: 1rem; -} - -.wrapper { - justify-content: start; -} diff --git a/documentation-site/src/components/_DemoCard.module.css b/documentation-site/src/components/_DemoCard.module.css index a70ddea3a..62efff041 100644 --- a/documentation-site/src/components/_DemoCard.module.css +++ b/documentation-site/src/components/_DemoCard.module.css @@ -2,6 +2,7 @@ display: flex; flex-direction: column; padding: 1.25rem; + border: 1px solid var(--card-border-color); border-radius: 12px; background-color: var(--card-background-color); color: var(--text-color); @@ -12,19 +13,22 @@ } .card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); + transform: translateY(-3px); + border-color: var(--ifm-color-primary); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); color: var(--text-color); text-decoration: none; } .title { margin: 0 0 0.4rem; + font-size: 1.2rem; } .description { margin: 0 0 0.75rem; - opacity: 0.85; + color: var(--text-muted-color); + font-size: 0.93rem; flex-grow: 1; } @@ -35,8 +39,12 @@ } .tag { - font-size: 0.75rem; - padding: 0.2rem 0.6rem; + padding: 0.15rem 0.55rem; + border: 1px solid var(--card-border-color); border-radius: 999px; - background-color: var(--card-highlight-color); + color: var(--ifm-color-primary); + font-family: var(--ifm-font-family-monospace); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; } diff --git a/documentation-site/src/components/_DemoCategoryCard.module.css b/documentation-site/src/components/_DemoCategoryCard.module.css index b0e4fd821..d1fbf3358 100644 --- a/documentation-site/src/components/_DemoCategoryCard.module.css +++ b/documentation-site/src/components/_DemoCategoryCard.module.css @@ -1,39 +1,70 @@ .card { display: flex; flex-direction: column; - overflow: hidden; + padding: 1.25rem; + border: 1px solid var(--card-border-color); border-radius: 12px; background-color: var(--card-background-color); color: var(--text-color); text-decoration: none; transition: transform 0.15s ease, + border-color 0.15s ease, box-shadow 0.15s ease; } .card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); + transform: translateY(-3px); + border-color: var(--ifm-color-primary); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); color: var(--text-color); text-decoration: none; } -.image { - width: 100%; - aspect-ratio: 16 / 9; - object-fit: cover; - display: block; +.top { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1.25rem; +} + +/* The icon sits on a faint teal tile, so it reads as a badge. */ +.iconTile { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + border: 1px solid rgba(79, 196, 196, 0.25); + border-radius: 10px; + background-color: rgba(79, 196, 196, 0.08); } -.body { - padding: 1rem 1.25rem 1.25rem; +.icon { + width: 2rem; + height: 2rem; +} + +.count { + color: var(--text-muted-color); + font-family: var(--ifm-font-family-monospace); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; } .title { margin: 0 0 0.4rem; + font-size: 1.2rem; + transition: color 0.15s; +} + +.card:hover .title { + color: var(--ifm-color-primary); } .description { margin: 0; - opacity: 0.85; + color: var(--text-muted-color); + font-size: 0.93rem; } diff --git a/documentation-site/src/components/_DemoSearch.module.css b/documentation-site/src/components/_DemoSearch.module.css index 1574bb979..e7a28d1fa 100644 --- a/documentation-site/src/components/_DemoSearch.module.css +++ b/documentation-site/src/components/_DemoSearch.module.css @@ -5,7 +5,7 @@ padding: 0.6rem 1rem; margin: 1.5rem 0; font-size: 1rem; - border: 2px solid var(--card-highlight-color); + border: 1px solid var(--card-border-color); border-radius: 8px; background-color: var(--card-background-color); color: var(--text-color); diff --git a/documentation-site/src/components/_FileSelectorDropdown.tsx b/documentation-site/src/components/_FileSelectorDropdown.tsx deleted file mode 100644 index 058c0f536..000000000 --- a/documentation-site/src/components/_FileSelectorDropdown.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { FC } from 'react'; -import styles from './_CodeSelector.module.css'; -import { CodeFile } from './_CodeSelector.types'; -import { - FILE_TYPES, - fileTypeIconLookup, - groupByFileType, -} from './_CodeSelector.utils'; -import { FileTypeGroup } from './_FileTypeGroup'; - -interface FileSelectorDropdownProps { - codeFiles: CodeFile[]; - selectedFileName: string; - onSelectFile: (file: CodeFile) => void; -} - -export const FileSelectorDropdown: FC = ({ - codeFiles, - selectedFileName, - onSelectFile, -}) => { - const groupedFiles = groupByFileType(codeFiles); - - return ( -
- {FILE_TYPES.map((type) => { - const typeIcon = fileTypeIconLookup[type]; - const files = groupedFiles[type]; - - if (files.length === 0) { - return null; - } - - return ( - - ); - })} -
- ); -}; diff --git a/documentation-site/src/components/_FileToolbar.tsx b/documentation-site/src/components/_FileToolbar.tsx deleted file mode 100644 index c0e2885e6..000000000 --- a/documentation-site/src/components/_FileToolbar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC } from 'react'; -import clsx from 'clsx'; -import styles from './_CodeSelector.module.css'; - -interface FileToolbarProps { - fileName: string; - fileTypeIcon: string; - onToggleSelector: () => void; - numberOfFiles: number; -} - -export const FileToolbar: FC = ({ - fileName, - fileTypeIcon, - onToggleSelector, - numberOfFiles, -}) => { - return ( -
- - {fileName} - -
- ); -}; diff --git a/documentation-site/src/components/_FileTypeGroup.tsx b/documentation-site/src/components/_FileTypeGroup.tsx deleted file mode 100644 index ca24b24e2..000000000 --- a/documentation-site/src/components/_FileTypeGroup.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FC } from 'react'; -import clsx from 'clsx'; -import styles from './_CodeSelector.module.css'; -import { CodeFile } from './_CodeSelector.types'; - -interface FileTypeGroupProps { - type: string; - typeIcon: string; - files: CodeFile[]; - selectedFileName: string; - onSelectFile: (file: CodeFile) => void; -} - -export const FileTypeGroup: FC = ({ - type, - typeIcon, - files, - selectedFileName, - onSelectFile, -}) => { - if (files.length === 0) { - return null; - } - - return ( -
- - {type} - {files.map((file) => { - const isSelected = file.name === selectedFileName; - - return ( - - ); - })} -
- ); -}; diff --git a/documentation-site/src/components/_InteractionInstruction.module.css b/documentation-site/src/components/_InteractionInstruction.module.css deleted file mode 100644 index 1674323ee..000000000 --- a/documentation-site/src/components/_InteractionInstruction.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.container { - display: inline-flex; - align-items: center; - padding: 8px; - gap: 8px; - max-width: 400px; - min-width: 200px; - margin-right: 10px; - vertical-align: top; - - background-color: var(--card-background-color); -} - -.container p { - display: inline-block; - max-width: 80%; - margin: 0; -} diff --git a/documentation-site/src/components/_InteractionInstruction.tsx b/documentation-site/src/components/_InteractionInstruction.tsx deleted file mode 100644 index f8b79c214..000000000 --- a/documentation-site/src/components/_InteractionInstruction.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { FC, ReactNode } from 'react'; -import styles from './_InteractionInstruction.module.css'; - -interface KeyboardKeyProps { - displayElement: ReactNode; - text: string; -} - -export const InteractionInstruction: FC = ({ - displayElement, - text, -}) => { - return ( -
- {displayElement} -

{text}

-
- ); -}; diff --git a/documentation-site/src/components/_KeyboardKey.module.css b/documentation-site/src/components/_KeyboardKey.module.css deleted file mode 100644 index 0544a2f71..000000000 --- a/documentation-site/src/components/_KeyboardKey.module.css +++ /dev/null @@ -1,23 +0,0 @@ -.keyBox { - border-radius: 3px; - border-left: 5px solid #d1d16c; - border-right: 5px solid #d1d16c; - border-bottom: 5px solid #a0a053; - width: 35px; - display: inline-block; - align-self: baseline; -} - -.keyTop { - padding-top: 5px; - width: 25px; - height: 30px; - background-color: #ffff87; - font-weight: bold; - font-size: 1em; - color: black; - font-family: - 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', - 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; - text-align: center; -} diff --git a/documentation-site/src/components/_KeyboardKey.tsx b/documentation-site/src/components/_KeyboardKey.tsx deleted file mode 100644 index a59236418..000000000 --- a/documentation-site/src/components/_KeyboardKey.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { FC } from 'react'; -import styles from './_KeyboardKey.module.css'; - -interface KeyboardKeyProps { - keyCode: string; -} - -export const KeyboardKey: FC = ({ keyCode }) => { - return ( -
-
{keyCode}
-
- ); -}; diff --git a/documentation-site/src/components/_ModuleIcon.module.css b/documentation-site/src/components/_ModuleIcon.module.css new file mode 100644 index 000000000..37d25c178 --- /dev/null +++ b/documentation-site/src/components/_ModuleIcon.module.css @@ -0,0 +1,11 @@ +.icon { + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + color: var(--ifm-color-primary); +} + +.accent { + fill: var(--forge-amber); + stroke: var(--forge-amber); +} diff --git a/documentation-site/src/components/_ScrollWheel.tsx b/documentation-site/src/components/_ScrollWheel.tsx deleted file mode 100644 index 8772ba1b2..000000000 --- a/documentation-site/src/components/_ScrollWheel.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { FC } from 'react'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; - -export const ScrollWheel: FC = () => { - const { siteConfig } = useDocusaurusContext(); - - return ( - scroll icon - ); -}; diff --git a/documentation-site/src/components/demo-page/CodeExplorer.module.css b/documentation-site/src/components/demo-page/CodeExplorer.module.css new file mode 100644 index 000000000..696889ed5 --- /dev/null +++ b/documentation-site/src/components/demo-page/CodeExplorer.module.css @@ -0,0 +1,231 @@ +.explorer { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + height: 640px; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--ifm-color-emphasis-300); +} + +.fileBrowser { + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.fileFilters { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.75rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} + +.fileSearch { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background-color: var(--ifm-background-color); + color: var(--ifm-color-emphasis-600); +} + +.fileSearch:focus-within { + border-color: var(--ifm-color-primary); +} + +.fileSearch input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.9rem; +} + +.typeFilter { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.typeFilterButton { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.55rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 999px; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.78rem; + cursor: pointer; +} + +.typeFilterButton .fileIcon { + width: auto; +} + +.typeFilterButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.typeFilterButtonSelected, +.typeFilterButtonSelected:hover { + border-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-200); + font-weight: bold; +} + +.typeFilterCount { + color: var(--ifm-color-emphasis-600); + font-weight: normal; +} + +.fileTree { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem 0; +} + +.noFiles { + margin: 0; + padding: 0 1rem; + font-size: 0.9rem; + color: var(--ifm-color-emphasis-600); +} + +.fileGroup + .fileGroup { + margin-top: 0.75rem; +} + +.fileGroupTitle { + padding: 0 1rem 0.25rem; + font-size: 0.72rem; + font-weight: bold; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-600); +} + +.fileTree ul { + margin: 0; + padding: 0; + list-style: none; +} + +.fileButton { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.3rem 1rem; + border: none; + border-left: 3px solid transparent; + background: none; + color: var(--ifm-font-color-base); + font-family: var(--ifm-font-family-monospace); + font-size: 0.82rem; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.fileName { + overflow: hidden; + text-overflow: ellipsis; +} + +.fileButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.fileButtonSelected, +.fileButtonSelected:hover { + border-left-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-200); + font-weight: bold; +} + +.fileIcon { + flex-shrink: 0; + width: 1rem; + text-align: center; +} + +i.fileIcon { + color: var(--ifm-color-emphasis-700); +} + +.fileTypeLabel { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.8rem; +} + +.fileTypeLabel .fileIcon { + width: auto; +} + +.filePaneTitle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.filePane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.filePaneHeader { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.filePaneName { + font-family: var(--ifm-font-family-monospace); + font-weight: bold; +} + +.filePaneSummary { + font-size: 0.88rem; + color: var(--ifm-color-emphasis-700); +} + +.codeBlock { + flex: 1; + min-height: 0; + margin: 0; + overflow-y: auto; + border-radius: 0; + box-shadow: none; +} + +@media (max-width: 996px) { + .explorer { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + height: 80vh; + } + + .fileBrowser { + max-height: 40vh; + border-right: none; + border-bottom: 1px solid var(--ifm-color-emphasis-300); + } +} diff --git a/documentation-site/src/components/demo-page/CodeExplorer.tsx b/documentation-site/src/components/demo-page/CodeExplorer.tsx new file mode 100644 index 000000000..f0afa474e --- /dev/null +++ b/documentation-site/src/components/demo-page/CodeExplorer.tsx @@ -0,0 +1,177 @@ +import React, { FC, useState } from 'react'; +import CodeBlock from '@theme/CodeBlock'; +import clsx from 'clsx'; +import { cleanCodeSnippet } from '@site/src/utils/clean-code-snippet'; +import styles from './CodeExplorer.module.css'; +import { + DemoFileType, + demoFileTypeOrder, + demoFileTypes, + getDemoFileType, +} from './file-types'; +import { DemoFile, DemoFileGroup } from './types'; + +// Below this many files the whole list fits on screen, so search and +// filters would only add clutter. +const minFilesForFilters = 6; + +interface CodeExplorerProps { + fileGroups: DemoFileGroup[]; + selectedFile: DemoFile; + onSelectFile: (fileName: string) => void; +} + +const FileTypeIcon: FC<{ type: DemoFileType }> = ({ type }) => ( + +); + +/** + * Browses a demo's source files: a grouped file list with search and a + * type filter, and the selected file's code. + */ +export const CodeExplorer: FC = ({ + fileGroups, + selectedFile, + onSelectFile, +}) => { + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + + const allFiles = fileGroups.flatMap((group) => group.files); + const typesPresent = demoFileTypeOrder.filter((type) => + allFiles.some((file) => getDemoFileType(file.name) === type), + ); + const normalizedQuery = query.trim().toLowerCase(); + + const matches = (file: DemoFile): boolean => + (typeFilter === 'all' || getDemoFileType(file.name) === typeFilter) && + file.name.toLowerCase().includes(normalizedQuery); + + const visibleGroups = fileGroups + .map((group) => ({ ...group, files: group.files.filter(matches) })) + .filter((group) => group.files.length > 0); + + const selectedType = getDemoFileType(selectedFile.name); + const showFilters = allFiles.length >= minFilesForFilters; + + const filterOptions: { value: DemoFileType | 'all'; label: string }[] = [ + { value: 'all', label: 'All' }, + ...typesPresent.map((type) => ({ + value: type, + label: demoFileTypes[type].pluralLabel, + })), + ]; + + return ( +
+
+ {showFilters && ( +
+
+ + setQuery(event.target.value)} + /> +
+
+ {filterOptions.map(({ value, label }) => { + const count = + value === 'all' + ? allFiles.length + : allFiles.filter( + (file) => getDemoFileType(file.name) === value, + ).length; + + return ( + + ); + })} +
+
+ )} + + +
+ +
+
+
+ {selectedFile.name} + + + {demoFileTypes[selectedType].label} + +
+ {selectedFile.summary} +
+ + {cleanCodeSnippet(selectedFile.content)} + +
+
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoControls.module.css b/documentation-site/src/components/demo-page/DemoControls.module.css new file mode 100644 index 000000000..6abcb467d --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoControls.module.css @@ -0,0 +1,59 @@ +.controls { + display: grid; + /* The keys column fits its widest row, up to about half the panel. */ + grid-template-columns: fit-content(55%) minmax(0, 1fr); + gap: 0.6rem 1rem; + align-items: center; + margin: 0; +} + +.control { + display: contents; +} + +.controlKeys { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin: 0; + font-weight: normal; +} + +.controlOr { + font-size: 0.75rem; + color: var(--ifm-color-emphasis-600); +} + +.key { + min-width: 2rem; + padding: 0.2rem 0.45rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-bottom-width: 3px; + border-radius: 6px; + background-color: var(--ifm-color-emphasis-100); + color: var(--ifm-font-color-base); + font-family: var(--ifm-font-family-base); + font-size: 0.95rem; + font-weight: bold; + line-height: 1.4; + text-align: center; + white-space: nowrap; + box-shadow: none; +} + +.controlAction { + margin: 0; + font-weight: 600; +} + +.controlDetail { + display: block; + font-size: 0.8rem; + font-weight: normal; + color: var(--ifm-color-emphasis-700); +} + +.inputIcon { + margin-right: 0.3rem; +} diff --git a/documentation-site/src/components/demo-page/DemoControls.tsx b/documentation-site/src/components/demo-page/DemoControls.tsx new file mode 100644 index 000000000..86094e5cd --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoControls.tsx @@ -0,0 +1,83 @@ +import React, { FC, Fragment } from 'react'; +import clsx from 'clsx'; +import styles from './DemoControls.module.css'; +import { DemoControl, DemoInput } from './types'; + +const deviceIcons = { + mouse: 'fa-computer-mouse', + gamepad: 'fa-gamepad', + touch: 'fa-hand-pointer', +}; + +const inputKey = (input: DemoInput): string => + typeof input === 'string' ? input : `${input.device}:${input.label}`; + +// Keys shown as their symbol rather than their name, so every key cap stays +// one short character wide. The name is still read out by screen readers. +const keySymbols: Record = { + Enter: '⏎', + Space: '␣', +}; + +const InputCap: FC<{ input: DemoInput }> = ({ input }) => { + if (typeof input === 'string') { + const symbol = keySymbols[input]; + + if (symbol) { + return ( + + {symbol} + + ); + } + + return {input}; + } + + return ( + + + {input.label} + + ); +}; + +interface DemoControlsProps { + controls: DemoControl[]; +} + +/** + * Lists a demo's controls: the inputs on the left, what they do on the + * right. + */ +export const DemoControls: FC = ({ controls }) => { + return ( +
+ {controls.map((control) => ( +
+
+ {control.inputs.map((input, index) => ( + + {index > 0 && or} + + + ))} +
+
+ {control.action} + {control.detail && ( + {control.detail} + )} +
+
+ ))} +
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoGame.module.css b/documentation-site/src/components/demo-page/DemoGame.module.css new file mode 100644 index 000000000..c0fa5fcbf --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoGame.module.css @@ -0,0 +1,56 @@ +.game { + position: relative; + align-self: stretch; + /* At least 16:9, and taller when the sidebar beside it is (up to 3:2, + * see DemoPage.module.css), so the two columns end level. */ + aspect-ratio: 16 / 9; + width: 100%; + overflow: hidden; + border-radius: 8px; + background-color: black; + box-shadow: 0 0 0 1px var(--ifm-color-emphasis-300); +} + +.game:focus { + outline: none; +} + +.game:focus-visible { + box-shadow: 0 0 0 3px var(--ifm-color-primary); +} + +.game canvas { + position: absolute; +} + +.game:fullscreen { + width: 100%; + height: 100%; + border-radius: 0; + aspect-ratio: auto; +} + +.focusHint { + position: absolute; + left: 50%; + bottom: 16px; + z-index: 1; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 999px; + background-color: rgba(11, 19, 43, 0.75); + color: white; + font-size: 0.9rem; + pointer-events: none; + transition: opacity 0.2s; +} + +.focusHintHidden { + opacity: 0; +} + +@media (max-width: 1200px) { + .game { + align-self: auto; + } +} diff --git a/documentation-site/src/components/demo-page/DemoGame.tsx b/documentation-site/src/components/demo-page/DemoGame.tsx new file mode 100644 index 000000000..7bd2ea4ab --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoGame.tsx @@ -0,0 +1,54 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; +import { CreateDemoGame, useGame } from '@site/src/hooks/useGame'; +import { useFullscreen } from '@site/src/hooks/useFullscreen'; +import { usePreventPageScrollKeys } from '@site/src/hooks/usePreventPageScrollKeys'; +import { FullscreenButton } from './FullscreenButton'; +import styles from './DemoGame.module.css'; + +interface DemoGameProps { + title: string; + createGame: CreateDemoGame; +} + +/** + * Runs a demo's game in a focusable, fullscreen-able box. Every demo's + * `createGame` renders into the `demo-game` element this creates. + */ +export const DemoGame: FC = ({ title, createGame }) => { + const gameRef = useRef(null); + const { isFullscreen, toggleFullscreen } = useFullscreen(gameRef); + const [hasFocus, setHasFocus] = useState(false); + + useGame(createGame); + usePreventPageScrollKeys(gameRef); + + // Focus the game straight away, so the controls work without a click and + // the arrow keys drive the game instead of scrolling the page. + useEffect(() => { + gameRef.current?.focus({ preventScroll: true }); + }, []); + + return ( +
setHasFocus(true)} + onBlur={() => setHasFocus(false)} + > + + +
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoHeader.module.css b/documentation-site/src/components/demo-page/DemoHeader.module.css new file mode 100644 index 000000000..a1dfaac2d --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoHeader.module.css @@ -0,0 +1,32 @@ +.back { + display: inline-block; + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +.header { + margin-bottom: 1.25rem; +} + +.title { + margin-bottom: 0.25rem; +} + +.summary { + max-width: 70ch; + margin: 0; + font-size: 1.1rem; + color: var(--ifm-color-emphasis-700); +} + +.docLinks { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; + margin-top: 0.5rem; + font-size: 0.95rem; +} + +.docLinks span { + color: var(--ifm-color-emphasis-700); +} diff --git a/documentation-site/src/components/demo-page/DemoHeader.tsx b/documentation-site/src/components/demo-page/DemoHeader.tsx new file mode 100644 index 000000000..a96dcd825 --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoHeader.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import Link from '@docusaurus/Link'; +import { useDemoBackLink } from '@site/src/hooks/useDemoBackLink'; +import styles from './DemoHeader.module.css'; +import { DemoDocLink } from './types'; + +interface DemoHeaderProps { + title: string; + summary: string; + docLinks: DemoDocLink[]; +} + +/** + * A demo page's back link, title, one-line summary and links to the docs. + */ +export const DemoHeader: FC = ({ + title, + summary, + docLinks, +}) => { + const backLink = useDemoBackLink(); + + return ( + <> + + {backLink.label} + +
+

{title}

+

{summary}

+ {docLinks.length > 0 && ( + + )} +
+ + ); +}; diff --git a/documentation-site/src/components/demo-page/DemoHighlights.module.css b/documentation-site/src/components/demo-page/DemoHighlights.module.css new file mode 100644 index 000000000..a92dd557a --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoHighlights.module.css @@ -0,0 +1,28 @@ +.highlights { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin: 0; + padding-left: 1.1rem; + font-size: 0.9rem; + line-height: 1.5; +} + +.fileLink { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin-left: 0.4rem; + padding: 0 0.4rem; + border: none; + border-radius: 4px; + background-color: var(--ifm-color-emphasis-200); + color: var(--ifm-link-color); + font-family: var(--ifm-font-family-monospace); + font-size: 0.78rem; + cursor: pointer; +} + +.fileLink:hover { + background-color: var(--ifm-color-emphasis-300); +} diff --git a/documentation-site/src/components/demo-page/DemoHighlights.tsx b/documentation-site/src/components/demo-page/DemoHighlights.tsx new file mode 100644 index 000000000..4e20f361c --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoHighlights.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import styles from './DemoHighlights.module.css'; +import { DemoHighlight } from './types'; + +interface DemoHighlightsProps { + highlights: DemoHighlight[]; + onOpenFile: (fileName: string) => void; +} + +/** + * The "How it works" list: a few short points, each with a link that opens + * the file implementing it in the code explorer. + */ +export const DemoHighlights: FC = ({ + highlights, + onOpenFile, +}) => { + return ( +
    + {highlights.map(({ text, file }, index) => ( +
  • + {text} + {file && ( + + )} +
  • + ))} +
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoLegend.module.css b/documentation-site/src/components/demo-page/DemoLegend.module.css new file mode 100644 index 000000000..704b3b993 --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoLegend.module.css @@ -0,0 +1,23 @@ +.legend { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.9rem; +} + +.item { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.marker { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + min-width: 1.25rem; +} diff --git a/documentation-site/src/components/demo-page/DemoLegend.tsx b/documentation-site/src/components/demo-page/DemoLegend.tsx new file mode 100644 index 000000000..ce48f3e0a --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoLegend.tsx @@ -0,0 +1,24 @@ +import React, { FC } from 'react'; +import styles from './DemoLegend.module.css'; +import { DemoLegendItem } from './types'; + +interface DemoLegendProps { + items: DemoLegendItem[]; +} + +/** + * A key to what the markers in a demo's scene mean, such as the color of + * each row. Put it in a {@link DemoPanel}. + */ +export const DemoLegend: FC = ({ items }) => { + return ( +
    + {items.map((item) => ( +
  • + {item.marker} + {item.label} +
  • + ))} +
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoPage.module.css b/documentation-site/src/components/demo-page/DemoPage.module.css new file mode 100644 index 000000000..cd68bd6ce --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoPage.module.css @@ -0,0 +1,61 @@ +.page { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 1.5rem 1.5rem 3rem; +} + +.stage { + --sidebar-width: 380px; + --stage-gap: 1.25rem; + + display: grid; + grid-template-columns: minmax(0, 1fr) var(--sidebar-width); + gap: var(--stage-gap); + container-type: inline-size; +} + +/* The game grows to end level with the sidebar, but never past a 3:2 + * shape: a taller canvas would show less of the world's width. Past that, + * the sidebar scrolls instead. */ +.sidebar { + display: flex; + flex-direction: column; + gap: 1rem; + max-height: calc((100cqw - var(--sidebar-width) - var(--stage-gap)) / 1.5); + overflow-y: auto; +} + +.source { + margin-top: 2.5rem; + scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem); +} + +.sourceTitle { + margin-bottom: 0.75rem; +} + +@media (max-width: 1200px) { + .stage { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + max-height: none; + overflow-y: visible; + } +} + +@media (max-width: 996px) { + .page { + padding: 1rem 1rem 2rem; + } +} + +/* The last panel fills any height left beside the game, so the sidebar + * ends level with it. */ +.sidebar > :last-child { + flex: 1; +} diff --git a/documentation-site/src/components/demo-page/DemoPage.tsx b/documentation-site/src/components/demo-page/DemoPage.tsx new file mode 100644 index 000000000..7ee70226e --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoPage.tsx @@ -0,0 +1,130 @@ +import React, { FC, ReactNode, useRef, useState } from 'react'; +import Layout from '@theme/Layout'; +import { CreateDemoGame } from '@site/src/hooks/useGame'; +import { demos } from '@site/src/data/demos'; +import styles from './DemoPage.module.css'; +import { CodeExplorer } from './CodeExplorer'; +import { DemoControls } from './DemoControls'; +import { DemoGame } from './DemoGame'; +import { DemoHeader } from './DemoHeader'; +import { DemoHighlights } from './DemoHighlights'; +import { DemoPanel } from './DemoPanel'; +import { + DemoControl, + DemoDocLink, + DemoFile, + DemoFileGroup, + DemoHighlight, +} from './types'; + +interface DemoPageProps { + /** The demo's entry in `src/data/demos.ts`, which holds its title and summary. */ + slug: string; + createGame: CreateDemoGame; + /** Leave out for a demo you only watch. */ + controls?: DemoControl[]; + /** + * Extra sidebar content shown after the controls, such as live settings + * or a legend. Wrap each in a `DemoPanel`. + */ + panels?: ReactNode; + /** The few ideas a reader should take away, each linked to its code. */ + highlights: DemoHighlight[]; + docLinks?: DemoDocLink[]; + /** The first group's first file is the one shown when the page opens. */ + fileGroups: DemoFileGroup[]; +} + +const findFile = (fileGroups: DemoFileGroup[], name: string): DemoFile => { + for (const group of fileGroups) { + const file = group.files.find((candidate) => candidate.name === name); + + if (file) { + return file; + } + } + + throw new Error(`The demo has no source file named "${name}".`); +}; + +/** + * A demo page: the running game with its controls and a short explanation + * beside it, and a code explorer for the demo's source below. + */ +export const DemoPage: FC = ({ + slug, + createGame, + controls = [], + panels, + highlights, + docLinks = [], + fileGroups, +}) => { + const explorerRef = useRef(null); + const [selectedFileName, setSelectedFileName] = useState( + fileGroups[0].files[0].name, + ); + const demo = demos.find((candidate) => candidate.slug === slug); + + if (!demo) { + throw new Error(`No demo with slug "${slug}" in src/data/demos.ts.`); + } + + // The explorer shows code a reader can use in their own game. React + // components that only drive this site's settings panels aren't that. + for (const file of fileGroups.flatMap((group) => group.files)) { + if (/\.[jt]sx$/.test(file.name)) { + throw new Error( + `"${file.name}" is page code, not engine code, so the code explorer doesn't show it.`, + ); + } + } + + for (const highlight of highlights) { + if (highlight.file) { + findFile(fileGroups, highlight.file); + } + } + + const openFile = (fileName: string): void => { + setSelectedFileName(fileName); + explorerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( + +
+ + +
+ + + +
+ +
+

Source code

+ +
+
+
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoPanel.module.css b/documentation-site/src/components/demo-page/DemoPanel.module.css new file mode 100644 index 000000000..348e1b254 --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoPanel.module.css @@ -0,0 +1,17 @@ +.panel { + padding: 0.9rem 1.1rem; + border-radius: 8px; + border: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.panelTitle { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.6rem; + font-size: 0.8rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-700); +} diff --git a/documentation-site/src/components/demo-page/DemoPanel.tsx b/documentation-site/src/components/demo-page/DemoPanel.tsx new file mode 100644 index 000000000..9870efdbc --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoPanel.tsx @@ -0,0 +1,25 @@ +import React, { FC, ReactNode } from 'react'; +import clsx from 'clsx'; +import styles from './DemoPanel.module.css'; + +interface DemoPanelProps { + title: string; + /** A Font Awesome solid icon name, such as `'fa-keyboard'`. */ + icon: string; + children: ReactNode; +} + +/** + * A titled card in a demo page's sidebar. Use it for anything a demo shows + * beside its game, such as live settings or a legend. + */ +export const DemoPanel: FC = ({ title, icon, children }) => { + return ( +
+

+ {title} +

+ {children} +
+ ); +}; diff --git a/documentation-site/src/components/demo-page/DemoSettings.module.css b/documentation-site/src/components/demo-page/DemoSettings.module.css new file mode 100644 index 000000000..02f48a7d1 --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoSettings.module.css @@ -0,0 +1,158 @@ +.settings { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} + +.fieldHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.85rem; + font-weight: 600; +} + +.fieldValue { + color: var(--ifm-color-emphasis-700); + font-family: var(--ifm-font-family-monospace); + font-size: 0.8rem; + font-weight: normal; +} + +.slider { + width: 100%; + margin: 0; + accent-color: var(--ifm-color-primary); +} + +.textInput { + width: 100%; + padding: 0.4rem 0.55rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background-color: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font: inherit; + font-size: 0.88rem; + resize: vertical; +} + +.textInput:focus { + border-color: var(--ifm-color-primary); + outline: none; +} + +.choice { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.choiceButton { + flex: 1 0 auto; + padding: 0.25rem 0.55rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.8rem; + cursor: pointer; +} + +.choiceButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.choiceButtonSelected, +.choiceButtonSelected:hover { + border-color: var(--ifm-color-primary); + background-color: var(--ifm-color-primary); + color: var(--ifm-background-color); + font-weight: 600; +} + +.toggle { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.toggleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.toggleInput { + position: relative; + flex-shrink: 0; + width: 2.2rem; + height: 1.2rem; + margin: 0; + border-radius: 999px; + background-color: var(--ifm-color-emphasis-400); + appearance: none; + cursor: pointer; + transition: background-color 0.15s; +} + +.toggleInput::after { + position: absolute; + top: 0.15rem; + left: 0.15rem; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background-color: white; + content: ''; + transition: transform 0.15s; +} + +.toggleInput:checked { + background-color: var(--ifm-color-primary); +} + +.toggleInput:checked::after { + transform: translateX(1rem); +} + +.toggleInput:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 2px; +} + +.toggleChildren { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding-left: 0.75rem; + border-left: 2px solid var(--ifm-color-emphasis-300); +} + +.section { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid var(--ifm-color-emphasis-200); +} + +.sectionTitle { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-600); +} diff --git a/documentation-site/src/components/demo-page/DemoSettings.tsx b/documentation-site/src/components/demo-page/DemoSettings.tsx new file mode 100644 index 000000000..8c2da267d --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoSettings.tsx @@ -0,0 +1,202 @@ +import React, { ReactNode, useId } from 'react'; +import clsx from 'clsx'; +import styles from './DemoSettings.module.css'; + +/** + * Stacks a demo's live settings (sliders, toggles, choices) in a + * `DemoPanel`, with even spacing. + */ +export const DemoSettings = ({ + children, +}: { + children: ReactNode; +}): ReactNode =>
{children}
; + +/** + * A titled group of settings, separated from the ones above it. + */ +export const DemoSettingsSection = ({ + title, + children, +}: { + title: string; + children: ReactNode; +}): ReactNode => ( +
+

{title}

+ {children} +
+); + +interface DemoSliderProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + /** Formats the value shown beside the label. Shows it as is by default. */ + format?: (value: number) => string; + onChange: (value: number) => void; +} + +/** + * A labeled range slider that shows its current value. + */ +export const DemoSlider = ({ + label, + value, + min, + max, + step = 1, + format = String, + onChange, +}: DemoSliderProps): ReactNode => { + const id = useId(); + + return ( +
+ + onChange(Number(event.target.value))} + /> +
+ ); +}; + +interface DemoTextFieldProps { + label: string; + value: string; + /** More than one row makes it a multi-line text area. */ + rows?: number; + onChange: (value: string) => void; +} + +/** + * A labeled text box, single- or multi-line. + */ +export const DemoTextField = ({ + label, + value, + rows = 1, + onChange, +}: DemoTextFieldProps): ReactNode => { + const id = useId(); + + return ( +
+ + {rows > 1 ? ( +