fix(studio): refine preset surfaces and repair gallery interactions
This commit is contained in:
1 parent
f642a84c58
commit
bb40dffee8
20 files changed
+345
-72
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/design-studio",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { handlePreviewClipboard } from "./preview-clipboard.ts";
|
||||
import { STUDIO_PRESETS } from "./presets.ts";
|
||||
import { setupGuidance } from "./guidance.ts";
|
||||
import { StudioSession, importDesign, exportDesign, reviewDesign } from "./session.ts";
|
||||
@@ -307,6 +308,13 @@ async function previews() {
|
||||
$("show-draft").hidden = !reviewed;
|
||||
}
|
||||
window.addEventListener("message", (event) => {
|
||||
void handlePreviewClipboard(
|
||||
event,
|
||||
[$<HTMLIFrameElement>("preview-frame"), $<HTMLIFrameElement>("compare-frame")],
|
||||
(text) => navigator.clipboard.writeText(text),
|
||||
navigator.userActivation?.isActive ?? false,
|
||||
);
|
||||
|
||||
const frame = $<HTMLIFrameElement>("preview-frame");
|
||||
if (
|
||||
event.source === frame.contentWindow &&
|
||||
|
||||
@@ -81,8 +81,44 @@ export function gallerySample(name: string) {
|
||||
],
|
||||
palette: [{ kind: "task", label: "Add task" }],
|
||||
});
|
||||
if (name === "Toaster")
|
||||
content = '<button type="button" data-preview-toast>Show sample notification</button>';
|
||||
if (name === "Toaster") {
|
||||
props.max = 1;
|
||||
content =
|
||||
'<button type="button" class="preview-action" data-preview-toast>Show sample notification</button>';
|
||||
}
|
||||
if (name === "PreferenceSwitcher")
|
||||
Object.assign(props, {
|
||||
themeLabel: "Theme",
|
||||
colorLabel: "Accent color",
|
||||
languageLabel: "Language",
|
||||
});
|
||||
if (name === "Tooltip")
|
||||
Object.assign(props, {
|
||||
variant: "soft",
|
||||
title: "Helpful information",
|
||||
description: "Hover or focus a control to see a short explanation.",
|
||||
});
|
||||
if (name === "PortalDashboard")
|
||||
Object.assign(props, {
|
||||
title: "Sample service dashboard",
|
||||
description: "Sample tasks, shortcuts and recent activity.",
|
||||
tasks: [
|
||||
{
|
||||
label: "Review submitted documents",
|
||||
detail: "Confirm the required attachments before approval.",
|
||||
status: "urgent",
|
||||
meta: "Due today",
|
||||
href: "#review",
|
||||
},
|
||||
{
|
||||
label: "Update contact information",
|
||||
detail: "Check the applicant’s preferred email address.",
|
||||
status: "warning",
|
||||
meta: "In progress",
|
||||
href: "#contact",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (name === "BackToTop") {
|
||||
props.threshold = 0;
|
||||
props.alwaysVisible = true;
|
||||
|
||||
@@ -24,9 +24,20 @@ function palette(tokens: TokenMap, values: Palette) {
|
||||
"text-subtle": subtle,
|
||||
"border-strong": border,
|
||||
"primary-muted": muted,
|
||||
muted: subtle,
|
||||
focus: primary,
|
||||
"input-background": surface,
|
||||
"input-border": border,
|
||||
"code-background": muted,
|
||||
"code-text": text,
|
||||
"code-border": border,
|
||||
};
|
||||
for (const [name, value] of Object.entries(colors))
|
||||
tokens[`color.${name}`] = { ...tokens[`color.${name}`]!, value };
|
||||
tokens[`color.${name}`] = {
|
||||
type: "color",
|
||||
legacy: tokens[`color.${name}`]?.legacy ?? `color-${name}`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
function direction(
|
||||
id: string,
|
||||
@@ -70,10 +81,10 @@ export const STUDIO_PRESETS: Record<string, DesignSystem> = {
|
||||
forest: direction(
|
||||
"forest",
|
||||
"Forest / Sage",
|
||||
["#E8DCC7", "#D4B895", "#C7CCB5", "#283320", "#48543E", "#7B866A", "#43552D", "#E8DCC7"],
|
||||
["#E5EBDD", "#F4F6EE", "#D5DFC8", "#233827", "#4C604C", "#9BAE91", "#355E40", "#FFFFFF"],
|
||||
["#20291C", "#2C3726", "#394530", "#E8DCC7", "#BDC5A5", "#718060", "#B7D092", "#20291C"],
|
||||
"24px",
|
||||
serif,
|
||||
"10px",
|
||||
sans,
|
||||
),
|
||||
ocean: direction(
|
||||
"ocean",
|
||||
@@ -103,10 +114,10 @@ export const STUDIO_PRESETS: Record<string, DesignSystem> = {
|
||||
ember: direction(
|
||||
"ember",
|
||||
"Ember / Terracotta",
|
||||
["#E8DCC7", "#D4B895", "#CEB094", "#3D241C", "#694837", "#A7795A", "#8E3C21", "#E8DCC7"],
|
||||
["#F5E3D5", "#FFF9F3", "#EBCDB7", "#48271D", "#775342", "#C59C81", "#A34525", "#FFFFFF"],
|
||||
["#2B1B15", "#3B2820", "#50362B", "#E8DCC7", "#CEB094", "#9C7057", "#F4AE80", "#2B1B15"],
|
||||
"16px",
|
||||
serif,
|
||||
"6px",
|
||||
sans,
|
||||
),
|
||||
editorial: direction(
|
||||
"editorial",
|
||||
@@ -129,18 +140,44 @@ export const STUDIO_PRESETS: Record<string, DesignSystem> = {
|
||||
brutalist: direction(
|
||||
"brutalist",
|
||||
"Brutalist / Primary",
|
||||
["#FFFFFF", "#FFFF00", "#FFFFFF", "#000000", "#000000", "#000000", "#0000FF", "#FFFFFF"],
|
||||
["#FFFFFF", "#FFFFFF", "#F0F0F0", "#000000", "#333333", "#000000", "#0000FF", "#FFFFFF"],
|
||||
["#000000", "#000000", "#202020", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFF00", "#000000"],
|
||||
"0",
|
||||
mono,
|
||||
"4px 4px 0 #000000",
|
||||
sans,
|
||||
"2px 2px 0 #000000",
|
||||
),
|
||||
contrast: direction(
|
||||
"contrast",
|
||||
"Contrast / Clear",
|
||||
["#FFFFFF", "#FFFFFF", "#E6E6E6", "#000000", "#333333", "#000000", "#002FA7", "#FFFFFF"],
|
||||
["#000000", "#000000", "#1B1B1B", "#FFFFFF", "#E0E0E0", "#FFFFFF", "#FFFF00", "#000000"],
|
||||
"4px",
|
||||
["#FFFFFF", "#FFFFFF", "#EDF1F6", "#101828", "#344054", "#667085", "#173EA5", "#FFFFFF"],
|
||||
["#0D1117", "#161D29", "#253044", "#FFFFFF", "#D0D8E8", "#8999B2", "#A5C8FF", "#0D1117"],
|
||||
"8px",
|
||||
sans,
|
||||
),
|
||||
};
|
||||
|
||||
// Neutral editorial status colors retain textual and icon cues without stray hues.
|
||||
for (const tokens of [
|
||||
STUDIO_PRESETS.editorial!.tokens,
|
||||
STUDIO_PRESETS.editorial!.modes!.light!,
|
||||
STUDIO_PRESETS.editorial!.modes!.dark!,
|
||||
]) {
|
||||
for (const name of ["secondary", "success", "warning", "danger", "info"]) {
|
||||
for (const suffix of ["", "-hover", "-active", "-text"])
|
||||
tokens[`color.${name}${suffix}`] = {
|
||||
type: "color",
|
||||
value: tokens["color.primary"]!.value,
|
||||
legacy: `color-${name}${suffix}`,
|
||||
};
|
||||
tokens[`color.${name}-soft`] = {
|
||||
type: "color",
|
||||
value: tokens["color.surface-2"]!.value,
|
||||
legacy: `color-${name}-soft`,
|
||||
};
|
||||
tokens[`color.${name}-contrast`] = {
|
||||
type: "color",
|
||||
value: tokens["color.primary-contrast"]!.value,
|
||||
legacy: `color-${name}-contrast`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** The sandbox can request a bounded text copy, never read clipboard contents. */
|
||||
export async function handlePreviewClipboard(
|
||||
event: Pick<MessageEvent, "source" | "data">,
|
||||
frames: Pick<HTMLIFrameElement, "contentWindow" | "src">[],
|
||||
writeText: (text: string) => Promise<void>,
|
||||
activated = true,
|
||||
) {
|
||||
const data = event.data;
|
||||
const frame = frames.find((frame) => frame.contentWindow === event.source);
|
||||
if (
|
||||
!frame ||
|
||||
!data ||
|
||||
data.type !== "wrn-studio-copy" ||
|
||||
typeof data.id !== "string" ||
|
||||
!/^[a-z0-9-]{1,80}$/.test(data.id) ||
|
||||
data.path !== new URL(frame.src).pathname ||
|
||||
typeof data.text !== "string" ||
|
||||
data.text.length > 16384
|
||||
)
|
||||
return false;
|
||||
let ok = false;
|
||||
try {
|
||||
if (!activated) throw Error("Copy requires a user gesture.");
|
||||
await writeText(data.text);
|
||||
ok = true;
|
||||
} catch {
|
||||
/* The control reports failure and keeps the value selectable. */
|
||||
}
|
||||
frame.contentWindow?.postMessage({ type: "wrn-studio-copy-result", id: data.id, ok }, "*");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Runs only in the generated trusted preview, with an opaque sandbox origin. */
|
||||
export const previewClipboardRuntime = `
|
||||
if (parent !== window) {
|
||||
const copies = new Map();
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== parent || event.data?.type !== 'wrn-studio-copy-result') return;
|
||||
const pending = copies.get(event.data.id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer); copies.delete(event.data.id);
|
||||
event.data.ok ? pending.resolve() : pending.reject(new Error('Clipboard copy was denied'));
|
||||
});
|
||||
window.wrnClipboard = {writeText(text) {
|
||||
return new Promise((resolve,reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
const timer = setTimeout(() => { copies.delete(id); reject(new Error('Clipboard copy timed out')); }, 4000);
|
||||
copies.set(id, {resolve,reject,timer});
|
||||
parent.postMessage({type:'wrn-studio-copy',id,path:location.pathname,text:String(text)}, '*');
|
||||
});
|
||||
}};
|
||||
}
|
||||
`;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { previewClipboardRuntime } from "./preview-clipboard.ts";
|
||||
import { validateAgentPage } from "./agent-pages.ts";
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -174,6 +175,7 @@ export async function previewDocument(
|
||||
}
|
||||
const runtime =
|
||||
bindings +
|
||||
previewClipboardRuntime +
|
||||
REACTIVE_RUNTIME +
|
||||
providers +
|
||||
"\ndocument.addEventListener('DOMContentLoaded',()=>parent.postMessage({type:'wrn-studio-preview-ready',path:location.pathname},'*'),{once:true});";
|
||||
@@ -181,9 +183,9 @@ export async function previewDocument(
|
||||
meta: { title: system.name + " preview" },
|
||||
body,
|
||||
htmlAttrs: ` dir="${options.direction === "rtl" ? "rtl" : "ltr"}" data-theme="${escape(mode)}"`,
|
||||
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}${previewIconCss(body + galleryIcons + [...names].map((name) => iconSources.get(name) ?? "").join(""))}${providerCss}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;border:1px solid var(--wrn-color-border);padding:20px;align-self:start}.sample-stage{min-width:0;position:relative}.sample-hint{font-size:12px;color:var(--wrn-color-text-muted);margin:0 0 16px}.sample-event{display:block;font-size:12px;min-height:1.5em;margin-top:12px}.sample canvas{max-width:100%}.sample [data-ui-component="BackToTop"]{position:static;inset:auto}.sample .showcase-slot,.sample .showcase-visual-panel{padding:20px;border:1px dashed var(--wrn-color-border);background:var(--wrn-color-surface-2)}.sample [data-gallery-component="DeviceFrame"]{min-width:0}.sample[data-gallery-component="Confetti"] .sample-stage{overflow:clip;padding:60px 16px;min-height:180px}.sample:is([data-gallery-component="SplitHero"],[data-gallery-component="FeatureGrid"],[data-gallery-component="MetricGrid"],[data-gallery-component="AnnouncementBar"],[data-gallery-component="CTASection"],[data-gallery-component="Chart"],[data-gallery-component="DataTable"],[data-gallery-component="DrawflowCanvas"],[data-gallery-component="FlowCanvas"],[data-gallery-component="Hero"],[data-gallery-component="Navbar"],[data-gallery-component="PortalDashboard"],[data-gallery-component="PublicPageShell"],[data-gallery-component="AuthSplitLayout"]){grid-column:1/-1}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
|
||||
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}${previewIconCss(body + galleryIcons + [...names].map((name) => iconSources.get(name) ?? "").join(""))}${providerCss}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;border:1px solid var(--wrn-color-border);padding:20px;align-self:start}.sample-stage{min-width:0;position:relative}.preview-action,.showcase-action{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border:1px solid var(--wrn-color-primary);border-radius:var(--wrn-radius-sm);background:var(--wrn-color-primary);color:var(--wrn-color-primary-contrast);font:inherit;font-size:14px;font-weight:600;cursor:pointer}.preview-action:focus-visible,.showcase-action:focus-visible{outline:2px solid var(--wrn-color-focus);outline-offset:3px}.sample-hint{font-size:12px;color:var(--wrn-color-text-muted);margin:0 0 16px}.sample-event{display:block;font-size:12px;min-height:1.5em;margin-top:12px}.sample canvas{max-width:100%}.sample [data-ui-component="BackToTop"]{position:static;inset:auto}.sample .showcase-slot,.sample .showcase-visual-panel{padding:20px;border:1px dashed var(--wrn-color-border);background:var(--wrn-color-surface-2)}.sample [data-gallery-component="DeviceFrame"]{min-width:0}.sample[data-gallery-component="Confetti"] .sample-stage{overflow:clip;padding:60px 16px;min-height:180px}.sample:is([data-gallery-component="Footer"],[data-gallery-component="SplitHero"],[data-gallery-component="FeatureGrid"],[data-gallery-component="MetricGrid"],[data-gallery-component="AnnouncementBar"],[data-gallery-component="CTASection"],[data-gallery-component="Chart"],[data-gallery-component="DataTable"],[data-gallery-component="DrawflowCanvas"],[data-gallery-component="FlowCanvas"],[data-gallery-component="Hero"],[data-gallery-component="Navbar"],[data-gallery-component="PortalDashboard"],[data-gallery-component="PublicPageShell"],[data-gallery-component="AuthSplitLayout"]){grid-column:1/-1}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
|
||||
componentRecipeCss: theme.recipeCss,
|
||||
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());document.addEventListener('click',e=>{const link=e.target.closest('a[href]');if(link){e.preventDefault();const card=link.closest('[data-gallery-component]');if(card)card.querySelector('.sample-event').textContent='Preview link: '+link.getAttribute('href');const href=link.getAttribute('href');if(href?.startsWith('#'))document.getElementById(href.slice(1))?.scrollIntoView({block:'nearest'});}if(e.target.closest('[data-preview-toast]'))window.toast.success('This is a sample notification.',{title:'Preview notification'});});document.querySelectorAll('[data-gallery-component]').forEach(card=>{['input','change','select','open','close','toggle','start','complete','add','connect','move'].forEach(name=>card.addEventListener(name,()=>{card.querySelector('.sample-event').textContent='Last event: '+name;}));});</script>`,
|
||||
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());document.addEventListener('click',e=>{const link=e.target.closest('a[href]');if(link){e.preventDefault();const card=link.closest('[data-gallery-component]');if(card)card.querySelector('.sample-event').textContent='Preview link: '+link.getAttribute('href');const href=link.getAttribute('href');if(href?.startsWith('#'))document.getElementById(href.slice(1))?.scrollIntoView({block:'nearest'});}if(e.target.closest('[data-preview-toast]'))window.toast('This is a sample notification.',{title:'Preview notification',duration:3500});});document.querySelectorAll('[data-gallery-component]').forEach(card=>{['input','change','select','open','close','toggle','start','complete','add','connect','move'].forEach(name=>card.addEventListener(name,()=>{card.querySelector('.sample-event').textContent='Last event: '+name;}));});</script>`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { handlePreviewClipboard } from "../src/preview-clipboard.ts";
|
||||
|
||||
test("clipboard broker accepts only current preview frames and bounded user-initiated text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const source = { postMessage: (value: unknown) => results.push(value) } as unknown as Window;
|
||||
const frames = [{ contentWindow: source, src: "https://example.test/preview/current" }];
|
||||
const event = {
|
||||
source,
|
||||
data: {
|
||||
type: "wrn-studio-copy",
|
||||
id: "copy-1",
|
||||
path: "/preview/current",
|
||||
text: "npm install @wrnexus/ui",
|
||||
},
|
||||
};
|
||||
const copied: string[] = [];
|
||||
const write = async (text: string) => {
|
||||
copied.push(text);
|
||||
};
|
||||
expect(await handlePreviewClipboard(event, frames, write)).toBe(true);
|
||||
expect(copied).toEqual(["npm install @wrnexus/ui"]);
|
||||
expect(results).toEqual([{ type: "wrn-studio-copy-result", id: "copy-1", ok: true }]);
|
||||
expect(await handlePreviewClipboard({ ...event, source: null }, frames, write)).toBe(false);
|
||||
expect(
|
||||
await handlePreviewClipboard(
|
||||
{ ...event, data: { ...event.data, path: "/preview/old" } },
|
||||
frames,
|
||||
write,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await handlePreviewClipboard(
|
||||
{ ...event, data: { ...event.data, text: "x".repeat(16385) } },
|
||||
frames,
|
||||
write,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(await handlePreviewClipboard(event, frames, write, false)).toBe(true);
|
||||
expect(copied).toHaveLength(1);
|
||||
expect(results.at(-1)).toEqual({ type: "wrn-studio-copy-result", id: "copy-1", ok: false });
|
||||
});
|
||||
|
||||
test("clipboard denial reports failure rather than claiming a copy succeeded", async () => {
|
||||
let result: any;
|
||||
const source = {
|
||||
postMessage: (value: unknown) => {
|
||||
result = value;
|
||||
},
|
||||
} as unknown as Window;
|
||||
await handlePreviewClipboard(
|
||||
{
|
||||
source,
|
||||
data: { type: "wrn-studio-copy", id: "copy-2", path: "/preview/current", text: "test" },
|
||||
},
|
||||
[{ contentWindow: source, src: "https://example.test/preview/current" }],
|
||||
async () => {
|
||||
throw Error("denied");
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
@@ -33,3 +33,18 @@ test("choosing a new preset stays undoable and never mutates the catalog", () =>
|
||||
session.undo();
|
||||
expect(session.valid.id).toBe("square");
|
||||
});
|
||||
|
||||
test("revised directions stay distinct and Editorial status tokens remain monochrome", () => {
|
||||
const organic = ["soft", "forest", "ember"].map((id) => STUDIO_PRESETS[id]!);
|
||||
expect(new Set(organic.map((s) => s.modes!.light!["color.bg"]!.value)).size).toBe(3);
|
||||
expect(new Set(organic.map((s) => s.tokens["shape.control"]!.value)).size).toBe(3);
|
||||
for (const tokens of [
|
||||
STUDIO_PRESETS.editorial!.tokens,
|
||||
...Object.values(STUDIO_PRESETS.editorial!.modes!),
|
||||
]) {
|
||||
for (const name of ["success", "warning", "danger", "info"]) {
|
||||
const color = String(tokens[`color.${name}`]!.value);
|
||||
expect(color).toMatch(/^#([0-9a-f]{2})\1\1$/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user