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,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);
|
||||
|
||||
Reference in new issue
Block a user