feat(studio): choose and load Google Fonts in manual design
Quality / quality (ubuntu-latest) (push) Failing after 10m7s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 22:06:46 +05:30
1 parent 7ef475119a
commit d5244eab15
12 files changed
+140 -12

No files matched your search

+2 -2
View File
@@ -391,7 +391,7 @@
}, },
"packages/design-studio": { "packages/design-studio": {
"name": "@wrnexus/design-studio", "name": "@wrnexus/design-studio",
"version": "0.1.9", "version": "0.1.10",
"bin": { "bin": {
"wrnexus-design-studio": "src/cli.ts", "wrnexus-design-studio": "src/cli.ts",
}, },
@@ -409,7 +409,7 @@
}, },
"packages/design-system": { "packages/design-system": {
"name": "@wrnexus/design-system", "name": "@wrnexus/design-system",
"version": "0.1.3", "version": "0.1.4",
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/design-studio", "name": "@wrnexus/design-studio",
"version": "0.1.9", "version": "0.1.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+19 -2
View File
@@ -1,4 +1,5 @@
import { import {
GOOGLE_FONTS,
MANUAL_COLORS, MANUAL_COLORS,
MANUAL_FONTS, MANUAL_FONTS,
MANUAL_TYPOGRAPHY, MANUAL_TYPOGRAPHY,
@@ -12,7 +13,7 @@ const escape = (value: unknown) =>
/[&<>"']/g, /[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
); );
export const manualHtml = `<section id="manual" class="editor"><p>Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.</p><button type="button" class="primary" data-manual-apply>Update preview</button><label>Default theme<select id="manual-default-mode"></select></label><label>Edit color mode<select id="manual-mode"></select></label><h3>Colors</h3><p class="help">Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.</p><div id="manual-colors" class="manual-grid"></div><details><summary>All other color tokens</summary><p class="help">Fine-tune hover, contrast, focus, input, code and custom color tokens.</p><div id="manual-extra-colors" class="manual-grid"></div></details><h3>Shape and spacing</h3><div id="manual-shapes" class="manual-grid"></div><p class="help">Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.</p><div class="manual-grid"><label>Button style<select id="manual-button-style"><option value="solid">Solid</option><option value="outline">Outlined</option></select></label><label>Tilt<select id="manual-tilt"><option value="none">None</option><option value="subtle">Subtle</option><option value="expressive">Expressive</option></select></label></div><h3>Typography</h3><div id="manual-fonts"></div><div id="manual-type-scale" class="manual-grid"></div><p class="help">Use a system font below or enter your own font stack. Custom fonts must also be loaded in your application.</p><h3>Surface</h3><label>Shadow<select id="manual-shadow"><option value="none">Flat / no shadow</option><option value="0 8px 24px #00000018">Soft shadow</option><option value="2px 2px 0 #000000">Crisp shadow</option></select></label><p class="help">Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review &amp; apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.</p><button type="button" class="primary" data-manual-apply>Update preview</button></section>`; export const manualHtml = `<section id="manual" class="editor"><p>Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.</p><button type="button" class="primary" data-manual-apply>Update preview</button><label>Default theme<select id="manual-default-mode"></select></label><label>Edit color mode<select id="manual-mode"></select></label><h3>Colors</h3><p class="help">Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.</p><div id="manual-colors" class="manual-grid"></div><details><summary>All other color tokens</summary><p class="help">Fine-tune hover, contrast, focus, input, code and custom color tokens.</p><div id="manual-extra-colors" class="manual-grid"></div></details><h3>Shape and spacing</h3><div id="manual-shapes" class="manual-grid"></div><p class="help">Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.</p><div class="manual-grid"><label>Button style<select id="manual-button-style"><option value="solid">Solid</option><option value="outline">Outlined</option></select></label><label>Tilt<select id="manual-tilt"><option value="none">None</option><option value="subtle">Subtle</option><option value="expressive">Expressive</option></select></label></div><h3>Typography</h3><div id="manual-fonts"></div><div id="manual-type-scale" class="manual-grid"></div><p class="help">Choose a Google Font to load it automatically in previews and exported themes. Or choose a device font or enter a custom font stack. For another Google Font, enter its exact family name in Other Google Font. Google Fonts requires an internet connection.</p><h3>Surface</h3><label>Shadow<select id="manual-shadow"><option value="none">Flat / no shadow</option><option value="0 8px 24px #00000018">Soft shadow</option><option value="2px 2px 0 #000000">Crisp shadow</option></select></label><p class="help">Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review &amp; apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.</p><button type="button" class="primary" data-manual-apply>Update preview</button></section>`;
export const manualCss = `.manual-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0 16px}.manual-color{display:flex;gap:8px;align-items:center}.manual-color input[type=color]{width:44px;height:38px;padding:3px;flex:none}.manual-color input[type=text]{flex:1;font-family:Consolas,monospace}.manual-grid input[type=number]{max-width:150px}`; export const manualCss = `.manual-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0 16px}.manual-color{display:flex;gap:8px;align-items:center}.manual-color input[type=color]{width:44px;height:38px;padding:3px;flex:none}.manual-color input[type=text]{flex:1;font-family:Consolas,monospace}.manual-grid input[type=number]{max-width:150px}`;
export function setupManual( export function setupManual(
session: StudioSession, session: StudioSession,
@@ -62,6 +63,12 @@ export function setupManual(
name, name,
value: input.value + (input.dataset.manualUnit ?? "px"), value: input.value + (input.dataset.manualUnit ?? "px"),
}); });
} else if (input.value.startsWith("google:")) {
edits.push({
kind: "googleFont",
name: name as "type.body" | "type.heading" | "type.code",
value: input.value.slice(7),
});
} else if (input.value !== raw) edits.push({ kind: "token", name, value: input.value }); } else if (input.value !== raw) edits.push({ kind: "token", name, value: input.value });
}); });
const value = (id: string) => ($(id) as HTMLSelectElement).value; const value = (id: string) => ($(id) as HTMLSelectElement).value;
@@ -90,6 +97,16 @@ export function setupManual(
}); });
if (value("manual-shadow") !== String(resolved.tokens["elevation.panel"]?.value ?? "none")) if (value("manual-shadow") !== String(resolved.tokens["elevation.panel"]?.value ?? "none"))
edits.push({ kind: "token", name: "elevation.panel", value: value("manual-shadow") }); edits.push({ kind: "token", name: "elevation.panel", value: value("manual-shadow") });
$("manual-fonts")
.querySelectorAll<HTMLInputElement>("[data-google-family]")
.forEach((input) => {
if (input.value.trim())
edits.push({
kind: "googleFont",
name: input.dataset.googleFamily as "type.body" | "type.heading" | "type.code",
value: input.value.trim(),
});
});
const next = edits.reduce((system, edit) => manualDesign(system, edit), session.valid); const next = edits.reduce((system, edit) => manualDesign(system, edit), session.valid);
if (checkDecisionLocks(session.valid, next, session.locks).length) if (checkDecisionLocks(session.valid, next, session.locks).length)
throw Error("This change affects a locked decision. Unlock it in Guided design first."); throw Error("This change affects a locked decision. Unlock it in Guided design first.");
@@ -225,7 +242,7 @@ export function setupManual(
.map(([label, font]) => `<option value="${escape(font)}">${label}</option>`) .map(([label, font]) => `<option value="${escape(font)}">${label}</option>`)
.join( .join(
"", "",
)}</select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"></label>`; )}<optgroup label="Google Fonts">${GOOGLE_FONTS.map((font) => `<option value="google:${font}">${font}</option>`).join("")}</optgroup></select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"><input aria-label="Other Google ${label!.toLowerCase()}" data-google-family="${name}" placeholder="Other Google Font family, e.g. Rubik"></label>`;
}) })
.join(""); .join("");
$("manual-type-scale").innerHTML = Object.entries(MANUAL_TYPOGRAPHY) $("manual-type-scale").innerHTML = Object.entries(MANUAL_TYPOGRAPHY)
+51
View File
@@ -33,6 +33,31 @@ export const MANUAL_FONTS = {
serif: 'Georgia, "Times New Roman", serif', serif: 'Georgia, "Times New Roman", serif',
mono: '"Courier New", monospace', mono: '"Courier New", monospace',
}; };
export const GOOGLE_FONTS = [
"Inter",
"Roboto",
"Open Sans",
"Lato",
"Montserrat",
"Poppins",
"Nunito",
"Raleway",
"Ubuntu",
"Merriweather",
"Playfair Display",
"Lora",
"Source Serif 4",
"DM Sans",
"Manrope",
"Outfit",
"Space Grotesk",
"IBM Plex Sans",
"Noto Sans",
"Noto Sans Devanagari",
"JetBrains Mono",
"Fira Code",
"Source Code Pro",
];
export const MANUAL_TYPOGRAPHY = { export const MANUAL_TYPOGRAPHY = {
"type.body-size": { "type.body-size": {
label: "Body size (px)", label: "Body size (px)",
@@ -138,6 +163,7 @@ function typographyRecipes(system: DesignSystem, token: string) {
} }
} }
export type ManualEdit = export type ManualEdit =
| { kind: "googleFont"; name: "type.body" | "type.heading" | "type.code"; value: string }
| { kind: "color"; mode: string; name: string; value: string } | { kind: "color"; mode: string; name: string; value: string }
| { kind: "token"; name: string; value: string } | { kind: "token"; name: string; value: string }
| { kind: "mode"; value: string } | { kind: "mode"; value: string }
@@ -153,6 +179,31 @@ function ink(hex: string) {
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#FFFFFF"; return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#FFFFFF";
} }
export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSystem { export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSystem {
if (edit.kind === "googleFont") {
if (!/^[A-Za-z][A-Za-z0-9 ]{0,79}$/.test(edit.value) || edit.value.trim() !== edit.value)
throw Error("Enter a Google Font family name, such as Inter or Noto Sans Devanagari.");
const next = manualDesign(source, {
kind: "token",
name: edit.name,
value: `"${edit.value}", ${edit.name === "type.code" ? "monospace" : "sans-serif"}`,
});
const fontTokens = [
next.tokens,
...Object.values(next.modes ?? {}),
...Object.values(next.densities ?? {}),
]
.flatMap((layer) => Object.values(layer))
.filter((token) => token.type === "fontFamily");
next.googleFonts = [...new Set([...(next.googleFonts ?? []), edit.value])].filter((family) =>
fontTokens.some(
(token) => typeof token.value === "string" && token.value.includes(`"${family}"`),
),
);
const errors = validateDesignSystem(next);
if (errors.length) throw Error(errors.map((e) => e.message).join("\n"));
return next;
}
const system = structuredClone(source); const system = structuredClone(source);
const set = ( const set = (
tokens: Record<string, DesignToken>, tokens: Record<string, DesignToken>,
+1 -1
View File
@@ -234,7 +234,7 @@ export async function previewAgentPage(
meta: { title: page.title }, meta: { title: page.title },
body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`, body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`,
htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`, htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`,
extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}${previewIconCss(body.join(""))}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}${typographyCss(system, options)}</style>`, extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}${previewIconCss(body.join(""))}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}${typographyCss(system, options)}</style>`,
componentRecipeCss: theme.recipeCss, componentRecipeCss: theme.recipeCss,
}); });
}); });
+1 -1
View File
@@ -55,7 +55,7 @@ export async function createStudioHandler(
...headers, ...headers,
"content-type": "text/html; charset=utf-8", "content-type": "text/html; charset=utf-8",
"content-security-policy": "content-security-policy":
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'", "default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
}, },
}); });
return async (request: Request): Promise<Response> => { return async (request: Request): Promise<Response> => {
+28 -2
View File
@@ -113,7 +113,7 @@ test("manual form saves one atomic revision and rejects locked changes without c
const input = win.document.querySelector('[data-manual-token="shape.control"]') as any; const input = win.document.querySelector('[data-manual-token="shape.control"]') as any;
input.value = "12"; input.value = "12";
(win.document.querySelector('select[data-manual-token="type.heading"]') as any).value = (win.document.querySelector('select[data-manual-token="type.heading"]') as any).value =
MANUAL_FONTS.georgia; "google:Inter";
(win.document.querySelector('[data-manual-token="type.body-size"]') as any).value = "18"; (win.document.querySelector('[data-manual-token="type.body-size"]') as any).value = "18";
(win.document.querySelector('[data-manual-token="type.body-leading"]') as any).value = "1.8"; (win.document.querySelector('[data-manual-token="type.body-leading"]') as any).value = "1.8";
(win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value = (win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value =
@@ -122,7 +122,8 @@ test("manual form saves one atomic revision and rejects locked changes without c
await Bun.sleep(10); await Bun.sleep(10);
expect(session.valid.tokens["shape.control"]!.value).toBe("12px"); expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
expect(session.valid.modes!.light!["color.primary"]!.value).toBe("#6336AD"); expect(session.valid.modes!.light!["color.primary"]!.value).toBe("#6336AD");
expect(session.valid.tokens["type.heading"]!.value).toBe(MANUAL_FONTS.georgia); expect(session.valid.tokens["type.heading"]!.value).toBe('"Inter", sans-serif');
expect(session.valid.googleFonts).toEqual(["Inter"]);
expect(session.valid.tokens["type.body-size"]!.value).toBe("18px"); expect(session.valid.tokens["type.body-size"]!.value).toBe("18px");
expect(session.valid.tokens["type.body-leading"]!.value).toBe("1.8"); expect(session.valid.tokens["type.body-leading"]!.value).toBe("1.8");
expect(session.history).toHaveLength(2); expect(session.history).toHaveLength(2);
@@ -173,3 +174,28 @@ test("manual typography controls persist in exported recipes and reject invalid
).toThrow(); ).toThrow();
} }
}); });
test("Google Fonts survive JSON export, compile first, and reject injected URLs", () => {
const source = CORE_DESIGN_SYSTEMS.soft;
const next = manualDesign(source, {
kind: "googleFont",
name: "type.heading",
value: "Playfair Display",
});
expect(source.googleFonts).toBeUndefined();
const portable = JSON.parse(JSON.stringify(next));
expect(validateDesignSystem(portable)).toEqual([]);
const compiled = compileDesignSystem(portable);
expect(
compiled.css.startsWith(
'@import url("https://fonts.googleapis.com/css2?family=Playfair%20Display:wght@400;700&display=swap");',
),
).toBe(true);
expect(compiled.recipeCss.startsWith("@import")).toBe(true);
expect(compiled.css.match(/@import/g)).toHaveLength(1);
expect(
validateDesignSystem({ ...next, googleFonts: ['Inter\";}@import url(https://evil.test)'] }),
).not.toHaveLength(0);
const changed = manualDesign(next, { kind: "googleFont", name: "type.heading", value: "Lora" });
expect(changed.googleFonts).toEqual(["Lora"]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/design-system", "name": "@wrnexus/design-system",
"version": "0.1.3", "version": "0.1.4",
"description": "Typed, deterministic design tokens and legacy WrNexus theme integration.", "description": "Typed, deterministic design tokens and legacy WrNexus theme integration.",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+13 -2
View File
@@ -58,9 +58,20 @@ export function compileDesignSystem(
compileComponentRecipes(modeResolved, `${selector}[data-theme="${mode}"]`, options.scope), compileComponentRecipes(modeResolved, `${selector}[data-theme="${mode}"]`, options.scope),
); );
} }
const recipeCss = recipeBlocks.filter(Boolean).join("\n"); const fontImports = (resolved.googleFonts ?? [])
.map(
(family) =>
`@import url("https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;700&display=swap");`,
)
.join("\n");
const bodyRecipes = recipeBlocks.filter(Boolean).join("\n");
const recipeCss = [fontImports, bodyRecipes].filter(Boolean).join("\n");
return { return {
css: blocks.join("\n") + "\n" + (recipeCss ? recipeCss + "\n" : ""), css:
(fontImports ? fontImports + "\n" : "") +
blocks.join("\n") +
"\n" +
(bodyRecipes ? bodyRecipes + "\n" : ""),
recipeCss, recipeCss,
resolved, resolved,
}; };
+3
View File
@@ -110,6 +110,9 @@ export function resolveDesignSystem(
version: system.version, version: system.version,
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(options.density ? { density: options.density } : {}), ...(options.density ? { density: options.density } : {}),
...(layers.some((layer) => layer.googleFonts)
? { googleFonts: [...new Set(layers.flatMap((layer) => layer.googleFonts ?? []))] }
: {}),
modes, modes,
tokens, tokens,
recipes, recipes,
+3
View File
@@ -47,6 +47,8 @@ export interface DesignSystem {
densities?: Record<string, TokenMap>; densities?: Record<string, TokenMap>;
defaultMode?: string; defaultMode?: string;
recipes?: Record<string, ComponentRecipe>; recipes?: Record<string, ComponentRecipe>;
/** Google Fonts families loaded with regular and bold weights and display=swap. */
googleFonts?: string[];
} }
export interface DesignDiagnostic { export interface DesignDiagnostic {
code: string; code: string;
@@ -77,6 +79,7 @@ export interface ResolvedDesignSystem {
modes: string[]; modes: string[];
tokens: Record<string, ResolvedToken>; tokens: Record<string, ResolvedToken>;
recipes: Record<string, ComponentRecipe>; recipes: Record<string, ComponentRecipe>;
googleFonts?: string[];
} }
export interface LegacyTheme { export interface LegacyTheme {
default?: string; default?: string;
+17
View File
@@ -127,6 +127,7 @@ export function validateDesignSystem(input: unknown): DesignDiagnostic[] {
"name", "name",
"version", "version",
"description", "description",
"googleFonts",
"extends", "extends",
"tokens", "tokens",
"modes", "modes",
@@ -156,6 +157,22 @@ export function validateDesignSystem(input: unknown): DesignDiagnostic[] {
(typeof input.defaultMode !== "string" || !validName(input.defaultMode)) (typeof input.defaultMode !== "string" || !validName(input.defaultMode))
) )
error("WRN-DS-NAME", "defaultMode", "Invalid mode name."); error("WRN-DS-NAME", "defaultMode", "Invalid mode name.");
if (
input.googleFonts !== undefined &&
(!Array.isArray(input.googleFonts) ||
input.googleFonts.length > 10 ||
input.googleFonts.some(
(name: unknown) =>
typeof name !== "string" ||
!/^[A-Za-z][A-Za-z0-9 ]{0,79}$/.test(name) ||
name.trim() !== name,
))
)
error(
"WRN-DS-SCHEMA",
"googleFonts",
"Choose up to ten Google Font family names using letters, numbers and spaces.",
);
const map = (value: unknown, path: string) => { const map = (value: unknown, path: string) => {
if (!isRecord(value)) { if (!isRecord(value)) {
error("WRN-DS-SCHEMA", path, "Expected a token map."); error("WRN-DS-SCHEMA", path, "Expected a token map.");