diff --git a/design-md-specs.md b/design-md-specs.md new file mode 100644 index 0000000..c3847a3 --- /dev/null +++ b/design-md-specs.md @@ -0,0 +1,318 @@ + + +A DESIGN.md file has two layers. The **YAML front matter** contains machine-readable design tokens — the precise values agents use to enforce consistency. The **markdown body** provides human-readable design rationale organized into `##` sections. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them. + +The spec is a **foundation, not a prescription**. It provides common ground that agents, tools, and teams can rely on, while preserving the freedom to extend the format for domain-specific needs. + +## Design tokens + +DESIGN.md embeds design tokens as YAML front matter at the beginning of the file. The front matter block must begin with a line containing exactly `---` and end with a line containing exactly `---`. The YAML content between these delimiters follows the schema defined below. + +The token system is inspired by the [W3C Design Token Format](https://www.designtokens.org/). Tokens are easily converted to and from `tokens.json`, Figma variables, and Tailwind theme configs. + +```yaml +--- +version: alpha +name: Daylight Prestige +colors: + primary: "#1A1C1E" + secondary: "#6C7278" + tertiary: "#B8422E" +typography: + h1: + fontFamily: Public Sans + fontSize: 48px + fontWeight: 600 + lineHeight: 1.1 + letterSpacing: -0.02em +rounded: + sm: 4px + md: 8px +spacing: + sm: 8px + md: 16px +components: + button-primary: + backgroundColor: "{colors.primary-60}" + textColor: "{colors.primary-20}" + rounded: "{rounded.md}" + padding: 12px +--- +``` + +### Schema + +```yaml +version: # optional, current version: "alpha" +name: +description: # optional +colors: + : +typography: + : +rounded: + : +spacing: + : +components: + : + : +``` + +The `` placeholder represents a named level in a sizing or spacing scale. Common level names include `xs`, `sm`, `md`, `lg`, `xl`, and `full`. Any descriptive string key is valid. + +## Token types + +| Type | Format | Example | +|---|---|---| +| **Color** | `#` + hex code (sRGB) | `"#1A1C1E"` | +| **Dimension** | number + unit (`px`, `em`, `rem`) | `48px`, `-0.02em` | +| **Token Reference** | `{path.to.token}` | `{colors.primary}` | +| **Typography** | composite object | See properties below | + +### Typography properties + +| Property | Type | Description | +|---|---|---| +| `fontFamily` | string | The font family name | +| `fontSize` | Dimension | The font size | +| `fontWeight` | number | Numeric weight (e.g., `400`, `700`). In YAML, bare numbers and quoted strings are equivalent | +| `lineHeight` | Dimension \| number | A dimension (e.g., `24px`) or a unitless multiplier (e.g., `1.6`). Unitless is recommended | +| `letterSpacing` | Dimension | Letter spacing adjustment | +| `fontFeature` | string | Configures [`font-feature-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-feature-settings) | +| `fontVariation` | string | Configures [`font-variation-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings) | + +### Token references + +A token reference is wrapped in curly braces and contains an object path to another value in the YAML tree. For most token groups, the reference must point to a primitive value (e.g., `{colors.primary-60}`), not a group. Within the `components` section, references to composite values (e.g., `{typography.label-md}`) are permitted. + +```yaml +components: + button-primary: + backgroundColor: "{colors.primary-60}" + textColor: "{colors.primary-20}" + rounded: "{rounded.md}" +``` + +## Sections + +Every DESIGN.md follows the same structure. Sections can be omitted if they are not relevant to the project, but those present should appear in the sequence listed below. All sections use `##` headings. An optional `#` heading may appear for document titling purposes but is not parsed as a section. + +The section structure is intentionally open-ended. The canonical sections provide a shared vocabulary; design systems are free to add domain-specific sections beyond these. + +### Section order + +| # | Section | Aliases | +|---|---|---| +| 1 | Overview | Brand & Style | +| 2 | Colors | | +| 3 | Typography | | +| 4 | Layout | Layout & Spacing | +| 5 | Elevation & Depth | Elevation | +| 6 | Shapes | | +| 7 | Components | | +| 8 | Do's and Don'ts | | + +### Overview + +Also known as "Brand & Style." A holistic description of the product's look and feel. This section defines the brand personality, target audience, and the emotional response the UI should evoke. It serves as foundational context when a specific rule or token is not defined. + +```markdown +## Overview +A calm, professional interface for a healthcare scheduling platform. +Accessibility-first design with high contrast and generous touch targets. +``` + +### Colors + +Defines the color palettes for the design system. At least the `primary` palette should be defined. Additional palettes may be named freely; a common convention is `primary`, `secondary`, `tertiary`, and `neutral`. + +```markdown +## Colors + +The palette is rooted in high-contrast neutrals and a single accent color. + +- **Primary (#1A1C1E):** Deep ink for headlines and core text. +- **Secondary (#6C7278):** Sophisticated slate for borders, captions, metadata. +- **Tertiary (#B8422E):** The sole driver for interaction. +- **Neutral (#F7F5F2):** Warm limestone foundation. +``` + +**Design tokens:** A `map` mapping the token name to its hex value. + +```yaml +colors: + primary: "#1A1C1E" + secondary: "#6C7278" + tertiary: "#B8422E" + neutral: "#F7F5F2" +``` + +### Typography + +Defines typography levels. Most design systems have 9–15 levels, each with a semantic role (headline, body, label) and size variant (small, medium, large). + +```markdown +## Typography + +- **Headlines:** Public Sans Semi-Bold for an institutional voice. +- **Body:** Public Sans Regular at 16px for long-form readability. +- **Labels:** Space Grotesk for technical data and metadata. +``` + +**Design tokens:** A `map` mapping the token name to its typography properties. + +```yaml +typography: + h1: + fontFamily: Public Sans + fontSize: 48px + fontWeight: 600 + lineHeight: 1.1 + letterSpacing: -0.02em + body-md: + fontFamily: Public Sans + fontSize: 16px + fontWeight: 400 + lineHeight: 1.6 + label-caps: + fontFamily: Space Grotesk + fontSize: 12px + fontWeight: 500 + lineHeight: 1 + letterSpacing: 0.1em +``` + +### Layout + +Also known as "Layout & Spacing." Describes the layout and spacing strategy — grid models, spacing scales, and containment principles. + +```markdown +## Layout + +The layout follows a Fluid Grid model for mobile and a Fixed-Max-Width +Grid for desktop (max 1200px). A strict 8px spacing scale is used. +``` + +**Design tokens:** A `map` mapping the spacing scale identifier to a dimension or unitless number (e.g., column counts or ratios). + +```yaml +spacing: + base: 16px + xs: 4px + sm: 8px + md: 16px + lg: 32px + xl: 64px + gutter: 24px + margin: 32px +``` + +### Elevation & Depth + +Also known as "Elevation." Describes how visual hierarchy is conveyed. For designs that use shadows, it defines the shadow properties. For flat designs, it explains the alternative methods (borders, tonal layers, color contrast). + +```markdown +## Elevation & Depth + +Depth is achieved through tonal layers rather than heavy shadows. +Background uses a soft off-white; primary content sits on pure white cards. +``` + +### Shapes + +Describes how visual elements are shaped — corner radii, edge treatments, and the overall shape language. + +```markdown +## Shapes + +All interactive elements use a minimal 4px corner radius. +Modern enough to feel current, rigid enough to feel engineered. +``` + +**Design tokens:** A `map` mapping the scale level to the corner radius. + +```yaml +rounded: + sm: 4px + md: 8px + lg: 12px + full: 9999px +``` + +### Components + +Style guidance for component atoms. The spec defines common component types — Buttons, Chips, Lists, Inputs, Checkboxes, Radio buttons, Tooltips — but design systems are encouraged to define additional components relevant to their domain. + +```markdown +## Components +- **Buttons**: Rounded (8px), primary uses brand blue fill, secondary uses outline +- **Inputs**: 1px border, surface-variant background, 12px padding +- **Cards**: No elevation, 1px outline border, 12px corner radius +``` + +**Design tokens:** A `map>` mapping a component identifier to a group of sub-token properties. Token values may be literal values or references to previously defined tokens. + +**Variants.** A component may have variants for different UI states (hover, active, pressed). Variants are defined as separate component entries with a related key name. + +```yaml +components: + button-primary: + backgroundColor: "{colors.primary-60}" + textColor: "{colors.primary-20}" + rounded: "{rounded.md}" + padding: 12px + button-primary-hover: + backgroundColor: "{colors.primary-70}" +``` + +#### Component property tokens + +| Property | Type | +|---|---| +| `backgroundColor` | Color | +| `textColor` | Color | +| `typography` | Typography | +| `rounded` | Dimension | +| `padding` | Dimension | +| `size` | Dimension | +| `height` | Dimension | +| `width` | Dimension | + +### Do's and Don'ts + +Practical guidelines and common pitfalls. These act as guardrails during generation. + +```markdown +## Do's and Don'ts + +- Do use the primary color only for the single most important action per screen +- Don't mix rounded and sharp corners in the same view +- Do maintain WCAG AA contrast ratios (4.5:1 for normal text) +- Don't use more than two font weights on a single screen +``` + +## Consumer behavior for unknown content + +The spec is designed to be extended. When a consumer encounters content not defined by this specification: + +| Scenario | Behavior | Example | +|---|---|---| +| Unknown section heading | Preserve; do not error | `## Iconography` | +| Unknown color token name | Accept if value is valid | `surface-container-high: '#ede7dd'` | +| Unknown typography token name | Accept as valid typography | `telemetry-data` | +| Unknown spacing value | Accept; store as string if not a valid dimension | `grid-columns: '5'` | +| Unknown component property | Accept with warning | `borderColor` | +| Duplicate section heading | Error; reject the file | Two `## Colors` headings | + +## Recommended token names + +The following names are commonly used across design systems. They are not required but are provided as guidance for consistency. + +**Colors:** `primary`, `secondary`, `tertiary`, `neutral`, `surface`, `on-surface`, `error` + +**Typography:** `headline-display`, `headline-lg`, `headline-md`, `body-lg`, `body-md`, `body-sm`, `label-lg`, `label-md`, `label-sm` + +**Rounded:** `none`, `sm`, `md`, `lg`, `xl`, `full` + + diff --git a/lib/generate-design-md.mjs b/lib/generate-design-md.mjs index abe662b..2da596b 100644 --- a/lib/generate-design-md.mjs +++ b/lib/generate-design-md.mjs @@ -1,3 +1,5 @@ +import { toColorEntries, toTypographyEntries, toRoundedEntries, toSpacingEntries, stripVar } from "./tokens.mjs"; + export function generateDesignMarkdown(context) { const { normalized, metadata = {} } = context; const siteProfile = normalized.siteProfile || {}; @@ -6,149 +8,141 @@ export function generateDesignMarkdown(context) { const extractionUrl = normalized.source?.url || "Unknown URL"; const audience = metadata.audience || siteProfile.audience || "website visitors and product users"; const productSurface = metadata.productSurface || siteProfile.productSurface || "web app"; - const visualStyle = inferVisualStyle(normalized); - const mainFontStyle = formatMainFontStyle(normalized.mainFontStyle); - const typographyScale = joinTokens(normalized.typographyScale, 8); - const colors = joinTokens(normalized.colorPalette, 10); - const spacing = joinTokens(normalized.spacingScale, 8); - const radiusShadowMotion = joinTokenGroups( - [normalized.radiusTokens, normalized.shadowTokens, normalized.motionDurationTokens], - 8 - ); - const componentNotes = normalized.componentHints - .map((item) => `${item.type} (${item.count})`) - .join(", "); - const diagnosticsNote = normalized.diagnostics.length - ? `\n- Extraction diagnostics: ${normalized.diagnostics.join(" ")}` - : ""; - - return `# ${systemName} - -## Mission -Create implementation-ready, token-driven UI guidance for ${brand} that is optimized for consistency, accessibility, and fast delivery across ${productSurface}. - -## Brand -- Product/brand: ${brand} -- URL: ${extractionUrl} -- Audience: ${audience} -- Product surface: ${productSurface} - -## Style Foundations -- Visual style: ${visualStyle} -- Main font style: ${mainFontStyle} -- Typography scale: ${typographyScale} -- Color palette: ${colors} -- Spacing scale: ${spacing} -- Radius/shadow/motion tokens: ${radiusShadowMotion} - -## Accessibility -- Target: WCAG 2.2 AA -- Keyboard-first interactions required. -- Focus-visible rules required. -- Contrast constraints required. - -## Writing Tone -Concise, confident, implementation-focused. - -## Rules: Do -- Use semantic tokens, not raw hex values, in component guidance. -- Every component must define states for default, hover, focus-visible, active, disabled, loading, and error. -- Component behavior should specify responsive and edge-case handling. -- Interactive components must document keyboard, pointer, and touch behavior. -- Accessibility acceptance criteria must be testable in implementation. - -## Rules: Don't -- Do not allow low-contrast text or hidden focus indicators. -- Do not introduce one-off spacing or typography exceptions. -- Do not use ambiguous labels or non-descriptive actions. -- Do not ship component guidance without explicit state rules. - -## Guideline Authoring Workflow -1. Restate design intent in one sentence. -2. Define foundations and semantic tokens. -3. Define component anatomy, variants, interactions, and state behavior. -4. Add accessibility acceptance criteria with pass/fail checks. -5. Add anti-patterns, migration notes, and edge-case handling. -6. End with a QA checklist. - -## Required Output Structure -- Context and goals. -- Design tokens and foundations. -- Component-level rules (anatomy, variants, states, responsive behavior). -- Accessibility requirements and testable acceptance criteria. -- Content and tone standards with examples. -- Anti-patterns and prohibited implementations. -- QA checklist. - -## Component Rule Expectations -- Include keyboard, pointer, and touch behavior. -- Include spacing and typography token requirements. -- Include long-content, overflow, and empty-state handling. -- Include known page component density: ${componentNotes || "not enough evidence from extraction"}. -${diagnosticsNote} - -## Quality Gates -- Every non-negotiable rule must use "must". -- Every recommendation should use "should". -- Every accessibility rule must be testable in implementation. -- Teams should prefer system consistency over local visual exceptions. -`; + + // Compute once — shared by YAML and prose so names stay consistent + const colorEntries = toColorEntries(normalized.colorPalette, 12); + const typoEntries = toTypographyEntries(normalized.mainFontStyle, normalized.typographyScale); + const roundedEntries = toRoundedEntries(normalized.radiusTokens, 8); + const spacingEntries = toSpacingEntries(normalized.spacingScale, 10); + + const frontMatter = buildFrontMatter(systemName, { colorEntries, typoEntries, roundedEntries, spacingEntries }); + const body = buildBody({ + brand, extractionUrl, audience, productSurface, visualStyle, normalized, + colorEntries, typoEntries, roundedEntries, spacingEntries, + }); + + return `${frontMatter}\n${body}`; } -function inferSystemName(title) { - if (!title) { - return "Extracted Design System"; +// --- YAML front matter --- + +function buildFrontMatter(name, { colorEntries, typoEntries, roundedEntries, spacingEntries }) { + const lines = ["---", `version: alpha`, `name: ${yamlString(name)}`]; + + if (colorEntries.length > 0) { + lines.push("colors:"); + for (const [k, v] of colorEntries) lines.push(` ${k}: "${v}"`); } - const clean = title - .replace(/\s*\|\s*.*/g, "") - .replace(/\s*-\s*.*/g, "") - .trim(); - return clean || "Extracted Design System"; -} -function inferVisualStyle(normalized) { - const colorCount = normalized.colorPalette.length; - const spacingCount = normalized.spacingScale.length; - if (colorCount >= 8 && spacingCount >= 6) { - return "structured, tokenized, content-first"; + if (typoEntries.length > 0) { + lines.push("typography:"); + for (const [entryName, props] of typoEntries) { + lines.push(` ${entryName}:`); + for (const [pk, pv] of Object.entries(props)) { + lines.push(` ${pk}: ${yamlTypoProp(pk, pv)}`); + } + } } - if (colorCount >= 5) { - return "clean, functional, implementation-oriented"; + + if (roundedEntries.length > 0) { + lines.push("rounded:"); + for (const [k, v] of roundedEntries) lines.push(` ${k}: ${v}`); } - return "minimal, utility-first, accessibility-prioritized"; -} -function formatMainFontStyle(mainFontStyle) { - if (!mainFontStyle || !mainFontStyle.familyStack) { - return "No reliable primary font family detected from computed styles."; + if (spacingEntries.length > 0) { + lines.push("spacing:"); + for (const [k, v] of spacingEntries) lines.push(` ${k}: ${v}`); } - const family = `\`font.family.primary=${mainFontStyle.primaryFamily || mainFontStyle.familyStack}\``; - const stack = mainFontStyle.familyStack ? `\`font.family.stack=${mainFontStyle.familyStack}\`` : ""; - const size = mainFontStyle.size ? `\`font.size.base=${mainFontStyle.size}\`` : ""; - const weight = mainFontStyle.weight ? `\`font.weight.base=${mainFontStyle.weight}\`` : ""; - const lineHeight = mainFontStyle.lineHeight ? `\`font.lineHeight.base=${mainFontStyle.lineHeight}\`` : ""; + lines.push("---"); + return lines.join("\n"); +} + +// --- Markdown body --- + +function buildBody({ brand, extractionUrl, audience, productSurface, visualStyle, normalized, + colorEntries, typoEntries, roundedEntries, spacingEntries }) { + const sections = []; + + // 1. Overview + sections.push( + `## Overview\n${brand} — ${visualStyle} for ${audience}.\nProduct surface: ${productSurface}. Source: ${extractionUrl}.` + ); + + // 2. Colors + const colorLines = colorEntries.map(([name, value]) => `- **${name}:** ${value}`); + sections.push(`## Colors\n${colorLines.length > 0 ? colorLines.join("\n") : "No color palette extracted."}`); + + // 3. Typography + const typoLines = typoEntries.map(([name, props]) => { + const parts = [props.fontFamily || "", props.fontWeight ? `${props.fontWeight}` : "", props.fontSize ? `at ${props.fontSize}` : ""] + .filter(Boolean).join(" "); + return `- **${name}:** ${parts}`; + }); + sections.push(`## Typography\n${typoLines.length > 0 ? typoLines.join("\n") : "No typography data extracted."}`); + + // 4. Layout + const spacingLines = spacingEntries.map(([name, value]) => `- **${name}:** ${value}`); + sections.push(`## Layout\n${spacingLines.length > 0 ? spacingLines.join("\n") : "No spacing tokens extracted."}`); + + // 5. Elevation & Depth + const shadowLines = (normalized.shadowTokens || []).slice(0, 6).map((row) => { + const name = stripVar(row.token).replace(/^(?:shadow|elevation|drop-shadow)-/, ""); + return `- **${name}:** ${row.value}`; + }); + sections.push( + `## Elevation & Depth\n${shadowLines.length > 0 ? shadowLines.join("\n") : "Flat design — depth conveyed through tonal layers and borders rather than drop shadows."}` + ); + + // 6. Shapes + const radiusLines = roundedEntries.map(([name, value]) => `- **${name}:** ${value}`); + sections.push(`## Shapes\n${radiusLines.length > 0 ? radiusLines.join("\n") : "No border-radius tokens extracted."}`); + + // 7. Components + const componentLines = (normalized.componentHints || []).map( + (item) => `- **${item.type}** (${item.count} instances)` + ); + sections.push(`## Components\n${componentLines.length > 0 ? componentLines.join("\n") : "No component data extracted."}`); - return [family, stack, size, weight, lineHeight].filter(Boolean).join(", "); + // 8. Do's and Don'ts + sections.push(`## Do's and Don'ts + +- Do use semantic color tokens, not raw hex values. +- Do maintain WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text and UI components). +- Do define states for default, hover, focus-visible, active, disabled, loading, and error on every interactive component. +- Don't introduce spacing or typography values outside the token scale. +- Don't use low-contrast text or hidden focus indicators. +- Don't mix corner radius styles within the same component surface.`); + + return sections.join("\n\n"); } -function joinTokens(rows, limit) { - if (!rows || rows.length === 0) { - return "No reliable extraction yet; teams should define explicit semantic tokens manually."; - } - return rows - .slice(0, limit) - .map((row) => `\`${row.token}=${row.value}\``) - .join(", "); +// --- helpers --- + +function yamlString(str) { + return /[:#\[\]{},&*?|<>=!%@`]/.test(str) ? `"${str}"` : str; } -function joinTokenGroups(groups, limitPerGroup) { - const lines = groups - .filter((rows) => rows && rows.length > 0) - .map((rows) => rows.slice(0, limitPerGroup).map((row) => `\`${row.token}=${row.value}\``).join(", ")); - if (lines.length === 0) { - return "No reliable extraction yet; motion and shape tokens should be defined manually."; - } - return lines.join(" | "); +function yamlTypoProp(key, value) { + if (key === "fontFamily") return `"${value}"`; + if (key === "fontWeight" || key === "lineHeight") return String(value); + return value; +} + +function inferSystemName(title) { + if (!title) return "Extracted Design System"; + const clean = title + .replace(/\s*\|\s*.*/g, "") + .replace(/\s*-\s*.*/g, "") + .trim(); + return clean || "Extracted Design System"; +} + +function inferVisualStyle(normalized) { + const colorCount = (normalized.colorPalette || []).length; + const spacingCount = (normalized.spacingScale || []).length; + if (colorCount >= 8 && spacingCount >= 6) return "structured, token-driven interface"; + if (colorCount >= 5) return "clean, functional interface"; + return "minimal, utility-first interface"; } diff --git a/lib/generate-skill-md.mjs b/lib/generate-skill-md.mjs index 459f270..6dfd842 100644 --- a/lib/generate-skill-md.mjs +++ b/lib/generate-skill-md.mjs @@ -1,3 +1,5 @@ +import { toColorEntries, toTypographyEntries, toRoundedEntries, toSpacingEntries, stripVar } from "./tokens.mjs"; + export function generateSkillMarkdown(context) { const { normalized, metadata = {} } = context; const siteProfile = normalized.siteProfile || {}; @@ -9,13 +11,10 @@ export function generateSkillMarkdown(context) { const productSurface = metadata.productSurface || siteProfile.productSurface || "web app"; const mainFontStyle = formatMainFontStyle(normalized.mainFontStyle); - const typographyScale = joinTokens(normalized.typographyScale, 8); - const colors = joinTokens(normalized.colorPalette, 10); - const spacing = joinTokens(normalized.spacingScale, 8); - const radiusShadowMotion = joinTokenGroups( - [normalized.radiusTokens, normalized.shadowTokens, normalized.motionDurationTokens], - 8 - ); + const colors = joinColorTokens(normalized.colorPalette, 10); + const typographyScale = joinTypographyTokens(normalized.mainFontStyle, normalized.typographyScale); + const spacing = joinSpacingTokens(normalized.spacingScale, 8); + const radiusShadowMotion = joinRadiusShadowMotion(normalized.radiusTokens, normalized.shadowTokens, normalized.motionDurationTokens, 6); return `--- name: design-system-${slug} @@ -95,45 +94,58 @@ concise, confident, implementation-focused `; } -function inferSystemName(title) { - if (!title) { - return "Extracted Design System"; - } - const clean = title - .replace(/\s*\|\s*.*/g, "") - .replace(/\s*-\s*.*/g, "") - .trim(); - return clean || "Extracted Design System"; +// --- token formatters (inline backtick style for skill.md) --- + +function joinColorTokens(colorPalette, limit) { + const entries = toColorEntries(colorPalette, limit); + if (entries.length === 0) return "manual token definitions required"; + return entries.map(([name, value]) => `\`${name}=${value}\``).join(", "); } -function slugify(value) { - return String(value || "extracted") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); +function joinTypographyTokens(mainFontStyle, typographyScale) { + const entries = toTypographyEntries(mainFontStyle, typographyScale); + if (entries.length === 0) return "manual token definitions required"; + return entries.map(([name, props]) => `\`${name}=${props.fontSize || "?"}\``).join(", "); } -function joinTokens(rows, limit) { - if (!rows || rows.length === 0) { - return "manual token definitions required"; - } - return rows - .slice(0, limit) - .map((row) => `\`${row.token}=${row.value}\``) - .join(", "); +function joinSpacingTokens(spacingScale, limit) { + const entries = toSpacingEntries(spacingScale, limit); + if (entries.length === 0) return "manual token definitions required"; + return entries.map(([name, value]) => `\`${name}=${value}\``).join(", "); } -function joinTokenGroups(groups, limitPerGroup) { - const lines = groups - .filter((rows) => rows && rows.length > 0) - .map((rows) => rows.slice(0, limitPerGroup).map((row) => `\`${row.token}=${row.value}\``).join(", ")); - if (lines.length === 0) { - return "manual token definitions required"; +function joinRadiusShadowMotion(radiusTokens, shadowTokens, motionTokens, limitPerGroup) { + const groups = []; + + const rounded = toRoundedEntries(radiusTokens, limitPerGroup); + if (rounded.length > 0) { + groups.push(rounded.map(([name, value]) => `\`${name}=${value}\``).join(", ")); } - return lines.join(" | "); + + if (shadowTokens && shadowTokens.length > 0) { + groups.push( + shadowTokens.slice(0, limitPerGroup) + .map((row) => { + const name = stripVar(row.token).replace(/^(?:shadow|elevation|drop-shadow)-/, "") || "shadow"; + return `\`${name}=${row.value}\``; + }) + .join(", ") + ); + } + + if (motionTokens && motionTokens.length > 0) { + groups.push( + motionTokens.slice(0, limitPerGroup) + .map((row) => `\`${stripVar(row.token)}=${row.value}\``) + .join(", ") + ); + } + + return groups.length > 0 ? groups.join(" | ") : "manual token definitions required"; } +// --- helpers --- + function formatMainFontStyle(mainFontStyle) { if (!mainFontStyle || !mainFontStyle.familyStack) { return "No reliable primary font family detected from computed styles."; @@ -147,3 +159,20 @@ function formatMainFontStyle(mainFontStyle) { return [family, stack, size, weight, lineHeight].filter(Boolean).join(", "); } + +function inferSystemName(title) { + if (!title) return "Extracted Design System"; + const clean = title + .replace(/\s*\|\s*.*/g, "") + .replace(/\s*-\s*.*/g, "") + .trim(); + return clean || "Extracted Design System"; +} + +function slugify(value) { + return String(value || "extracted") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); +} diff --git a/lib/tokens.mjs b/lib/tokens.mjs new file mode 100644 index 0000000..c60697e --- /dev/null +++ b/lib/tokens.mjs @@ -0,0 +1,160 @@ +// Shared token builders — recommended names per design-md-specs.md +// Used by both generate-design-md.mjs and generate-skill-md.mjs + +export const COLOR_RECOMMENDED = ["primary", "secondary", "tertiary", "neutral", "surface", "on-surface", "error"]; +export const TYPO_ABOVE_BODY = ["headline-display", "headline-lg", "headline-md"]; +export const TYPO_BELOW_BODY = ["body-sm", "label-lg", "label-md", "label-sm"]; +export const SPACING_SCALE = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"]; +export const ROUNDED_ORDER = ["none", "sm", "md", "lg", "xl", "full"]; + +const COLOR_SEMANTIC_PATTERNS = new Map([ + ["primary", /primary|brand|(? { + const raw = stripVar(row.token); + for (const [name, pattern] of COLOR_SEMANTIC_PATTERNS) { + if (!used.has(name) && pattern.test(raw)) { + used.add(name); + return { name, value: row.value }; + } + } + return { name: null, value: row.value, token: row.token }; + }); + + const remaining = COLOR_RECOMMENDED.filter((n) => !used.has(n)); + let ri = 0; + for (const entry of entries) { + if (!entry.name) { + entry.name = remaining[ri] ?? stripVar(entry.token); + ri++; + } + } + + return entries.map((e) => [e.name, e.value]); +} + +export function toTypographyEntries(mainFontStyle, typographyScale) { + if (!mainFontStyle?.familyStack) return []; + + const family = mainFontStyle.primaryFamily || mainFontStyle.familyStack; + const bodySize = mainFontStyle.size; + const bodyPx = bodySize ? toPx(bodySize) : 16; + const scale = typographyScale || []; + const entries = []; + + const above = scale + .filter((row) => toPx(row.value) > bodyPx) + .sort((a, b) => toPx(b.value) - toPx(a.value)); + + const below = scale + .filter((row) => toPx(row.value) < bodyPx) + .sort((a, b) => toPx(b.value) - toPx(a.value)); + + above.slice(0, 3).forEach((row, i) => { + const props = {}; + if (family) props.fontFamily = family; + props.fontSize = row.value; + if (mainFontStyle.weight) props.fontWeight = toNumeric(mainFontStyle.weight); + entries.push([TYPO_ABOVE_BODY[i], props]); + }); + + const bodyProps = {}; + if (family) bodyProps.fontFamily = family; + if (bodySize) bodyProps.fontSize = bodySize; + if (mainFontStyle.weight) bodyProps.fontWeight = toNumeric(mainFontStyle.weight); + if (mainFontStyle.lineHeight) bodyProps.lineHeight = toNumeric(mainFontStyle.lineHeight); + entries.push(["body-md", bodyProps]); + + below.slice(0, 4).forEach((row, i) => { + const props = {}; + if (family) props.fontFamily = family; + props.fontSize = row.value; + if (mainFontStyle.weight) props.fontWeight = toNumeric(mainFontStyle.weight); + entries.push([TYPO_BELOW_BODY[i], props]); + }); + + return entries; +} + +export function toRoundedEntries(radiusTokens, limit) { + if (!radiusTokens || radiusTokens.length === 0) return []; + + const items = radiusTokens.slice(0, limit).map((row) => ({ + value: row.value, + px: toPx(row.value), + raw: String(row.value).toLowerCase(), + name: null, + })); + + const used = new Set(); + for (const item of items) { + if (!used.has("none") && (item.px === 0 || item.raw === "none" || item.raw === "0px")) { + item.name = "none"; + used.add("none"); + } else if (!used.has("full") && (item.px >= 500 || item.raw === "9999px" || item.raw.includes("50%"))) { + item.name = "full"; + used.add("full"); + } + } + + const scaleNames = ["sm", "md", "lg", "xl"].filter((n) => !used.has(n)); + const untagged = items.filter((i) => !i.name).sort((a, b) => a.px - b.px); + untagged.forEach((item, i) => { item.name = scaleNames[i] ?? `r${i + 1}`; }); + + items.sort((a, b) => { + const ai = ROUNDED_ORDER.indexOf(a.name); + const bi = ROUNDED_ORDER.indexOf(b.name); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + }); + + return items.map((i) => [i.name, i.value]); +} + +export function toSpacingEntries(spacingScale, limit) { + if (!spacingScale || spacingScale.length === 0) return []; + + const rows = spacingScale.slice(0, limit); + const cleaned = rows.map((row) => ({ + name: stripVar(row.token).replace(/^(?:spacing|space|gap)-/, ""), + value: row.value, + px: toPx(row.value), + })); + + if (cleaned.every((item) => SPACING_SCALE.includes(item.name))) { + return cleaned.map((item) => [item.name, item.value]); + } + + const sorted = [...cleaned].sort((a, b) => a.px - b.px); + return sorted.map((item, i) => [SPACING_SCALE[i] ?? item.name, item.value]); +} + +export function stripVar(token) { + return token.replace(/^--/, "").toLowerCase(); +} + +export function toPx(val) { + const m = String(val).match(/^([\d.]+)(px|rem|em)?$/); + if (!m) return 0; + const n = parseFloat(m[1]); + const unit = m[2] || "px"; + if (unit === "rem" || unit === "em") return n * 16; + return n; +} + +export function toNumeric(val) { + const n = parseFloat(val); + return isNaN(n) ? val : n; +} diff --git a/lib/validate.mjs b/lib/validate.mjs index 53bb45d..0f83db9 100644 --- a/lib/validate.mjs +++ b/lib/validate.mjs @@ -1,4 +1,15 @@ -const REQUIRED_HEADINGS = [ +const DESIGN_REQUIRED_HEADINGS = [ + "## Overview", + "## Colors", + "## Typography", + "## Layout", + "## Elevation & Depth", + "## Shapes", + "## Components", + "## Do's and Don'ts", +]; + +const SKILL_REQUIRED_HEADINGS = [ "## Mission", "## Brand", "## Style Foundations", @@ -9,7 +20,7 @@ const REQUIRED_HEADINGS = [ "## Guideline Authoring Workflow", "## Required Output Structure", "## Component Rule Expectations", - "## Quality Gates" + "## Quality Gates", ]; const REQUIRED_STATES = [ @@ -19,7 +30,7 @@ const REQUIRED_STATES = [ "active", "disabled", "loading", - "error" + "error", ]; export function validateMarkdownOutput(mode, markdown) { @@ -27,6 +38,28 @@ export function validateMarkdownOutput(mode, markdown) { const warnings = []; const checks = []; + if (mode === "design") { + runCheck(markdown.startsWith("---"), "YAML frontmatter exists", checks, errors); + runCheck(markdown.includes("version: alpha"), "Frontmatter includes spec version", checks, errors); + runCheck(markdown.includes("name:"), "Frontmatter includes name", checks, errors); + + for (const heading of DESIGN_REQUIRED_HEADINGS) { + runCheck(markdown.includes(heading), `Required section present: ${heading}`, checks, errors); + } + + for (const state of REQUIRED_STATES) { + if (!markdown.toLowerCase().includes(state)) { + warnings.push(`Missing explicit state mention: ${state}`); + } + } + + if (!markdown.includes("WCAG AA")) { + errors.push("Accessibility target 'WCAG AA' is missing."); + } else { + checks.push({ label: "Accessibility target included", ok: true }); + } + } + if (mode === "skill") { runCheck(markdown.startsWith("---"), "Frontmatter block exists", checks, errors); runCheck(markdown.includes("name: design-system-"), "Frontmatter includes design-system name", checks, errors); @@ -38,41 +71,41 @@ export function validateMarkdownOutput(mode, markdown) { checks, errors ); - } - for (const heading of REQUIRED_HEADINGS) { - runCheck(markdown.includes(heading), `Required section present: ${heading}`, checks, errors); - } + for (const heading of SKILL_REQUIRED_HEADINGS) { + runCheck(markdown.includes(heading), `Required section present: ${heading}`, checks, errors); + } - for (const state of REQUIRED_STATES) { - if (!markdown.toLowerCase().includes(state)) { - warnings.push(`Missing explicit state mention: ${state}`); + for (const state of REQUIRED_STATES) { + if (!markdown.toLowerCase().includes(state)) { + warnings.push(`Missing explicit state mention: ${state}`); + } } - } - if (!markdown.includes("WCAG 2.2 AA")) { - errors.push("Accessibility target 'WCAG 2.2 AA' is missing."); - } else { - checks.push({ label: "Accessibility target included", ok: true }); - } + if (!markdown.includes("WCAG 2.2 AA")) { + errors.push("Accessibility target 'WCAG 2.2 AA' is missing."); + } else { + checks.push({ label: "Accessibility target included", ok: true }); + } - if (!markdown.includes("must")) { - warnings.push("No 'must' wording detected for non-negotiable rules."); - } else { - checks.push({ label: "Contains non-negotiable rule wording", ok: true }); - } + if (!markdown.includes("must")) { + warnings.push("No 'must' wording detected for non-negotiable rules."); + } else { + checks.push({ label: "Contains non-negotiable rule wording", ok: true }); + } - if (!markdown.includes("should")) { - warnings.push("No 'should' wording detected for recommendation rules."); - } else { - checks.push({ label: "Contains recommendation wording", ok: true }); + if (!markdown.includes("should")) { + warnings.push("No 'should' wording detected for recommendation rules."); + } else { + checks.push({ label: "Contains recommendation wording", ok: true }); + } } return { isValid: errors.length === 0, errors, warnings, - checks + checks, }; } diff --git a/tests/run-tests.mjs b/tests/run-tests.mjs index 3f997e6..4967c3d 100644 --- a/tests/run-tests.mjs +++ b/tests/run-tests.mjs @@ -105,16 +105,24 @@ const skillValidation = validateMarkdownOutput("skill", skillMd); assert.ok(designValidation.isValid, `DESIGN.md should be valid: ${designValidation.errors.join(", ")}`); assert.ok(skillValidation.isValid, `SKILL.md should be valid: ${skillValidation.errors.join(", ")}`); + +// DESIGN.md — spec-compliant structure +assert.ok(designMd.startsWith("---"), "DESIGN.md should start with YAML frontmatter"); +assert.ok(designMd.includes("version: alpha"), "DESIGN.md should include spec version"); +assert.ok(designMd.includes("## Overview"), "DESIGN.md should include Overview section"); +assert.ok(designMd.includes("## Do's and Don'ts"), "DESIGN.md should include Do's and Don'ts section"); +assert.ok(designMd.includes("WCAG AA"), "DESIGN.md should include accessibility target"); +assert.ok(designMd.includes("https://example.com/dashboard"), "DESIGN.md should include extraction URL"); +assert.ok(designMd.includes("Inter"), "DESIGN.md should include inferred main font family"); +assert.ok(designMd.includes("authenticated users and operators"), "DESIGN.md should infer audience from site signals"); +assert.ok(designMd.includes("Product surface: dashboard web app"), "DESIGN.md should infer product surface from site signals"); +assert.ok(!designMd.includes("Audience/surface inference confidence"), "DESIGN.md should not include inference confidence text"); + +// SKILL.md — unchanged format assert.ok(skillMd.includes("TYPEUI_SH_MANAGED_START"), "SKILL.md should include managed markers"); -assert.ok(designMd.includes("WCAG 2.2 AA"), "DESIGN.md should include accessibility target"); -assert.ok(designMd.includes("- URL: https://example.com/dashboard"), "DESIGN.md should include extraction URL"); -assert.ok(designMd.includes("- Main font style: `font.family.primary=Inter`"), "DESIGN.md should include inferred main font style"); assert.ok(skillMd.includes("- URL: https://example.com/dashboard"), "SKILL.md should include extraction URL"); assert.ok(skillMd.includes("- Main font style: `font.family.primary=Inter`"), "SKILL.md should include inferred main font style"); -assert.ok(designMd.includes("- Audience: authenticated users and operators"), "DESIGN.md should infer audience from site signals"); -assert.ok(designMd.includes("- Product surface: dashboard web app"), "DESIGN.md should infer product surface from site signals"); assert.ok(skillMd.includes("- Product surface: dashboard web app"), "SKILL.md should infer product surface from site signals"); -assert.ok(!designMd.includes("Audience/surface inference confidence"), "DESIGN.md should not include inference confidence text"); assert.ok(!skillMd.includes("Audience/surface inference confidence"), "SKILL.md should not include inference confidence text"); console.log("All tests passed.");