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
@@ -391,7 +391,7 @@
|
|||||||
},
|
},
|
||||||
"packages/design-studio": {
|
"packages/design-studio": {
|
||||||
"name": "@wrnexus/design-studio",
|
"name": "@wrnexus/design-studio",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"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.2",
|
"version": "0.1.3",
|
||||||
},
|
},
|
||||||
"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.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"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 { checkDecisionLocks } from "./decisions.ts";
|
||||||
import type { StudioSession } from "./session.ts";
|
import type { StudioSession } from "./session.ts";
|
||||||
const escape = (value: unknown) =>
|
const escape = (value: unknown) =>
|
||||||
@@ -6,7 +12,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><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 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,
|
||||||
@@ -51,7 +57,11 @@ export function setupManual(
|
|||||||
if (input instanceof HTMLInputElement && input.type === "number") {
|
if (input instanceof HTMLInputElement && input.type === "number") {
|
||||||
const previous = Number(input.dataset.manualOriginal);
|
const previous = Number(input.dataset.manualOriginal);
|
||||||
if (Number(input.value) !== previous)
|
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 });
|
} 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;
|
||||||
@@ -202,9 +212,13 @@ export function setupManual(
|
|||||||
$("manual-fonts").innerHTML = [
|
$("manual-fonts").innerHTML = [
|
||||||
["type.body", "Body font"],
|
["type.body", "Body font"],
|
||||||
["type.heading", "Heading font"],
|
["type.heading", "Heading font"],
|
||||||
|
["type.code", "Code font"],
|
||||||
]
|
]
|
||||||
.map(([name, label]) => {
|
.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(
|
return `<label>${label}<select data-manual-token="${name}"><option value="${escape(current)}">Current: ${escape(current)}</option>${Object.entries(
|
||||||
MANUAL_FONTS,
|
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>`;
|
)}</select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"></label>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.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 shadow = $("manual-shadow") as HTMLSelectElement;
|
||||||
const current = String(resolved.tokens["elevation.panel"]?.value ?? "none");
|
const current = String(resolved.tokens["elevation.panel"]?.value ?? "none");
|
||||||
Array.from(shadow.options)
|
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 = {
|
export const MANUAL_COLORS = {
|
||||||
primary: "Brand / primary",
|
primary: "Brand / primary",
|
||||||
@@ -16,10 +21,122 @@ export const MANUAL_COLORS = {
|
|||||||
} as const;
|
} as const;
|
||||||
export const MANUAL_FONTS = {
|
export const MANUAL_FONTS = {
|
||||||
sans: '"Helvetica Neue", Arial, sans-serif',
|
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',
|
humanist: '"Trebuchet MS", Arial, sans-serif',
|
||||||
serif: 'Georgia, "Times New Roman", serif',
|
serif: 'Georgia, "Times New Roman", serif',
|
||||||
mono: '"Courier New", monospace',
|
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 =
|
export type ManualEdit =
|
||||||
| { 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 }
|
||||||
@@ -157,6 +274,13 @@ export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSyst
|
|||||||
"shape.dialog": ["dimension", "radius-lg"],
|
"shape.dialog": ["dimension", "radius-lg"],
|
||||||
"type.body": ["fontFamily", "font-sans"],
|
"type.body": ["fontFamily", "font-sans"],
|
||||||
"type.heading": ["fontFamily", undefined],
|
"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"],
|
"elevation.panel": ["shadow", "shadow-1"],
|
||||||
"space.control": ["dimension", undefined],
|
"space.control": ["dimension", undefined],
|
||||||
"space.panel": ["dimension", "space-panel"],
|
"space.panel": ["dimension", "space-panel"],
|
||||||
@@ -165,11 +289,24 @@ export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSyst
|
|||||||
const [type, legacy] = allowed[edit.name]!;
|
const [type, legacy] = allowed[edit.name]!;
|
||||||
if (
|
if (
|
||||||
type === "dimension" &&
|
type === "dimension" &&
|
||||||
|
!Object.hasOwn(MANUAL_TYPOGRAPHY, edit.name) &&
|
||||||
(!/^\d+(?:\.\d+)?px$/.test(edit.value) ||
|
(!/^\d+(?:\.\d+)?px$/.test(edit.value) ||
|
||||||
parseFloat(edit.value) > (edit.name.startsWith("shape.") ? 999 : 64))
|
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.");
|
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);
|
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.
|
// Existing mode overrides must not silently win over a shared foundation edit.
|
||||||
for (const layer of Object.values(system.modes ?? {}))
|
for (const layer of Object.values(system.modes ?? {}))
|
||||||
if (layer[edit.name]) set(layer, edit.name, edit.value, type, legacy);
|
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 { resolveThemeConfig, renderThemeCss } from "@wrnexus/styles/theme";
|
||||||
import {
|
import {
|
||||||
COMPONENT_CONTRACTS,
|
COMPONENT_CONTRACTS,
|
||||||
|
resolveDesignSystem,
|
||||||
type DesignSystem,
|
type DesignSystem,
|
||||||
type ResolveOptions,
|
type ResolveOptions,
|
||||||
} from "@wrnexus/design-system";
|
} 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));
|
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(
|
export async function previewDocument(
|
||||||
system: DesignSystem,
|
system: DesignSystem,
|
||||||
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
|
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
|
||||||
@@ -183,7 +209,7 @@ export async function previewDocument(
|
|||||||
meta: { title: system.name + " preview" },
|
meta: { title: system.name + " preview" },
|
||||||
body,
|
body,
|
||||||
htmlAttrs: ` dir="${options.direction === "rtl" ? "rtl" : "ltr"}" data-theme="${escape(mode)}"`,
|
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,
|
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>`,
|
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 },
|
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)}</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,
|
componentRecipeCss: theme.recipeCss,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
validateDesignSystem,
|
validateDesignSystem,
|
||||||
compileDesignSystem,
|
compileDesignSystem,
|
||||||
} from "@wrnexus/design-system";
|
} 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 { StudioSession } from "../src/session.ts";
|
||||||
import { checkDecisionLocks } from "../src/decisions.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();
|
editor.render();
|
||||||
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 =
|
||||||
|
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 =
|
(win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value =
|
||||||
"#6336AD";
|
"#6336AD";
|
||||||
(win.document.querySelector("[data-manual-apply]") as any).click();
|
(win.document.querySelector("[data-manual-apply]") as any).click();
|
||||||
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.body-size"]!.value).toBe("18px");
|
||||||
|
expect(session.valid.tokens["type.body-leading"]!.value).toBe("1.8");
|
||||||
expect(session.history).toHaveLength(2);
|
expect(session.history).toHaveLength(2);
|
||||||
session.setLocks(["/tokens/shape.control"]);
|
session.setLocks(["/tokens/shape.control"]);
|
||||||
editor.render();
|
editor.render();
|
||||||
@@ -132,3 +139,37 @@ test("manual form saves one atomic revision and rejects locked changes without c
|
|||||||
await win.happyDOM.close();
|
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",
|
"name": "@wrnexus/design-system",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Shared immutable property profiles keep catalog metadata small and explicit. */
|
/** Shared immutable property profiles keep catalog metadata small and explicit. */
|
||||||
export const CATALOG_PART_PROPERTIES = {
|
export const CATALOG_PART_PROPERTIES = {
|
||||||
text: ["color", "fontFamily", "fontSize", "fontWeight", "letterSpacing"],
|
text: ["color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"],
|
||||||
layout: ["color", "fontFamily", "gap", "paddingBlock", "paddingInline"],
|
layout: ["color", "fontFamily", "gap", "paddingBlock", "paddingInline"],
|
||||||
surface: [
|
surface: [
|
||||||
"backgroundColor",
|
"backgroundColor",
|
||||||
@@ -25,6 +25,7 @@ export const CATALOG_PART_PROPERTIES = {
|
|||||||
"fontFamily",
|
"fontFamily",
|
||||||
"fontSize",
|
"fontSize",
|
||||||
"fontWeight",
|
"fontWeight",
|
||||||
|
"lineHeight",
|
||||||
"letterSpacing",
|
"letterSpacing",
|
||||||
"outlineColor",
|
"outlineColor",
|
||||||
"paddingBlock",
|
"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",
|
fontFamily: "fontFamily",
|
||||||
fontSize: "dimension",
|
fontSize: "dimension",
|
||||||
fontWeight: "fontWeight",
|
fontWeight: "fontWeight",
|
||||||
|
lineHeight: "number",
|
||||||
letterSpacing: "dimension",
|
letterSpacing: "dimension",
|
||||||
outlineColor: "color",
|
outlineColor: "color",
|
||||||
opacity: "number",
|
opacity: "number",
|
||||||
@@ -74,6 +75,7 @@ function literal(property: string, value: unknown): boolean {
|
|||||||
Math.abs(parseFloat(value)) <= 8
|
Math.abs(parseFloat(value)) <= 8
|
||||||
);
|
);
|
||||||
if (!type || !validTokenValue(type, value) || isRecord(value)) return false;
|
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 (property === "opacity") return Number(value) >= 0 && Number(value) <= 1;
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in new issue
Block a user