feat: deployable design studio and updated component showcase
This commit is contained in:
1 parent
3dddcaac1a
commit
3987ddbcfe
113 files changed
+4544
-1039
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createStudioHandler } from "@wrnexus/design-studio/server";
|
||||
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
||||
|
||||
test("deployed Studio serves assets and isolated previews without application writes", async () => {
|
||||
const handler = await createStudioHandler({
|
||||
hosted: { basePath: "/design-studio" },
|
||||
});
|
||||
const origin = "https://components.example";
|
||||
const html = await handler(new Request(origin + "/design-studio/"));
|
||||
expect(await html.text()).toContain('<base href="/design-studio/">');
|
||||
expect(
|
||||
(await handler(new Request(origin + "/design-studio/studio.js"))).status,
|
||||
).toBe(200);
|
||||
const request = (path: string, from = origin) =>
|
||||
handler(
|
||||
new Request(origin + "/design-studio/api/" + path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: from },
|
||||
body: JSON.stringify({
|
||||
system: CORE_DESIGN_SYSTEMS.soft,
|
||||
base: "none",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect((await request("apply")).status).toBe(409);
|
||||
expect((await request("preview", "https://other.example")).status).toBe(403);
|
||||
const preview = await request("preview");
|
||||
expect(preview.status).toBe(200);
|
||||
const { url } = await preview.json();
|
||||
expect(url).toStartWith("/design-studio/preview/");
|
||||
expect((await handler(new Request(origin + url))).status).toBe(200);
|
||||
});
|
||||
@@ -31,9 +31,12 @@ test("preview input never requests a replacement; settings still do", async () =
|
||||
test("stored theme updates its stylesheet before the theme runtime loads", async () => {
|
||||
const window = new Window({ url: "http://localhost/components/alert" });
|
||||
window.document.cookie = "wrn-ui-mode=light;path=/";
|
||||
window.document.head.innerHTML = '<link rel="stylesheet" data-wrnexus-theme href="/__wrnexus/theme/dark/violet.css">';
|
||||
window.document.head.innerHTML =
|
||||
'<link rel="stylesheet" data-wrnexus-theme href="/__wrnexus/theme/dark/violet.css">';
|
||||
window.eval(readFileSync(new URL("../public/playground.js", import.meta.url), "utf8"));
|
||||
expect(window.document.documentElement.dataset.theme).toBe("light");
|
||||
expect(window.document.querySelector("link")?.getAttribute("href")).toBe("/__wrnexus/theme/light/violet.css");
|
||||
expect(window.document.querySelector("link")?.getAttribute("href")).toBe(
|
||||
"/__wrnexus/theme/light/violet.css",
|
||||
);
|
||||
await window.happyDOM.close();
|
||||
});
|
||||
+175
-54
@@ -8,7 +8,10 @@ const root = join(import.meta.dir, "..");
|
||||
const pagesDir = join(root, "app", "pages");
|
||||
const detailPagesDir = join(pagesDir, "components");
|
||||
const reference = JSON.parse(
|
||||
readFileSync(join(root, "node_modules", "@wrnexus", "ui", "component-reference.json"), "utf8"),
|
||||
readFileSync(
|
||||
join(root, "node_modules", "@wrnexus", "ui", "component-reference.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as {
|
||||
count: number;
|
||||
components: Array<{
|
||||
@@ -20,9 +23,11 @@ const reference = JSON.parse(
|
||||
events: string[];
|
||||
}>;
|
||||
};
|
||||
const manifest = JSON.parse(readFileSync(join(root, "showcase-manifest.json"), "utf8")) as {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(root, "showcase-manifest.json"), "utf8"),
|
||||
) as {
|
||||
removed: string[];
|
||||
blocks: Array<{name: string; slug: string; path: string}>;
|
||||
blocks: Array<{ name: string; slug: string; path: string }>;
|
||||
componentCount: number;
|
||||
categoryCount: number;
|
||||
totalDemoCount: number;
|
||||
@@ -39,7 +44,8 @@ const manifest = JSON.parse(readFileSync(join(root, "showcase-manifest.json"), "
|
||||
}>;
|
||||
};
|
||||
|
||||
const detailSource = (slug: string) => readFileSync(join(detailPagesDir, `${slug}.wrn`), "utf8");
|
||||
const detailSource = (slug: string) =>
|
||||
readFileSync(join(detailPagesDir, `${slug}.wrn`), "utf8");
|
||||
|
||||
const interactiveOverlayNames = [
|
||||
"ContextMenu",
|
||||
@@ -57,32 +63,56 @@ function mountCount(source: string, mount: string): number {
|
||||
}
|
||||
|
||||
test("showcase configures local Lucide icon generation", () => {
|
||||
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
) as {
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
const styles = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||
const styles = readFileSync(
|
||||
join(root, "app", "styles", "global.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(packageJson.devDependencies["@iconify/tailwind4"]).toBeTruthy();
|
||||
expect(packageJson.devDependencies["@iconify-json/lucide"]).toBeTruthy();
|
||||
expect(styles).toContain('@plugin "@iconify/tailwind4";');
|
||||
expect(styles).toContain(".playground-preview-source > .wrn-action:not(.w-full)");
|
||||
expect(styles).toContain(
|
||||
".playground-preview-source > .wrn-action:not(.w-full)",
|
||||
);
|
||||
expect(styles).toContain('[data-ui-component="MetricGrid"]');
|
||||
expect(styles).toContain("width: 100%;");
|
||||
});
|
||||
|
||||
test("showcase manifest matches the generated UI reference", () => {
|
||||
expect(manifest.componentCount + manifest.blocks.length + manifest.removed.length).toBe(reference.count);
|
||||
expect(manifest.removed).toEqual(["AuthForm", "AuthSplitLayout", "PortalDashboard", "PublicPageShell"]);
|
||||
expect(manifest.blocks.some(block => block.name === "Hero")).toBe(true);
|
||||
expect(manifest.blocks.some(block => block.name === "CTASection")).toBe(true);
|
||||
expect(manifest.components).toHaveLength(manifest.componentCount);
|
||||
expect(manifest.totalDemoCount).toBeGreaterThanOrEqual(manifest.componentCount * 2);
|
||||
expect(new Set(manifest.components.map((component) => component.slug)).size).toBe(
|
||||
manifest.componentCount,
|
||||
expect(
|
||||
manifest.componentCount + manifest.blocks.length + manifest.removed.length,
|
||||
).toBe(reference.count);
|
||||
expect(manifest.removed).toEqual([
|
||||
"AuthForm",
|
||||
"AuthSplitLayout",
|
||||
"PortalDashboard",
|
||||
"PublicPageShell",
|
||||
]);
|
||||
expect(manifest.blocks.some((block) => block.name === "Hero")).toBe(true);
|
||||
expect(manifest.blocks.some((block) => block.name === "CTASection")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(manifest.components).toHaveLength(manifest.componentCount);
|
||||
expect(manifest.totalDemoCount).toBeGreaterThanOrEqual(
|
||||
manifest.componentCount * 2,
|
||||
);
|
||||
expect(
|
||||
new Set(manifest.components.map((component) => component.slug)).size,
|
||||
).toBe(manifest.componentCount);
|
||||
|
||||
for (const component of reference.components.filter(entry => !manifest.removed.includes(entry.name) && !manifest.blocks.some(block => block.name === entry.name))) {
|
||||
const documented = manifest.components.find((entry) => entry.name === component.name);
|
||||
for (const component of reference.components.filter(
|
||||
(entry) =>
|
||||
!manifest.removed.includes(entry.name) &&
|
||||
!manifest.blocks.some((block) => block.name === entry.name),
|
||||
)) {
|
||||
const documented = manifest.components.find(
|
||||
(entry) => entry.name === component.name,
|
||||
);
|
||||
expect(documented).toBeTruthy();
|
||||
expect(documented?.mount).toBe(component.mount);
|
||||
expect(documented?.events).toEqual(component.events);
|
||||
@@ -91,20 +121,31 @@ test("showcase manifest matches the generated UI reference", () => {
|
||||
});
|
||||
|
||||
test("showcase uses a searchable documentation shell", () => {
|
||||
const layout = readFileSync(join(root, "app", "layouts", "showcase.wrn"), "utf8");
|
||||
const styles = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||
const layout = readFileSync(
|
||||
join(root, "app", "layouts", "showcase.wrn"),
|
||||
"utf8",
|
||||
);
|
||||
const styles = readFileSync(
|
||||
join(root, "app", "styles", "global.css"),
|
||||
"utf8",
|
||||
);
|
||||
const runtime = readFileSync(join(root, "public", "playground.js"), "utf8");
|
||||
|
||||
expect(layout).toContain("data-docs-directory");
|
||||
expect(layout).toContain("data-docs-search");
|
||||
expect(layout.match(/data-docs-component-link/g)).toHaveLength(manifest.componentCount);
|
||||
expect(layout.match(/data-docs-component-link/g)).toHaveLength(
|
||||
manifest.componentCount,
|
||||
);
|
||||
expect(styles).toContain("grid-template-columns: 15rem minmax(0, 1fr)");
|
||||
expect(runtime).toContain("data-docs-menu-toggle");
|
||||
expect(runtime).toContain('event.key.toLowerCase() === "k"');
|
||||
});
|
||||
|
||||
test("showcase SSR document layout maps persisted design cookies to html attributes", () => {
|
||||
const source = readFileSync(join(root, "app", "layouts", "document.wrn"), "utf8");
|
||||
const source = readFileSync(
|
||||
join(root, "app", "layouts", "document.wrn"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("<html");
|
||||
expect(source).toContain("<head></head>");
|
||||
expect(source).toContain('<div id="app"><slot /></div>');
|
||||
@@ -116,22 +157,39 @@ test("showcase SSR document layout maps persisted design cookies to html attribu
|
||||
|
||||
test("category pages preview every UI component exactly once", () => {
|
||||
const categoryPages = readdirSync(pagesDir)
|
||||
.filter((file) => manifest.components.some((component) => `${component.category}.wrn` === file))
|
||||
.filter((file) =>
|
||||
manifest.components.some(
|
||||
(component) => `${component.category}.wrn` === file,
|
||||
),
|
||||
)
|
||||
.map((file) => readFileSync(join(pagesDir, file), "utf8"))
|
||||
.join("\n");
|
||||
|
||||
const invalidPreviews = reference.components.filter(entry => manifest.components.some(component => component.name === entry.name)).flatMap((component) => {
|
||||
const count = mountCount(categoryPages, component.mount);
|
||||
return count === 1
|
||||
? []
|
||||
: [{ name: component.name, mount: component.mount, category: component.category, count }];
|
||||
});
|
||||
const invalidPreviews = reference.components
|
||||
.filter((entry) =>
|
||||
manifest.components.some((component) => component.name === entry.name),
|
||||
)
|
||||
.flatMap((component) => {
|
||||
const count = mountCount(categoryPages, component.mount);
|
||||
return count === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: component.name,
|
||||
mount: component.mount,
|
||||
category: component.category,
|
||||
count,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
expect(invalidPreviews).toEqual([]);
|
||||
});
|
||||
|
||||
test("every component has a detail page, playground, and manifest-driven demos", () => {
|
||||
const files = readdirSync(detailPagesDir).filter((entry) => entry.endsWith(".wrn"));
|
||||
const files = readdirSync(detailPagesDir).filter((entry) =>
|
||||
entry.endsWith(".wrn"),
|
||||
);
|
||||
expect(files).toHaveLength(manifest.componentCount);
|
||||
|
||||
for (const component of manifest.components) {
|
||||
@@ -140,13 +198,20 @@ test("every component has a detail page, playground, and manifest-driven demos",
|
||||
expect(source).toContain("data-playground-form");
|
||||
expect(source).toContain("data-playground-code");
|
||||
expect(source).toContain('href="#playground"');
|
||||
expect(source.match(/class="demo-workbench"/g)).toHaveLength(component.demoCount);
|
||||
expect(mountCount(source, component.mount)).toBeGreaterThanOrEqual(component.demoCount + 1);
|
||||
expect(source.match(/class="demo-workbench"/g)).toHaveLength(
|
||||
component.demoCount,
|
||||
);
|
||||
expect(mountCount(source, component.mount)).toBeGreaterThanOrEqual(
|
||||
component.demoCount + 1,
|
||||
);
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
test("playgrounds preserve typed values, safe DOM replacement, themes, and event output", () => {
|
||||
const generator = readFileSync(join(root, "scripts", "generate-showcase.mjs"), "utf8");
|
||||
const generator = readFileSync(
|
||||
join(root, "scripts", "generate-showcase.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const runtime = readFileSync(join(root, "public", "playground.js"), "utf8");
|
||||
|
||||
expect(generator).toContain("JSON.parse(");
|
||||
@@ -158,7 +223,9 @@ test("playgrounds preserve typed values, safe DOM replacement, themes, and event
|
||||
'data-slot="${escapeAttribute(slot)}"',
|
||||
"",
|
||||
);
|
||||
expect(generatorWithoutDataSlots).not.toContain('slot="${escapeAttribute(slot)}"');
|
||||
expect(generatorWithoutDataSlots).not.toContain(
|
||||
'slot="${escapeAttribute(slot)}"',
|
||||
);
|
||||
|
||||
expect(runtime).toContain("fetch(url");
|
||||
expect(runtime).toContain("navigator.clipboard.writeText");
|
||||
@@ -171,21 +238,34 @@ test("playgrounds preserve typed values, safe DOM replacement, themes, and event
|
||||
});
|
||||
|
||||
test("playground enum controls use public profiles instead of inferred implementation comparisons", () => {
|
||||
const generator = readFileSync(join(root, "scripts", "generate-showcase.mjs"), "utf8");
|
||||
expect(generator).toContain("profileFor(component.name)?.options?.[prop.name]");
|
||||
const generator = readFileSync(
|
||||
join(root, "scripts", "generate-showcase.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
expect(generator).toContain(
|
||||
"profileFor(component.name)?.options?.[prop.name]",
|
||||
);
|
||||
expect(generator).not.toContain("source.matchAll(pattern)");
|
||||
|
||||
const pageHeader = manifest.components.find((component) => component.name === "PageHeader");
|
||||
const pageHeader = manifest.components.find(
|
||||
(component) => component.name === "PageHeader",
|
||||
);
|
||||
if (pageHeader) {
|
||||
const source = detailSource(pageHeader.slug);
|
||||
expect(source).toContain('name="pg_icon" type="text"');
|
||||
expect(source).toMatch(/<option value="default"(?: selected)?>default<\/option>/);
|
||||
expect(source).toMatch(/<option value="solid"(?: selected)?>solid<\/option>/);
|
||||
expect(source).toMatch(
|
||||
/<option value="default"(?: selected)?>default<\/option>/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/<option value="solid"(?: selected)?>solid<\/option>/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every declared public event is documented and visible in the playground", () => {
|
||||
for (const component of manifest.components.filter((entry) => entry.events.length > 0)) {
|
||||
for (const component of manifest.components.filter(
|
||||
(entry) => entry.events.length > 0,
|
||||
)) {
|
||||
const source = detailSource(component.slug);
|
||||
expect(source).toContain('id="events"');
|
||||
expect(source).toContain('href="#events"');
|
||||
@@ -232,12 +312,17 @@ test("new and recently repaired components have production use-case profiles", (
|
||||
if (!component) continue;
|
||||
expect(component.profiled).toBe(true);
|
||||
const source = detailSource(component.slug);
|
||||
expect(source.match(/class="demo-workbench"/g)?.length ?? 0).toBeGreaterThanOrEqual(3);
|
||||
expect(
|
||||
source.match(/class="demo-workbench"/g)?.length ?? 0,
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
test("grid, hero, shell, and overlay detail pages use responsive showcase stages", () => {
|
||||
const styles = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||
const styles = readFileSync(
|
||||
join(root, "app", "styles", "global.css"),
|
||||
"utf8",
|
||||
);
|
||||
for (const name of [
|
||||
"MetricGrid",
|
||||
"FeatureGrid",
|
||||
@@ -251,7 +336,9 @@ test("grid, hero, shell, and overlay detail pages use responsive showcase stages
|
||||
expect(styles).toContain(`[data-ui-component="${name}"]`);
|
||||
}
|
||||
|
||||
const metricGrid = manifest.components.find((component) => component.name === "MetricGrid");
|
||||
const metricGrid = manifest.components.find(
|
||||
(component) => component.name === "MetricGrid",
|
||||
);
|
||||
if (metricGrid) {
|
||||
const source = detailSource(metricGrid.slug);
|
||||
expect(source).toContain('columns="3"');
|
||||
@@ -266,9 +353,18 @@ test("grid, hero, shell, and overlay detail pages use responsive showcase stages
|
||||
});
|
||||
|
||||
test("overlay previews start closed and expose usable click, hover, or context triggers", () => {
|
||||
const generator = readFileSync(join(root, "scripts", "generate-showcase.mjs"), "utf8");
|
||||
const profiles = readFileSync(join(root, "scripts", "showcase-profiles.mjs"), "utf8");
|
||||
const styles = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||
const generator = readFileSync(
|
||||
join(root, "scripts", "generate-showcase.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const profiles = readFileSync(
|
||||
join(root, "scripts", "showcase-profiles.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const styles = readFileSync(
|
||||
join(root, "app", "styles", "global.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(generator).toContain("interactiveOverlayComponents");
|
||||
expect(generator).toContain("overlayVisibilityProps.has(name)");
|
||||
@@ -286,7 +382,9 @@ test("overlay previews start closed and expose usable click, hover, or context t
|
||||
|
||||
const detail = detailSource(component.slug);
|
||||
expect(detail).toContain("demo-interaction-hint");
|
||||
expect(detail).not.toMatch(new RegExp(`<${component.mount}[^>]*(?:open|defaultOpen)="true"`));
|
||||
expect(detail).not.toMatch(
|
||||
new RegExp(`<${component.mount}[^>]*(?:open|defaultOpen)="true"`),
|
||||
);
|
||||
expect(overlaysPage).not.toMatch(
|
||||
new RegExp(`<${component.mount}[^>]*(?:open|defaultOpen)="true"`),
|
||||
);
|
||||
@@ -302,17 +400,24 @@ test("overlay previews start closed and expose usable click, hover, or context t
|
||||
});
|
||||
|
||||
test("advanced select demos use validation schemas and a real remote API", () => {
|
||||
const component = manifest.components.find((entry) => entry.name === "AdvancedSelect");
|
||||
const component = manifest.components.find(
|
||||
(entry) => entry.name === "AdvancedSelect",
|
||||
);
|
||||
if (!component) return;
|
||||
const page = detailSource(component.slug);
|
||||
expect(page).toContain('data-schema="advanced-select-validation"');
|
||||
expect(page).toContain('data-schema="advanced-select-dynamic-validation"');
|
||||
expect(readFileSync(join(root, "app", "api", "teams.ts"), "utf8")).toContain("export const GET");
|
||||
expect(readFileSync(join(root, "app", "api", "teams.ts"), "utf8")).toContain(
|
||||
"export const GET",
|
||||
);
|
||||
});
|
||||
|
||||
test("specialized form component examples remain complete", () => {
|
||||
const checks: Array<[string, string[]]> = [
|
||||
["PinInput", ["Different lengths", 'length="3"', 'length="5"', 'length="7"']],
|
||||
[
|
||||
"PinInput",
|
||||
["Different lengths", 'length="3"', 'length="5"', 'length="7"'],
|
||||
],
|
||||
[
|
||||
"TogglePassword",
|
||||
[
|
||||
@@ -323,7 +428,11 @@ test("specialized form component examples remain complete", () => {
|
||||
],
|
||||
[
|
||||
"StrongPassword",
|
||||
["Live strength meter", "Requirements in a popover", 'specialCharactersSet="@#_-."'],
|
||||
[
|
||||
"Live strength meter",
|
||||
"Requirements in a popover",
|
||||
'specialCharactersSet="@#_-."',
|
||||
],
|
||||
],
|
||||
];
|
||||
for (const [name, needles] of checks) {
|
||||
@@ -335,22 +444,34 @@ test("specialized form component examples remain complete", () => {
|
||||
});
|
||||
|
||||
test("complex component props are complete readable multiline values", () => {
|
||||
for (const name of ["AdvancedSelect", "ComboBox", "Select", "TogglePassword"]) {
|
||||
for (const name of [
|
||||
"AdvancedSelect",
|
||||
"ComboBox",
|
||||
"Select",
|
||||
"TogglePassword",
|
||||
]) {
|
||||
const component = manifest.components.find((entry) => entry.name === name);
|
||||
if (!component) continue;
|
||||
const source = detailSource(component.slug);
|
||||
expect(source).not.toMatch(/\b(?:options|groups|items|fields)='\[\.\.\.\]'/);
|
||||
expect(source).not.toMatch(
|
||||
/\b(?:options|groups|items|fields)='\[\.\.\.\]'/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("generated playground boolean states avoid multiline-sensitive ternaries", () => {
|
||||
const generator = readFileSync(join(root, "scripts", "generate-showcase.mjs"), "utf8");
|
||||
const generator = readFileSync(
|
||||
join(root, "scripts", "generate-showcase.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
expect(generator).toContain('?? "${initial}") === "true"');
|
||||
expect(generator).not.toContain("=== null ? ${initial} :");
|
||||
|
||||
for (const component of manifest.components) {
|
||||
const source = detailSource(component.slug);
|
||||
expect(source).not.toMatch(/state\s+playground_[A-Za-z0-9_$]+\s*=.*===\s*null\s*\?/);
|
||||
expect(source).not.toMatch(
|
||||
/state\s+playground_[A-Za-z0-9_$]+\s*=.*===\s*null\s*\?/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user