feat(studio): expand manual typography controls and theme recipes
This commit is contained in:
1 parent
e0298ac471
commit
7ef475119a
10 files changed
+392
-13
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/design-studio",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 & 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 & 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/design-system",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Typed, deterministic design tokens and legacy WrNexus theme integration.",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Shared immutable property profiles keep catalog metadata small and explicit. */
|
||||
export const CATALOG_PART_PROPERTIES = {
|
||||
text: ["color", "fontFamily", "fontSize", "fontWeight", "letterSpacing"],
|
||||
text: ["color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"],
|
||||
layout: ["color", "fontFamily", "gap", "paddingBlock", "paddingInline"],
|
||||
surface: [
|
||||
"backgroundColor",
|
||||
@@ -25,6 +25,7 @@ export const CATALOG_PART_PROPERTIES = {
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"outlineColor",
|
||||
"paddingBlock",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,7 @@ export const RECIPE_PROPERTIES: Record<string, TokenType | "borderStyle" | "angl
|
||||
fontFamily: "fontFamily",
|
||||
fontSize: "dimension",
|
||||
fontWeight: "fontWeight",
|
||||
lineHeight: "number",
|
||||
letterSpacing: "dimension",
|
||||
outlineColor: "color",
|
||||
opacity: "number",
|
||||
@@ -74,6 +75,7 @@ function literal(property: string, value: unknown): boolean {
|
||||
Math.abs(parseFloat(value)) <= 8
|
||||
);
|
||||
if (!type || !validTokenValue(type, value) || isRecord(value)) return false;
|
||||
if (property === "lineHeight") return Number(value) >= 1 && Number(value) <= 3;
|
||||
if (property === "opacity") return Number(value) >= 0 && Number(value) <= 1;
|
||||
if (
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user