feat(studio): expand manual typography controls and theme recipes
Quality / quality (ubuntu-latest) (push) Failing after 6m43s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 21:17:50 +05:30
1 parent e0298ac471
commit 7ef475119a
10 files changed
+392 -13

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/design-studio",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"main": "src/index.ts",
+25 -4
View File
@@ -1,4 +1,10 @@
import { MANUAL_COLORS, MANUAL_FONTS, manualDesign, type ManualEdit } from "./manual.ts";
import {
MANUAL_COLORS,
MANUAL_FONTS,
MANUAL_TYPOGRAPHY,
manualDesign,
type ManualEdit,
} from "./manual.ts";
import { checkDecisionLocks } from "./decisions.ts";
import type { StudioSession } from "./session.ts";
const escape = (value: unknown) =>
@@ -6,7 +12,7 @@ const escape = (value: unknown) =>
/[&<>"']/g,
(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><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">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 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,
@@ -51,7 +57,11 @@ export function setupManual(
if (input instanceof HTMLInputElement && input.type === "number") {
const previous = Number(input.dataset.manualOriginal);
if (Number(input.value) !== previous)
edits.push({ kind: "token", name, value: input.value + "px" });
edits.push({
kind: "token",
name,
value: input.value + (input.dataset.manualUnit ?? "px"),
});
} else if (input.value !== raw) edits.push({ kind: "token", name, value: input.value });
});
const value = (id: string) => ($(id) as HTMLSelectElement).value;
@@ -202,9 +212,13 @@ export function setupManual(
$("manual-fonts").innerHTML = [
["type.body", "Body font"],
["type.heading", "Heading font"],
["type.code", "Code font"],
]
.map(([name, label]) => {
const current = String(resolved.tokens[name!]?.value ?? MANUAL_FONTS.sans);
const current = String(
resolved.tokens[name!]?.value ??
(name === "type.code" ? MANUAL_FONTS.mono : MANUAL_FONTS.sans),
);
return `<label>${label}<select data-manual-token="${name}"><option value="${escape(current)}">Current: ${escape(current)}</option>${Object.entries(
MANUAL_FONTS,
)
@@ -214,6 +228,13 @@ export function setupManual(
)}</select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"></label>`;
})
.join("");
$("manual-type-scale").innerHTML = Object.entries(MANUAL_TYPOGRAPHY)
.map(([name, setting]) => {
const raw = String(resolved.tokens[name]?.value ?? setting.initial);
const numeric = parseFloat(raw) * (raw.endsWith("rem") ? 16 : 1);
return `<label>${setting.label}<input type="number" step="${setting.type === "fontWeight" ? 100 : 0.1}" min="${setting.min}" max="${setting.max}" data-manual-token="${name}" data-manual-unit="${setting.unit}" data-manual-original="${numeric}" value="${numeric}"></label>`;
})
.join("");
const shadow = $("manual-shadow") as HTMLSelectElement;
const current = String(resolved.tokens["elevation.panel"]?.value ?? "none");
Array.from(shadow.options)
+138 -1
View File
@@ -1,4 +1,9 @@
import { validateDesignSystem, type DesignSystem, type DesignToken } from "@wrnexus/design-system";
import {
COMPONENT_CONTRACTS,
validateDesignSystem,
type DesignSystem,
type DesignToken,
} from "@wrnexus/design-system";
export const MANUAL_COLORS = {
primary: "Brand / primary",
@@ -16,10 +21,122 @@ export const MANUAL_COLORS = {
} as const;
export const MANUAL_FONTS = {
sans: '"Helvetica Neue", Arial, sans-serif',
system: "system-ui, sans-serif",
arial: "Arial, Helvetica, sans-serif",
verdana: "Verdana, Geneva, sans-serif",
tahoma: "Tahoma, sans-serif",
georgia: "Georgia, serif",
times: '"Times New Roman", Times, serif',
palatino: '"Palatino Linotype", Palatino, serif',
consolas: 'Consolas, "Courier New", monospace',
humanist: '"Trebuchet MS", Arial, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
mono: '"Courier New", monospace',
};
export const MANUAL_TYPOGRAPHY = {
"type.body-size": {
label: "Body size (px)",
type: "dimension",
min: 10,
max: 32,
initial: 16,
unit: "px",
},
"type.heading-size": {
label: "Heading size (px)",
type: "dimension",
min: 16,
max: 96,
initial: 28,
unit: "px",
},
"type.code-size": {
label: "Code size (px)",
type: "dimension",
min: 10,
max: 32,
initial: 14,
unit: "px",
},
"type.body-weight": {
label: "Body weight",
type: "fontWeight",
min: 100,
max: 900,
initial: 400,
unit: "",
},
"type.heading-weight": {
label: "Heading weight",
type: "fontWeight",
min: 100,
max: 900,
initial: 700,
unit: "",
},
"type.body-leading": {
label: "Body line height",
type: "number",
min: 1,
max: 2.5,
initial: 1.5,
unit: "",
},
"type.heading-leading": {
label: "Heading line height",
type: "number",
min: 1,
max: 2,
initial: 1.2,
unit: "",
},
"type.body-tracking": {
label: "Body letter spacing (px)",
type: "dimension",
min: -2,
max: 8,
initial: 0,
unit: "px",
},
"type.heading-tracking": {
label: "Heading letter spacing (px)",
type: "dimension",
min: -4,
max: 12,
initial: 0,
unit: "px",
},
} as const;
function typographyRecipes(system: DesignSystem, token: string) {
const suffix = token.replace(/^type\./, "");
const group = suffix.split("-")[0]!;
const property =
(
{
size: "fontSize",
weight: "fontWeight",
leading: "lineHeight",
tracking: "letterSpacing",
} as Record<string, string>
)[suffix.split("-")[1]!] ?? "fontFamily";
for (const [name, contract] of Object.entries(COMPONENT_CONTRACTS)) {
for (const [part, descriptor] of Object.entries(contract.parts)) {
const properties: readonly string[] = descriptor.properties;
if (!properties.includes(property)) continue;
const code = /code|kbd/i.test(name) || /code/i.test(part);
const heading = /title|heading|headline/i.test(part);
if ((code ? "code" : heading ? "heading" : "body") !== group) continue;
// Keep icon geometry and managed/provider content independent of text settings.
if (
/icon|spinner|thumb|track|srOnly/i.test(part) ||
("ownership" in descriptor && descriptor.ownership !== "framework")
)
continue;
const recipe = ((system.recipes ??= {})[name] ??= { contractVersion: 1 });
((recipe.base ??= {})[part] ??= {})[property] = { ref: token };
}
}
}
export type ManualEdit =
| { kind: "color"; mode: string; name: string; value: string }
| { kind: "token"; name: string; value: string }
@@ -157,6 +274,13 @@ export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSyst
"shape.dialog": ["dimension", "radius-lg"],
"type.body": ["fontFamily", "font-sans"],
"type.heading": ["fontFamily", undefined],
"type.code": ["fontFamily", "font-mono"],
...Object.fromEntries(
Object.entries(MANUAL_TYPOGRAPHY).map(([name, setting]) => [
name,
[setting.type, undefined],
]),
),
"elevation.panel": ["shadow", "shadow-1"],
"space.control": ["dimension", undefined],
"space.panel": ["dimension", "space-panel"],
@@ -165,11 +289,24 @@ export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSyst
const [type, legacy] = allowed[edit.name]!;
if (
type === "dimension" &&
!Object.hasOwn(MANUAL_TYPOGRAPHY, edit.name) &&
(!/^\d+(?:\.\d+)?px$/.test(edit.value) ||
parseFloat(edit.value) > (edit.name.startsWith("shape.") ? 999 : 64))
)
throw Error("Enter corners between 0 and 999 pixels, or padding between 0 and 64 pixels.");
const typography = MANUAL_TYPOGRAPHY[edit.name as keyof typeof MANUAL_TYPOGRAPHY];
if (typography) {
const numeric = Number(typography.unit ? edit.value.replace(/px$/, "") : edit.value);
if (
!Number.isFinite(numeric) ||
numeric < typography.min ||
numeric > typography.max ||
(typography.unit && !/^-?\d+(?:\.\d+)?px$/.test(edit.value))
)
throw Error(`${typography.label} must be between ${typography.min} and ${typography.max}.`);
}
set(system.tokens, edit.name, edit.value, type, legacy);
if (edit.name.startsWith("type.")) typographyRecipes(system, edit.name);
// Existing mode overrides must not silently win over a shared foundation edit.
for (const layer of Object.values(system.modes ?? {}))
if (layer[edit.name]) set(layer, edit.name, edit.value, type, legacy);
+28 -2
View File
@@ -16,6 +16,7 @@ import { uiCss, uiComponentPath } from "@wrnexus/ui/registry";
import { resolveThemeConfig, renderThemeCss } from "@wrnexus/styles/theme";
import {
COMPONENT_CONTRACTS,
resolveDesignSystem,
type DesignSystem,
type ResolveOptions,
} from "@wrnexus/design-system";
@@ -68,6 +69,31 @@ async function component(name: string, props: Record<string, unknown> = {}) {
return String((await modules.get(name)!).render(props));
}
function typographyCss(system: DesignSystem, options: ResolveOptions) {
const tokens = resolveDesignSystem(system, options).tokens;
return (
[
["body", "body"],
["h1,h2,h3,h4,h5,h6", "heading"],
["code,pre,kbd,samp", "code"],
] as const
)
.map(([selector, role]) => {
const rules = [
["font-family", `type.${role}`],
["font-size", `type.${role}-size`],
["font-weight", `type.${role}-weight`],
["line-height", `type.${role}-leading`],
["letter-spacing", `type.${role}-tracking`],
]
.filter(([, name]) => tokens[name!])
.map(([property, name]) => `${property}:${tokens[name!]!.value};`)
.join("");
return `${selector}{${rules}}`;
})
.join("");
}
export async function previewDocument(
system: DesignSystem,
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
@@ -183,7 +209,7 @@ 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}.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>`,
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}${typographyCss(system, options)}.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('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>`,
});
@@ -208,7 +234,7 @@ export async function previewAgentPage(
meta: { title: page.title },
body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`,
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)}</style>`,
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>`,
componentRecipeCss: theme.recipeCss,
});
});
+42 -1
View File
@@ -4,7 +4,7 @@ import {
validateDesignSystem,
compileDesignSystem,
} from "@wrnexus/design-system";
import { manualDesign } from "../src/manual.ts";
import { MANUAL_TYPOGRAPHY, MANUAL_FONTS, manualDesign } from "../src/manual.ts";
import { StudioSession } from "../src/session.ts";
import { checkDecisionLocks } from "../src/decisions.ts";
@@ -112,12 +112,19 @@ test("manual form saves one atomic revision and rejects locked changes without c
editor.render();
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;
(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 =
"#6336AD";
(win.document.querySelector("[data-manual-apply]") as any).click();
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.body-size"]!.value).toBe("18px");
expect(session.valid.tokens["type.body-leading"]!.value).toBe("1.8");
expect(session.history).toHaveLength(2);
session.setLocks(["/tokens/shape.control"]);
editor.render();
@@ -132,3 +139,37 @@ test("manual form saves one atomic revision and rejects locked changes without c
await win.happyDOM.close();
}
});
test("manual typography controls persist in exported recipes and reject invalid ranges", () => {
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
let system = structuredClone(original);
for (const [name, setting] of Object.entries(MANUAL_TYPOGRAPHY))
system = manualDesign(system, {
kind: "token",
name,
value: String(setting.initial) + setting.unit,
});
system = manualDesign(system, {
kind: "token",
name: "type.code",
value: MANUAL_FONTS.consolas,
});
expect(validateDesignSystem(system)).toEqual([]);
const css = compileDesignSystem(system).css;
expect(css).toContain("font-size:16px");
expect(css).toContain("font-weight:700");
expect(css).toContain("line-height:1.5");
expect(JSON.parse(JSON.stringify(system)).tokens["type.code"].value).toBe(
MANUAL_FONTS.consolas,
);
expect(() =>
manualDesign(system, { kind: "token", name: "type.body-size", value: "200px" }),
).toThrow();
expect(() =>
manualDesign(system, { kind: "token", name: "type.body-leading", value: "0" }),
).toThrow();
expect(() =>
manualDesign(system, { kind: "token", name: "type.heading", value: "serif; color:red" }),
).toThrow();
}
});