feat(studio): choose and load Google Fonts in manual design
This commit is contained in:
1 parent
7ef475119a
commit
d5244eab15
12 files changed
+140
-12
No files matched your search
@@ -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,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",
|
||||||
|
|||||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 & 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 & 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)
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
Reference in new issue
Block a user