diff --git a/.changeset/gallery-item-tabs.md b/.changeset/gallery-item-tabs.md new file mode 100644 index 00000000..431d3913 --- /dev/null +++ b/.changeset/gallery-item-tabs.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +A gallery item opens on Preview, Code, Prompt and Anatomy tabs (`?tab=prompt`, `?tab=anatomy`). Prompt is the text to hand an AI: the `get_demo` call, the library parts, the behaviour to keep, the exact motion timings and the reduced-motion and keyboard promises read from the index. Anatomy lists the components with their package and docs link, how the demo is built, and related items. Pages show Preview and Prompt. diff --git a/packages/ui/tests/item-tabs.test.ts b/packages/ui/tests/item-tabs.test.ts new file mode 100644 index 00000000..b9f3c1df --- /dev/null +++ b/packages/ui/tests/item-tabs.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildAnatomy, buildPrompt, type ItemRecord } from "../../../site/src/lib/item-tabs.ts"; +import type { IndexEntry } from "../scripts/index-build.ts"; + +const root = join(import.meta.dir, "../../../site/public/api/v1"); +const elements = ( + JSON.parse(readFileSync(join(root, "catalog.json"), "utf8")) as { elements: ItemRecord[] } +).elements; +const entries = ( + JSON.parse(readFileSync(join(root, "index.json"), "utf8")) as { entries: IndexEntry[] } +).entries; +const fade = elements.find((e) => e.id === "256") as ItemRecord; +const fadeEntry = entries.find((e) => e.id === "256"); + +describe("item prompt", () => { + it("names the demo, the fetch call, the library parts and the exact timing", () => { + const p = buildPrompt(fade, fadeEntry); + expect(p).toContain('get_demo({ ids: ["#256"] })'); + expect(p).toContain("MotionStage"); + expect(p).toContain( + "opacity, transform: translateY: 420ms, cubic-bezier(0.2, 0, 0, 1), delay 80ms" + ); + expect(p).toContain("Reduced motion: the demo handles it"); + }); + + it("never mentions a facet that is not published", () => { + for (const e of elements.slice(0, 60)) { + const p = buildPrompt( + e, + entries.find((x) => x.id === e.id) + ); + expect(p.toLowerCase()).not.toContain("industry"); + expect(p).toContain(`#${e.id}`); + } + }); + + it("states keyboard behaviour from the index, not a guess", () => { + const interactive = entries.find((e) => e.a11y?.keyboard) as IndexEntry; + const rec = elements.find((e) => e.id === interactive.id) as ItemRecord; + expect(buildPrompt(rec, interactive)).toContain("Keyboard: operable"); + const still = entries.find((e) => !e.a11y?.keyboard) as IndexEntry; + const rec2 = elements.find((e) => e.id === still.id) as ItemRecord; + expect(buildPrompt(rec2, still)).toContain("Keyboard: not an interactive control"); + }); +}); + +describe("item anatomy", () => { + const slugs = new Map([["card", "display/card"]]); + it("lists every component with its package and links only when a docs page exists", () => { + const parts = buildAnatomy(fade, slugs); + expect(parts.map((p) => p.name)).toEqual(fade.components); + const card = parts.find((p) => p.name === "Card"); + expect(card?.href).toBe("/docs/display/card/"); + expect(card?.from).toBe("@codeatlasdev/ui"); + expect(parts.find((p) => p.name === "MotionStage")?.href).toBeUndefined(); + }); + + it("kebab-cases multi-word names when matching a docs slug", () => { + const rec = { ...fade, components: ["DataTable"], imports: [] }; + expect(buildAnatomy(rec, new Map([["data-table", "display/data-table"]]))[0].href).toBe( + "/docs/display/data-table/" + ); + }); +}); diff --git a/site/src/components/gallery/GalleryViewer.svelte b/site/src/components/gallery/GalleryViewer.svelte index fb904e54..8136ab9f 100644 --- a/site/src/components/gallery/GalleryViewer.svelte +++ b/site/src/components/gallery/GalleryViewer.svelte @@ -17,7 +17,9 @@ import { ArrowLeft, ArrowRight, ExternalLink, Moon, RotateCcw, Sun } from "@lucide/svelte"; import type { Component } from "svelte"; import { onMount } from "svelte"; + import type { IndexEntry } from "../../../../packages/ui/scripts/index-build"; import type { GalleryItem } from "~/lib/gallery-items"; + import { type AnatomyPart, buildPrompt, type ItemRecord } from "~/lib/item-tabs"; import { loadDemo, loadSource } from "./demo-loader"; import { ensureDemoSession } from "./demo-session"; import { copyText, parsePicks } from "./picks"; @@ -30,8 +32,17 @@ /** Known ids, for `?pick=`. */ known: string[]; presets: { value: string; label: string }[]; + /** Catalog record of a demo (spec, imports, motion); pages have none. */ + record?: ItemRecord; + /** Index facets of a demo that the Prompt tab reads (keyboard, reduced motion). */ + entry?: IndexEntry; + /** Components of a demo with the package and docs link of each. */ + anatomy?: AnatomyPart[]; + /** Related demos: id, title and subtype. */ + related?: { id: string; title: string; subtype: string }[]; } - let { item, order, known, presets }: Props = $props(); + let { item, order, known, presets, record, entry, anatomy = [], related = [] }: Props = $props(); + const TABS = ["preview", "code", "prompt", "anatomy"]; const WIDTHS = [390, 768, 1280]; @@ -56,7 +67,9 @@ const label = $derived(`${item.title} at ${width === "fill" ? "full width" : `${width} pixels`}`); const prompt = $derived( - isPage + record + ? buildPrompt(record, entry) + : isPage ? `Use the "${item.title}" page from the @codeatlasdev/ui gallery (${item.docs}) as the starting point for my screen. Keep the layout, components and tokens; replace the copy and data with mine.` : `Use @codeatlasdev/ui demo #${item.id} "${item.title}" (${item.group}/${item.page}). Call get_demo({ ids: ["#${item.id}"] }), keep the components, props and tokens, and change only the data and copy.` ); @@ -136,7 +149,7 @@ if (item.auth) ensureDemoSession(); if (isPage && item.route) src = stageUrl(item.route, { scheme, preset: preset || undefined }); const t = params.get("tab"); - if (t === "code" && !isPage) tab = "code"; + if (t && TABS.includes(t) && (!isPage || t === "preview" || t === "prompt")) tab = t; }); // Keep the stage state in the URL so a view can be shared as is. @@ -147,7 +160,7 @@ params.set("scheme", scheme); if (preset) params.set("preset", preset); else params.delete("preset"); - if (tab === "code") params.set("tab", "code"); + if (tab !== "preview") params.set("tab", tab); else params.delete("tab"); history.replaceState(history.state, "", `${location.pathname}?${params}${location.hash}`); }); @@ -219,6 +232,8 @@ Preview {#if !isPage}Code{/if} + Prompt + {#if !isPage}Anatomy{/if}
@@ -241,7 +256,43 @@ {/key}
+ +
+

+ Hand this to your AI. It names the exact library parts, timings and accessibility promises of this {isPage ? "page" : "demo"}. +

+
{prompt}
+ +
+
{#if !isPage} + +
+

Library parts

+ + {#if record?.spec.length} +

How it is built

+ + {/if} + {#if related.length} +

Related

+ + {/if} +
+
{#if source} @@ -345,6 +396,39 @@ .viewer__reserve { height: calc(var(--reserve-h) + var(--ca-space-12)); } + .viewer__panel { + display: grid; + gap: var(--ca-space-3); + min-width: 0; + } + .viewer__h { + margin: var(--ca-space-3) 0 0; + font-size: var(--ca-font-size-h5); + } + .viewer__prompt { + margin: 0; + padding: var(--ca-space-4); + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--ca-font-mono); + font-size: var(--ca-font-size-caption); + background: var(--ca-surface-container, transparent); + border-radius: var(--ca-radius-md); + } + .viewer__parts, + .viewer__spec, + .viewer__related { + display: grid; + gap: var(--ca-space-2); + margin: 0; + padding-inline-start: var(--ca-space-5); + } + .viewer__parts code { + margin-inline-start: var(--ca-space-2); + font-family: var(--ca-font-mono); + font-size: var(--ca-font-size-caption); + color: var(--ca-text-secondary); + } .viewer__code { min-height: 20rem; } diff --git a/site/src/lib/item-tabs.ts b/site/src/lib/item-tabs.ts new file mode 100644 index 00000000..1c43a364 --- /dev/null +++ b/site/src/lib/item-tabs.ts @@ -0,0 +1,88 @@ +// Content of the Prompt and Anatomy tabs of /gallery//, built from the catalog record and the index entry. +// Pure: the viewer renders it, the tests read it. +import type { IndexEntry } from "../../../packages/ui/scripts/index-build"; + +/** The fields of a catalog element the tabs read. */ +export interface ItemRecord { + id: string; + title: string; + group: string; + page: string; + use: string; + spec: string[]; + components: string[]; + imports: { from: string; names: string[] }[]; + motion?: { + property: string; + duration: string; + easing: string; + delay?: string; + stagger?: string; + spring?: string; + }[]; + needs: string; +} + +/** One line of timing, in the words an AI can apply without guessing. */ +export function beat(m: NonNullable[number]): string { + const parts = [`${m.property}: ${m.duration}`, m.easing]; + if (m.delay) parts.push(`delay ${m.delay}`); + if (m.stagger) parts.push(`stagger ${m.stagger}`); + if (m.spring) parts.push(`spring ${m.spring}`); + return parts.join(", "); +} + +/** + * The prompt to hand an AI so it builds this item with the library: what to fetch, which components and exact timings + * to keep, what the item is for and what it promises about reduced motion and keyboard. + */ +export function buildPrompt(item: ItemRecord, entry?: IndexEntry): string { + const a11y = entry?.a11y; + const lines = [ + `Build the "${item.title}" pattern (@codeatlasdev/ui demo #${item.id}, ${item.group}/${item.page}) in my app.`, + "", + `Call get_demo({ ids: ["#${item.id}"] }) first and keep its structure. It needs ${item.needs}.`, + `Use: ${item.use}`, + "", + "Keep these library parts, with the props and tokens the demo uses:", + ...item.imports.map((i) => `- ${i.names.join(", ")} from "${i.from}"`), + ]; + if (item.spec.length) lines.push("", "Behaviour to keep:", ...item.spec.map((s) => `- ${s}`)); + if (item.motion?.length) + lines.push("", "Exact motion (do not approximate):", ...item.motion.map((m) => `- ${beat(m)}`)); + if (a11y) { + lines.push( + "", + `Reduced motion: ${a11y.reducedMotion ? "the demo handles it; keep that branch" : "not applicable, nothing animates on its own"}.`, + `Keyboard: ${a11y.keyboard ? "operable; keep the focus order and key handling" : "not an interactive control"}.` + ); + } + lines.push( + "", + "Change only the copy and the data. Use the theme tokens (--ca-*), never raw colours." + ); + return lines.join("\n"); +} + +export interface AnatomyPart { + name: string; + from: string; + /** Docs page, only when a page of that exact name exists. */ + href?: string; +} + +/** Components used by the item with the package each is imported from and a docs link when one exists. */ +export function buildAnatomy( + item: ItemRecord, + docSlugs: ReadonlyMap +): AnatomyPart[] { + const from = new Map(item.imports.flatMap((i) => i.names.map((n) => [n, i.from] as const))); + return item.components.map((name) => { + const slug = docSlugs.get(name.replace(/(? ({ params: { id: item.id }, props: { item } })); @@ -15,6 +21,15 @@ const { item } = Astro.props; const order = ITEMS.map((i) => i.id); const known = order; const presets = THEME_PRESETS.map((p) => ({ value: p.id, label: p.label })); +// A demo carries its catalog record and index entry; a page has neither. +const record = catalog.elements.find((e) => e.id === item.id) as ItemRecord | undefined; +const entries = index.entries as IndexEntry[]; +const entry = entries.find((e) => e.id === item.id); +const docSlugs = new Map((await getCollection("docs")).map((d) => [d.id.split("/").pop() as string, d.id])); +const anatomy = record ? buildAnatomy(record, docSlugs) : []; +const related = record + ? relatedEntries(entries, item.id, 6).map((e) => ({ id: e.id, title: e.title, subtype: `${e.kind}/${e.subtype}` })) + : []; const label = item.kind === "page" ? "Page" : `#${item.id}`; --- - + diff --git a/site/tests/gallery-viewer.spec.ts b/site/tests/gallery-viewer.spec.ts index 03e2b52c..2f67c799 100644 --- a/site/tests/gallery-viewer.spec.ts +++ b/site/tests/gallery-viewer.spec.ts @@ -38,6 +38,45 @@ test.describe("gallery viewer", () => { await expect(page).toHaveURL(/\/gallery\/002\//); }); + test("Prompt and Anatomy tabs carry the exact parts, timings and links, and live in the URL", async ({ + page, + }) => { + await gotoReady(page, "/gallery/256/"); + await page.getByRole("tab", { name: "Prompt" }).click(); + await expect(page).toHaveURL(/tab=prompt/); + const text = page.getByTestId("viewer-prompt-text"); + await expect(text).toContainText('get_demo({ ids: ["#256"] })'); + await expect(text).toContainText("opacity, transform: translateY: 420ms"); + await expect(text).not.toContainText(/industry/i); + // The copy button copies the same text. + expect(await page.getByTestId("viewer-prompt").getAttribute("data-value")).toBe( + await text.textContent() + ); + + await page.getByRole("tab", { name: "Anatomy" }).click(); + await expect(page).toHaveURL(/tab=anatomy/); + await expect( + page.locator(".viewer__parts").getByRole("link", { name: "Card", exact: true }) + ).toHaveAttribute("href", /^\/docs\/.+\/card\/$/); + await expect(page.locator(".viewer__parts").getByText("MotionStage")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Related" })).toBeVisible(); + const related = page.locator(".viewer__related a").first(); + await expect(related).toHaveAttribute("href", /^\/gallery\/\d+\/$/); + + await gotoReady(page, "/gallery/256/?tab=anatomy"); + await expect(page.getByRole("tab", { name: "Anatomy" })).toHaveAttribute( + "aria-selected", + "true" + ); + }); + + test("a page has Preview and Prompt only", async ({ page }) => { + await gotoReady(page, "/gallery/page-auth-sign-in/"); + await expect(page.getByRole("tab", { name: "Prompt" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Code" })).toHaveCount(0); + await expect(page.getByRole("tab", { name: "Anatomy" })).toHaveCount(0); + }); + test("Pick and Copy prompt", async ({ page }) => { await gotoReady(page, "/gallery/005/"); await page.getByRole("button", { name: "Pick", exact: true }).click();