diff --git a/bun.lock b/bun.lock index 13ddc412..59df384e 100644 --- a/bun.lock +++ b/bun.lock @@ -391,7 +391,7 @@ }, "packages/design-studio": { "name": "@wrnexus/design-studio", - "version": "0.1.9", + "version": "0.1.10", "bin": { "wrnexus-design-studio": "src/cli.ts", }, @@ -409,7 +409,7 @@ }, "packages/design-system": { "name": "@wrnexus/design-system", - "version": "0.1.3", + "version": "0.1.4", }, "packages/dev-server": { "name": "@wrnexus/dev-server", diff --git a/packages/design-studio/package.json b/packages/design-studio/package.json index fa29f2c3..ddc3c646 100644 --- a/packages/design-studio/package.json +++ b/packages/design-studio/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/design-studio", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/design-studio/src/manual-editor.ts b/packages/design-studio/src/manual-editor.ts index 380b5662..5a6947ca 100644 --- a/packages/design-studio/src/manual-editor.ts +++ b/packages/design-studio/src/manual-editor.ts @@ -1,4 +1,5 @@ import { + GOOGLE_FONTS, MANUAL_COLORS, MANUAL_FONTS, MANUAL_TYPOGRAPHY, @@ -12,7 +13,7 @@ const escape = (value: unknown) => /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!, ); -export const manualHtml = `

Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.

Colors

Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.

All other color tokens

Fine-tune hover, contrast, focus, input, code and custom color tokens.

Shape and spacing

Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.

Typography

Use a system font below or enter your own font stack. Custom fonts must also be loaded in your application.

Surface

Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review & apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.

`; +export const manualHtml = `

Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.

Colors

Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.

All other color tokens

Fine-tune hover, contrast, focus, input, code and custom color tokens.

Shape and spacing

Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.

Typography

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.

Surface

Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review & apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.

`; 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( session: StudioSession, @@ -62,6 +63,12 @@ export function setupManual( name, 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 }); }); 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")) edits.push({ kind: "token", name: "elevation.panel", value: value("manual-shadow") }); + $("manual-fonts") + .querySelectorAll("[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); if (checkDecisionLocks(session.valid, next, session.locks).length) throw Error("This change affects a locked decision. Unlock it in Guided design first."); @@ -225,7 +242,7 @@ export function setupManual( .map(([label, font]) => ``) .join( "", - )}`; + )}${GOOGLE_FONTS.map((font) => ``).join("")}`; }) .join(""); $("manual-type-scale").innerHTML = Object.entries(MANUAL_TYPOGRAPHY) diff --git a/packages/design-studio/src/manual.ts b/packages/design-studio/src/manual.ts index 89cefaeb..d304b459 100644 --- a/packages/design-studio/src/manual.ts +++ b/packages/design-studio/src/manual.ts @@ -33,6 +33,31 @@ export const MANUAL_FONTS = { serif: 'Georgia, "Times New Roman", serif', 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 = { "type.body-size": { label: "Body size (px)", @@ -138,6 +163,7 @@ function typographyRecipes(system: DesignSystem, token: string) { } } export type ManualEdit = + | { kind: "googleFont"; name: "type.body" | "type.heading" | "type.code"; value: string } | { kind: "color"; mode: string; name: string; value: string } | { kind: "token"; name: string; 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"; } 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 set = ( tokens: Record, diff --git a/packages/design-studio/src/preview.ts b/packages/design-studio/src/preview.ts index 97d69779..2489cbc6 100644 --- a/packages/design-studio/src/preview.ts +++ b/packages/design-studio/src/preview.ts @@ -234,7 +234,7 @@ export async function previewAgentPage( meta: { title: page.title }, body: `

${escape(page.title)}

${body.join("")}
`, htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`, - extraHead: ``, + extraHead: ``, componentRecipeCss: theme.recipeCss, }); }); diff --git a/packages/design-studio/src/server.ts b/packages/design-studio/src/server.ts index f2f3853d..25c26a85 100644 --- a/packages/design-studio/src/server.ts +++ b/packages/design-studio/src/server.ts @@ -55,7 +55,7 @@ export async function createStudioHandler( ...headers, "content-type": "text/html; charset=utf-8", "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 => { diff --git a/packages/design-studio/test/manual.test.ts b/packages/design-studio/test/manual.test.ts index 72e694cf..41de60d4 100644 --- a/packages/design-studio/test/manual.test.ts +++ b/packages/design-studio/test/manual.test.ts @@ -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; input.value = "12"; (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-leading"]') as any).value = "1.8"; (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); expect(session.valid.tokens["shape.control"]!.value).toBe("12px"); 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-leading"]!.value).toBe("1.8"); expect(session.history).toHaveLength(2); @@ -173,3 +174,28 @@ test("manual typography controls persist in exported recipes and reject invalid ).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"]); +}); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 913119f5..46b05b0f 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/design-system", - "version": "0.1.3", + "version": "0.1.4", "description": "Typed, deterministic design tokens and legacy WrNexus theme integration.", "type": "module", "main": "src/index.ts", diff --git a/packages/design-system/src/compile.ts b/packages/design-system/src/compile.ts index 4f2884f0..9b1d08a3 100644 --- a/packages/design-system/src/compile.ts +++ b/packages/design-system/src/compile.ts @@ -58,9 +58,20 @@ export function compileDesignSystem( 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 { - css: blocks.join("\n") + "\n" + (recipeCss ? recipeCss + "\n" : ""), + css: + (fontImports ? fontImports + "\n" : "") + + blocks.join("\n") + + "\n" + + (bodyRecipes ? bodyRecipes + "\n" : ""), recipeCss, resolved, }; diff --git a/packages/design-system/src/resolve.ts b/packages/design-system/src/resolve.ts index 6399c250..4bb505ab 100644 --- a/packages/design-system/src/resolve.ts +++ b/packages/design-system/src/resolve.ts @@ -110,6 +110,9 @@ export function resolveDesignSystem( version: system.version, ...(mode ? { mode } : {}), ...(options.density ? { density: options.density } : {}), + ...(layers.some((layer) => layer.googleFonts) + ? { googleFonts: [...new Set(layers.flatMap((layer) => layer.googleFonts ?? []))] } + : {}), modes, tokens, recipes, diff --git a/packages/design-system/src/types.ts b/packages/design-system/src/types.ts index e896e7c7..313311d5 100644 --- a/packages/design-system/src/types.ts +++ b/packages/design-system/src/types.ts @@ -47,6 +47,8 @@ export interface DesignSystem { densities?: Record; defaultMode?: string; recipes?: Record; + /** Google Fonts families loaded with regular and bold weights and display=swap. */ + googleFonts?: string[]; } export interface DesignDiagnostic { code: string; @@ -77,6 +79,7 @@ export interface ResolvedDesignSystem { modes: string[]; tokens: Record; recipes: Record; + googleFonts?: string[]; } export interface LegacyTheme { default?: string; diff --git a/packages/design-system/src/validation.ts b/packages/design-system/src/validation.ts index bb75b9fc..578e24d5 100644 --- a/packages/design-system/src/validation.ts +++ b/packages/design-system/src/validation.ts @@ -127,6 +127,7 @@ export function validateDesignSystem(input: unknown): DesignDiagnostic[] { "name", "version", "description", + "googleFonts", "extends", "tokens", "modes", @@ -156,6 +157,22 @@ export function validateDesignSystem(input: unknown): DesignDiagnostic[] { (typeof input.defaultMode !== "string" || !validName(input.defaultMode)) ) 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) => { if (!isRecord(value)) { error("WRN-DS-SCHEMA", path, "Expected a token map.");