mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-03 12:27:55 +08:00
feat(craft): add brand-agnostic craft references + Refero-derived lint rules (#225)
* feat(craft): add brand-agnostic craft references and refero-derived lint rules Introduce `craft/` as a third top-level content axis alongside `skills/` and `design-systems/`, holding universal (brand-agnostic) craft rules that apply on top of any DESIGN.md. Skills opt in via a new `od.craft.requires` front-matter array; the daemon resolves the slug list and injects the matching files between DESIGN.md and the skill body in the system prompt. Initial vendor (MIT, adapted from referodesign/refero_skill): typography craft, color craft, anti-ai-slop. Pilot wired on saas-landing. Extend the existing lint-artifact pass with two refero-derived rules: - P0 ai-default-indigo — solid #6366f1 / #4f46e5 / #4338ca / #8b5cf6 as accent (not just gradients) is the most-reported AI tell. - P1 all-caps-no-tracking — `text-transform: uppercase` rules without ≥0.06em letter-spacing. The craft loader silently drops missing files so a skill can forward-reference future sections (e.g. `motion`) without breaking. * fix(daemon): skip :root token blocks in ai-default-indigo lint The ai-default-indigo P0 check scanned the whole HTML for the raw hex, so brands that intentionally encode indigo as `--accent: #6366f1` in :root and consume it via var(--accent) downstream were flagged as AI-default — a false positive that forced the agent to "fix" valid output. Strip :root token-definition blocks (including attribute-selector theme variants) before scanning, mirroring the existing pattern used by the raw-hex P1 check. Hex still flagged when it appears in component rules or inline styles. * docs(craft): address PR #225 P3 review feedback - craft/README.md: explain why missing craft sections are silently dropped (forward-compatibility) instead of surfacing a warning. - craft/typography.md: ground the 0.06em ALL CAPS tracking floor in Bringhurst-derived typographic practice rather than presenting the threshold as unattributed. - craft/color.md: cover the edge case where a brand's DESIGN.md intentionally encodes indigo as --accent — `var(--accent)` uses remain unflagged because the linter only inspects hardcoded hex. - docs/skills-protocol.md: link the "missing files dropped silently" note back to craft/README.md for the canonical slug list and the rationale behind the choice. * fix(craft): address PR #225 P0 review feedback - tools/pack: copy `craft/` into the packaged resource root alongside `skills`, `design-systems`, and `frames`, so the `od.craft.requires` integration isn't a silent no-op when the daemon resolves `${OD_RESOURCE_ROOT}/craft` in packaged builds. - packages/contracts: add `craftRequires?: string[]` to `SkillSummary` (and therefore `SkillDetail`) so the field that `listSkills()` already returns and `/api/skills(/:id)` already serializes via `...rest` is part of the documented web/daemon contract instead of leaking through as an untyped property. - apps/daemon/lint-artifact: expand the indigo token-strip pass to cover selector lists containing `:root` (e.g. `:root, [data-theme="light"]`) and any rule whose body is custom-property-only (e.g. a `[data-theme="dark"] { --accent: ... }` theme variant). Real component rules with a hardcoded indigo are still preserved so the P0 finding still fires; tests cover the new selector-list and theme-variant cases. * fix(craft): address PR #225 follow-up review feedback - lint-artifact: scope the indigo token-strip to <style> blocks so the rule-shaped regex no longer captures leading `<style>` text into the selector (which broke `:root` recognition for token blocks that mix `color-scheme`/etc. with `--accent`). Run the strip on the extracted CSS instead, with a regression covering `:root { color-scheme: light; --accent: #6366f1 }`. - lint-artifact: tighten the custom-property-only exemption to global theme-scope selectors (`:root`, `html`, `body`, bare attribute selectors like `[data-theme="dark"]`). Component-local rules such as `.cta { --cta-bg: #6366f1 }` are no longer exempted, so an agent cannot launder default indigo through a local var. Regression test added. - craft/anti-ai-slop.md: stop claiming every rule below is enforced by the linter; only several are. The unenforced rules (standard Hero→Features→Pricing→FAQ→CTA flow, decorative blob/wave SVG backgrounds, perfect symmetry) are now flagged inline as "(guidance, not auto-checked)" so the contract with the lint surface stays honest. * fix(daemon): tighten lint-artifact iteration and :root token gating - all-caps-no-tracking: iterate every <style> block. The previous check called `exec` once on a non-global regex, so an artifact whose offending uppercase rule sat in a second <style> block (e.g. a reset block followed by a components block) slipped past. Switch to `matchAll` and break across both loops once a violation is found. Regression test covers a second-block uppercase rule. - ai-default-indigo: stop unconditionally exempting any selector list containing `:root`. The exemption now requires both conditions to hold: every selector in the list is global theme scope AND the body is token-shaped (CSS custom properties or the `color-scheme` keyword). So `:root { background: #6366f1 }` and `:root, .cta { --cta-bg: #6366f1 }` no longer launder a hardcoded indigo through the strip pass. Regression tests cover both bypass shapes. * fix(daemon): scope theme-attr exemption and strip CSS comments in token blocks Address PR #225 review feedback on `ai-default-indigo`: - The bare-attribute branch of `selectorListIsGlobalThemeScope` accepted any `[attr=...]` selector, so a custom-property-only rule on a component/state attribute (e.g. `[data-variant="primary"]`, `[aria-current="page"]`) was treated as a global theme block and stripped before the indigo scan — exactly the component-local indigo laundering this lint is meant to catch. Restrict the exemption to a small allowlist of known theme switches: `data-theme`, `data-color-scheme`, `data-mode`. - `stripTokenBlocksFromCss` split rule bodies on `;` and matched each fragment from the start, so a token block whose body contained a normal CSS comment such as `:root { /* brand accent */ --accent: #6366f1; }` produced a fragment beginning with the comment, failed `isTokenShapedDeclaration`, and the rule was left in scope of the indigo scan — a false P0 on a legitimate token definition. Strip CSS comments before splitting/classifying declarations. Add regression coverage: arbitrary component/state attribute selectors still trip `ai-default-indigo`; `data-color-scheme` theme variants stay exempted; `:root` token blocks with leading, trailing, and between-declaration CSS comments are recognized. * fix(daemon): strip CSS comments and recognize tokens nested in at-rules The all-caps-no-tracking scan ran against raw `<style>` content, so a commented-out rule like `/* .eyebrow { text-transform: uppercase; } */` matched `upperRe` and emitted a P1 for CSS the browser ignores. Strip CSS comments from the style body before structural matching. `stripTokenBlocksFromCss` only matched flat `selector { body }` rules, so a media-query-wrapped token block like `@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }` had its outer `@media` rule treated as the selector/body pair and the inner `:root` token block was never stripped, producing a P0 false positive on legitimate responsive theme CSS. Tighten the body alternation to `[^{}]*` so the regex matches innermost rules and recognizes the inner `:root` block directly while preserving the outer at-rule wrapper. * fix(daemon): align ai-default-indigo list with documented cardinal sins The lint's AI_DEFAULT_INDIGO subset omitted #3730a3 and #a855f7, which craft/anti-ai-slop.md lists as P0-blocked solid accents. An artifact could hard-code one of those documented colors as a button fill and slip past the indigo scan unless it happened to be inside a gradient. Bring the lint set to the exact list documented in the craft doc, and tighten the doc's wording from "etc." to an explicit enumeration that points at AI_DEFAULT_INDIGO so the prompt contract and daemon behavior stay in sync. Add regression tests pinning each newly-included hex. * fix(daemon): tighten theme-scope selector and scan inline ALL CAPS The theme-scope exemption used to accept any attribute on `:root`, `html`, or `body` (e.g. `:root[data-variant="primary"]`), letting an agent launder default indigo through a component/state attribute and slip past the `ai-default-indigo` lint. The prefixed branches now require the attribute name to be one of GLOBAL_THEME_ATTRIBUTES, matching the bare-attribute branch. The `all-caps-no-tracking` rule only iterated `<style>` blocks, so inline declarations like `<span style="text-transform: uppercase">` produced no finding even though craft/typography.md treats the ≥0.06em tracking floor as having no exceptions. Added a second scan over `style="..."` attributes that runs the same letter-spacing check and dedupes against the existing `<style>`-block finding so the agent gets a single corrective signal per artifact. * fix(daemon): align uppercase tracking px floor with the 0.06em rule The previous absolute fallback (>=1.5px) was stricter than the craft rule it enforces. `font-size: 12px; letter-spacing: 1px` is 0.083em — above the 0.06em floor — but 1.5px would reject it and trigger an unnecessary correction loop on compliant small-label CSS. Extract `hasAdequateUppercaseTracking`: read `font-size` from the same rule body and compare px tracking against `fontSize * 0.06`; fall back to a conservative >=1px floor when font-size is inherited (covers the default 16px body where 1px ≈ 0.0625em). Apply the helper to both the <style>-block scan and the inline-style scan, and add 12–14px label tests in both branches. * fix(daemon): treat rem letter-spacing as absolute, not per-element em `rem` was previously folded into the same branch as `em` and accepted at the 0.06 threshold. But `rem` is relative to the root font-size (16px default), not the element's own font-size, so on a 48px heading `letter-spacing: 0.06rem` resolves to 0.96px — about 0.02em of the element, well below the 0.06em rule the lint enforces. Convert rem to absolute px through the 16px root assumption and reuse the same px-vs-element-font-size resolution: same-rule `font-size: <n>px` gives an exact `n * 0.06` floor; otherwise the conservative >=1px fallback applies. Add regression tests for 48px headings with 0.06rem tracking (must flag) plus the 16px-element and rem-floor matches that must keep passing, in both <style>-block and inline-style branches. * fix(daemon): resolve var() refs in uppercase tracking lint `hasAdequateUppercaseTracking` only matched literal numeric values, so a tokenized rule like `letter-spacing: var(--caps-tracking)` — exactly the pattern the craft prompt steers artifacts toward — was falsely reported as `all-caps-no-tracking`. Extract `--name: value` declarations from global theme scopes (`:root`, `html`, theme-attribute selectors) once per artifact, then expand simple `var(--name)` (and `var(--name, fallback)`) references in the inspected rule body before applying the existing 0.06em / px-floor / rem-conversion logic. References without a matching token and no fallback stay in place, preserving the conservative "missing tracking" finding. * fix(daemon): resolve rem and var() font-size in uppercase tracking lint Previously the px-vs-element-font-size resolution only matched `font-size: <n>px`. Any rem-based or tokenized display size fell through to the lenient `>= 1px` body-text fallback, so an artifact emitting `.display { font-size: 3rem; text-transform: uppercase; letter-spacing: 1px; }` (a ~48px heading with a 2.88px floor) slipped past the lint that this helper exists to enforce. Resolve `rem` font-size via the same root-font assumption already used for tracking, and treat any explicitly declared but unresolvable unit (`em`, `%`, `calc(...)`, an unresolved `var(...)`) conservatively — refuse the lenient fallback so the rule must use either an `em` letter-spacing or a verifiable px/rem font-size. `var()` font-size declarations resolve through the existing `resolveCssVars` pass before the size scan runs, so the same fix catches the tokenized-display-size pattern (`--display-size: 3rem`). * fix(daemon): parse declarations to ignore custom-prop names in uppercase tracking lint The hasAdequateUppercaseTracking and resolveFontSizePx helpers used substring regexes against the rule body, so a token-name declaration such as `--letter-spacing: 0.08em` or `--display-font-size: 48px` could satisfy the `letter-spacing` / `font-size` checks even though it has no rendered effect — letting actual ALL-CAPS-without-tracking rules slip past the P1 lint. Parse the declaration list, compare exact property names, and skip declarations whose property starts with `--`. Adds regression tests covering token-name letter-spacing (style-block + inline) and a token-name font-size masking the bail-out branch. * fix(daemon): scope indigo token exemption to --accent only Previously stripTokenBlocksFromCss removed every custom-property-only global theme block before the ai-default-indigo scan, which let a laundered indigo token like `:root { --primary: #6366f1 }` consumed via `var(--primary)` slip past the lint. The craft contract is that the only escape hatch is encoding indigo as the design system's `--accent` token; any other token name is still the LLM-default color hidden behind an arbitrary name. Narrow the strip pass so a non-`--accent` token whose value carries an AI-default indigo hex keeps the rule in scope, and add regression tests for `--primary` / `--button-bg` global tokens feeding a CTA, including the at-rule and theme-attribute variants. * fix(daemon): model CSS cascade in tracking lint and detect blue→cyan trust gradients Address PR #225 review feedback (3 comments): - `letter-spacing` / `font-size` selection now picks the LAST matching declaration in the rule body, modeling CSS source-order cascade. `.eyebrow { letter-spacing: 0.08em; letter-spacing: 0.02em }` renders the noncompliant 0.02em the browser actually shows; the previous first-match behaviour silently passed it. - `extractCssTokens` now records every distinct value seen for a token across global theme scopes, and `hasAdequateUppercaseTracking` enumerates each combination so a default-theme value below the floor cannot be rescued by a scoped override that happened to be parsed later (`:root { --caps-tracking: 0.02em }` + `[data-theme="dark"] { --caps-tracking: 0.08em }` now fires). - New `trust-gradient` P0 rule pairs blue/sky tokens against cyan tokens in `linear-gradient(...)` bodies so `blue→cyan` two-stop trust gradients (documented as a cardinal sin in `craft/anti-ai-slop.md`) are actually enforced — both the hex form (`linear-gradient(90deg, #3b82f6, #06b6d4)`) and the keyword form (`linear-gradient(90deg, blue, cyan)`). Adds 11 regression tests covering each path (cascade override in <style> and inline form, font-size cascade shifting the floor, both orderings of the conflicting-token cascade, the don't-over-fire case when every theme value clears the floor, hex / keyword / sky variants of the trust gradient, and the don't-double-fire case when purple-gradient already caught a mixed gradient). * fix(daemon): apply per-scope cascade in extractCssTokens When the same CSS custom property is declared more than once inside a single rule body (e.g. `:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em }`), CSS source-order cascade collapses to the last value; the earlier declaration never reaches any element. `extractCssTokens` was treating intra-scope duplicates as simultaneous theme alternatives, so `hasAdequateUppercaseTracking` enumerated the stale 0.02em and emitted a spurious all-caps-no-tracking finding. Collapse duplicate token declarations within a rule body to the last value before merging into the cross-scope distinct-value map. Cross-scope overrides (separate `:root` and `[data-theme]` rules) remain preserved as distinct values so the conservative theme-cascade check still fires when ANY applicable theme renders below the floor. * fix(daemon): scope tracking lint to innermost rules and per-theme tokens Restrict the upperRe body alternation to [^{}]* so the regex matches innermost CSS rules and skips at-rule wrappers — an outer @media or @supports could otherwise capture as a single rule whose selector was the at-rule and whose body began with the inner selector token, masking the same-rule font-size and letting noncompliant tracking on large headings slip through the lenient inherited-size fallback. Replace the by-name-distinct-values token map with per-scope token records and a buildResolvedThemes pass that materializes one effective map per theme. Paired token declarations now stay paired during evaluation, so theme variants like :root + [data-theme=dark] no longer generate cross-theme cartesian pairings (e.g. default-size + dark-track) that emit false positives on legitimate light/dark themes. --------- Co-authored-by: looper <looper@open-claude.dev>
This commit is contained in:
@@ -13,6 +13,7 @@ This file is the single source of truth for agents entering this repository. Rea
|
||||
## Workspace directories
|
||||
|
||||
- Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`.
|
||||
- Top-level content directories: `skills/` (artifact-shape skills), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`).
|
||||
- `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`.
|
||||
- `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving.
|
||||
- `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC.
|
||||
|
||||
46
apps/daemon/src/craft.ts
Normal file
46
apps/daemon/src/craft.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// @ts-nocheck
|
||||
// Craft references loader. The active skill declares which sections it
|
||||
// needs via `od.craft.requires`; this module reads the matching files
|
||||
// from <projectRoot>/craft/<slug>.md and returns a single concatenated
|
||||
// body ready to splice into the system prompt. Missing files are
|
||||
// dropped silently — a skill that lists `motion` before we ship a
|
||||
// motion.md should still work, just without the motion section.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* @param {string} craftDir absolute path to the craft/ directory
|
||||
* @param {string[]} requested slugs from `od.craft.requires`
|
||||
* @returns {Promise<{ body: string, sections: string[] }>}
|
||||
* body is the concatenated markdown (each file preceded by a level-3
|
||||
* section header). sections lists which slugs actually resolved.
|
||||
*/
|
||||
export async function loadCraftSections(craftDir, requested) {
|
||||
if (!craftDir || !Array.isArray(requested) || requested.length === 0) {
|
||||
return { body: "", sections: [] };
|
||||
}
|
||||
const seen = new Set();
|
||||
const parts = [];
|
||||
const sections = [];
|
||||
for (const raw of requested) {
|
||||
if (typeof raw !== "string") continue;
|
||||
const slug = raw.trim().toLowerCase();
|
||||
if (!SLUG_RE.test(slug) || seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
try {
|
||||
const filePath = path.join(craftDir, `${slug}.md`);
|
||||
const text = await readFile(filePath, "utf8");
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) continue;
|
||||
parts.push(`### ${slug}\n\n${trimmed}`);
|
||||
sections.push(slug);
|
||||
} catch {
|
||||
// File doesn't exist or unreadable — skip silently. Skills can
|
||||
// forward-reference future craft sections without breaking.
|
||||
}
|
||||
}
|
||||
return { body: parts.join("\n\n---\n\n"), sections };
|
||||
}
|
||||
@@ -28,8 +28,48 @@
|
||||
*/
|
||||
|
||||
const PURPLE_HEXES = [
|
||||
// Tailwind violet / purple — the original AI-slop palette.
|
||||
'#a855f7', '#9333ea', '#7c3aed', '#6d28d9', '#581c87',
|
||||
'#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe', '#ede9fe',
|
||||
// Tailwind indigo — Refero's #1 reported AI tell. Common solid uses
|
||||
// (button fill, accent badge), not just gradients, are flagged
|
||||
// separately by `ai-default-indigo` below.
|
||||
'#6366f1', '#4f46e5', '#4338ca', '#3730a3', '#312e81',
|
||||
'#818cf8', '#a5b4fc', '#c7d2fe', '#e0e7ff', '#eef2ff',
|
||||
];
|
||||
|
||||
// Blue / cyan stops used in the documented "blue→cyan two-stop trust
|
||||
// gradient" cardinal sin. The purple-gradient rule above only catches
|
||||
// gradients that contain a violet/indigo hex or the literal
|
||||
// `purple`/`violet` keyword, so an artifact emitting
|
||||
// `linear-gradient(90deg, #3b82f6, #06b6d4)` (or the keyword form
|
||||
// `linear-gradient(90deg, blue, cyan)`) slipped past P0 even though
|
||||
// `craft/anti-ai-slop.md` explicitly flags it. The `trust-gradient`
|
||||
// rule below pairs these against each other to close the gap.
|
||||
const TRUST_GRADIENT_BLUE_HEXES = [
|
||||
// Tailwind blue 500–900 + 400/300/200.
|
||||
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
|
||||
'#60a5fa', '#93c5fd', '#bfdbfe',
|
||||
// Tailwind sky 400–700 — the same blue→cyan ramp under a different name.
|
||||
'#0ea5e9', '#0284c7', '#0369a1', '#38bdf8', '#7dd3fc',
|
||||
];
|
||||
const TRUST_GRADIENT_CYAN_HEXES = [
|
||||
// Tailwind cyan 500–900 + 400/300/200.
|
||||
'#06b6d4', '#0891b2', '#0e7490', '#155e75', '#164e63',
|
||||
'#22d3ee', '#67e8f9', '#a5f3fc',
|
||||
];
|
||||
|
||||
// Subset of PURPLE_HEXES that constitute the canonical "default LLM
|
||||
// accent" — even a single solid use is a tell. The DESIGN.md provides
|
||||
// `var(--accent)`; if a brief truly needs indigo, the design system
|
||||
// should encode it explicitly so we know it's intentional.
|
||||
//
|
||||
// Keep this in sync with the explicit list in `craft/anti-ai-slop.md`'s
|
||||
// "Default Tailwind indigo as accent" cardinal-sin entry — the prompt
|
||||
// contract documents the exact set the lint enforces.
|
||||
const AI_DEFAULT_INDIGO = [
|
||||
'#6366f1', '#4f46e5', '#4338ca', '#3730a3',
|
||||
'#8b5cf6', '#7c3aed', '#a855f7',
|
||||
];
|
||||
|
||||
const SLOP_EMOJI = [
|
||||
@@ -112,6 +152,59 @@ export function lintArtifact(rawHtml) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-1c: blue→cyan "trust" two-stop gradient ─────────────────────
|
||||
// craft/anti-ai-slop.md documents three flavours of the two-stop
|
||||
// "trust" gradient — purple→blue, blue→cyan, indigo→pink. The first
|
||||
// and third are caught by `purple-gradient` above because the
|
||||
// relevant indigo/violet hex appears in PURPLE_HEXES, but a pure
|
||||
// blue→cyan gradient has no overlap with that list and slipped
|
||||
// past unflagged. Detect a `linear-gradient(...)` whose stop list
|
||||
// contains both a blue token (hex or keyword) and a cyan token
|
||||
// (hex or keyword). Skip if the purple-gradient rule already fired
|
||||
// so we emit a single corrective signal per artifact.
|
||||
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
|
||||
const tg = detectBlueCyanTrustGradient(html);
|
||||
if (tg) {
|
||||
out.push({
|
||||
severity: 'P0',
|
||||
id: 'trust-gradient',
|
||||
message: `Found a blue→cyan two-stop "trust" gradient — anti-slop list says no.`,
|
||||
fix: 'Replace the gradient with a flat surface (var(--bg) or var(--surface)) or use a single design-token color. Two-stop blue→cyan trust gradients are a SaaS hero cliché.',
|
||||
snippet: clip(tg),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-1b: solid AI-default indigo as accent ──────────────────────
|
||||
// Even outside a gradient, a single use of #6366f1 et al. is the
|
||||
// textbook LLM tell. We only fire if the existing purple-gradient
|
||||
// check didn't already, since they overlap in spirit. Strip
|
||||
// token-definition blocks first: a brief whose accent is
|
||||
// intentionally indigo declares it as `--accent: #6366f1` inside
|
||||
// a selector list containing `:root` (or another known global
|
||||
// theme scope like `html` / bare `[data-theme="..."]`) and uses
|
||||
// var(--accent) downstream. That is the design system speaking,
|
||||
// not the model defaulting, and must not fire. Component-local
|
||||
// variables (e.g. `.cta { --cta-bg: #6366f1; }`) stay in scope so
|
||||
// the lint still catches indigo laundered through a local var.
|
||||
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
|
||||
const htmlForIndigo = stripTokenBlocks(html);
|
||||
for (const hex of AI_DEFAULT_INDIGO) {
|
||||
const re = new RegExp(escapeRe(hex), 'i');
|
||||
const m = re.exec(htmlForIndigo);
|
||||
if (m) {
|
||||
out.push({
|
||||
severity: 'P0',
|
||||
id: 'ai-default-indigo',
|
||||
message: `Found a default LLM accent color (${hex}) — this is the most-reported AI design tell.`,
|
||||
fix: 'Replace with var(--accent) from the active DESIGN.md. If the brief truly requires indigo, encode it as the design system\'s accent so it reads as intentional, not default.',
|
||||
snippet: clip(m[0]),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-2: emoji used as feature/UI icons ──────────────────────────
|
||||
for (const e of SLOP_EMOJI) {
|
||||
if (html.includes(e)) {
|
||||
@@ -203,6 +296,89 @@ export function lintArtifact(rawHtml) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── P1-0: ALL-CAPS without letter-spacing ─────────────────────────
|
||||
// Refero's typography rules: any `text-transform: uppercase` rule
|
||||
// must pair with `letter-spacing: >= 0.06em` (or an absolute px
|
||||
// equivalent). Iterate every <style> block (artifacts often emit
|
||||
// a reset block followed by a tokens/components block) and scan
|
||||
// each CSS body for an uppercase declaration whose selector body
|
||||
// is missing letter-spacing or sets it visibly too low.
|
||||
// Token-aware tracking: collect per-scope `--name: value` declarations
|
||||
// from global theme scopes once, then pass them to the tracking helper
|
||||
// so a rule like `letter-spacing: var(--caps-tracking)` is judged by
|
||||
// the token's literal value in every applicable theme instead of being
|
||||
// treated as missing.
|
||||
const tokenScopes = extractCssTokens(html);
|
||||
outer: for (const styleBlock of html.matchAll(
|
||||
/<style[^>]*>([\s\S]*?)<\/style>/gi,
|
||||
)) {
|
||||
// Strip CSS comments before structural matching: a `<style>` body
|
||||
// such as `/* .eyebrow { text-transform: uppercase; } */` is
|
||||
// commented-out by the browser but the rule-shaped regex below
|
||||
// would otherwise match it and emit a P1 finding for CSS that has
|
||||
// no rendered effect.
|
||||
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
// Match a CSS rule body containing text-transform: uppercase.
|
||||
// Capture the selector + body so we can inspect tracking. The body
|
||||
// alternation is `[^{}]*` (not `[^}]*`) so the regex matches only
|
||||
// innermost `selector { body }` rules. With `[^}]*`, an outer
|
||||
// `@media (...) { .display { font-size: 48px; text-transform:
|
||||
// uppercase; … } }` matches as a single rule whose selector is the
|
||||
// `@media (...)` wrapper and whose body begins with `.display {
|
||||
// font-size: …` — so `parseDeclarations()` sees the first property
|
||||
// as `.display { font-size`, not `font-size`, the same-rule
|
||||
// font-size is lost, and `hasAdequateUppercaseTracking()` falls
|
||||
// back to the lenient inherited-size path that accepts 1px
|
||||
// tracking on a 48px heading. Restricting the body to `[^{}]*`
|
||||
// makes the regex skip the wrapper and match the inner rule
|
||||
// directly.
|
||||
const upperRe = /([^{}]*)\{([^{}]*text-transform\s*:\s*uppercase[^{}]*)\}/gi;
|
||||
let m;
|
||||
while ((m = upperRe.exec(css)) !== null) {
|
||||
const selector = (m[1] ?? '').trim();
|
||||
const body = m[2] ?? '';
|
||||
if (!hasAdequateUppercaseTracking(body, tokenScopes)) {
|
||||
out.push({
|
||||
severity: 'P1',
|
||||
id: 'all-caps-no-tracking',
|
||||
message: `Selector \`${selector.slice(0, 60)}\` sets text-transform: uppercase without sufficient letter-spacing (≥0.06em).`,
|
||||
fix: 'Add `letter-spacing: 0.08em` (typical) to the same rule. ALL CAPS without tracking looks cramped — Refero\'s typography rules call this out as a top-tier amateur tell.',
|
||||
snippet: clip(`${selector} { ${body.trim()} }`),
|
||||
});
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1-0b: ALL-CAPS in inline style attributes ────────────────────
|
||||
// The <style>-block scan above misses inline declarations such as
|
||||
// `<span style="text-transform: uppercase">NEW</span>`, which the
|
||||
// browser still renders ALL CAPS. craft/typography.md treats the
|
||||
// tracking floor as having no exceptions, so the inline form runs
|
||||
// through the same `hasAdequateUppercaseTracking` check used by the
|
||||
// <style>-block branch — no separate threshold. Only fire if the
|
||||
// <style>-block scan above didn't already produce this id, so the
|
||||
// agent gets a single corrective signal per artifact.
|
||||
if (out.find((f) => f.id === 'all-caps-no-tracking') === undefined) {
|
||||
const inlineStyleRe = /(?:^|\s)style\s*=\s*(["'])([\s\S]*?)\1/gi;
|
||||
let im;
|
||||
while ((im = inlineStyleRe.exec(html)) !== null) {
|
||||
const decl = im[2] ?? '';
|
||||
if (!/text-transform\s*:\s*uppercase/i.test(decl)) continue;
|
||||
if (!hasAdequateUppercaseTracking(decl, tokenScopes)) {
|
||||
out.push({
|
||||
severity: 'P1',
|
||||
id: 'all-caps-no-tracking',
|
||||
message:
|
||||
'Inline style sets text-transform: uppercase without sufficient letter-spacing (≥0.06em).',
|
||||
fix: 'Add `letter-spacing: 0.08em` (typical) to the same inline style. ALL CAPS without tracking looks cramped — Refero\'s typography rules call this out as a top-tier amateur tell.',
|
||||
snippet: clip(decl.trim()),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1-1: external image URLs (CDN / unsplash / placehold.co) ─────
|
||||
// Allow data: urls and same-origin paths.
|
||||
const extImg =
|
||||
@@ -366,3 +542,439 @@ function clip(s) {
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Scan every `linear-gradient(...)` body for a blue→cyan two-stop
|
||||
// trust gradient. Returns the first matching gradient text or `null`.
|
||||
// The check accepts either Tailwind blue/sky/cyan hex stops or the
|
||||
// literal `blue`/`cyan` keywords, so both
|
||||
// `linear-gradient(90deg, #3b82f6, #06b6d4)` and
|
||||
// `linear-gradient(90deg, blue, cyan)` fire P0.
|
||||
function detectBlueCyanTrustGradient(html) {
|
||||
const re = /linear-gradient\([^)]*\)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const grad = m[0].toLowerCase();
|
||||
const hasBlue =
|
||||
TRUST_GRADIENT_BLUE_HEXES.some((h) => grad.includes(h.toLowerCase())) ||
|
||||
/\bblue\b/.test(grad);
|
||||
const hasCyan =
|
||||
TRUST_GRADIENT_CYAN_HEXES.some((h) => grad.includes(h.toLowerCase())) ||
|
||||
/\bcyan\b/.test(grad);
|
||||
if (hasBlue && hasCyan) return m[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when the declaration body has letter-spacing satisfying the
|
||||
// craft rule: `letter-spacing >= 0.06em` of the element's own font.
|
||||
//
|
||||
// `em` maps directly to the 0.06 floor — it is relative to the
|
||||
// element's own font-size, which is what the rule measures against.
|
||||
//
|
||||
// `rem` and `px` are absolute relative to the element: `rem` resolves
|
||||
// against the root font-size (assumed 16px — the browser default and
|
||||
// the value all OD seed templates use), so `0.06rem` on a 48px heading
|
||||
// is `0.96px`, only `0.02em` of the element. Treating `rem` like `em`
|
||||
// (the previous behaviour) accepts that as compliant when the rule
|
||||
// it enforces is the per-element em floor; convert `rem` to absolute
|
||||
// px and reuse the same px-vs-element-font-size resolution.
|
||||
//
|
||||
// px (and the converted-rem path) resolve in three steps:
|
||||
// 1. If the same rule body declares `font-size` in `px` or `rem`
|
||||
// (after `var()` resolution), convert it to absolute px and
|
||||
// compare px tracking against `fs * 0.06` — exact translation
|
||||
// of the em rule. `rem` font sizes resolve via the same root
|
||||
// assumption used for tracking, so a `font-size: 3rem` heading
|
||||
// enforces a 2.88px floor instead of the lenient body fallback.
|
||||
// 2. If the rule explicitly declares a `font-size` in a unit we
|
||||
// can't resolve (`em`, `%`, `calc(...)`, an unresolved var,
|
||||
// etc.), refuse the lenient fallback: the heading might be
|
||||
// arbitrarily large, in which case 1px tracking is well below
|
||||
// 0.06em. Treat as missing tracking — the agent can either
|
||||
// switch to `em` letter-spacing or declare an explicit px/rem
|
||||
// font-size we can verify.
|
||||
// 3. Otherwise (no font-size declared at all, font-size inherited),
|
||||
// use a conservative `>= 1px` absolute fallback. That stays
|
||||
// correct for the typical body-text default of 16px (1px / 16px
|
||||
// ≈ 0.0625em, just over the floor) and for any smaller label
|
||||
// (1px / 14px ≈ 0.071em, 1px / 12px ≈ 0.083em).
|
||||
//
|
||||
// `scopes` (optional) is the array of per-scope token records
|
||||
// harvested from global theme scopes elsewhere in the artifact (see
|
||||
// `extractCssTokens`). Each record carries the scope's per-scope
|
||||
// last-write-wins token map plus enough metadata to identify which
|
||||
// themes the scope applies to. Per-theme effective maps are built
|
||||
// here via `buildResolvedThemes` so simple `var(--name)` (and
|
||||
// `var(--name, fallback)`) references in the body resolve to the
|
||||
// value the browser would render in that theme — keeping values
|
||||
// declared in the same scope paired together. References without a
|
||||
// matching token but with an inline fallback (`var(--x, 0.08em)`)
|
||||
// resolve to the fallback; unresolved references with no fallback
|
||||
// stay in place so the existing "no numeric value" path returns
|
||||
// false.
|
||||
//
|
||||
// When a token resolves to different values in different themes
|
||||
// (e.g. `:root { --caps-tracking: 0.02em }` overridden by
|
||||
// `[data-theme="dark"] { --caps-tracking: 0.08em }`), the helper is
|
||||
// conservative: it walks every per-theme map produced by
|
||||
// `buildResolvedThemes` and returns true only if EVERY theme satisfies
|
||||
// the 0.06em floor. A theme-scoped override that lifts the value
|
||||
// above the floor must not silently rescue a default value that
|
||||
// renders below it. Crucially, theme maps preserve the scope-internal
|
||||
// relationship between tokens, so a paired declaration such as
|
||||
// `:root { --display-size: 16px; --caps-tracking: 1px }` is judged
|
||||
// against (16px, 1px) — never against the impossible cross-theme
|
||||
// pairing (48px, 1px) that an independent per-token cartesian would
|
||||
// emit.
|
||||
const ROOT_FONT_PX = 16;
|
||||
function hasAdequateUppercaseTracking(body, scopes) {
|
||||
const themes = buildResolvedThemes(scopes ?? []);
|
||||
for (const themeMap of themes) {
|
||||
const resolved = resolveCssVars(body, themeMap);
|
||||
if (!isResolvedTrackingAdequate(resolved)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single-resolution tracking check. Parses the declaration list with
|
||||
// exact property names (so token-name declarations such as
|
||||
// `--letter-spacing: 0.08em` cannot satisfy the rule) and selects the
|
||||
// LAST matching `letter-spacing` and `font-size` declarations to model
|
||||
// CSS source-order cascade — `.eyebrow { letter-spacing: 0.08em;
|
||||
// letter-spacing: 0.02em }` renders the noncompliant `0.02em` value,
|
||||
// so the lint must judge against the last declaration, not the first.
|
||||
function isResolvedTrackingAdequate(body) {
|
||||
const decls = parseDeclarations(body);
|
||||
const ls = findLastDecl(decls, 'letter-spacing');
|
||||
if (!ls) return false;
|
||||
const lsMatch = /^(-?\d*\.?\d+)\s*(em|px|rem)\b/i.exec(ls.value);
|
||||
if (!lsMatch) return false;
|
||||
const v = parseFloat(lsMatch[1]);
|
||||
const unit = lsMatch[2].toLowerCase();
|
||||
if (unit === 'em') return v >= 0.06;
|
||||
const trackingPx = unit === 'rem' ? v * ROOT_FONT_PX : v;
|
||||
const fsPx = resolveFontSizePx(decls);
|
||||
if (fsPx != null) {
|
||||
return fsPx > 0 && trackingPx >= fsPx * 0.06;
|
||||
}
|
||||
if (decls.some((d) => d.prop === 'font-size')) return false;
|
||||
return trackingPx >= 1;
|
||||
}
|
||||
|
||||
// Build per-theme effective token maps from the per-scope records
|
||||
// produced by `extractCssTokens`. A "theme" is the default rendering
|
||||
// (no theme attribute set) plus one entry per distinct theme-attribute
|
||||
// selector seen across scopes. Default-applying scopes (whose selector
|
||||
// list contains a bare `:root` / `html` / `body`) apply to every theme
|
||||
// as a baseline; variant scopes apply only to the themes their
|
||||
// selector targets. Within a single theme, scopes are applied in
|
||||
// source order so the final value reflects the cascade the browser
|
||||
// would render.
|
||||
//
|
||||
// Returned as an array — one map per theme. The lint passes only when
|
||||
// every theme map satisfies the rule, so a default-theme value below
|
||||
// the floor flags even if a variant overrides it above the floor (and
|
||||
// vice versa). Building per-theme maps preserves the scope-internal
|
||||
// relationship between tokens, so values declared together in the
|
||||
// same scope (e.g. `--display-size` and `--caps-tracking` both on
|
||||
// `:root`) stay paired during evaluation. The previous design merged
|
||||
// values by token name across scopes and then took an independent
|
||||
// per-token cartesian product, which generated impossible cross-theme
|
||||
// pairings such as `(default-size, dark-track)` and emitted false
|
||||
// positives on legitimate light/dark theme variants.
|
||||
function buildResolvedThemes(scopes) {
|
||||
const themeKeys = new Set(['default']);
|
||||
for (const scope of scopes) {
|
||||
for (const k of scope.themeKeys) themeKeys.add(k);
|
||||
}
|
||||
const themes = new Map();
|
||||
for (const k of themeKeys) themes.set(k, new Map());
|
||||
for (const scope of scopes) {
|
||||
if (scope.isDefault) {
|
||||
for (const map of themes.values()) {
|
||||
for (const [k, v] of scope.tokens) map.set(k, v);
|
||||
}
|
||||
} else {
|
||||
for (const themeKey of scope.themeKeys) {
|
||||
const map = themes.get(themeKey);
|
||||
if (map) {
|
||||
for (const [k, v] of scope.tokens) map.set(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(themes.values());
|
||||
}
|
||||
|
||||
function isBareGlobalSelector(s) {
|
||||
return /^(?::root|html|body)$/.test(s);
|
||||
}
|
||||
|
||||
function findLastDecl(decls, prop) {
|
||||
for (let i = decls.length - 1; i >= 0; i--) {
|
||||
if (decls[i].prop === prop) return decls[i];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Split a CSS declaration body into `{ prop, value }` entries, lowercasing
|
||||
// the property name and skipping custom properties (`--name`). Used by
|
||||
// the uppercase-tracking lint so substring matches on `letter-spacing`
|
||||
// or `font-size` cannot collide with token-name declarations.
|
||||
function parseDeclarations(body) {
|
||||
const out = [];
|
||||
for (const raw of body.split(';')) {
|
||||
const idx = raw.indexOf(':');
|
||||
if (idx < 0) continue;
|
||||
const prop = raw.slice(0, idx).trim().toLowerCase();
|
||||
if (!prop || prop.startsWith('--')) continue;
|
||||
const value = raw.slice(idx + 1).trim();
|
||||
if (!value) continue;
|
||||
out.push({ prop, value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Resolve a same-rule `font-size` declaration to absolute px. Returns
|
||||
// the px value when font-size is declared in `px` or `rem` (rem maps
|
||||
// via the root font-size assumption shared with tracking); returns
|
||||
// `null` when font-size is absent OR present in an unresolvable unit
|
||||
// (`em`, `%`, `calc(...)`, an unresolved `var(--...)`). The caller
|
||||
// distinguishes those two `null` cases by re-checking the parsed
|
||||
// declarations for an exact `font-size` property.
|
||||
//
|
||||
// Selects the LAST `font-size` declaration in source order so that a
|
||||
// rule like `.display { font-size: 48px; font-size: 1em }` is judged
|
||||
// against the noncompliant `1em` the browser actually renders, not the
|
||||
// stale earlier `48px`. CSS cascade is last-write-wins on conflicting
|
||||
// declarations within a single rule body.
|
||||
function resolveFontSizePx(decls) {
|
||||
const fs = findLastDecl(decls, 'font-size');
|
||||
if (!fs) return null;
|
||||
const m = /^(-?\d*\.?\d+)\s*(px|rem)\b/i.exec(fs.value);
|
||||
if (!m) return null;
|
||||
const v = parseFloat(m[1]);
|
||||
const unit = m[2].toLowerCase();
|
||||
return unit === 'rem' ? v * ROOT_FONT_PX : v;
|
||||
}
|
||||
|
||||
// Collect CSS custom properties (`--name: value`) declared in global
|
||||
// theme scopes (`:root`, `html`, theme-attribute selectors) from every
|
||||
// `<style>` block in the artifact. Tokens declared on component
|
||||
// selectors are intentionally ignored: the lint must still catch
|
||||
// indigo / under-tracking laundered through a local var, and the
|
||||
// tracking helper resolves only the global-scope tokens artifacts use
|
||||
// to express design intent.
|
||||
//
|
||||
// Returns an array of per-scope records:
|
||||
// `{ selectors, tokens, isDefault, themeKeys }`
|
||||
// where `tokens` is the per-scope last-write-wins map of CSS custom
|
||||
// properties, `selectors` lists the parsed selectors from the rule,
|
||||
// `isDefault` is true if any selector is a bare global
|
||||
// (`:root` / `html` / `body` without an attribute suffix), and
|
||||
// `themeKeys` is the set of theme-attribute selector strings the rule
|
||||
// targets. Per-theme effective maps are derived downstream from these
|
||||
// records by `buildResolvedThemes`, which preserves the scope-internal
|
||||
// relationship between values so a paired declaration like
|
||||
// `:root { --display-size: 16px; --caps-tracking: 1px }` is judged
|
||||
// as `(16px, 1px)` together, not against the impossible cross-theme
|
||||
// pairing `(48px, 1px)` that an independent per-token cartesian over
|
||||
// distinct values would emit.
|
||||
//
|
||||
// Within a single rule body, CSS cascade is last-write-wins: a block
|
||||
// like `:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em; }`
|
||||
// renders the second value, and the first never reaches any element.
|
||||
// Per-scope, we keep only the LAST value declared for each token
|
||||
// name; cross-scope merging happens later in `buildResolvedThemes`,
|
||||
// where the same source-order cascade is applied between scopes that
|
||||
// target the same theme.
|
||||
function extractCssTokens(html) {
|
||||
const scopes = [];
|
||||
for (const styleBlock of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
|
||||
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const ruleRe = /([^{}]*)\{([^{}]*)\}/g;
|
||||
let m;
|
||||
while ((m = ruleRe.exec(css)) !== null) {
|
||||
const sel = (m[1] ?? '').trim();
|
||||
if (!selectorListIsGlobalThemeScope(sel)) continue;
|
||||
const selectors = sel.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const isDefault = selectors.some(isBareGlobalSelector);
|
||||
const themeKeys = new Set(
|
||||
selectors.filter((s) => !isBareGlobalSelector(s)),
|
||||
);
|
||||
const body = m[2] ?? '';
|
||||
const tokens = new Map();
|
||||
for (const decl of body.split(';').map((d) => d.trim()).filter(Boolean)) {
|
||||
const dm = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
|
||||
if (dm) {
|
||||
tokens.set(dm[1], dm[2].trim());
|
||||
}
|
||||
}
|
||||
if (tokens.size === 0) continue;
|
||||
scopes.push({ selectors, tokens, isDefault, themeKeys });
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
// Replace simple `var(--name)` (and `var(--name, fallback)`) references
|
||||
// in a CSS declaration body with the literal token value. Iterates a
|
||||
// few times so a token whose value is itself another `var(--...)`
|
||||
// resolves through one or two hops; bounded depth so a cyclic
|
||||
// definition (`--a: var(--b); --b: var(--a)`) terminates instead of
|
||||
// looping forever. Only one-level fallbacks are recognised — enough
|
||||
// for the typography pattern this lint cares about, and keeps the
|
||||
// regex linear-time on artifact-sized inputs.
|
||||
const VAR_RESOLVE_MAX_DEPTH = 4;
|
||||
function resolveCssVars(body, tokens) {
|
||||
let out = body;
|
||||
for (let i = 0; i < VAR_RESOLVE_MAX_DEPTH; i++) {
|
||||
const next = out.replace(
|
||||
/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g,
|
||||
(full, name, fallback) => {
|
||||
const v = tokens.get(name);
|
||||
if (v != null) return v;
|
||||
if (fallback != null) return fallback.trim();
|
||||
return full;
|
||||
},
|
||||
);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remove CSS rule blocks that look like design-token definitions.
|
||||
// Operates only on CSS extracted from <style> blocks — running the
|
||||
// rule-shaped regex against the full HTML string makes the first
|
||||
// selector capture include leading text like `<style>`, which then
|
||||
// fails the `:root` selector test.
|
||||
//
|
||||
// A rule is treated as a token block only when ALL THREE conditions hold:
|
||||
// 1. every selector in the list is a global theme-scope selector
|
||||
// (`:root`, `:root[data-theme="..."]`, `html`, `body`, or a bare
|
||||
// attribute selector for a known global-theme switch —
|
||||
// `data-theme`, `data-color-scheme`, `data-mode`). Selector lists
|
||||
// that mix in a component selector — e.g.
|
||||
// `:root, .cta { --cta-bg: #6366f1 }` — or that target an
|
||||
// arbitrary component/state attribute like `[data-variant="primary"]`
|
||||
// or `[aria-current="page"]` fail this test, so indigo laundered
|
||||
// through a local var or rule still trips the lint.
|
||||
// 2. its body is token-shaped: only CSS custom properties
|
||||
// (`--name: value`), with a small allowlist for global-theme
|
||||
// metadata such as `color-scheme` that legitimately accompanies
|
||||
// tokens in `:root` and cannot smuggle a visible color.
|
||||
// A non-token declaration on `:root` (e.g.
|
||||
// `:root { background: #6366f1 }`) keeps the rule in scope so
|
||||
// the indigo check fires.
|
||||
// 3. no token in the body launders an indigo hex through a
|
||||
// non-`--accent` name. The craft contract's escape hatch is to
|
||||
// encode indigo as the active design system's `--accent` token;
|
||||
// anything else (`:root { --primary: #6366f1 }`,
|
||||
// `:root { --button-bg: #4f46e5 }`) is still the LLM-default
|
||||
// color hidden behind an arbitrary token name and must stay in
|
||||
// scope of the indigo scan.
|
||||
function stripTokenBlocks(input) {
|
||||
return input.replace(
|
||||
/(<style[^>]*>)([\s\S]*?)(<\/style>)/gi,
|
||||
(_m, open, css, close) => `${open}${stripTokenBlocksFromCss(css)}${close}`,
|
||||
);
|
||||
}
|
||||
|
||||
function stripTokenBlocksFromCss(css) {
|
||||
// Strip CSS comments before any structural matching: a block like
|
||||
// `:root { /* brand accent */ --accent: #6366f1; }` would otherwise
|
||||
// produce a declaration fragment that begins with the comment,
|
||||
// fail `isTokenShapedDeclaration`, and leave a legitimate token
|
||||
// definition in scope of the indigo scan.
|
||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
// The body alternation is `[^{}]*` (not `[^}]*`) so the regex matches
|
||||
// only innermost `selector { body }` rules. That lets us recognize
|
||||
// global token blocks nested inside at-rule wrappers — e.g.
|
||||
// `@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }`
|
||||
// — by matching the inner `:root { ... }` directly. The outer
|
||||
// `@media` wrapper is preserved with the inner token block stripped,
|
||||
// so the indigo scan no longer fires on legitimate responsive theme
|
||||
// declarations.
|
||||
return cleaned.replace(/([^{}]*)\{([^{}]*)\}/g, (full, selector, body) => {
|
||||
const sel = (selector || '').trim();
|
||||
if (!selectorListIsGlobalThemeScope(sel)) return full;
|
||||
const decls = (body || '')
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
if (decls.length === 0) return full;
|
||||
const tokenShaped = decls.every(isTokenShapedDeclaration);
|
||||
if (!tokenShaped) return full;
|
||||
// The `--accent` escape hatch is for `--accent` only. Any other
|
||||
// global token whose value carries an AI-default indigo hex is
|
||||
// still laundering the LLM-default color through an arbitrary
|
||||
// name (`--primary: #6366f1`, `--button-bg: #4f46e5`, …). Keep
|
||||
// the rule in scope so the indigo lint fires on the literal hex.
|
||||
if (decls.some(declarationLaundersIndigo)) return full;
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
function declarationLaundersIndigo(decl) {
|
||||
const m = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
|
||||
if (!m) return false;
|
||||
if (m[1].toLowerCase() === '--accent') return false;
|
||||
const value = m[2].toLowerCase();
|
||||
for (const hex of AI_DEFAULT_INDIGO) {
|
||||
if (value.includes(hex.toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTokenShapedDeclaration(decl) {
|
||||
// CSS custom property — the canonical token shape.
|
||||
if (/^--[\w-]+\s*:/.test(decl)) return true;
|
||||
// Global-theme metadata that legitimately accompanies tokens in
|
||||
// `:root` / `html` / `[data-theme="..."]` and whose values are
|
||||
// keywords, so they cannot smuggle a hardcoded color.
|
||||
if (/^color-scheme\s*:/i.test(decl)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectorListIsGlobalThemeScope(selector) {
|
||||
const parts = selector.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return false;
|
||||
return parts.every(isGlobalThemeScopeSelector);
|
||||
}
|
||||
|
||||
// Attribute selectors — bare or attached to `:root`/`html`/`body` —
|
||||
// are exempted only when the attribute is one of the known
|
||||
// global-theme switches. A broader exemption would also strip
|
||||
// arbitrary component/state attribute rules
|
||||
// (e.g. `[data-variant="primary"] { --button-bg: #6366f1; }`,
|
||||
// `:root[data-variant="primary"] { --button-bg: #6366f1; }`, or
|
||||
// `html[aria-current="page"] { --nav-accent: #6366f1; }`), which
|
||||
// is the exact component-local indigo laundering this lint is
|
||||
// meant to catch.
|
||||
const GLOBAL_THEME_ATTRIBUTES = new Set([
|
||||
'data-theme',
|
||||
'data-color-scheme',
|
||||
'data-mode',
|
||||
]);
|
||||
|
||||
function isGlobalThemeScopeSelector(s) {
|
||||
// :root / html / body, optionally suffixed with a single attribute
|
||||
// selector. The bare form (no attribute) is always a global theme
|
||||
// scope; the prefixed form is only a theme scope when the attribute
|
||||
// names one of GLOBAL_THEME_ATTRIBUTES. A component/state attribute
|
||||
// suffix (`:root[data-variant="primary"]`, `html[aria-current="page"]`)
|
||||
// must keep the rule in scope of the indigo lint.
|
||||
const tagAttr = /^(?::root|html|body)(?:\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\])?$/.exec(s);
|
||||
if (tagAttr) {
|
||||
const attrName = tagAttr[1];
|
||||
if (!attrName) return true;
|
||||
return GLOBAL_THEME_ATTRIBUTES.has(attrName.toLowerCase());
|
||||
}
|
||||
// Bare attribute selector restricted to known global-theme switches.
|
||||
const bareAttr = /^\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\]$/.exec(s);
|
||||
if (bareAttr && GLOBAL_THEME_ATTRIBUTES.has(bareAttr[1].toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ export interface ComposeInput {
|
||||
| undefined;
|
||||
designSystemBody?: string | undefined;
|
||||
designSystemTitle?: string | undefined;
|
||||
// Craft references the active skill opted into via `od.craft.requires`.
|
||||
// The daemon resolves the slug list to file contents and concatenates
|
||||
// them with section headers; we inject them between the DESIGN.md and
|
||||
// the skill body so brand tokens win on conflict but craft rules
|
||||
// (letter-spacing, accent caps, anti-slop) cover everything below.
|
||||
craftBody?: string | undefined;
|
||||
craftSections?: string[] | undefined;
|
||||
// Project-level metadata captured by the new-project panel. Drives the
|
||||
// agent's understanding of artifact kind, fidelity, speaker-notes intent
|
||||
// and animation intent. Missing fields here are exactly what the
|
||||
@@ -105,6 +112,8 @@ export function composeSystemPrompt({
|
||||
skillMode,
|
||||
designSystemBody,
|
||||
designSystemTitle,
|
||||
craftBody,
|
||||
craftSections,
|
||||
metadata,
|
||||
template,
|
||||
}: ComposeInput): string {
|
||||
@@ -124,6 +133,16 @@ export function composeSystemPrompt({
|
||||
);
|
||||
}
|
||||
|
||||
if (craftBody && craftBody.trim().length > 0) {
|
||||
const sectionLabel =
|
||||
Array.isArray(craftSections) && craftSections.length > 0
|
||||
? ` — ${craftSections.join(', ')}`
|
||||
: '';
|
||||
parts.push(
|
||||
`\n\n## Active craft references${sectionLabel}\n\nThe following craft rules are universal — they apply on top of the active design system above, regardless of brand. The DESIGN.md decides *which* tokens to use; craft rules decide *how* to use them. On any conflict between a craft rule and a brand DESIGN.md, the brand wins for token values; craft rules still apply to anything the brand does not override (letter-spacing, accent overuse caps, anti-slop patterns).\n\n${craftBody.trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (skillBody && skillBody.trim().length > 0) {
|
||||
const preflight = derivePreflight(skillBody);
|
||||
parts.push(
|
||||
|
||||
@@ -29,6 +29,7 @@ import { importClaudeDesignZip } from './claude-design-import.js';
|
||||
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
|
||||
import { buildDocumentPreview } from './document-preview.js';
|
||||
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
|
||||
import { loadCraftSections } from './craft.js';
|
||||
import { generateMedia } from './media.js';
|
||||
import {
|
||||
AUDIO_DURATIONS_SEC,
|
||||
@@ -180,6 +181,11 @@ const DESIGN_SYSTEMS_DIR = resolveDaemonResourceDir(
|
||||
'design-systems',
|
||||
path.join(PROJECT_ROOT, 'design-systems'),
|
||||
);
|
||||
const CRAFT_DIR = resolveDaemonResourceDir(
|
||||
DAEMON_RESOURCE_ROOT,
|
||||
'craft',
|
||||
path.join(PROJECT_ROOT, 'craft'),
|
||||
);
|
||||
const FRAMES_DIR = resolveDaemonResourceDir(
|
||||
DAEMON_RESOURCE_ROOT,
|
||||
'frames',
|
||||
@@ -1568,12 +1574,24 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
|
||||
let skillBody;
|
||||
let skillName;
|
||||
let skillMode;
|
||||
let skillCraftRequires = [];
|
||||
if (effectiveSkillId) {
|
||||
const skill = (await listSkills(SKILLS_DIR)).find((s) => s.id === effectiveSkillId);
|
||||
if (skill) {
|
||||
skillBody = skill.body;
|
||||
skillName = skill.name;
|
||||
skillMode = skill.mode;
|
||||
if (Array.isArray(skill.craftRequires)) skillCraftRequires = skill.craftRequires;
|
||||
}
|
||||
}
|
||||
|
||||
let craftBody;
|
||||
let craftSections;
|
||||
if (skillCraftRequires.length > 0) {
|
||||
const loaded = await loadCraftSections(CRAFT_DIR, skillCraftRequires);
|
||||
if (loaded.body) {
|
||||
craftBody = loaded.body;
|
||||
craftSections = loaded.sections;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,6 +1614,8 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
|
||||
skillMode,
|
||||
designSystemBody,
|
||||
designSystemTitle,
|
||||
craftBody,
|
||||
craftSections,
|
||||
metadata,
|
||||
template,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function listSkills(skillsRoot) {
|
||||
triggers: Array.isArray(data.triggers) ? data.triggers : [],
|
||||
mode,
|
||||
surface,
|
||||
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
|
||||
platform: normalizePlatform(
|
||||
data.od?.platform,
|
||||
mode,
|
||||
@@ -97,6 +98,27 @@ async function dirHasAttachments(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Craft sections live at <projectRoot>/craft/<name>.md. We accept any
|
||||
// alphanumeric+dash slug here so adding a new section is as simple as
|
||||
// dropping a file in craft/ and listing its name in the skill — no
|
||||
// daemon-side allowlist to keep in sync. The compose path checks the
|
||||
// file actually exists before injecting; missing files fall through
|
||||
// silently. The frontend can render the requested list verbatim.
|
||||
function normalizeCraftRequires(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const v of value) {
|
||||
if (typeof v !== "string") continue;
|
||||
const slug = v.trim().toLowerCase();
|
||||
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) continue;
|
||||
if (seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
out.push(slug);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeDefaultFor(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
|
||||
72
apps/daemon/tests/craft.test.ts
Normal file
72
apps/daemon/tests/craft.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadCraftSections } from '../src/craft.js';
|
||||
|
||||
let craftDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
craftDir = await mkdtemp(path.join(tmpdir(), 'od-craft-test-'));
|
||||
await writeFile(
|
||||
path.join(craftDir, 'typography.md'),
|
||||
'# typography\n\nALL CAPS ≥ 0.06em.\n',
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(craftDir, 'color.md'),
|
||||
'# color\n\nAccent ≤ 2 per screen.\n',
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(path.join(craftDir, 'empty.md'), ' \n\n', 'utf8');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (craftDir) await rm(craftDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('loadCraftSections', () => {
|
||||
it('returns empty when nothing requested', async () => {
|
||||
const r = await loadCraftSections(craftDir, []);
|
||||
expect(r.body).toBe('');
|
||||
expect(r.sections).toEqual([]);
|
||||
});
|
||||
|
||||
it('concatenates requested sections in order with section headers', async () => {
|
||||
const r = await loadCraftSections(craftDir, ['typography', 'color']);
|
||||
expect(r.sections).toEqual(['typography', 'color']);
|
||||
expect(r.body.startsWith('### typography')).toBe(true);
|
||||
expect(r.body.includes('### color')).toBe(true);
|
||||
expect(r.body.indexOf('### typography')).toBeLessThan(r.body.indexOf('### color'));
|
||||
});
|
||||
|
||||
it('drops missing files silently (forward-compatible)', async () => {
|
||||
const r = await loadCraftSections(craftDir, ['typography', 'motion', 'color']);
|
||||
expect(r.sections).toEqual(['typography', 'color']);
|
||||
});
|
||||
|
||||
it('drops empty files silently', async () => {
|
||||
const r = await loadCraftSections(craftDir, ['empty', 'typography']);
|
||||
expect(r.sections).toEqual(['typography']);
|
||||
});
|
||||
|
||||
it('rejects bogus slugs (path traversal, special chars)', async () => {
|
||||
const r = await loadCraftSections(craftDir, [
|
||||
'../etc/passwd',
|
||||
'typo/graphy',
|
||||
'typography',
|
||||
]);
|
||||
expect(r.sections).toEqual(['typography']);
|
||||
});
|
||||
|
||||
it('dedupes repeated requests', async () => {
|
||||
const r = await loadCraftSections(craftDir, [
|
||||
'typography',
|
||||
'TYPOGRAPHY',
|
||||
'typography',
|
||||
]);
|
||||
expect(r.sections).toEqual(['typography']);
|
||||
});
|
||||
});
|
||||
1226
apps/daemon/tests/lint-artifact.test.ts
Normal file
1226
apps/daemon/tests/lint-artifact.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
62
craft/README.md
Normal file
62
craft/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Craft references
|
||||
|
||||
Brand-agnostic craft knowledge. Each file is a small, dense rulebook on one
|
||||
dimension of professional UI craft (typography, color, motion, …). Skills
|
||||
opt into the references they need; the daemon injects only the requested
|
||||
ones into the system prompt above the active skill body.
|
||||
|
||||
## Why a third axis next to `skills/` and `design-systems/`
|
||||
|
||||
| Axis | Scope | Example |
|
||||
|---|---|---|
|
||||
| `skills/` | Artifact shape | `saas-landing`, `dashboard`, `pricing-page` |
|
||||
| `design-systems/` | Brand visual language (the 9-section `DESIGN.md`) | `linear-app`, `apple`, `notion` |
|
||||
| `craft/` | **Universal** craft knowledge — true regardless of brand | letter-spacing rules, accent-overuse caps, anti-AI-slop |
|
||||
|
||||
`DESIGN.md` tells the agent which colors and fonts a brand uses. `craft/`
|
||||
tells the agent the universal rules a competent designer applies on top —
|
||||
e.g. ALL CAPS always needs ≥0.06em tracking, regardless of the brand.
|
||||
|
||||
## How a skill opts in
|
||||
|
||||
Add an `od.craft.requires` array to the skill's front-matter. Only the
|
||||
listed sections are injected, so a skill that needs only typography pays
|
||||
no token cost for color/motion content.
|
||||
|
||||
```yaml
|
||||
od:
|
||||
craft:
|
||||
requires: [typography, color, anti-ai-slop]
|
||||
```
|
||||
|
||||
Allowed values match the file names in this directory minus the `.md`
|
||||
extension. Unknown values are silently ignored (forward-compatible).
|
||||
|
||||
### Why silent fallback instead of fail-fast?
|
||||
|
||||
A skeptical reader will ask: "If a skill requests `motion` and we don't
|
||||
ship `motion.md` yet, shouldn't we warn the user?" We chose
|
||||
forward-compatibility over fail-fast: a skill authored today can list
|
||||
`motion` and start benefiting the moment we vendor `craft/motion.md` in
|
||||
a follow-up PR, with no skill edit needed. The cost of a missed
|
||||
reference is a missing paragraph in the system prompt, not a broken
|
||||
skill — so the loud failure mode is not worth the friction.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Section name | When to require |
|
||||
|---|---|---|
|
||||
| `typography.md` | `typography` | Any skill that emits typed content (~all skills) |
|
||||
| `color.md` | `color` | Any skill that emits styled output (~all skills) |
|
||||
| `anti-ai-slop.md` | `anti-ai-slop` | Marketing pages, landing pages, decks |
|
||||
|
||||
More sections (`motion`, `icons`, `craft-details`) will be added in
|
||||
follow-up PRs as we wire the linter side.
|
||||
|
||||
## Attribution
|
||||
|
||||
Craft content is adapted from the MIT-licensed
|
||||
[refero_skill](https://github.com/referodesign/refero_skill) project
|
||||
(© Refero Design), with edits to fit Open Design's house style and link
|
||||
back to OD's design tokens (`var(--accent)` etc.) instead of generic
|
||||
Tailwind hex values.
|
||||
84
craft/anti-ai-slop.md
Normal file
84
craft/anti-ai-slop.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Anti-AI-slop rules
|
||||
|
||||
Concrete, checkable rules that distinguish "designed by a human who has
|
||||
shipped product" from "default LLM output." Several rules below are
|
||||
auto-enforced by the daemon's `lint-artifact` linter — failing an
|
||||
enforced rule is not a style preference, it is a regression. The
|
||||
rest are guidance for agents and reviewers and are flagged inline as
|
||||
"(guidance, not auto-checked)" so the contract with the linter stays
|
||||
honest.
|
||||
|
||||
> Adapted from [refero_skill](https://github.com/referodesign/refero_skill)
|
||||
> (MIT), tightened to match Open Design's lint surface.
|
||||
|
||||
## The seven cardinal sins
|
||||
|
||||
These are the patterns the linter blocks at P0 (must-fix):
|
||||
|
||||
1. **Default Tailwind indigo as accent** — exactly `#6366f1`, `#4f46e5`,
|
||||
`#4338ca`, `#3730a3`, `#8b5cf6`, `#7c3aed`, `#a855f7`. The active
|
||||
`DESIGN.md` provides `--accent`; use it. Indigo is the textbook AI
|
||||
tell. (The daemon's `lint-artifact` flags any of these as a solid
|
||||
accent; keep this list in sync with `AI_DEFAULT_INDIGO` in
|
||||
`apps/daemon/src/lint-artifact.ts`.)
|
||||
2. **Two-stop "trust" gradient on the hero** — purple→blue, blue→cyan,
|
||||
indigo→pink. A flat surface + intentional type beats this every
|
||||
time.
|
||||
3. **Emoji as feature icons** — `✨`, `🚀`, `🎯`, `⚡`, `🔥`, `💡`
|
||||
inside `<h*>`, `<button>`, `<li>`, or `class*="icon"`. Use
|
||||
1.6–1.8px-stroke monoline SVG with `currentColor`.
|
||||
4. **Sans-serif on display text when the seed binds a serif** — h1/h2
|
||||
must use `var(--font-display)`, not a hardcoded Inter / Roboto /
|
||||
`system-ui`.
|
||||
5. **Rounded card with a colored left-border accent** — the canonical
|
||||
"AI dashboard tile" shape. Drop either the radius or the left
|
||||
border.
|
||||
6. **Invented metrics** — "10× faster", "99.9% uptime", "3× more
|
||||
productive". Either pull from a real source or use a labelled
|
||||
placeholder.
|
||||
7. **Filler copy** — `lorem ipsum`, `feature one / two / three`,
|
||||
`placeholder text`, `sample content`. An empty section is a design
|
||||
problem to solve with composition, not by inventing words.
|
||||
|
||||
## Soft tells (P1 — should fix)
|
||||
|
||||
- **Standard "Hero → Features → Pricing → FAQ → CTA" sequence with no
|
||||
variation** *(guidance, not auto-checked)*. This is the AI-template
|
||||
skeleton; introduce at least one unconventional section (testimonial
|
||||
wall as full-bleed quote, pricing as comparison-against-status-quo,
|
||||
an inline mini-product-demo).
|
||||
- **External placeholder image CDNs** (`unsplash.com`, `placehold.co`,
|
||||
`placekitten.com`, `picsum.photos`). Fragile and obvious. Use the
|
||||
shipped `.ph-img` placeholder class.
|
||||
- **More than ~12 raw hex values outside `:root`.** Tokens were not
|
||||
honoured.
|
||||
- **`var(--accent)` used 6+ times in the rendered body.** Cap at 2
|
||||
visible uses per screen.
|
||||
|
||||
## Polish tells (P2 — nice to fix)
|
||||
|
||||
- **Sections without `data-od-id`** — comment mode can't target them.
|
||||
- **Decorative blob / wave SVG backgrounds** *(guidance, not
|
||||
auto-checked)* — meaningless geometry.
|
||||
- **Perfect symmetric layout with no visual tension** *(guidance, not
|
||||
auto-checked)* — alternating density (one tight section, one
|
||||
breathing section) reads as intentional.
|
||||
|
||||
## How to add soul without breaking the rules
|
||||
|
||||
Aim for **~80% proven patterns + ~20% distinctive choice**. The 20%
|
||||
should live in:
|
||||
|
||||
- One bold visual move — a typography choice, a single color decision,
|
||||
an unexpected proportion.
|
||||
- Voice and microcopy — a button that says "Start tracking" beats one
|
||||
that says "Get started".
|
||||
- One micro-interaction the user will remember — a button press that
|
||||
moves 2px, a number that counts up.
|
||||
- One detail that could only have been put there by someone who used
|
||||
the product (a subtle kbd shortcut hint, a status badge with
|
||||
product-specific phrasing).
|
||||
|
||||
If a reviewer screenshots the artifact and someone outside the project
|
||||
can identify which product it's from — you have soul. If not, you
|
||||
shipped a template.
|
||||
88
craft/color.md
Normal file
88
craft/color.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Color craft rules
|
||||
|
||||
Universal color rules applied on top of the active `DESIGN.md`. The
|
||||
design system supplies the palette tokens; this file enforces how to
|
||||
*use* them.
|
||||
|
||||
> Adapted from [refero_skill](https://github.com/referodesign/refero_skill)
|
||||
> (MIT). All examples reference Open Design's standard tokens
|
||||
> (`--bg`, `--surface`, `--fg`, `--muted`, `--border`, `--accent`).
|
||||
|
||||
## Palette structure
|
||||
|
||||
A coherent palette has four layers. Plan all four before writing any CSS.
|
||||
|
||||
| Layer | Share of pixels | Tokens |
|
||||
|---|---|---|
|
||||
| **Neutrals** | 70–90% | `--bg`, `--surface`, `--fg`, `--muted`, `--border` |
|
||||
| **Accent** (one) | 5–10% | `--accent` only — never invent a second accent |
|
||||
| **Semantic** | 0–5% | `--success`, `--warn`, `--danger` |
|
||||
| **Effect** | <1% | gradients, glows; rarely justified |
|
||||
|
||||
## Accent discipline
|
||||
|
||||
The single biggest readability failure in AI-generated UIs is accent
|
||||
overuse. Hard caps:
|
||||
|
||||
- **At most 2 visible uses of `--accent` per screen.** Typical pair:
|
||||
one eyebrow / chip + one primary CTA. Or one accent card + one tab
|
||||
pill. Pick a pair, not a flood.
|
||||
- Links count as accent; demote to `--fg` underline if you also have a
|
||||
CTA on the same screen.
|
||||
- Hover/focus rings count as accent. Ration accordingly.
|
||||
|
||||
## Contrast minimums
|
||||
|
||||
Run these as gates, not goals:
|
||||
|
||||
| Pair | Minimum |
|
||||
|---|---|
|
||||
| Body text (≤16 px) on background | **4.5:1** |
|
||||
| Large text (>18 px or 14 px bold) | **3:1** |
|
||||
| UI components against adjacent surfaces | **3:1** |
|
||||
|
||||
When the brand color clashes (low-contrast indigo on light background is
|
||||
common), darken the accent to a `600`-level shade for text use; reserve
|
||||
the brand-bright variant for fills only.
|
||||
|
||||
## Dark themes
|
||||
|
||||
Avoid pure black and pure white — both cause vibration and eye strain.
|
||||
|
||||
| Token | Dark theme | Light theme |
|
||||
|---|---|---|
|
||||
| Background | `#0f0f0f` (not `#000`) | `#fafafa` (not `#fff`) |
|
||||
| Foreground | `#f0f0f0` (not `#fff`) | `#111111` (not `#000`) |
|
||||
|
||||
On dark surfaces, prefer **semi-transparent white borders** over solid
|
||||
dark borders — a 1px `rgba(255,255,255,0.08)` reads as structure
|
||||
without adding visual noise.
|
||||
|
||||
## Semantic color naming
|
||||
|
||||
Always name tokens by **purpose**, never by hue:
|
||||
|
||||
```css
|
||||
/* good */
|
||||
--accent: #2f6feb;
|
||||
--success: #17a34a;
|
||||
|
||||
/* bad — locks you out of theming */
|
||||
--blue-500: #2f6feb;
|
||||
--green-500: #17a34a;
|
||||
```
|
||||
|
||||
## Anti-defaults
|
||||
|
||||
- **Indigo `#6366f1`** (Tailwind `indigo-500`) is the most reliable
|
||||
AI-slop tell. The active `DESIGN.md` provides `--accent`; use it. If
|
||||
the brief truly needs indigo, make the user say so explicitly. If
|
||||
your `DESIGN.md` encodes indigo as `--accent`, that is intentional —
|
||||
the linter only flags hardcoded hex, so `var(--accent)` uses are
|
||||
unaffected even when the resolved color happens to be `#6366f1`.
|
||||
- **Two-stop "trust" gradient** (purple → blue, blue → cyan, etc.) on a
|
||||
hero is the second most reliable tell. A flat surface + one
|
||||
type-driven hierarchy beats it every time.
|
||||
- **Decorative gradients with no functional purpose**. Gradients should
|
||||
separate hierarchies (header → body, primary CTA → secondary), not
|
||||
decorate empty space.
|
||||
88
craft/typography.md
Normal file
88
craft/typography.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Typography craft rules
|
||||
|
||||
Universal typography rules that apply on top of any `DESIGN.md`. The
|
||||
active design system decides *which* fonts; this file decides *how* they
|
||||
behave at every size.
|
||||
|
||||
> Adapted from [refero_skill](https://github.com/referodesign/refero_skill)
|
||||
> (MIT) — distilled and re-tuned for Open Design's token system.
|
||||
|
||||
## Type scale
|
||||
|
||||
Use a multiplicative scale (1.2 or 1.25). Cap at 6–8 sizes per artifact.
|
||||
|
||||
| Role | Range |
|
||||
|---|---|
|
||||
| Display | 48–72 px |
|
||||
| H1 | 32–48 px |
|
||||
| H2 | 24–32 px |
|
||||
| H3 | 20–24 px |
|
||||
| Body | 15–18 px |
|
||||
| Small | 13–14 px |
|
||||
| Caption | 11–12 px |
|
||||
|
||||
## Line height (leading)
|
||||
|
||||
| Text size | Line height |
|
||||
|---|---|
|
||||
| Display / H1 (≥32 px) | `1.0`–`1.2` (tight) |
|
||||
| Body (15–18 px) | `1.5`–`1.6` |
|
||||
| Small (≤14 px) | `1.5` |
|
||||
|
||||
## Letter-spacing — the rule that makes or breaks craft
|
||||
|
||||
This is the single most-skipped rule in AI-generated design. **No
|
||||
exceptions.**
|
||||
|
||||
| Context | Letter-spacing |
|
||||
|---|---|
|
||||
| Body text (14–18 px) | `0` (default) |
|
||||
| Small text (11–13 px) | `0.01em` to `0.02em` (positive) |
|
||||
| UI labels and button text | `0.02em` |
|
||||
| **ALL CAPS** | **`0.06em` to `0.1em` (required)** |
|
||||
| Headings 32 px+ | `-0.01em` to `-0.02em` |
|
||||
| Display 48 px+ | `-0.02em` to `-0.03em` |
|
||||
|
||||
ALL CAPS without positive tracking looks cramped and amateur. Display
|
||||
text without negative tracking looks loose and weak. These two failures
|
||||
are the most reliable AI-slop tells.
|
||||
|
||||
The `0.06em` floor is not arbitrary: it is the empirical lower bound
|
||||
that print and web typographers have converged on for uppercase
|
||||
tracking (cf. Bringhurst's *Elements of Typographic Style* §3.2.7,
|
||||
which recommends 5–10% of the em for caps; modern screen practice
|
||||
rounds the lower end to 0.06em). Anything tighter and the counters
|
||||
collide on screen; the upper bound `0.1em` keeps the word from
|
||||
disintegrating into letters.
|
||||
|
||||
## Font pairing
|
||||
|
||||
- Maximum 2 typefaces per artifact (display + body, or one variable face
|
||||
used at multiple weights).
|
||||
- Always declare a system fallback chain. If the active `DESIGN.md`
|
||||
ships a webfont URL, the fallback must still produce a coherent look.
|
||||
- Never set `font-family: system-ui` alone on a heading — that is the
|
||||
textbook AI default; always pair it with an intentional first choice.
|
||||
|
||||
## Line length
|
||||
|
||||
Limit body copy to **50–75 characters** per line. In CSS:
|
||||
`max-width: 65ch` is a safe default.
|
||||
|
||||
## Three-weight system
|
||||
|
||||
Most well-crafted UIs use exactly 3 weights:
|
||||
- **Read** (400 / 450) — body copy
|
||||
- **Emphasize** (510 / 550) — UI text, labels, navigation
|
||||
- **Announce** (590 / 600) — headlines, buttons
|
||||
|
||||
Weight 700+ is rarely needed. If your design uses bold for "emphasis on
|
||||
emphasis," it likely lacks weight discipline elsewhere.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
- ALL CAPS without `letter-spacing` ≥ `0.06em`.
|
||||
- Display text (≥32 px) without negative tracking.
|
||||
- More than 3 type sizes visible above the fold.
|
||||
- Mixed serif and slab on the same screen without a clear role split.
|
||||
- Body copy in `text-align: justify` (creates rivers; never use on the web).
|
||||
@@ -63,6 +63,8 @@ od:
|
||||
design_system:
|
||||
requires: true # this skill reads the active DESIGN.md
|
||||
sections: [color, typography] # which sections it actually uses (for prompt pruning)
|
||||
craft: # universal, brand-agnostic craft references
|
||||
requires: [typography, color, anti-ai-slop]
|
||||
inputs: # typed inputs the user can fill in the UI
|
||||
- name: title
|
||||
type: string
|
||||
@@ -102,6 +104,7 @@ od:
|
||||
| `od.preview.type` | picking the right iframe renderer |
|
||||
| `od.design_system.requires` | whether to inject `DESIGN.md` |
|
||||
| `od.design_system.sections` | pruning the injected DESIGN.md to relevant sections only (token savings) |
|
||||
| `od.craft.requires` | which brand-agnostic `craft/<slug>.md` references to inject (e.g. `typography`, `color`, `anti-ai-slop`); injected between DESIGN.md and the skill body |
|
||||
| `od.inputs` | rendering a typed form in the sidebar instead of only free-text |
|
||||
| `od.parameters` | rendering live sliders that re-prompt on change |
|
||||
| `od.outputs.primary` | which file the iframe loads |
|
||||
@@ -209,6 +212,36 @@ The 9-section DESIGN.md format is **not invented by OD**; it's the [awesome-clau
|
||||
|
||||
Full schema and examples: [`schemas/design-system.md`](schemas/design-system.md) and [`examples/DESIGN.sample.md`](examples/DESIGN.sample.md) (TODO).
|
||||
|
||||
## 5.5 Craft references (`craft/`)
|
||||
|
||||
Some craft knowledge is **universal** — true regardless of brand. ALL CAPS always needs ≥0.06em letter-spacing; `var(--accent)` should appear at most 2 times per screen; `#6366f1` is always the AI-default tell. These rules don't belong in any one `DESIGN.md` because they apply across every brand.
|
||||
|
||||
OD ships these as a third axis at `<projectRoot>/craft/`:
|
||||
|
||||
```
|
||||
craft/
|
||||
├── README.md
|
||||
├── typography.md
|
||||
├── color.md
|
||||
└── anti-ai-slop.md
|
||||
```
|
||||
|
||||
A skill opts in by listing the slugs it needs:
|
||||
|
||||
```yaml
|
||||
od:
|
||||
craft:
|
||||
requires: [typography, color, anti-ai-slop]
|
||||
```
|
||||
|
||||
Resolution at compose time:
|
||||
|
||||
1. `apps/daemon/src/skills.ts` reads `od.craft.requires` from front-matter and surfaces it on the skill record.
|
||||
2. `apps/daemon/src/craft.ts` reads each `<slug>.md` from `CRAFT_DIR`. Missing files are dropped silently — a skill can forward-reference `craft/motion.md` before we ship it. See [`craft/README.md`](../craft/README.md) for the canonical slug list and the rationale behind the silent-fallback choice.
|
||||
3. `apps/daemon/src/prompts/system.ts` injects the concatenated craft body **between** the active DESIGN.md and the skill body. Brand tokens in DESIGN.md win on conflict; craft rules cover everything DESIGN.md does not override.
|
||||
|
||||
The split keeps DESIGN.md authors free of universal-craft duplication and keeps craft authors free of brand-specific drift.
|
||||
|
||||
## 6. Skill installation
|
||||
|
||||
```sh
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface SkillSummary {
|
||||
fidelity?: 'wireframe' | 'high-fidelity' | null;
|
||||
speakerNotes?: boolean | null;
|
||||
animations?: boolean | null;
|
||||
craftRequires?: string[];
|
||||
hasBody: boolean;
|
||||
examplePrompt: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ od:
|
||||
design_system:
|
||||
requires: true
|
||||
sections: [color, typography, layout, components]
|
||||
craft:
|
||||
requires: [typography, color, anti-ai-slop]
|
||||
inputs:
|
||||
- name: product_name
|
||||
type: string
|
||||
|
||||
@@ -269,6 +269,9 @@ async function copyResourceTree(config: ToolPackConfig, paths: MacPaths): Promis
|
||||
await cp(join(config.workspaceRoot, "design-systems"), join(paths.resourceRoot, "design-systems"), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(join(config.workspaceRoot, "craft"), join(paths.resourceRoot, "craft"), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(join(config.workspaceRoot, "assets", "frames"), join(paths.resourceRoot, "frames"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user