@fracazo/design-system 0.2.1 → 0.7.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 +11 -1
- package/README.md +78 -13
- 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/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/ui/offer-card.d.ts +52 -0
- package/dist/src/ui/offer-card.js +7 -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 +43 -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
|
@@ -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/dist/src/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./ui/dialog.js";
|
|
|
16
16
|
export * from "./ui/form.js";
|
|
17
17
|
export * from "./ui/input.js";
|
|
18
18
|
export * from "./ui/label.js";
|
|
19
|
+
export * from "./ui/offer-card.js";
|
|
19
20
|
export * from "./ui/popover.js";
|
|
20
21
|
export * from "./ui/progress.js";
|
|
21
22
|
export * from "./ui/radio-group.js";
|
package/dist/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./ui/dialog.js";
|
|
|
16
16
|
export * from "./ui/form.js";
|
|
17
17
|
export * from "./ui/input.js";
|
|
18
18
|
export * from "./ui/label.js";
|
|
19
|
+
export * from "./ui/offer-card.js";
|
|
19
20
|
export * from "./ui/popover.js";
|
|
20
21
|
export * from "./ui/progress.js";
|
|
21
22
|
export * from "./ui/radio-group.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type LucideIcon } from "lucide-react";
|
|
3
|
+
/**
|
|
4
|
+
* A linked offer surface: media on top, a tag, a title, a line of
|
|
5
|
+
* description and a footer that names the source, with an arrow that
|
|
6
|
+
* answers hover.
|
|
7
|
+
*
|
|
8
|
+
* Use for: a grid of peer offers the reader picks from (deals, price
|
|
9
|
+
* matches, plans), where the whole card is one link and the footer
|
|
10
|
+
* carries the source (a retailer, a brand, a promo code, a was-price).
|
|
11
|
+
* Avoid when: the item has more than one action (use a house card with
|
|
12
|
+
* Buttons), or when the cards would sit in a carousel; the system rejects
|
|
13
|
+
* carousels, so lay OfferCards in a grid and let it wrap.
|
|
14
|
+
* Slots: `media` is a node, not a URL, so a product can pass its own tile
|
|
15
|
+
* (an initial-letter fallback, a next/image) and it fills a 16:9 area;
|
|
16
|
+
* `footer.avatar` fills a 40px circle; `footer.meta` is a node so it can
|
|
17
|
+
* carry a struck-through was-price, not only a code. `tagIcon` defaults to
|
|
18
|
+
* Lucide's Tag.
|
|
19
|
+
* States: `highlighted` tints the body with the primary at low opacity, the
|
|
20
|
+
* one emphasis signal for "cheapest" or "best"; nothing else changes, so a
|
|
21
|
+
* grid keeps one signal per card. `external` opens in a new tab and says so
|
|
22
|
+
* in the accessible name.
|
|
23
|
+
* Motion: hover lifts the card 8px, scales the media and rotates the arrow,
|
|
24
|
+
* all as CSS transitions on the anchor's hover, and only under
|
|
25
|
+
* motion-safe; with reduced motion the card sits still and the arrow only
|
|
26
|
+
* fills. The card fills its column at any width; height follows a grid
|
|
27
|
+
* row's tallest card because the anchor is h-full and the body flexes.
|
|
28
|
+
*/
|
|
29
|
+
type OfferCardProps = Omit<React.ComponentProps<"a">, "href" | "title" | "media"> & {
|
|
30
|
+
href: string;
|
|
31
|
+
/** Opens in a new tab, with rel and an sr-only note on the accessible name. */
|
|
32
|
+
external?: boolean;
|
|
33
|
+
/** Fills a 16:9 area; give an image `fill` (next/image) or `size-full object-cover`. */
|
|
34
|
+
media?: React.ReactNode;
|
|
35
|
+
tag?: string;
|
|
36
|
+
tagIcon?: LucideIcon;
|
|
37
|
+
title: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
footer?: {
|
|
40
|
+
/** Fills a 40px circle. */
|
|
41
|
+
avatar?: React.ReactNode;
|
|
42
|
+
name: string;
|
|
43
|
+
/** Second line under the name: a code, a was-price, a note. */
|
|
44
|
+
meta?: React.ReactNode;
|
|
45
|
+
};
|
|
46
|
+
/** The one emphasis signal: a subtle primary tint on the body. */
|
|
47
|
+
highlighted?: boolean;
|
|
48
|
+
/** Heading level for the title; h3 suits a card under a section heading. */
|
|
49
|
+
titleAs?: "h2" | "h3" | "h4" | "p";
|
|
50
|
+
};
|
|
51
|
+
declare function OfferCard({ href, external, media, tag, tagIcon: TagIcon, title, description, footer, highlighted, titleAs: TitleTag, className, ...props }: OfferCardProps): React.JSX.Element;
|
|
52
|
+
export { OfferCard, type OfferCardProps };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ArrowRight, Tag } from "lucide-react";
|
|
3
|
+
import { cn } from "../cn.js";
|
|
4
|
+
function OfferCard({ href, external = false, media, tag, tagIcon: TagIcon = Tag, title, description, footer, highlighted = false, titleAs: TitleTag = "h3", className, ...props }) {
|
|
5
|
+
return (_jsxs("a", { "data-slot": "offer-card", "data-highlighted": highlighted || undefined, href: href, target: external ? "_blank" : undefined, rel: external ? "noopener noreferrer" : undefined, className: cn("group flex h-full flex-col overflow-hidden rounded-2xl bg-card text-card-foreground shadow-card", "transition-[translate,box-shadow] duration-200 ease-out hover:shadow-card-hover motion-safe:hover:-translate-y-2", "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className), ...props, children: [media !== undefined && media !== null ? (_jsx("div", { className: "aspect-video overflow-hidden bg-muted", children: _jsx("div", { className: "relative size-full transition-transform duration-300 ease-out *:size-full *:object-cover motion-safe:group-hover:scale-105", children: media }) })) : null, _jsxs("div", { className: cn("flex flex-1 flex-col", highlighted && "bg-primary/5"), children: [_jsxs("div", { className: "flex flex-1 flex-col gap-2 px-5 pt-5 pb-4", children: [tag ? (_jsxs("span", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(TagIcon, { "aria-hidden": "true", className: "size-4 shrink-0" }), tag] })) : null, _jsx(TitleTag, { className: "text-xl font-semibold tracking-tight text-balance", children: title }), description ? (_jsx("p", { className: "text-sm text-muted-foreground text-pretty", children: description })) : null] }), footer ? (_jsxs("div", { className: "mx-5 mb-4 flex items-center gap-3 border-t border-border pt-4", children: [footer.avatar !== undefined && footer.avatar !== null ? (_jsx("span", { className: "size-10 shrink-0 overflow-hidden rounded-full *:size-full *:object-cover", children: footer.avatar })) : null, _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-semibold", children: footer.name }), footer.meta !== undefined && footer.meta !== null ? (_jsx("span", { className: "block truncate text-sm text-muted-foreground", children: footer.meta })) : null] }), _jsx("span", { "aria-hidden": "true", className: "flex size-11 shrink-0 items-center justify-center rounded-full bg-secondary text-secondary-foreground transition-[background-color,color,rotate] duration-200 ease-out group-hover:bg-primary group-hover:text-primary-foreground motion-safe:group-hover:-rotate-45", children: _jsx(ArrowRight, { className: "size-4" }) })] })) : null] }), external ? _jsx("span", { className: "sr-only", children: " (opens in a new tab)" }) : null] }));
|
|
6
|
+
}
|
|
7
|
+
export { OfferCard };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fracazo/design-system",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.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",
|