@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.
- package/DESIGN.md +366 -8
- package/README.md +77 -11
- package/css/motion.css +155 -0
- package/css/roles.css +3 -0
- package/dist/guardrails/eslint.d.ts +72 -5
- package/dist/guardrails/eslint.js +197 -29
- package/dist/guardrails/init.d.ts +2 -0
- package/dist/guardrails/init.js +65 -0
- package/dist/guardrails/intake.d.ts +2 -0
- package/dist/guardrails/intake.js +131 -0
- package/package.json +8 -3
- package/skills/product-design/SKILL.md +142 -0
- package/skills/product-design/coverage-gaps.md +41 -0
- package/skills/product-design/exemplars/calm-the-offering-cards.md +33 -0
- package/skills/product-design/exemplars/clamp-drift-to-named-roles.md +37 -0
- package/skills/product-design/exemplars/concentric-radii-and-button-optics.md +36 -0
- package/skills/product-design/exemplars/dialog-close-focus-visible.md +36 -0
- package/skills/product-design/exemplars/hero-glow-seam.md +34 -0
- package/skills/product-design/intake/2026-09-07.md +413 -0
- package/skills/product-design/references/components.md +42 -0
- package/skills/product-design/references/copy.md +25 -0
- package/skills/product-design/references/intake.md +66 -0
- package/skills/product-design/references/motion.md +22 -0
- package/skills/product-design/references/rules.md +319 -0
- package/skills/product-design/references/surfaces.md +50 -0
- package/skills/product-design/references/tokens.md +54 -0
- package/skills/product-design/references/type-and-space.md +42 -0
- package/skills/product-design/references/verification.md +35 -0
- package/template/CLAUDE.md +47 -0
- package/template/README.md +16 -0
- package/template/eslint.config.mjs +20 -0
- package/template/gitignore +44 -0
- package/template/next.config.ts +7 -0
- package/template/package.json +40 -0
- package/template/pnpm-workspace.yaml +14 -0
- package/template/postcss.config.mjs +7 -0
- package/template/src/app/globals.css +66 -0
- package/template/src/app/layout.tsx +55 -0
- package/template/src/app/page.tsx +59 -0
- package/template/src/components/ThemeSync.tsx +21 -0
- package/template/src/system/brands/starter.css +143 -0
- 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
|
|
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:
|
|
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
|
-
|
|
18
|
-
'
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
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(
|
|
41
|
-
export const noArbitraryTypeClamp = forClassName(
|
|
42
|
-
/**
|
|
43
|
-
export
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
216
|
+
plugins: { 'design-system': plugin },
|
|
217
|
+
rules: ruleConfig,
|
|
50
218
|
};
|
|
51
219
|
}
|
|
@@ -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,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.
|
|
4
|
-
"description": "
|
|
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",
|