From 771012fa889fe5d69008ae6e71538c031de82c37 Mon Sep 17 00:00:00 2001 From: Spencer Chang Date: Tue, 5 May 2026 11:53:03 -0700 Subject: [PATCH 1/3] docs: add examples registry --- apps/docs/src/examples/example-registry.ts | 524 +++++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 apps/docs/src/examples/example-registry.ts diff --git a/apps/docs/src/examples/example-registry.ts b/apps/docs/src/examples/example-registry.ts new file mode 100644 index 000000000..3224c351e --- /dev/null +++ b/apps/docs/src/examples/example-registry.ts @@ -0,0 +1,524 @@ +// ABOUTME: Typed catalogue for the docs examples index. +// ABOUTME: Keeps example metadata, links, snippets, and preview kinds in one place. + +export type ExampleType = + | "capability" + | "data" + | "presence" + | "event" + | "react"; + +export type ExampleStatus = "stable" | "experimental" | "reference"; + +export type ExampleSource = "docs" | "react" | "docs-and-react"; + +export type ExamplePreviewKind = + | "docs-toggle" + | "docs-lamp" + | "docs-move" + | "docs-grow" + | "docs-spin" + | "docs-hover" + | "docs-duplicate" + | "docs-guestbook" + | "docs-spinner" + | "docs-smiley" + | "docs-presence" + | "docs-rain" + | "docs-reactions" + | "static"; + +export type ExampleLink = { + label: string; + href: string; + kind: "read" | "source"; +}; + +export type ExampleEntry = { + slug: string; + name: string; + type: ExampleType; + description: string; + capabilities: string[]; + tags: string[]; + framework: "HTML" | "React" | "shared"; + status: ExampleStatus; + source: ExampleSource; + preview: ExamplePreviewKind; + html?: string; + react?: string; + playRecipeId?: string; + links: ExampleLink[]; +}; + +const docsCapabilityHref = "/docs/capabilities/"; +const reactExamplesHref = + "https://github.com/spencerc99/playhtml/tree/main/packages/react/examples"; + +function reactSource(path: string): ExampleLink { + return { + label: "source", + href: `https://github.com/spencerc99/playhtml/blob/main/packages/react/examples/${path}`, + kind: "source", + }; +} + +function docsSource(path: string): ExampleLink { + return { + label: "source", + href: `https://github.com/spencerc99/playhtml/blob/main/apps/docs/src/components/react/${path}`, + kind: "source", + }; +} + +export const exampleEntries: ExampleEntry[] = [ + { + slug: "lamp-toggle", + name: "Lamp toggle", + type: "capability", + description: "A shared paper lamp that flips on and off for everyone.", + capabilities: ["can-toggle"], + tags: ["toggle", "shared state", "lamp"], + framework: "shared", + status: "stable", + source: "docs-and-react", + preview: "docs-lamp", + html: `Paper lamp`, + react: ``, + playRecipeId: "lamp-toggle", + links: [ + { label: "read can-toggle", href: `${docsCapabilityHref}#can-toggle`, kind: "read" }, + reactSource("SharedLamp.tsx"), + ], + }, + { + slug: "interactive-toggle", + name: "Interactive toggle", + type: "capability", + description: "The smallest can-toggle demo used on the getting started page.", + capabilities: ["can-toggle"], + tags: ["toggle", "getting started"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-toggle", + links: [ + { label: "read can-toggle", href: `${docsCapabilityHref}#can-toggle`, kind: "read" }, + docsSource("InteractiveToggleDemo.tsx"), + ], + }, + { + slug: "move-hat-cat", + name: "Move hat cat", + type: "capability", + description: "A bounded can-move demo with draggable image elements.", + capabilities: ["can-move"], + tags: ["drag", "bounds"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-move", + links: [ + { label: "read can-move", href: `${docsCapabilityHref}#can-move`, kind: "read" }, + docsSource("capability-demos/MoveHatCatDemo.tsx"), + ], + }, + { + slug: "grow-balloon", + name: "Grow balloon", + type: "capability", + description: "Click to grow, modifier-click to shrink.", + capabilities: ["can-grow"], + tags: ["scale", "image"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-grow", + links: [ + { label: "read can-grow", href: `${docsCapabilityHref}#can-grow`, kind: "read" }, + docsSource("capability-demos/GrowBalloonDemo.tsx"), + ], + }, + { + slug: "spin-wheel", + name: "Spin wheel", + type: "capability", + description: "A can-spin wheel that persists rotation.", + capabilities: ["can-spin"], + tags: ["rotation", "image"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-spin", + links: [ + { label: "read can-spin", href: `${docsCapabilityHref}#can-spin`, kind: "read" }, + docsSource("capability-demos/SpinWheelDemo.tsx"), + ], + }, + { + slug: "hover-cursor-colors", + name: "Hover cursor colors", + type: "presence", + description: "A can-hover demo that shows transient awareness state.", + capabilities: ["can-hover", "awareness"], + tags: ["hover", "presence"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-hover", + links: [ + { label: "read can-hover", href: `${docsCapabilityHref}#can-hover`, kind: "read" }, + docsSource("capability-demos/HoverCursorColorsDemo.tsx"), + ], + }, + { + slug: "duplicate-rabbit", + name: "Duplicate rabbit", + type: "capability", + description: "A can-duplicate demo that adds synced copies.", + capabilities: ["can-duplicate"], + tags: ["clone", "spawn"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-duplicate", + links: [ + { label: "read can-duplicate", href: `${docsCapabilityHref}#can-duplicate`, kind: "read" }, + docsSource("capability-demos/DuplicateRabbitDemo.tsx"), + ], + }, + { + slug: "docs-guestbook", + name: "Docs guestbook", + type: "data", + description: "A capped shared list powered by a custom can-play element.", + capabilities: ["can-play", "element data"], + tags: ["guestbook", "form", "shared list"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-guestbook", + links: [ + { label: "read can-play", href: `${docsCapabilityHref}#can-play`, kind: "read" }, + docsSource("capability-demos/DocsGuestbook.tsx"), + ], + }, + { + slug: "docs-spinner", + name: "Docs spinner", + type: "data", + description: "A can-play prize wheel with shared spin state.", + capabilities: ["can-play"], + tags: ["spinner", "random", "wheel"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-spinner", + links: [ + { label: "read can-play", href: `${docsCapabilityHref}#can-play`, kind: "read" }, + docsSource("capability-demos/DocsSpinner.tsx"), + ], + }, + { + slug: "smiley-row", + name: "Smiley row", + type: "data", + description: "A tiny collaborative drawing strip capped to recent submissions.", + capabilities: ["can-play"], + tags: ["drawing", "shared list"], + framework: "React", + status: "stable", + source: "docs", + preview: "docs-smiley", + links: [ + { label: "read can-play", href: `${docsCapabilityHref}#can-play`, kind: "read" }, + docsSource("capability-demos/SmileyRow.tsx"), + ], + }, + { + slug: "online-indicator", + name: "Online indicator", + type: "presence", + description: "Colored dots for readers currently connected to the page.", + capabilities: ["presence", "awareness"], + tags: ["presence", "online"], + framework: "React", + status: "stable", + source: "docs-and-react", + preview: "docs-presence", + links: [ + { label: "read presence", href: "/docs/data/presence/", kind: "read" }, + reactSource("OnlineIndicator.tsx"), + ], + }, + { + slug: "rain-sprinkler", + name: "Rain sprinkler", + type: "event", + description: "A shared rain event dispatched from a fire hydrant.", + capabilities: ["events"], + tags: ["event", "broadcast", "rain"], + framework: "React", + status: "stable", + source: "docs-and-react", + preview: "docs-rain", + links: [ + { label: "read events", href: "/docs/data/events/", kind: "read" }, + reactSource("RainSprinkler.tsx"), + ], + }, + { + slug: "live-reactions", + name: "Live reactions", + type: "event", + description: "Short-lived emoji bursts sent to everyone on the page.", + capabilities: ["events"], + tags: ["emoji", "broadcast", "reaction"], + framework: "React", + status: "stable", + source: "docs-and-react", + preview: "docs-reactions", + links: [ + { label: "read events", href: "/docs/data/events/", kind: "read" }, + reactSource("LiveReactions.tsx"), + ], + }, + { + slug: "reaction-view", + name: "Reaction button", + type: "data", + description: "A shared count with local reacted state.", + capabilities: ["can-play", "local state"], + tags: ["reaction", "counter"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("Reaction.tsx")], + }, + { + slug: "reactive-orb", + name: "Reactive orb", + type: "data", + description: "A synchronized click count rendered as color and scale.", + capabilities: ["can-play", "element data"], + tags: ["counter", "visual"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("ReactiveOrb.tsx")], + }, + { + slug: "poll", + name: "Poll", + type: "data", + description: "A collaborative single-select poll with optional user-added choices.", + capabilities: ["can-play", "element data"], + tags: ["poll", "votes", "form"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("Poll.tsx")], + }, + { + slug: "random-spinner", + name: "Random spinner", + type: "data", + description: "A shared picker that animates toward the same selected option.", + capabilities: ["can-play", "element data"], + tags: ["spinner", "random", "animation"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("RandomSpinner.tsx")], + }, + { + slug: "shared-slider", + name: "Shared slider", + type: "data", + description: "A range input backed by synchronized element data.", + capabilities: ["can-play", "element data"], + tags: ["range", "input"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("SharedSlider.tsx")], + }, + { + slug: "shared-timer", + name: "Shared timer", + type: "data", + description: "A synchronized timer with start, pause, and reset controls.", + capabilities: ["can-play", "element data"], + tags: ["timer", "controls"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("SharedTimer.tsx")], + }, + { + slug: "toggle-square", + name: "Toggle square", + type: "data", + description: "A minimal shared boolean rendered as a red or green square.", + capabilities: ["can-play", "element data"], + tags: ["toggle", "boolean"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("ToggleSquare.tsx")], + }, + { + slug: "view-count", + name: "View count", + type: "data", + description: "A shared counter that increments when the room syncs.", + capabilities: ["can-play", "element data"], + tags: ["counter", "views"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("ViewCount.tsx")], + }, + { + slug: "visitor-count", + name: "Visitor count", + type: "presence", + description: "A presence-backed count of connected visitors.", + capabilities: ["presence", "awareness"], + tags: ["presence", "count"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("VisitorCount.tsx")], + }, + { + slug: "cursor-online-indicator", + name: "Cursor online indicator", + type: "presence", + description: "A cursor-room indicator built with usePlayContext.", + capabilities: ["cursors", "presence"], + tags: ["cursor", "presence"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("CursorOnlineIndicator.tsx")], + }, + { + slug: "unique-people-pill", + name: "Unique people pill", + type: "presence", + description: "A non-cursor presence room deduped by player identity.", + capabilities: ["presence", "playerIdentity"], + tags: ["presence", "dedupe"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("UniquePeoplePill.tsx")], + }, + { + slug: "fridge-word", + name: "Fridge word", + type: "capability", + description: "A draggable word tile built on can-move.", + capabilities: ["can-move"], + tags: ["drag", "word", "magnet"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("FridgeWord.tsx")], + }, + { + slug: "live-chat", + name: "Live chat", + type: "data", + description: "A shared message list with a minimized chat window.", + capabilities: ["can-play", "element data"], + tags: ["chat", "messages"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("LiveChat.tsx")], + }, + { + slug: "live-chat-controller", + name: "Live chat controller", + type: "data", + description: "A shared controller that creates named chat rooms.", + capabilities: ["can-play", "element data"], + tags: ["chat", "controller"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("LiveChat.tsx")], + }, + { + slug: "shared-sound", + name: "Shared sound", + type: "data", + description: "A shared play/pause state for an audio element.", + capabilities: ["can-play", "element data"], + tags: ["audio", "media"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("SharedSound.tsx")], + }, + { + slug: "confetti-zone", + name: "Confetti zone", + type: "event", + description: "A click target that dispatches a confetti event.", + capabilities: ["events"], + tags: ["confetti", "broadcast"], + framework: "React", + status: "reference", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("Confetti.tsx")], + }, + { + slug: "shared-color", + name: "Shared color", + type: "data", + description: "A skeletal shared color example kept as a React reference.", + capabilities: ["can-play", "element data"], + tags: ["color", "reference"], + framework: "React", + status: "experimental", + source: "react", + preview: "static", + react: ``, + links: [{ label: "react examples", href: reactExamplesHref, kind: "read" }, reactSource("SharedColor.tsx")], + }, +]; From 38077fa1b3413a3f7b0875d8b217cf10022a092d Mon Sep 17 00:00:00 2001 From: Spencer Chang Date: Tue, 5 May 2026 11:53:21 -0700 Subject: [PATCH 2/3] docs: add examples index page --- apps/docs/astro.config.mjs | 13 +- .../react/examples/ExampleIndex.tsx | 390 ++++++++++++++++++ apps/docs/src/content/docs/examples/index.mdx | 13 + 3 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 apps/docs/src/components/react/examples/ExampleIndex.tsx create mode 100644 apps/docs/src/content/docs/examples/index.mdx diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 06b49ba46..4777a0f2a 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -86,15 +86,16 @@ export default defineConfig({ // // Start โ†’ getting-started, concepts, using-react // Capabilities โ†’ capabilities (single page) + // Examples โ†’ examples (single page) // Data โ†’ autogenerate (data/) // Advanced โ†’ autogenerate (advanced/) // Integrations โ†’ autogenerate (integrations/) // Reference โ†’ autogenerate (reference/) // - // Why hybrid: the top groups ("Start", "Capabilities") don't have a - // matching directory on disk, and we want them as named groups for IA - // reasons โ€” so they have to be hand-rolled. Everything below is in a - // real subdirectory, so we let Starlight autogenerate from the + // Why hybrid: the top groups ("Start", "Capabilities", "Examples") are + // deliberately named groups for IA reasons, so they have to be + // hand-rolled. Everything below is in a real subdirectory, so we let + // Starlight autogenerate from the // `sidebar.order` frontmatter on each page. New pages added to // data/ / advanced/ / integrations/ / reference/ show up in the // sidebar automatically โ€” set `sidebar.order` in the page frontmatter @@ -128,6 +129,10 @@ export default defineConfig({ label: "Capabilities", items: [{ label: "All capabilities", slug: "capabilities" }], }, + { + label: "Examples", + items: [{ label: "Example index", slug: "examples" }], + }, { label: "Data", autogenerate: { directory: "data" } }, { label: "Advanced", autogenerate: { directory: "advanced" } }, { label: "Integrations", autogenerate: { directory: "integrations" } }, diff --git a/apps/docs/src/components/react/examples/ExampleIndex.tsx b/apps/docs/src/components/react/examples/ExampleIndex.tsx new file mode 100644 index 000000000..7c06be422 --- /dev/null +++ b/apps/docs/src/components/react/examples/ExampleIndex.tsx @@ -0,0 +1,390 @@ +// ABOUTME: Interactive examples index for the docs examples page. +// ABOUTME: Filters registry entries and renders the selected example specimen. +import { type ReactNode, useMemo, useState } from "react"; +import { PlayContext, playhtml } from "@playhtml/react"; +import { + exampleEntries, + type ExampleEntry, + type ExampleSource, + type ExampleType, +} from "@/examples/example-registry"; +import { InteractiveToggleDemo } from "@/components/react/InteractiveToggleDemo"; +import { LampToggleDemo } from "@/components/react/LampToggleDemo"; +import { DocsGuestbook } from "@/components/react/capability-demos/DocsGuestbook"; +import { DocsSpinner } from "@/components/react/capability-demos/DocsSpinner"; +import { DuplicateRabbitDemo } from "@/components/react/capability-demos/DuplicateRabbitDemo"; +import { GrowBalloonDemo } from "@/components/react/capability-demos/GrowBalloonDemo"; +import { HoverCursorColorsDemo } from "@/components/react/capability-demos/HoverCursorColorsDemo"; +import { MoveHatCatDemo } from "@/components/react/capability-demos/MoveHatCatDemo"; +import { SmileyRow } from "@/components/react/capability-demos/SmileyRow"; +import { SpinWheelDemo } from "@/components/react/capability-demos/SpinWheelDemo"; +import { LiveReactionsDemo } from "@/components/react/data-demos/LiveReactionsDemo"; +import { OnlineIndicatorDemo } from "@/components/react/data-demos/OnlineIndicatorDemo"; +import { RainSprinklerDemo } from "@/components/react/data-demos/RainSprinklerDemo"; + +const typeLabels: Record = { + capability: "Capability", + data: "Data", + presence: "Presence", + event: "Event", + react: "React", +}; + +const sourceLabels: Record = { + docs: "Docs demo", + react: "React example", + "docs-and-react": "Docs + React", +}; + +const statusLabels: Record = { + stable: "Stable", + experimental: "Experimental", + reference: "Reference", +}; + +function DocsPlayContextProvider({ children }: { children: ReactNode }) { + const value = useMemo( + () => ({ + setupPlayElements: () => playhtml.setupPlayElements(), + dispatchPlayEvent: playhtml.dispatchPlayEvent, + registerPlayEventListener: playhtml.registerPlayEventListener, + removePlayEventListener: playhtml.removePlayEventListener, + deleteElementData: playhtml.deleteElementData, + hasSynced: true, + isLoading: false, + isProviderMissing: false, + configureCursors: () => {}, + getMyPlayerIdentity: () => playhtml.cursorClient?.getMyPlayerIdentity() ?? null, + triggerCursorAnimation: ( + stableId: string, + animationClass: string, + durationMs?: number, + ) => + playhtml.cursorClient?.triggerCursorAnimation( + stableId, + animationClass, + durationMs, + ) ?? false, + registerCursorZone: (element: HTMLElement, options?: any) => { + playhtml.cursorClient?.registerZone(element, options); + }, + unregisterCursorZone: (elementId: string) => { + playhtml.cursorClient?.unregisterZone(elementId); + }, + cursors: { + allColors: playhtml.cursorClient?.getSnapshot().allColors ?? [], + color: playhtml.cursorClient?.getSnapshot().color ?? "", + name: playhtml.cursorClient?.getSnapshot().name, + }, + cursorPresences: playhtml.cursorClient?.getCursorPresences() ?? new Map(), + }), + [], + ); + + return {children}; +} + +function matchesEntry( + entry: ExampleEntry, + query: string, + activeType: string, +): boolean { + const haystack = [ + entry.name, + entry.type, + entry.status, + entry.framework, + entry.source, + entry.description, + ...entry.capabilities, + ...entry.tags, + ] + .join(" ") + .toLowerCase(); + + return haystack.includes(query) && (activeType === "all" || entry.type === activeType); +} + +function ExamplePreview({ entry }: { entry: ExampleEntry }) { + if (entry.preview === "docs-toggle") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-lamp") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-move") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-grow") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-spin") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-hover") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-duplicate") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-guestbook") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-spinner") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-smiley") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-presence") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-rain") { + return ( +
+ +
+ ); + } + + if (entry.preview === "docs-reactions") { + return ( +
+ +
+ ); + } + + return ( +
+

{entry.description}

+

+ This is listed as a source example rather than mounted inline. Open the + source to inspect the complete component. +

+
+ ); +} + +function ExampleSnippet({ entry }: { entry: ExampleEntry }) { + const snippet = entry.html ?? entry.react; + if (!snippet) return null; + const label = entry.html ? "HTML" : "React"; + + return ( +
+ {label} +
+        {snippet}
+      
+
+ ); +} + +export function ExampleIndex() { + const [query, setQuery] = useState(""); + const [activeType, setActiveType] = useState("all"); + const [selectedSlug, setSelectedSlug] = useState(exampleEntries[0].slug); + + const normalizedQuery = query.trim().toLowerCase(); + const entries = useMemo( + () => + exampleEntries.filter((entry) => + matchesEntry(entry, normalizedQuery, activeType), + ), + [activeType, normalizedQuery], + ); + + const selected = + entries.find((entry) => entry.slug === selectedSlug) ?? + exampleEntries.find((entry) => entry.slug === selectedSlug) ?? + exampleEntries[0]; + + const typeOptions = [ + "all", + ...Array.from(new Set(exampleEntries.map((entry) => entry.type))), + ]; + const hasPlayRecipe = Boolean(selected.playRecipeId); + + return ( +
+
+
+ {typeOptions.map((type) => ( + + ))} +
+ + + +
+ {entries.length === 0 ? ( +

No examples match these filters.

+ ) : ( + entries.map((entry) => ( + + )) + )} +
+ +
+ {entries.length} items + +
+
+ +
+
+ examples {selected.slug} +
+
+
+

{typeLabels[selected.type]}

+

{selected.name}

+
+ + {statusLabels[selected.status]} + +
+

{selected.description}

+ + + + + +
+ {selected.framework} + {sourceLabels[selected.source]} + {selected.capabilities.map((capability) => ( + {capability} + ))} + {selected.tags.map((tag) => ( + {tag} + ))} +
+ + + +
+ {hasPlayRecipe && ( + + )} + {selected.links.map((link) => ( + + {link.label} + + ))} +
+
+
+ ); +} diff --git a/apps/docs/src/content/docs/examples/index.mdx b/apps/docs/src/content/docs/examples/index.mdx new file mode 100644 index 000000000..5b53e95be --- /dev/null +++ b/apps/docs/src/content/docs/examples/index.mdx @@ -0,0 +1,13 @@ +--- +title: "Examples" +description: "A searchable catalogue of self-contained playhtml docs demos and React examples." +sidebar: + order: 1 +tableOfContents: false +--- + +import { ExampleIndex } from "@/components/react/examples/ExampleIndex"; + +Use this index to find working specimens by capability, data primitive, framework, or source. Pick a row to inspect the current specimen, then jump to docs or source. Some examples also show a disabled `/play` action for the named recipe route the editor should expose. + + From 22cb8c8f7394dacf94a2a0b0c1089826955631d9 Mon Sep 17 00:00:00 2001 From: Spencer Chang Date: Tue, 5 May 2026 11:53:40 -0700 Subject: [PATCH 3/3] docs: style examples index --- apps/docs/src/styles/docs-extras.css | 383 ++++++++++++++++++++++++++- 1 file changed, 382 insertions(+), 1 deletion(-) diff --git a/apps/docs/src/styles/docs-extras.css b/apps/docs/src/styles/docs-extras.css index f27fbec5a..59053d626 100644 --- a/apps/docs/src/styles/docs-extras.css +++ b/apps/docs/src/styles/docs-extras.css @@ -1821,7 +1821,388 @@ article pre.astro-code:not([data-ph-copy-enhanced]) { } /* --------------------------------------------------------------------- - 17c. Data-layer live demos + 17c. Examples index + --------------------------------------------------------------------- */ + +.ph-example-index { + display: grid; + grid-template-columns: minmax(18rem, 0.9fr) minmax(0, 1.45fr); + gap: 1.25rem; + margin-top: 1.6rem; + align-items: start; +} + +.ph-example-index__list, +.ph-example-specimen { + border: 1px solid var(--ph-hairline); + border-radius: 6px; + background: color-mix(in srgb, var(--ph-paper) 90%, white); +} + +.ph-example-index__list { + position: sticky; + top: 5rem; + max-height: calc(100vh - 6rem); + overflow: auto; + padding: 0.85rem; +} + +.ph-example-index__tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.8rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--ph-hairline-soft); +} + +.ph-example-index__tabs button, +.ph-example-index__footer button, +.ph-example-row, +.ph-example-actions a, +.ph-example-actions button { + font: inherit; +} + +.ph-example-index__tabs button { + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--ph-ink-2); + padding: 0.25rem 0.45rem; + cursor: pointer; +} + +.ph-example-index__tabs button:hover { + border-color: var(--ph-hairline-soft); + background: var(--ph-paper-warm); +} + +.ph-example-index__tabs button.is-active { + border-color: var(--ph-ultramarine); + background: var(--ph-ultramarine-wash); + color: var(--ph-ultramarine-deep); +} + +.ph-example-index__search { + display: grid; + gap: 0.3rem; + margin-bottom: 0.9rem; + color: var(--ph-ink-3); + font-size: 0.85rem; +} + +.ph-example-index__search input { + width: 100%; + min-height: 2.55rem; + border: 1px solid var(--ph-hairline); + border-radius: 4px; + background: var(--ph-paper); + color: var(--ph-ink); + padding: 0 0.7rem; + font: inherit; +} + +.ph-example-index__search input:focus { + outline: 2px solid var(--ph-ultramarine); + outline-offset: 2px; +} + +.ph-example-table { + display: grid; + border-top: 1px dashed var(--ph-hairline-soft); +} + +.ph-example-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + width: 100%; + border: 0; + border-bottom: 1px dashed var(--ph-hairline-soft); + background: transparent; + color: var(--ph-ink); + text-align: left; + padding: 0.78rem 0.35rem; + cursor: pointer; +} + +.ph-example-row:hover, +.ph-example-row.is-selected { + background: var(--ph-ultramarine-wash); +} + +.ph-example-row:focus-visible { + outline: 2px solid var(--ph-ultramarine); + outline-offset: -2px; +} + +.ph-example-row__name { + grid-column: 1; + font-weight: 700; + line-height: 1.25; +} + +.ph-example-row__tags, +.ph-example-meta { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.ph-example-row__tags { + grid-column: 1; +} + +.ph-example-row__tags span, +.ph-example-meta span { + border: 1px solid var(--ph-hairline-soft); + background: var(--ph-paper-warm); + color: var(--ph-ink-2); + border-radius: 4px; + padding: 0.1rem 0.35rem; + font-family: var(--ph-font-mono); + font-size: 0.72rem; + line-height: 1.5; +} + +.ph-example-status { + grid-column: 2; + grid-row: 1 / span 2; + justify-self: end; + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--ph-ink-3); + font-family: var(--ph-font-mono); + font-size: 0.68rem; + line-height: 1.2; + text-transform: uppercase; +} + +.ph-example-status::before { + content: ""; + width: 0.55rem; + height: 0.55rem; + flex: 0 0 auto; + border-radius: 999px; + background: var(--ph-ink-3); +} + +.ph-example-status--stable::before { + background: #2fa34a; +} + +.ph-example-status--experimental::before { + background: var(--ph-mustard); +} + +.ph-example-status--reference::before { + background: var(--ph-ink-3); +} + +.ph-example-empty { + margin: 0; + padding: 1rem 0.35rem; + color: var(--ph-ink-3); + font-size: 0.9rem; +} + +.ph-example-index__footer { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-top: 0.85rem; + color: var(--ph-ink-3); + font-size: 0.85rem; +} + +.ph-example-index__footer button { + border: 0; + background: transparent; + color: var(--ph-ultramarine-deep); + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.ph-example-specimen { + padding: clamp(1rem, 2vw, 1.5rem); + overflow: hidden; +} + +.ph-example-specimen__crumbs, +.ph-example-specimen__type { + color: var(--ph-ink-3); + font-family: var(--ph-font-mono); + font-size: 0.78rem; +} + +.ph-example-specimen__crumbs { + margin-bottom: 0.65rem; +} + +.ph-example-specimen__type { + margin: 0 0 0.25rem; + text-transform: uppercase; +} + +.ph-example-specimen__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.ph-example-specimen__head h2 { + margin: 0; + padding: 0; + border: 0; + font-size: clamp(2rem, 4vw, 3.1rem); +} + +.ph-example-specimen__description { + margin: 0.8rem 0 1rem; + max-width: 62ch; + color: var(--ph-ink-2); +} + +.ph-example-preview { + min-height: 16rem; + display: grid; + place-items: center; + border: 1px dashed var(--ph-hairline); + border-radius: 6px; + background-image: linear-gradient( + to right, + var(--ph-grid-line) 1px, + transparent 1px + ), + linear-gradient(to bottom, var(--ph-grid-line) 1px, transparent 1px); + background-size: var(--ph-grid-size) var(--ph-grid-size); + margin: 1rem 0; + padding: 1rem; + overflow: auto; +} + +.ph-example-preview--compact { + min-height: 11rem; +} + +.ph-example-preview--lamp { + min-height: 19rem; +} + +.ph-example-preview--lamp .ph-lamp__img { + max-height: 17rem; + width: auto; +} + +.ph-example-preview--wide { + justify-items: stretch; +} + +.ph-example-preview--static { + justify-items: start; + align-content: center; + gap: 0.5rem; +} + +.ph-example-preview--static p { + margin: 0; + max-width: 52ch; +} + +.ph-example-code-block { + margin-top: 1rem; +} + +.ph-example-code-block > span { + display: inline-flex; + margin-bottom: 0.35rem; + color: var(--ph-ink-3); + font-family: var(--ph-font-mono); + font-size: 0.72rem; + text-transform: uppercase; +} + +.ph-example-code { + margin: 0; + max-height: 14rem; + overflow: auto; + white-space: pre-wrap; +} + +.ph-example-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 1rem; +} + +.ph-example-actions a, +.ph-example-actions button { + min-height: 2.6rem; + border: 1px solid var(--ph-hairline); + border-radius: 4px; + background: var(--ph-paper); + color: var(--ph-ink); + text-decoration: none; + padding: 0.65rem 0.85rem; +} + +.ph-example-actions a:hover { + border-color: var(--ph-ink); + background: var(--ph-mustard-wash); +} + +.ph-example-actions button:disabled { + cursor: not-allowed; + opacity: 0.72; +} + +.ph-example-actions__primary { + background: var(--ph-ultramarine) !important; + color: var(--ph-paper) !important; + border-color: var(--ph-ultramarine-deep) !important; +} + +@media (max-width: 58rem) { + .ph-example-index { + grid-template-columns: 1fr; + } + + .ph-example-index__list { + position: static; + max-height: none; + overflow: visible; + } +} + +@media (max-width: 42rem) { + .ph-example-row { + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .ph-example-status { + grid-column: 1; + grid-row: auto; + justify-self: start; + } + + .ph-example-specimen__head { + display: grid; + } + + .ph-example-preview { + min-height: 13rem; + } +} + +/* --------------------------------------------------------------------- + 17d. Data-layer live demos --------------------------------------------------------------------- Three small inline demos that drop into the concept pages under /data/*: - .ph-rain-demo (events) โ€” fire hydrant + rain