feat(registry): seed transition blocks — 14 shader + 14 CSS showcase (#270)

## What

Add 28 transition blocks from the Hyperframe Template Structure catalog, bringing the registry to 53 total items.

### Shader transitions (14 blocks, WebGL, 4s each)
`domain-warp-dissolve`, `ridged-burn`, `whip-pan`, `sdf-iris`, `ripple-waves`, `gravitational-lens`, `cinematic-zoom`, `chromatic-radial-split`, `glitch`, `swirl-vortex`, `thermal-distortion`, `flash-through-white`, `cross-warp-morph`, `light-leak`

### CSS transition showcases (14 blocks, various durations)
`transitions-3d`, `transitions-blur`, `transitions-cover`, `transitions-destruction`, `transitions-dissolve`, `transitions-distortion`, `transitions-grid`, `transitions-light`, `transitions-mechanical`, `transitions-other`, `transitions-push`, `transitions-radial`, `transitions-scale`, `transitions-shader`

## Why

Phase D content accumulation. Transitions are the most-requested category for the catalog.

## How

- Shader transitions extracted from `shader-showcase.zip`, each a standalone HTML with WebGL shaders
- CSS transitions extracted from `showcase-bundle.zip`, each a standalone showcase page
- All tagged with `transition` + `shader` or `showcase` for catalog grouping
- Preview thumbnails generated for all 28 blocks
- Catalog pages + index regenerated

## Test plan

- [x] All 28 blocks produce preview thumbnails
- [x] `registry-item.json` validates for all blocks
- [x] Catalog pages generated (45 total items in catalog-index.json)
- [x] `oxfmt --check` passes
This commit is contained in:
James Russo
2026-04-14 16:32:27 -07:00
committed by GitHub
parent d37d738be9
commit 9943091247
212 changed files with 12653 additions and 1614 deletions
+62 -26
View File
@@ -61,7 +61,13 @@ function discoverItems(): { kind: ItemKind; manifest: RegistryItem }[] {
const manifestPath = join(dir, entry.name, "registry-item.json");
if (!existsSync(manifestPath)) continue;
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
let manifest: RegistryItem;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
} catch (err) {
console.warn(` ⚠ Skipping ${manifestPath}: ${(err as Error).message}`);
continue;
}
items.push({ kind, manifest });
}
}
@@ -100,10 +106,10 @@ function generateItemMdx(kind: ItemKind, manifest: RegistryItem): string {
lines.push(tagBadges, "");
}
// Preview video with poster fallback — matches the examples page pattern.
// Preview video with poster — muted loop, no autoPlay (matches examples page).
const previewPath = `/images/catalog/${typeDir(kind)}/${manifest.name}`;
lines.push(
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4" poster="${previewPath}.png" muted loop playsInline autoPlay />`,
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4" poster="${previewPath}.png" autoPlay muted loop playsInline />`,
"",
);
@@ -235,39 +241,69 @@ function main(): void {
// Update docs.json navigation with generated catalog pages.
const docsJsonPath = join(docsDir, "docs.json");
const docsJson = JSON.parse(readFileSync(docsJsonPath, "utf-8"));
const tabs = docsJson.navigation?.tabs as Array<{ tab: string; groups: unknown[] }>;
const tabs = docsJson.navigation?.tabs;
if (!Array.isArray(tabs)) {
console.warn(" ⚠ docs.json has no navigation.tabs — skipping nav update");
console.log("\nDone.");
return;
}
// Build catalog groups from discovered items
const blockPages = catalogIndex
.filter((i) => i.type === "block")
.map((i) => `catalog/blocks/${i.name}`);
const componentPages = catalogIndex
.filter((i) => i.type === "component")
.map((i) => `catalog/components/${i.name}`);
// Build catalog groups by category (first tag), like shadcn/ui.
// Items with the same first tag are grouped together. Items without tags
// go into an "Other" group. Groups are sorted with a priority order.
const GROUP_ORDER: Record<string, number> = {
"Social Overlays": 0,
"Shader Transitions": 1,
"CSS Transitions": 2,
Showcases: 3,
Data: 4,
Effects: 5,
Blocks: 6,
};
const catalogGroups: { group: string; pages: string[] }[] = [];
if (blockPages.length > 0) catalogGroups.push({ group: "Blocks", pages: blockPages });
if (componentPages.length > 0) catalogGroups.push({ group: "Components", pages: componentPages });
function groupForItem(entry: CatalogEntry): string {
const tags = entry.tags;
// Two-tag combos for specific grouping
if (tags.includes("transition") && tags.includes("shader")) return "Shader Transitions";
if (tags.includes("transition") && tags.includes("showcase")) return "CSS Transitions";
// Single-tag mapping
if (tags.includes("social")) return "Social Overlays";
if (tags.includes("transition"))
return entry.type === "component" ? "Effects" : "CSS Transitions";
if (tags.includes("showcase") || tags.includes("3d")) return "Showcases";
if (tags.includes("data") || tags.includes("chart") || tags.includes("ascii")) return "Data";
if (entry.type === "component") return "Effects";
// Remaining blocks
return "Blocks";
}
const groupMap = new Map<string, string[]>();
for (const entry of catalogIndex) {
const group = groupForItem(entry);
const dir = entry.type === "block" ? "blocks" : "components";
const page = `catalog/${dir}/${entry.name}`;
if (!groupMap.has(group)) groupMap.set(group, []);
groupMap.get(group)!.push(page);
}
const catalogGroups = [...groupMap.entries()]
.sort(([a], [b]) => (GROUP_ORDER[a] ?? 50) - (GROUP_ORDER[b] ?? 50))
.map(([group, pages]) => ({ group, pages }));
if (catalogGroups.length > 0) {
// Replace or insert the Catalog tab
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
const catalogTab = { tab: "Catalog", groups: catalogGroups };
// Remove existing Catalog tab if present, then insert at position 1
// (after Documentation, before Packages).
if (existingIdx >= 0) {
tabs[existingIdx] = catalogTab;
} else {
// Insert before the last tab (Reference)
const refIdx = tabs.findIndex((t) => t.tab === "Reference");
if (refIdx >= 0) {
tabs.splice(refIdx, 0, catalogTab);
} else {
tabs.push(catalogTab);
}
tabs.splice(existingIdx, 1);
}
const docsIdx = tabs.findIndex((t) => t.tab === "Documentation");
tabs.splice(docsIdx >= 0 ? docsIdx + 1 : 1, 0, catalogTab);
writeFileSync(docsJsonPath, JSON.stringify(docsJson, null, 2) + "\n", "utf-8");
console.log(
` ✓ docs.json updated with ${blockPages.length} blocks + ${componentPages.length} components`,
);
const totalPages = catalogGroups.reduce((n, g) => n + g.pages.length, 0);
console.log(` ✓ docs.json updated with ${catalogGroups.length} groups, ${totalPages} pages`);
}
console.log("\nDone.");
+71 -16
View File
@@ -2,10 +2,11 @@
/**
* Generate Catalog Preview Images + Videos
*
* Extends the template preview pipeline to handle all registry item types:
* - Examples: renders index.html (same as generate-template-previews.ts)
* - Blocks: renders the block's standalone HTML directly
* - Components: renders the component's demo.html
* Renders preview thumbnails and videos for registry blocks and components.
* Examples use the separate generate-template-previews.ts script.
*
* - Blocks: renders the block's standalone HTML via a wrapper index.html
* - Components: renders the component's demo.html via a wrapper index.html
*
* Output: docs/images/catalog/<type>/<name>.png + <name>.mp4
*
@@ -127,7 +128,49 @@ function prepareProjectDir(item: CatalogItem): string {
// The HyperFrames producer navigates to index.html at the project root.
// Blocks and component demos are standalone HTML files, not index.html.
// Create a wrapper index.html that loads the entry file as a sub-composition.
// If the entry file is a standalone HTML (has its own timeline registration),
// just rename it to index.html. Otherwise create a wrapper.
if (!existsSync(join(tmpDir, "index.html")) && existsSync(join(tmpDir, item.entryFile))) {
const entryContent = readFileSync(join(tmpDir, item.entryFile), "utf-8");
const hasTimeline = entryContent.includes("__timelines");
if (hasTimeline) {
// Standalone block — copy to index.html and render directly.
// For social overlays with transparent backgrounds, inject a dark bg
// so the overlay card is visible against something.
let content = entryContent;
const hasSocialTag = (() => {
try {
const m = JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8"));
return (m.tags ?? []).includes("social");
} catch {
return false;
}
})();
if (hasSocialTag) {
// Dark bg for transparent overlays
if (content.includes("background: transparent")) {
content = content.replace("background: transparent", "background: #1a1a2e");
}
// Reposition bottom-anchored overlays to center for preview.
// Social overlays use "bottom: Npx" positioning — replace with
// "top: 50%; transform: translate(-50%, -50%)" for a centered preview.
content = content.replace(
/bottom:\s*\d+px;\s*\n(\s*)left:\s*50%;\s*\n(\s*)transform:\s*translateX\(-50%\)/,
"top: 50%;\n$1left: 50%;\n$2transform: translate(-50%, -50%)",
);
// Scale down large centered cards (like Spotify) that use
// margin-based centering with large negative margins.
if (/margin-top:\s*-[3-9]\d\dpx/.test(content)) {
content = content.replace(
/(<body[^>]*>)/,
"$1\n<style>body { transform: scale(0.55); transform-origin: center center; }</style>",
);
}
}
writeFileSync(join(tmpDir, "index.html"), content, "utf-8");
return tmpDir;
}
}
if (!existsSync(join(tmpDir, "index.html"))) {
const manifestPath = join(tmpDir, "registry-item.json");
let width = 1920;
@@ -140,13 +183,24 @@ function prepareProjectDir(item: CatalogItem): string {
duration = m.duration ?? duration;
}
// Dark background for social overlays so transparent cards are visible.
const tags: string[] = (() => {
try {
return JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8")).tags ?? [];
} catch {
return [];
}
})();
const isSocialOverlay = tags.includes("social") || tags.includes("overlay");
const bgColor = isSocialOverlay ? "#1a1a2e" : "#ffffff";
const wrapper = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${width}, height=${height}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>* { margin: 0; padding: 0; } html, body { width: ${width}px; height: ${height}px; overflow: hidden; }</style>
<style>* { margin: 0; padding: 0; } html, body { width: ${width}px; height: ${height}px; overflow: hidden; background: ${bgColor}; }</style>
</head>
<body>
<div data-composition-id="preview-root" data-width="${width}" data-height="${height}" data-start="0" data-duration="${duration}">
@@ -168,17 +222,16 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
const outDir = outputDir(item.kind);
mkdirSync(outDir, { recursive: true });
// Read dimensions from registry-item.json or default to 1920x1080
// Read dimensions from the wrapper index.html (which may differ from native
// dimensions for portrait overlays that are scaled to fit landscape).
let width = 1920;
let height = 1080;
const manifestPath = join(item.sourceDir, "registry-item.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
if (manifest.dimensions) {
width = manifest.dimensions.width ?? width;
height = manifest.dimensions.height ?? height;
}
}
const wrapperPath = join(projectDir, "index.html");
const wrapperHtml = readFileSync(wrapperPath, "utf-8");
const wMatch = wrapperHtml.match(/data-width="(\d+)"/);
const hMatch = wrapperHtml.match(/data-height="(\d+)"/);
if (wMatch) width = parseInt(wMatch[1], 10);
if (hMatch) height = parseInt(hMatch[1], 10);
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
@@ -201,7 +254,9 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
}
// Capture at 40% of duration for a representative frame
const captureTime = Math.min(2.0, duration * 0.4);
// Capture at 60% of duration so the animation is well underway.
// Cap at 3s to avoid overly-late captures on long compositions.
const captureTime = Math.min(3.0, duration * 0.6);
const result = await captureFrame(session, 0, captureTime);
cpSync(result.path, join(outDir, `${item.name}.png`));
console.log(`${item.name}.png (${result.captureTimeMs}ms)`);