AI CSS Animations

design-enforce

96 lines

Installs a lint script that works in any framework and flags one-off bracket values, hex codes, off-scale spacing and font sizes, and Tailwind colors used instead of named roles. Builds its config from the project's own tokens, records a baseline so CI only fails when things get worse, and can check shadcn/ui components. Use as phase 5 of design-system, after design-apply.

skills/design-enforce/SKILL.md

Design Enforce

Install a lint script that flags any value outside the design system, using the project's own tokens as its rules.

Why a script and not an ESLint rule

ESLint rules read JavaScript and JSX. They can't see Svelte markup, Vue templates, Astro files or plain HTML. assets/design-lint.mjs reads files as plain text, so it works the same in every framework and needs no plugins.

Install

mkdir -p .design scripts
cp <skill>/assets/design-lint.mjs scripts/design-lint.mjs

Add it to package.json:

{ "scripts": { "design-lint": "node scripts/design-lint.mjs" } }

Config

Write .design/lint.config.json using the values in DESIGN.md, not the defaults built into the script.

{
  "include": ["src"],
  "extensions": [".svelte", ".tsx", ".jsx", ".vue", ".astro", ".html"],
  "spacingScale": ["0", "1", "2", "3", "4", "6", "8", "12", "16"],
  "textScale": ["xs", "sm", "base", "lg", "xl", "2xl", "3xl"],
  "radiusScale": ["none", "sm", "md", "lg", "full"],
  "semanticColors": ["background", "foreground", "primary", "muted", "accent", "card", "destructive", "border"],
  "allowRawPalette": false,
  "exceptions": []
}

spacingScale and textScale must match DESIGN.md exactly. If they don't, the linter enforces a different system from the one the docs describe.

Rules

Rule Level What it flags
arbitrary-value error one-off bracket values like p-[13px] or w-[327px]
raw-hex error hex codes like #3b82f6 in markup
off-scale-spacing error spacing like p-7 when 7 isn't one of the spacing sizes
off-scale-text error font sizes like text-7xl that aren't in the type scale
raw-palette warning Tailwind colors like text-neutral-700 used instead of a named role
off-scale-radius warning one-off corner radius like rounded-[3px]

Errors make the script exit with a failure. Warnings don't.

Keep raw-palette as a warning while the code is being migrated, and change it to an error after batch 3 of design-apply is done. Tell the user this is the plan when you install it.

Baseline

On an existing codebase, the first run usually finds hundreds or thousands of problems.

Save that number as the baseline in .design/lint.config.json, and set CI to fail only when the count goes above it. Don't make CI fail on every finding. A check that can never pass gets switched off, and a switched-off check looks like it's working while catching nothing.

Lower the baseline as each design-apply batch lands.

Exceptions

Put // design-lint-disable on a line to skip it, and always include a reason in the comment. Without a reason, nobody can tell later whether the exception is still needed.

shadcn/ui components

Do this only if components.json exists.

shadcn/ui copies component source files into the repo instead of installing them as a package. Over time they drift away from the originals and from each other. And a problem inside a shared component spreads: a Tailwind color hard-coded in ui/button ends up on every button in the product.

Follow the four steps in references/shadcn-alignment.md: fix components.json, check the files in ui/ for hard-coded values and local edits, replace hand-built components that duplicate shadcn ones, and treat findings inside ui/ as errors.

Optional extras

Offer these, but don't install them without asking.

A pre-commit hook:

echo 'npm run design-lint --quiet' >> .husky/pre-commit

A CI step that runs npm run design-lint.

Report

Give the number of findings per rule, the three files with the most findings, and the baseline. Say which rules currently fail the build and which only warn.

Reference references/shadcn-alignment.md 81 lines

shadcn/ui Alignment

shadcn/ui copies each component's source into the repo, where it gets edited like any other file. Over time the copies drift away from the originals and from each other. Anything inside ui/ affects every place that component is used.

Only do this if components.json exists.

1. Fix components.json

cat components.json

Check each field against the project:

Field Should be
tailwind.config the real path to the Tailwind config, or an empty string on v4
tailwind.css the CSS file the tokens were written to
tailwind.baseColor the gray the tokens use
tailwind.cssVariables true
aliases.* paths that actually exist
rsc / tsx settings that match the framework

Fix cssVariables first if it's false. While it's false, every component you add is generated with hard-coded Tailwind colors instead of your tokens, which skips the design system completely.

2. Check the installed components

ls src/lib/components/ui 2>/dev/null || ls components/ui 2>/dev/null

Check three things.

Hard-coded values. Components in ui/ should only use named roles, never raw colors or sizes.

npm run design-lint -- --json | python3 -c "import json,sys; d=json.load(sys.stdin); [print(f['file'], f['line'], f['msg']) for f in d['findings'] if '/ui/' in f['file']]"

Local edits. Compare each component with a fresh copy from shadcn:

npx shadcn@latest add button --path /tmp/shadcn-ref --yes 2>/dev/null
diff -u /tmp/shadcn-ref/button.tsx src/lib/components/ui/button/button.svelte 2>/dev/null

Sort each difference into one of two groups: a deliberate change the project wants, which gets written into DESIGN.md, or an undocumented edit, which gets changed back to use the token.

Variants. Read each component's cva or tv definition (the list of variants like default, ghost or sm). Every variant should match a decision from round 4 (components) of design-grill. An extra size variant nobody decided on is really an extra spacing value.

3. Replace hand-built duplicates

Find components someone built by hand that do the same job as an installed shadcn component:

grep -rln "class=.*rounded.*px-.*py-" src --include="*.svelte" --include="*.tsx" \
  | grep -v "/ui/" | head -20

Replace them with the shadcn component. Don't just convert their values to tokens.

4. Treat problems in ui/ as errors

Add this to .design/lint.config.json:

{
  "shadcn": {
    "uiPath": "src/lib/components/ui",
    "requireCssVariables": true,
    "allowedVariants": { "button": ["default", "secondary", "ghost", "destructive", "outline", "link"] }
  }
}

Treat findings inside ui/ as errors, even for rules that are only warnings elsewhere.

Report

List the components.json fields you fixed, the components you checked, hard-coded values found in ui/, local edits split into deliberate and undocumented, variants nobody decided on, and duplicate components you replaced. Say which edits you reverted and which you wrote into DESIGN.md.

Asset assets/design-lint.mjs 132 lines

Show source
#!/usr/bin/env node
/**
 * design-lint: drift checker for token-based design systems.
 * Framework-agnostic: scans template text, so Svelte/Vue/JSX/Astro all work.
 *
 *   node design-lint.mjs [--fix-hints] [--json] [--quiet]
 *
 * Config: .design/lint.config.json (written by the design-enforce skill).
 */
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, extname } from "node:path";

const ROOT = process.cwd();
const CFG = ".design/lint.config.json";
const args = new Set(process.argv.slice(2));

const cfg = existsSync(CFG)
  ? JSON.parse(readFileSync(CFG, "utf8"))
  : {
      include: ["src"],
      extensions: [".svelte", ".tsx", ".jsx", ".vue", ".astro", ".html"],
      spacingScale: ["0","0.5","1","1.5","2","2.5","3","4","5","6","8","10","12","16","20","24"],
      textScale: ["xs","sm","base","lg","xl","2xl","3xl","4xl","5xl"],
      radiusScale: ["none","sm","md","lg","xl","2xl","full"],
      semanticColors: ["background","foreground","primary","secondary","muted","accent","card","popover","destructive","border","input","ring"],
      allowRawPalette: false,
      ignore: ["node_modules",".svelte-kit","build","dist",".git"],
      exceptions: [],
    };

const SPACE_PREFIX = "(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|gap-x|gap-y|space-x|space-y)";
const PALETTE = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";

const RULES = [
  { id: "arbitrary-value", sev: "error",
    re: new RegExp(`\\b(?:${SPACE_PREFIX}|w|h|text|rounded|top|left|right|bottom|z|max-w|min-h)-\\[[^\\]]+\\]`, "g"),
    msg: (m) => `arbitrary value \`${m}\`. No token covers this, fix the scale or use one`,
  },
  { id: "raw-hex", sev: "error",
    re: /#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g,
    msg: (m) => `raw hex \`${m}\`. Use a semantic color token`,
    skip: (f) => /\.(css|svg)$/.test(f),
  },
  { id: "raw-palette", sev: "warn",
    re: new RegExp(`\\b(?:bg|text|border|ring|from|to|via|divide)-(?:${PALETTE})-\\d{2,3}\\b`, "g"),
    msg: (m) => `raw palette \`${m}\`. Use a semantic role instead`,
    enabled: () => !cfg.allowRawPalette,
  },
  { id: "off-scale-spacing", sev: "error",
    re: new RegExp(`\\b${SPACE_PREFIX}-(\\d+(?:\\.\\d+)?)\\b`, "g"),
    check: (m, g) => !cfg.spacingScale.includes(g[1]),
    msg: (m) => `\`${m}\` is off the spacing scale`,
  },
  { id: "off-scale-text", sev: "error",
    re: /\btext-([a-z0-9]+)\b/g,
    check: (m, g) => {
      const v = g[1];
      if (cfg.semanticColors.includes(v)) return false;
      if (new RegExp(`^(?:${PALETTE})$`).test(v)) return false;
      // non-size `text-*` utilities: alignment, wrapping, overflow, and bare colors
      if (["left","center","right","justify","start","end","wrap","nowrap","balance","pretty",
           "ellipsis","clip","transparent","current","inherit","white","black"].includes(v)) return false;
      return !cfg.textScale.includes(v);
    },
    msg: (m) => `\`${m}\` is off the type scale`,
  },
  { id: "off-scale-radius", sev: "warn",
    re: /\brounded-([a-z0-9]+)\b/g,
    check: (m, g) => !cfg.radiusScale.includes(g[1]) && !/^(t|b|l|r|tl|tr|bl|br|s|e)$/.test(g[1]),
    msg: (m) => `\`${m}\` is off the radius scale`,
  },
];

function walk(dir, out = []) {
  let entries;
  try { entries = readdirSync(dir); } catch { return out; }
  for (const e of entries) {
    if (cfg.ignore.includes(e)) continue;
    const p = join(dir, e);
    let st; try { st = statSync(p); } catch { continue; }
    if (st.isDirectory()) walk(p, out);
    else if (cfg.extensions.includes(extname(p))) out.push(p);
  }
  return out;
}

const files = cfg.include.flatMap((d) => walk(join(ROOT, d)));
const findings = [];

for (const file of files) {
  const rel = relative(ROOT, file);
  if (cfg.exceptions.some((x) => rel.startsWith(x))) continue;
  const lines = readFileSync(file, "utf8").split("\n");

  lines.forEach((line, i) => {
    if (/design-lint-disable/.test(line)) return;
    for (const rule of RULES) {
      if (rule.enabled && !rule.enabled()) continue;
      if (rule.skip && rule.skip(rel)) continue;
      rule.re.lastIndex = 0;
      let m;
      while ((m = rule.re.exec(line)) !== null) {
        if (rule.check && !rule.check(m[0], m)) continue;
        findings.push({ file: rel, line: i + 1, rule: rule.id, sev: rule.sev, msg: rule.msg(m[0]) });
      }
    }
  });
}

if (args.has("--json")) {
  console.log(JSON.stringify({ findings, scanned: files.length }, null, 2));
} else if (!args.has("--quiet")) {
  const byFile = new Map();
  for (const f of findings) (byFile.get(f.file) ?? byFile.set(f.file, []).get(f.file)).push(f);
  for (const [file, fs] of [...byFile].sort((a, b) => b[1].length - a[1].length)) {
    console.log(`\n\x1b[1m${file}\x1b[0m  \x1b[2m${fs.length}\x1b[0m`);
    for (const f of fs.slice(0, 20)) {
      const c = f.sev === "error" ? "\x1b[31m" : "\x1b[33m";
      console.log(`  ${c}${f.sev}\x1b[0m  ${String(f.line).padStart(4)}  ${f.msg}`);
    }
    if (fs.length > 20) console.log(`  \x1b[2m… ${fs.length - 20} more\x1b[0m`);
  }
  const errs = findings.filter((f) => f.sev === "error").length;
  const warns = findings.length - errs;
  console.log(`\n\x1b[1m${files.length}\x1b[0m files · \x1b[31m${errs} errors\x1b[0m · \x1b[33m${warns} warnings\x1b[0m`);
  const counts = {};
  for (const f of findings) counts[f.rule] = (counts[f.rule] ?? 0) + 1;
  for (const [r, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) console.log(`  ${String(n).padStart(5)}  ${r}`);
}

process.exit(findings.some((f) => f.sev === "error") ? 1 : 0);