@fracazo/design-system 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/DESIGN.md +366 -8
  2. package/README.md +77 -11
  3. package/css/motion.css +155 -0
  4. package/css/roles.css +3 -0
  5. package/dist/guardrails/eslint.d.ts +72 -5
  6. package/dist/guardrails/eslint.js +197 -29
  7. package/dist/guardrails/init.d.ts +2 -0
  8. package/dist/guardrails/init.js +65 -0
  9. package/dist/guardrails/intake.d.ts +2 -0
  10. package/dist/guardrails/intake.js +131 -0
  11. package/package.json +8 -3
  12. package/skills/product-design/SKILL.md +142 -0
  13. package/skills/product-design/coverage-gaps.md +41 -0
  14. package/skills/product-design/exemplars/calm-the-offering-cards.md +33 -0
  15. package/skills/product-design/exemplars/clamp-drift-to-named-roles.md +37 -0
  16. package/skills/product-design/exemplars/concentric-radii-and-button-optics.md +36 -0
  17. package/skills/product-design/exemplars/dialog-close-focus-visible.md +36 -0
  18. package/skills/product-design/exemplars/hero-glow-seam.md +34 -0
  19. package/skills/product-design/intake/2026-09-07.md +413 -0
  20. package/skills/product-design/references/components.md +42 -0
  21. package/skills/product-design/references/copy.md +25 -0
  22. package/skills/product-design/references/intake.md +66 -0
  23. package/skills/product-design/references/motion.md +22 -0
  24. package/skills/product-design/references/rules.md +319 -0
  25. package/skills/product-design/references/surfaces.md +50 -0
  26. package/skills/product-design/references/tokens.md +54 -0
  27. package/skills/product-design/references/type-and-space.md +42 -0
  28. package/skills/product-design/references/verification.md +35 -0
  29. package/template/CLAUDE.md +47 -0
  30. package/template/README.md +16 -0
  31. package/template/eslint.config.mjs +20 -0
  32. package/template/gitignore +44 -0
  33. package/template/next.config.ts +7 -0
  34. package/template/package.json +40 -0
  35. package/template/pnpm-workspace.yaml +14 -0
  36. package/template/postcss.config.mjs +7 -0
  37. package/template/src/app/globals.css +66 -0
  38. package/template/src/app/layout.tsx +55 -0
  39. package/template/src/app/page.tsx +59 -0
  40. package/template/src/components/ThemeSync.tsx +21 -0
  41. package/template/src/system/brands/starter.css +143 -0
  42. package/template/tsconfig.json +34 -0
package/css/roles.css CHANGED
@@ -61,6 +61,9 @@
61
61
  highlight primitive in light and follows it into dark by itself.
62
62
  ============================================================================= */
63
63
 
64
+ /* Motion vocabulary (animate-in, fade-in-0, accordion-down ...). */
65
+ @import "./motion.css";
66
+
64
67
  @custom-variant dark (&:is(.dark *));
65
68
 
66
69
  @theme inline {
@@ -1,21 +1,88 @@
1
+ type Node = {
2
+ type: string;
3
+ [key: string]: unknown;
4
+ };
5
+ type ReportDescriptor = {
6
+ node?: Node;
7
+ loc?: {
8
+ line: number;
9
+ column: number;
10
+ } | {
11
+ start: {
12
+ line: number;
13
+ column: number;
14
+ };
15
+ end: {
16
+ line: number;
17
+ column: number;
18
+ };
19
+ };
20
+ messageId: string;
21
+ data?: Record<string, string>;
22
+ };
23
+ type RuleContext = {
24
+ report(descriptor: ReportDescriptor): void;
25
+ sourceCode: {
26
+ text: string;
27
+ };
28
+ };
29
+ type RuleModule = {
30
+ meta: {
31
+ type: 'problem' | 'suggestion';
32
+ docs: {
33
+ description: string;
34
+ url?: string;
35
+ };
36
+ messages: Record<string, string>;
37
+ schema: [];
38
+ };
39
+ create(context: RuleContext): Record<string, (node: Node) => void>;
40
+ };
41
+ export declare const rules: Record<string, RuleModule>;
42
+ /**
43
+ * @deprecated Selector arrays for `no-restricted-syntax`, kept so configs
44
+ * written against 0.2 and 0.3 keep working. Use designSystemGuardrails()
45
+ * or the plugin's rules instead; they carry per-rule IDs and severities.
46
+ */
1
47
  type Restriction = {
2
48
  selector: string;
3
49
  message: string;
4
50
  };
5
51
  export declare const noArbitraryColour: Restriction[];
6
52
  export declare const noArbitraryTypeClamp: Restriction[];
53
+ export type RuleId = keyof typeof rules & string;
54
+ export type Severity = 'error' | 'warn' | 'off';
55
+ /** The plugin object, for configs that want to wire rules by hand. */
56
+ export declare const plugin: {
57
+ meta: {
58
+ name: string;
59
+ version: string;
60
+ };
61
+ rules: Record<string, RuleModule>;
62
+ };
63
+ /** Defaults: clean rules are errors; rules that need a cleanup pass first are warnings. */
64
+ export declare const defaultSeverity: Record<RuleId, Severity>;
7
65
  export interface GuardrailOptions {
8
66
  /** Glob(s) the rules apply to. Default: src/**\/*.{ts,tsx}. */
9
67
  files?: string[];
10
- /** Glob(s) exempt from the rules: renderers that genuinely cannot use CSS variables. */
68
+ /** Glob(s) exempt from every rule: renderers that genuinely cannot use CSS variables. */
11
69
  ignores?: string[];
70
+ /** Per-rule overrides of the default severities. */
71
+ severity?: Partial<Record<RuleId, Severity>>;
12
72
  }
13
- /** A flat-config block: spread it into your eslint.config array. */
14
- export declare function designSystemGuardrails({ files, ignores, }?: GuardrailOptions): {
73
+ /** A flat-config block: put it in your eslint.config array. */
74
+ export declare function designSystemGuardrails({ files, ignores, severity }?: GuardrailOptions): {
15
75
  files: string[];
16
76
  ignores: string[];
17
- rules: {
18
- 'no-restricted-syntax': (string | Restriction)[];
77
+ plugins: {
78
+ 'design-system': {
79
+ meta: {
80
+ name: string;
81
+ version: string;
82
+ };
83
+ rules: Record<string, RuleModule>;
84
+ };
19
85
  };
86
+ rules: Record<string, Severity>;
20
87
  };
21
88
  export {};
@@ -1,18 +1,11 @@
1
1
  // =============================================================================
2
2
  // ESLint guardrails.
3
3
  //
4
- // Two rules that keep design decisions in the token layer instead of in
5
- // component files:
6
- // 1. No raw colour values in a className (hex, oklch(), rgb(), hsl()),
7
- // including Tailwind arbitrary utilities like bg-[#fff]. Colours come
8
- // from the semantic or primitive utilities the roles define.
9
- // 2. No arbitrary fluid type size in a className (text-[clamp(...)]).
10
- // Fluid sizes are named roles (text-display, text-section-title,
11
- // text-lede); a genuinely new size becomes a token first.
12
- //
13
- // Both match string literals and template-literal chunks nested under any
14
- // className attribute, so cn() and ternaries are covered. Non-className
15
- // colour (JS colour maps, inline style objects) is deliberately not matched.
4
+ // The mechanical half of the design system's rules, as a flat-config plugin.
5
+ // Every rule here has a record in skills/product-design/references/rules.md
6
+ // under the same ID (design-system/<id> matches rule/<id>), which carries the
7
+ // scope, the why, the exceptions and an example pair. The message cites the
8
+ // ID so a finding can be traced.
16
9
  //
17
10
  // import { designSystemGuardrails } from '@fracazo/design-system/eslint'
18
11
  // export default defineConfig([
@@ -20,32 +13,207 @@
20
13
  // designSystemGuardrails({
21
14
  // files: ['src/**/*.{ts,tsx}'],
22
15
  // ignores: ['src/components/pdf/**'], // renderers that cannot use CSS vars
16
+ // severity: { 'no-stock-palette': 'error' }, // override a default
23
17
  // }),
24
18
  // ])
25
19
  //
26
- // A deliberate one-off carries `// eslint-disable-next-line
27
- // no-restricted-syntax -- <reason>` so the exception is visible in review.
20
+ // Defaults: the rules a clean codebase already satisfies are errors; the ones
21
+ // that need a cleanup pass first (stock palette, focus: rings, em dashes,
22
+ // on-dark text) are warnings, so they surface without blocking a merge. Turn
23
+ // each up to error once the product is clean. A deliberate one-off carries
24
+ // `// eslint-disable-next-line design-system/<id> -- <reason>` so the
25
+ // exception is visible in review.
26
+ //
27
+ // No dependency on eslint's types: the rule shapes below are the subset the
28
+ // plugin needs, typed locally so the package stays dependency-free.
28
29
  // =============================================================================
29
- const COLOUR_REGEX = '(#[0-9a-fA-F]{3,8}|oklch\\(|rgba?\\(|hsla?\\()';
30
- const COLOUR_MESSAGE = 'Arbitrary colour value in className. Use a semantic or primitive utility (bg-primary, text-muted-foreground, border-border, bg-surface) instead; see DESIGN.md. A deliberate one-off carries an eslint-disable-next-line stating why.';
31
- const TYPE_CLAMP_REGEX = 'text-\\[clamp\\(';
32
- const TYPE_CLAMP_MESSAGE = 'Arbitrary fluid type size in className. Use a named type role (text-display, text-section-title, text-lede) or add a token to roles.css; a deliberate one-off carries an eslint-disable-next-line stating why.';
30
+ const RULES_DOC = 'skills/product-design/references/rules.md';
31
+ // ─── helpers ─────────────────────────────────────────────────────────────────
32
+ const SKIP_KEYS = new Set(['parent', 'loc', 'range', 'tokens', 'comments']);
33
+ /** Depth-first walk over every ESTree node under `node`, including itself. */
34
+ function walk(node, visit) {
35
+ visit(node);
36
+ for (const key of Object.keys(node)) {
37
+ if (SKIP_KEYS.has(key))
38
+ continue;
39
+ const value = node[key];
40
+ if (Array.isArray(value)) {
41
+ for (const item of value)
42
+ if (item && typeof item === 'object' && 'type' in item)
43
+ walk(item, visit);
44
+ }
45
+ else if (value && typeof value === 'object' && 'type' in value) {
46
+ walk(value, visit);
47
+ }
48
+ }
49
+ }
50
+ /** Every string chunk inside a className attribute: literals and template quasis. */
51
+ function classChunks(attr) {
52
+ const out = [];
53
+ walk(attr, (n) => {
54
+ if (n.type === 'Literal' && typeof n.value === 'string')
55
+ out.push({ node: n, text: n.value });
56
+ if (n.type === 'TemplateElement') {
57
+ const cooked = n.value?.cooked;
58
+ if (typeof cooked === 'string')
59
+ out.push({ node: n, text: cooked });
60
+ }
61
+ });
62
+ return out;
63
+ }
64
+ function isClassName(attr) {
65
+ const name = attr.name;
66
+ return attr.type === 'JSXAttribute' && name?.name === 'className';
67
+ }
68
+ /** A rule that reports any className chunk matching `pattern`. */
69
+ function classNameRule(id, description, pattern, message) {
70
+ return {
71
+ meta: {
72
+ type: 'problem',
73
+ docs: { description, url: `${RULES_DOC}#rule${id}` },
74
+ messages: { violation: `${message} (rule/${id})` },
75
+ schema: [],
76
+ },
77
+ create(context) {
78
+ return {
79
+ JSXAttribute(node) {
80
+ if (!isClassName(node))
81
+ return;
82
+ for (const chunk of classChunks(node)) {
83
+ const match = chunk.text.match(pattern);
84
+ if (match)
85
+ context.report({ node: chunk.node, messageId: 'violation', data: { match: match[0] } });
86
+ }
87
+ },
88
+ };
89
+ },
90
+ };
91
+ }
92
+ // ─── rules ───────────────────────────────────────────────────────────────────
93
+ const STOCK_HUES = 'red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone';
94
+ const COLOUR_UTILITIES = 'bg|text|border|ring|from|to|via|fill|stroke|outline|decoration|divide|accent|caret|placeholder|shadow';
95
+ export const rules = {
96
+ 'no-colour-literal': classNameRule('no-colour-literal', 'No raw colour value (hex, oklch, rgb, hsl) in a className', /#[0-9a-fA-F]{3,8}|oklch\(|rgba?\(|hsla?\(/, 'Arbitrary colour value in className. Use a semantic or primitive utility (bg-primary, text-muted-foreground, border-border, bg-surface); a missing value is a role to add, not a literal to inline'),
97
+ 'no-arbitrary-clamp': classNameRule('no-arbitrary-clamp', 'No arbitrary fluid type size in a className', /text-\[clamp\(/, 'Arbitrary fluid type size in className. Use a named type role (text-display, text-section-title, text-lede) or add a token to roles.css'),
98
+ 'no-dark-pairs': classNameRule('no-dark-pairs', 'No hand-authored dark: colour with an arbitrary value', new RegExp(`(^|[\\s"'\`])dark:(${COLOUR_UTILITIES})-\\[`), 'Hand-authored dark colour pair in className. The token owns both themes; write the single theme-aware class'),
99
+ 'no-radius-literal': classNameRule('no-radius-literal', 'No arbitrary radius in a className', /(^|[\s"'`])rounded(-[a-z]{1,2})?-\[/, 'Arbitrary radius in className. The ramp is rounded-sm to rounded-4xl plus rounded-20; a new radius becomes a token first'),
100
+ 'no-stock-palette': classNameRule('no-stock-palette', "No Tailwind default palette colour in product UI", new RegExp(`(^|[\\s"'\`:])(${COLOUR_UTILITIES})-(${STOCK_HUES})-(50|[1-9]00|950)(?![\\w-])`), "Tailwind's stock palette in className ({{match}}). It competes with the brand and ignores dark mode; use a token"),
101
+ 'focus-visible': classNameRule('focus-visible', 'Focus rings use focus-visible:, not focus:', /(^|[\s"'`])focus:(ring|outline|border)/, 'focus: paints a ring for pointer users too (Radix autofocus makes this visible on open). Use focus-visible:'),
102
+ 'on-dark-ramp': {
103
+ meta: {
104
+ type: 'problem',
105
+ docs: { description: 'Text on an always-dark surface comes from the on-dark ramp', url: `${RULES_DOC}#ruleon-dark-ramp` },
106
+ messages: {
107
+ violation: 'Theme-varying text token ({{match}}) inside an always-dark surface (bg-dark). It only matches in one theme; use the on-dark ramp, text-dark-ink to text-dark-faint-2 (rule/on-dark-ramp)',
108
+ },
109
+ schema: [],
110
+ },
111
+ create(context) {
112
+ const DARK_SURFACE = /(^|[\s"'`])bg-dark(-2)?(?![\w-])/;
113
+ // A light surface nested inside a dark one (a phone mock's screen, a
114
+ // force-light document preview) resets the context; its subtree is
115
+ // not walked.
116
+ const LIGHT_SURFACE = /(^|[\s"'`])(bg-(white|background|card|popover|surface|surface-2|band|band-2|primary|secondary|muted|accent)|force-light)(?![\w-])/;
117
+ const VARYING_TEXT = /(^|[\s"'`:])text-(ink|ink-2|ink-3|foreground|muted-foreground)(?![\w-])/;
118
+ const attributes = (el) => {
119
+ const opening = el.openingElement;
120
+ return (opening?.attributes ?? []).filter(isClassName);
121
+ };
122
+ const hasClass = (el, re) => attributes(el).some((a) => classChunks(a).some((c) => re.test(c.text)));
123
+ const check = (el) => {
124
+ for (const attr of attributes(el)) {
125
+ for (const chunk of classChunks(attr)) {
126
+ const match = chunk.text.match(VARYING_TEXT);
127
+ if (match)
128
+ context.report({ node: chunk.node, messageId: 'violation', data: { match: match[0].trim() } });
129
+ }
130
+ }
131
+ for (const child of el.children ?? [])
132
+ walkJsx(child);
133
+ };
134
+ // Walk JSX children, descending through expressions (ternaries, maps)
135
+ // but stopping at any element that paints a light surface.
136
+ const walkJsx = (n) => {
137
+ if (n.type === 'JSXElement') {
138
+ if (hasClass(n, LIGHT_SURFACE))
139
+ return;
140
+ check(n);
141
+ return;
142
+ }
143
+ for (const key of Object.keys(n)) {
144
+ if (SKIP_KEYS.has(key))
145
+ continue;
146
+ const v = n[key];
147
+ if (Array.isArray(v))
148
+ v.forEach((x) => x && typeof x === 'object' && 'type' in x && walkJsx(x));
149
+ else if (v && typeof v === 'object' && 'type' in v)
150
+ walkJsx(v);
151
+ }
152
+ };
153
+ return {
154
+ JSXElement(node) {
155
+ if (!hasClass(node, DARK_SURFACE))
156
+ return;
157
+ for (const child of node.children ?? [])
158
+ walkJsx(child);
159
+ },
160
+ };
161
+ },
162
+ },
163
+ 'no-em-dash': {
164
+ meta: {
165
+ type: 'suggestion',
166
+ docs: { description: 'No em dashes anywhere in source, comments included', url: `${RULES_DOC}#ruleno-em-dash` },
167
+ messages: { violation: 'Em dash. Use a comma, colon, full stop or parentheses (rule/no-em-dash)' },
168
+ schema: [],
169
+ },
170
+ create(context) {
171
+ return {
172
+ Program() {
173
+ const lines = context.sourceCode.text.split('\n');
174
+ lines.forEach((line, i) => {
175
+ let col = line.indexOf('\u2014');
176
+ while (col !== -1) {
177
+ context.report({ loc: { start: { line: i + 1, column: col }, end: { line: i + 1, column: col + 1 } }, messageId: 'violation' });
178
+ col = line.indexOf('\u2014', col + 1);
179
+ }
180
+ });
181
+ },
182
+ };
183
+ },
184
+ },
185
+ };
33
186
  const forClassName = (regex, message) => [
34
187
  { selector: `JSXAttribute[name.name='className'] Literal[value=/${regex}/]`, message },
35
- {
36
- selector: `JSXAttribute[name.name='className'] TemplateElement[value.cooked=/${regex}/]`,
37
- message,
38
- },
188
+ { selector: `JSXAttribute[name.name='className'] TemplateElement[value.cooked=/${regex}/]`, message },
39
189
  ];
40
- export const noArbitraryColour = forClassName(COLOUR_REGEX, COLOUR_MESSAGE);
41
- export const noArbitraryTypeClamp = forClassName(TYPE_CLAMP_REGEX, TYPE_CLAMP_MESSAGE);
42
- /** A flat-config block: spread it into your eslint.config array. */
43
- export function designSystemGuardrails({ files = ['src/**/*.{ts,tsx}'], ignores = [], } = {}) {
190
+ export const noArbitraryColour = forClassName('(#[0-9a-fA-F]{3,8}|oklch\\(|rgba?\\(|hsla?\\()', 'Arbitrary colour value in className. Use a semantic or primitive utility instead (rule/no-colour-literal).');
191
+ export const noArbitraryTypeClamp = forClassName('text-\\[clamp\\(', 'Arbitrary fluid type size in className. Use a named type role (rule/no-arbitrary-clamp).');
192
+ /** The plugin object, for configs that want to wire rules by hand. */
193
+ export const plugin = {
194
+ meta: { name: '@fracazo/design-system', version: '0.4.0' },
195
+ rules,
196
+ };
197
+ /** Defaults: clean rules are errors; rules that need a cleanup pass first are warnings. */
198
+ export const defaultSeverity = {
199
+ 'no-colour-literal': 'error',
200
+ 'no-arbitrary-clamp': 'error',
201
+ 'no-dark-pairs': 'error',
202
+ 'no-radius-literal': 'error',
203
+ 'no-stock-palette': 'warn',
204
+ 'focus-visible': 'warn',
205
+ 'on-dark-ramp': 'warn',
206
+ 'no-em-dash': 'warn',
207
+ };
208
+ /** A flat-config block: put it in your eslint.config array. */
209
+ export function designSystemGuardrails({ files = ['src/**/*.{ts,tsx}'], ignores = [], severity = {} } = {}) {
210
+ const ruleConfig = {};
211
+ for (const id of Object.keys(rules))
212
+ ruleConfig[`design-system/${id}`] = severity[id] ?? defaultSeverity[id];
44
213
  return {
45
214
  files,
46
215
  ignores,
47
- rules: {
48
- 'no-restricted-syntax': ['error', ...noArbitraryColour, ...noArbitraryTypeClamp],
49
- },
216
+ plugins: { 'design-system': plugin },
217
+ rules: ruleConfig,
50
218
  };
51
219
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // ds-init: write a new product from the package's template.
4
+ //
5
+ // ds-init <dir> [--name <package-name>]
6
+ //
7
+ // Copies template/ (Next 16, Tailwind v4, the package, one blank brand file,
8
+ // the guardrails on) into <dir>, which must not exist or must be empty.
9
+ // The package name defaults to the directory's basename, and the dependency
10
+ // on @fracazo/design-system is set to the version of the package that ran
11
+ // this, so the template and the contract it satisfies always match. Then
12
+ // prints the steps that make it a product: install, rename the brand file,
13
+ // replace its values, pick a typeface. No overwriting, ever: a non-empty
14
+ // directory is an error, not a merge.
15
+ // =============================================================================
16
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ function fail(message) {
20
+ console.error(`ds-init: ${message}`);
21
+ process.exit(1);
22
+ }
23
+ const argv = process.argv.slice(2);
24
+ const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--name');
25
+ const nameFlag = argv.indexOf('--name');
26
+ const nameArg = nameFlag >= 0 ? argv[nameFlag + 1] : undefined;
27
+ if (positional.length !== 1)
28
+ fail('usage: ds-init <dir> [--name <package-name>]');
29
+ const dir = path.resolve(positional[0]);
30
+ const name = nameArg ?? path.basename(dir);
31
+ if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name))
32
+ fail(`"${name}" is not a valid package name; pass --name`);
33
+ if (existsSync(dir) && readdirSync(dir).length > 0)
34
+ fail(`${dir} is not empty; ds-init writes into a new or empty directory only`);
35
+ // dist/guardrails/init.js sits two levels below the package root.
36
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
37
+ const template = path.join(packageRoot, 'template');
38
+ const own = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
39
+ if (!existsSync(template))
40
+ fail(`template not found at ${template}`);
41
+ mkdirSync(dir, { recursive: true });
42
+ cpSync(template, dir, { recursive: true });
43
+ // npm renames a packed .gitignore, so the template carries it undotted.
44
+ renameSync(path.join(dir, 'gitignore'), path.join(dir, '.gitignore'));
45
+ const pkgPath = path.join(dir, 'package.json');
46
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
47
+ pkg.name = name;
48
+ pkg.dependencies[own.name] = `^${own.version}`;
49
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
50
+ const relative = path.relative(process.cwd(), dir);
51
+ const rel = relative === '' ? '.' : relative.startsWith('..') ? dir : relative;
52
+ console.log(`ds-init: wrote ${name} to ${rel} on ${own.name} ${own.version}
53
+
54
+ Next:
55
+ 1. cd ${rel} && pnpm install
56
+ 2. Rename src/system/brands/starter.css to the product and update the two
57
+ paths that name it: the @import in src/app/globals.css and the brand:*
58
+ scripts in package.json.
59
+ 3. Replace every value in the brand file, light and dark. Keep the property
60
+ names; pnpm brand:contract holds you to the contract.
61
+ 4. Pick the typeface in src/app/layout.tsx and point the brand file's
62
+ @theme block at its variable.
63
+ 5. Rewrite the top of CLAUDE.md and README.md for the product, delete
64
+ src/app/page.tsx and build the first surface.
65
+ 6. pnpm lint && pnpm typecheck && pnpm build, before every merge.`);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // ds-intake: the collector half of the design intake loop.
4
+ //
5
+ // Gathers raw evidence for rule candidates from one or more product repos:
6
+ // every commit since a ref or date whose subject, body or touched files look
7
+ // like a design decision. Writes a review packet in markdown with the
8
+ // commits verbatim and empty sections for the judge and the human reviewer.
9
+ // It never scores, groups or proposes rules; that is the judge's job
10
+ // (skills/product-design/references/intake.md), and acceptance is a human's.
11
+ //
12
+ // ds-intake --repo ~/Developer/birthguide --repo ~/Developer/birthplans \
13
+ // --since 2026-09-01 [--out skills/product-design/intake/2026-09-07.md]
14
+ // ds-intake --repo . --since v0.4.0 (a ref works too: commits after it)
15
+ //
16
+ // Design-relevant means: a touched path under src/components, src/app (tsx
17
+ // or css), src/system, src/stories, or a subject/body that mentions one of
18
+ // the design keywords below. Everything else is left out of the packet.
19
+ // =============================================================================
20
+ import { spawnSync } from 'node:child_process';
21
+ import { writeFileSync, mkdirSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ function args(name) {
24
+ const out = [];
25
+ for (let i = 0; i < process.argv.length; i++) {
26
+ if (process.argv[i] === `--${name}` && process.argv[i + 1])
27
+ out.push(process.argv[i + 1]);
28
+ }
29
+ return out;
30
+ }
31
+ const repos = args('repo');
32
+ const since = args('since')[0];
33
+ const out = args('out')[0];
34
+ if (repos.length === 0 || !since) {
35
+ console.error('usage: ds-intake --repo <path> [--repo <path>...] --since <date|ref> [--out <file.md>]');
36
+ process.exit(2);
37
+ }
38
+ const PATHS = /^(src\/components\/|src\/app\/.*\.(tsx|css)$|src\/system\/|src\/stories\/|src\/ui\/|css\/roles\.css|.*\/brands\/)/;
39
+ const KEYWORDS = /\b(design|token|colou?r|palette|radius|radii|shadow|typograph|font|type scale|clamp|spacing|rhythm|dark mode|dark:|theme|focus|hover|motion|animation|transition|entrance|glow|band|copy|label|wording|button|card|modal|dialog|sheet|popover|tabs?|accordion|form|input|contrast|accessib|a11y|tap target|layout|hierarchy|emphasis|snapshot|storybook|brand|guardrail|lint)\b/i;
40
+ // ASCII unit and record separators keep multi-line bodies parseable; built
41
+ // from char codes so no control character sits in this source.
42
+ const SEP = String.fromCharCode(31);
43
+ const END = String.fromCharCode(30);
44
+ function git(repo, argv) {
45
+ const r = spawnSync('git', ['-C', repo, ...argv], { encoding: 'utf8' });
46
+ if (r.status !== 0)
47
+ throw new Error(`git ${argv.join(' ')} in ${repo}: ${r.stderr.trim()}`);
48
+ return r.stdout;
49
+ }
50
+ function isRef(repo, value) {
51
+ return spawnSync('git', ['-C', repo, 'rev-parse', '--verify', '--quiet', `${value}^{commit}`]).status === 0;
52
+ }
53
+ function collect(repo) {
54
+ const range = isRef(repo, since) ? [`${since}..HEAD`] : [`--since=${since}`];
55
+ // Each record: END hash SEP date SEP author SEP subject SEP body END files
56
+ const log = git(repo, ['log', ...range, '--no-merges', '--date=short', `--format=${END}%h${SEP}%ad${SEP}%an${SEP}%s${SEP}%b${END}`, '--name-only']);
57
+ const commits = [];
58
+ const records = log.split(END);
59
+ // records alternate: [preamble, header, files, header, files, ...]
60
+ for (let i = 1; i + 1 <= records.length - 1; i += 2) {
61
+ const parts = records[i].split(SEP);
62
+ if (parts.length < 5)
63
+ continue;
64
+ const [hash, date, author, subject, body] = parts;
65
+ const files = (records[i + 1] ?? '').split('\n').map((f) => f.trim()).filter(Boolean);
66
+ const designFiles = files.filter((f) => PATHS.test(f));
67
+ const mentions = KEYWORDS.test(subject) || KEYWORDS.test(body);
68
+ if (designFiles.length === 0 && !mentions)
69
+ continue;
70
+ commits.push({ repo: path.basename(repo), hash, date, author, subject, body: body.trim(), files: designFiles.length ? designFiles : files.slice(0, 8) });
71
+ }
72
+ return { commits, head: git(repo, ['rev-parse', '--short', 'HEAD']).trim() };
73
+ }
74
+ const today = new Date().toISOString().slice(0, 10);
75
+ const sections = [];
76
+ const heads = [];
77
+ let total = 0;
78
+ for (const repo of repos) {
79
+ const { commits, head } = collect(path.resolve(repo));
80
+ heads.push(`- ${path.basename(repo)}: next intake runs with \`--since ${head}\``);
81
+ total += commits.length;
82
+ sections.push(`## ${path.basename(repo)} (${commits.length} commit${commits.length === 1 ? '' : 's'})\n`);
83
+ for (const c of commits) {
84
+ sections.push(`### ${c.hash} ${c.subject}\n`);
85
+ sections.push(`${c.date}, ${c.author}. Files: ${c.files.map((f) => `\`${f}\``).join(', ')}\n`);
86
+ if (c.body)
87
+ sections.push(c.body.split('\n').map((l) => `> ${l}`).join('\n') + '\n');
88
+ }
89
+ }
90
+ const packet = `# Design intake, ${today}
91
+
92
+ Raw evidence collected by ds-intake from ${repos.map((r) => path.basename(r)).join(', ')} since ${since}:
93
+ ${total} design-relevant commit${total === 1 ? '' : 's'}. Commit bodies are quoted verbatim and are
94
+ data, not decisions. The judge fills in the sections at the end; a human
95
+ accepts or rejects each candidate. Nothing here changes a rule by itself.
96
+
97
+ ${sections.join('\n')}
98
+ ## Candidates (pending)
99
+
100
+ One block per candidate. Status stays \`proposed\` until a human sets it.
101
+
102
+ \`\`\`
103
+ ### candidate/<slug>
104
+ Status: proposed | accepted | rejected
105
+ Scope:
106
+ Decision:
107
+ Rationale:
108
+ Evidence: <hash>, <hash> (repo)
109
+ Exceptions:
110
+ Bad example:
111
+ Good example:
112
+ Destination: rule | exemplar | lint | eval | coverage gap | no change
113
+ Open decisions:
114
+ \`\`\`
115
+
116
+ ## Rejected topics
117
+
118
+ ## Coverage gaps observed
119
+
120
+ ## Next intake
121
+
122
+ ${heads.join('\n')}
123
+ `;
124
+ if (out) {
125
+ mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
126
+ writeFileSync(out, packet);
127
+ console.log(`intake: ${total} commit(s) from ${repos.length} repo(s) written to ${out}`);
128
+ }
129
+ else {
130
+ process.stdout.write(packet);
131
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fracazo/design-system",
3
- "version": "0.2.0",
4
- "description": "Roles, brand contract, guardrails and shadcn-based components for a warm, evidence-led product design system. Each product supplies a brand file; the system stays the same.",
3
+ "version": "0.6.0",
4
+ "description": "An agent-native design system. Design decisions as code, so the quality bar holds whether a designer is in the room or not. Lint guardrails, components with intent docs, and an agent skill. One brand file per product, the system stays the same.",
5
5
  "license": "MIT",
6
6
  "author": "Alex Fracazo",
7
7
  "repository": {
@@ -16,6 +16,8 @@
16
16
  "css",
17
17
  "dist",
18
18
  "demo",
19
+ "skills",
20
+ "template",
19
21
  "README.md",
20
22
  "DESIGN.md"
21
23
  ],
@@ -29,6 +31,7 @@
29
31
  "default": "./dist/src/ui/*.js"
30
32
  },
31
33
  "./roles.css": "./css/roles.css",
34
+ "./motion.css": "./css/motion.css",
32
35
  "./eslint": {
33
36
  "types": "./dist/guardrails/eslint.d.ts",
34
37
  "default": "./dist/guardrails/eslint.js"
@@ -37,7 +40,9 @@
37
40
  },
38
41
  "bin": {
39
42
  "ds-check-brand": "dist/guardrails/check-brand.js",
40
- "ds-build-brand-css": "dist/guardrails/build-brand-css.js"
43
+ "ds-build-brand-css": "dist/guardrails/build-brand-css.js",
44
+ "ds-intake": "dist/guardrails/intake.js",
45
+ "ds-init": "dist/guardrails/init.js"
41
46
  },
42
47
  "peerDependencies": {
43
48
  "@dnd-kit/core": "^6.3.1",