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:
Tom Huang
2026-05-02 11:00:33 +08:00
committed by GitHub
parent bc16a2d5b8
commit 1edab990bb
16 changed files with 2379 additions and 0 deletions

View File

@@ -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
View 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 };
}

View File

@@ -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 500900 + 400/300/200.
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
'#60a5fa', '#93c5fd', '#bfdbfe',
// Tailwind sky 400700 — the same blue→cyan ramp under a different name.
'#0ea5e9', '#0284c7', '#0369a1', '#38bdf8', '#7dd3fc',
];
const TRUST_GRADIENT_CYAN_HEXES = [
// Tailwind cyan 500900 + 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;
}

View File

@@ -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(

View File

@@ -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,
});

View File

@@ -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);

View 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']);
});
});

File diff suppressed because it is too large Load Diff

62
craft/README.md Normal file
View 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
View 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.61.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
View 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** | 7090% | `--bg`, `--surface`, `--fg`, `--muted`, `--border` |
| **Accent** (one) | 510% | `--accent` only — never invent a second accent |
| **Semantic** | 05% | `--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
View 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 68 sizes per artifact.
| Role | Range |
|---|---|
| Display | 4872 px |
| H1 | 3248 px |
| H2 | 2432 px |
| H3 | 2024 px |
| Body | 1518 px |
| Small | 1314 px |
| Caption | 1112 px |
## Line height (leading)
| Text size | Line height |
|---|---|
| Display / H1 (≥32 px) | `1.0``1.2` (tight) |
| Body (1518 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 (1418 px) | `0` (default) |
| Small text (1113 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 510% 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 **5075 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).

View File

@@ -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

View File

@@ -42,6 +42,7 @@ export interface SkillSummary {
fidelity?: 'wireframe' | 'high-fidelity' | null;
speakerNotes?: boolean | null;
animations?: boolean | null;
craftRequires?: string[];
hasBody: boolean;
examplePrompt: string;
}

View File

@@ -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

View File

@@ -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,
});