Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gallery-item-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions packages/ui/tests/item-tabs.test.ts
Original file line number Diff line number Diff line change
@@ -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/"
);
});
});
92 changes: 88 additions & 4 deletions site/src/components/gallery/GalleryViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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];

Expand All @@ -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.`
);
Expand Down Expand Up @@ -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.
Expand All @@ -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}`);
});
Expand Down Expand Up @@ -219,6 +232,8 @@
<TabsList aria-label="View">
<TabsTrigger value="preview">Preview</TabsTrigger>
{#if !isPage}<TabsTrigger value="code">Code</TabsTrigger>{/if}
<TabsTrigger value="prompt">Prompt</TabsTrigger>
{#if !isPage}<TabsTrigger value="anatomy">Anatomy</TabsTrigger>{/if}
</TabsList>
<TabsContent value="preview">
<div bind:this={iframeHost}>
Expand All @@ -241,7 +256,43 @@
{/key}
</div>
</TabsContent>
<TabsContent value="prompt">
<div class="viewer__panel">
<p class="viewer__note">
Hand this to your AI. It names the exact library parts, timings and accessibility promises of this {isPage ? "page" : "demo"}.
</p>
<pre class="viewer__prompt" data-testid="viewer-prompt-text">{prompt}</pre>
<CopyButton value={prompt} label="Copy prompt" variant="outlined" size="sm" toast="Prompt copied" />
</div>
</TabsContent>
{#if !isPage}
<TabsContent value="anatomy">
<div class="viewer__panel">
<h2 class="viewer__h">Library parts</h2>
<ul class="viewer__parts">
{#each anatomy as p (p.name)}
<li>
{#if p.href}<a href={p.href}>{p.name}</a>{:else}<span>{p.name}</span>{/if}
<code>{p.from}</code>
</li>
{/each}
</ul>
{#if record?.spec.length}
<h2 class="viewer__h">How it is built</h2>
<ul class="viewer__spec">
{#each record.spec as line (line)}<li>{line}</li>{/each}
</ul>
{/if}
{#if related.length}
<h2 class="viewer__h">Related</h2>
<ul class="viewer__related">
{#each related as r (r.id)}
<li><a href={`/gallery/${r.id}/`}>#{r.id} {r.title}</a> <span class="sk-caption">{r.subtype}</span></li>
{/each}
</ul>
{/if}
</div>
</TabsContent>
<TabsContent value="code">
<div class="viewer__code">
{#if source}
Expand Down Expand Up @@ -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;
}
Expand Down
88 changes: 88 additions & 0 deletions site/src/lib/item-tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Content of the Prompt and Anatomy tabs of /gallery/<id>/, 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<ItemRecord["motion"]>[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<string, string>
): 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(/(?<!^)([A-Z])/g, "-$1").toLowerCase());
return {
name,
from: from.get(name) ?? "@codeatlasdev/ui",
href: slug ? `/docs/${slug}/` : undefined,
};
});
}
17 changes: 16 additions & 1 deletion site/src/pages/gallery/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import Base from "~/layouts/Base.astro";
import { ITEMS } from "~/lib/gallery-items";
import { SECTIONS } from "~/lib/nav";
import { THEME_PRESETS } from "@codeatlasdev/ui";
import { getCollection } from "astro:content";
import type { IndexEntry } from "../../../../packages/ui/scripts/index-build";
import { relatedEntries } from "../../../../packages/ui/scripts/index-search";
import { buildAnatomy, type ItemRecord } from "~/lib/item-tabs";
import catalog from "../../../public/api/v1/catalog.json";
import index from "../../../public/api/v1/index.json";

export function getStaticPaths() {
return ITEMS.map((item) => ({ params: { id: item.id }, props: { item } }));
Expand All @@ -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}`;
---
<Base
Expand All @@ -23,7 +38,7 @@ const label = item.kind === "page" ? "Page" : `#${item.id}`;
skipLink={false}
>
<SiteDocsShell client:load sections={SECTIONS} active="gallery" pathname={Astro.url.pathname}>
<GalleryViewer client:load {item} {order} {known} {presets} />
<GalleryViewer client:load {item} {order} {known} {presets} {record} {entry} {anatomy} {related} />
</SiteDocsShell>
<SiteFooter />
</Base>
39 changes: 39 additions & 0 deletions site/tests/gallery-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down