@fracazo/design-system 0.2.1 → 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 +5 -0
  2. package/README.md +77 -12
  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/DESIGN.md CHANGED
@@ -14,6 +14,11 @@ refuses to ship, and where each brand differs. It is deliberately short.
14
14
  The stylesheet carries the visual decisions; this file carries judgment,
15
15
  and gains a rule only when the same correction has been made twice.
16
16
 
17
+ Agents load `skills/product-design/SKILL.md` first: it names the request
18
+ mode, routes to the reference that applies, and cites rules by stable ID
19
+ from `skills/product-design/references/rules.md`. This file stays the
20
+ narrative those rules point back to.
21
+
17
22
  Read `css/roles.css` for the roles and the brand contract, the component
18
23
  source in `src/ui/*` for each component's intent, and the product's own
19
24
  CLAUDE.md for the rules that are local to it.
package/README.md CHANGED
@@ -1,13 +1,20 @@
1
1
  # @fracazo/design-system
2
2
 
3
- Roles, a brand contract, guardrails and components for a warm, evidence-led
4
- product design system. The package ships the half of the system that is the same
5
- for every product; each product supplies one brand file with its values.
6
- Change the brand file and the whole product re-skins; the roles never move.
3
+ An agent-native design system. Design decisions as code, so the quality bar lives in the tooling.
7
4
 
8
- Built for [BirthGuide](https://birthguide.com.au) and
9
- [birthplans.app](https://www.birthplans.app), designed to start the next
10
- product from.
5
+ When a team ships faster, the design review queue is the first thing that breaks. Either every change waits on a designer, or the bar drops quietly. Neither works.
6
+
7
+ This package moves the bar out of the review queue and into the tooling, where it holds whether a designer is in the room or not.
8
+
9
+ It lives in three places.
10
+
11
+ **Lint.** Eight ESLint rules catch what a reviewer would: colour literals, radius literals, stock palette, dark pairs, arbitrary sizes, focus rings, text on dark surfaces. The build fails before anyone posts a screenshot.
12
+
13
+ **Components.** Each of the seventeen components carries its own guidance in JSDoc: use for, avoid when, variants. The decision sits where it gets made, not in a doc nobody opens.
14
+
15
+ **The agent skill.** AI tools load the design rules before they build or review any UI, route to the reference that applies, and cite rules by stable ID. The system proposes, the human commits.
16
+
17
+ Built for BirthGuide and birthplans.app, and designed to start the next product from.
11
18
 
12
19
  ## What is in the package
13
20
 
@@ -15,15 +22,46 @@ product from.
15
22
  |---|---|
16
23
  | `css/roles.css` | The system: the dark variant, the Tailwind v4 `@theme` mapping, the radius ramp, fluid type roles, band rhythm, the eight aliasing semantics, and the **brand contract** at the top |
17
24
  | `ds-check-brand` | Holds a brand file to the contract: nothing missing, nothing extra |
25
+ | `ds-intake` | Collects design-relevant commits from product repos into an intake packet the agent proposes and a human commits (see the skill's `references/intake.md`) |
18
26
  | `ds-build-brand-css` | Composes the plain-CSS token file a product serves publicly (e.g. `/brand.css`) |
27
+ | `ds-init` and `template/` | Writes a new product: Next 16, Tailwind v4, this package, a blank brand file and the guardrails on, pinned to the package version that wrote it |
28
+ | `css/motion.css` | The animation vocabulary the components use (enter, exit, accordion, the fade, zoom, blur and slide utilities); `roles.css` imports it |
19
29
  | `@fracazo/design-system` and `./ui/*` | `cn` and seventeen shadcn-based components (button, card, dialog, form, select, sortable-list and the rest), each with intent JSDoc: use for, avoid when, variants |
20
- | `@fracazo/design-system/eslint` | Two guardrails: no raw colours and no arbitrary fluid type sizes in a `className` |
30
+ | `@fracazo/design-system/eslint` | Eight guardrails as an ESLint plugin, one per rule ID: colour literals, arbitrary clamp sizes, dark pairs, radius literals, stock palette, `focus:` rings, text on always-dark surfaces, em dashes |
21
31
  | `demo/index.html` | A showcase page that renders the roles in both modes off a served `/brand.css` |
32
+ | `skills/product-design/` | The agent skill: request modes, routed references, rules with stable IDs, exemplars, coverage gaps. Point your CLAUDE.md or AGENTS.md at its `SKILL.md` |
22
33
  | `DESIGN.md` | The written authority: who the reader is, the priority order, how a page is composed, the rejection list, one short chapter per brand |
23
34
 
24
35
  Brand files live in each product repo, not here. The contract is what keeps
25
- them honest. A new product starts from the `design-system-starter` template:
26
- Next 16, Tailwind v4, this package, a blank brand file and the guardrails on.
36
+ them honest.
37
+
38
+ ## Start a product
39
+
40
+ ```bash
41
+ pnpm dlx --package @fracazo/design-system ds-init my-product
42
+ ```
43
+
44
+ That writes `template/` into `my-product`: Next 16, Tailwind v4, this
45
+ package, every role with an achromatic placeholder in
46
+ `src/system/brands/starter.css`, the house base layer in `globals.css`, the
47
+ pre-paint dark script, `designSystemGuardrails()` with no exemptions, and
48
+ `pnpm lint` running ESLint plus `ds-check-brand`. It refuses a non-empty
49
+ directory. Then:
50
+
51
+ 1. `pnpm install`.
52
+ 2. Rename the brand file to the product and update the two paths that name
53
+ it: the `@import` in `globals.css` and the `brand:*` scripts.
54
+ 3. Replace every value in the brand file, light and dark. Keep the property
55
+ names; `pnpm brand:contract` holds you to them. `DESIGN.md` says what
56
+ each role is for and which six semantics are meant to diverge in dark.
57
+ 4. Pick the typeface in `layout.tsx` and point the brand file's `@theme`
58
+ block at its variable.
59
+ 5. Rewrite the top of `CLAUDE.md`, delete `page.tsx`, build the first
60
+ surface. `pnpm lint && pnpm typecheck && pnpm build` before every merge.
61
+
62
+ The template is proven against the package it ships with: its brand file
63
+ passes the contract and a product written from it lints, typechecks and
64
+ builds before a release.
27
65
 
28
66
  ## Consume it
29
67
 
@@ -38,7 +76,8 @@ pnpm add @fracazo/design-system
38
76
  ```
39
77
 
40
78
  Import the roles, then exactly one brand file, at the top of your global
41
- stylesheet. Order matters: roles first.
79
+ stylesheet. Order matters: roles first. `roles.css` also brings in the
80
+ motion vocabulary the components use, so no animation library is needed.
42
81
 
43
82
  ```css
44
83
  @import "tailwindcss";
@@ -89,10 +128,35 @@ export default defineConfig([
89
128
  designSystemGuardrails({
90
129
  files: ['src/**/*.{ts,tsx}'],
91
130
  ignores: ['src/components/pdf/**', 'src/lib/email.ts'],
131
+ // Clean rules are errors by default; stock palette, focus: rings,
132
+ // on-dark text and em dashes start as warnings. Turn each up once
133
+ // the product is clean, or down while a pass is pending.
134
+ severity: { 'no-stock-palette': 'error', 'no-arbitrary-clamp': 'warn' },
92
135
  }),
93
136
  ])
94
137
  ```
95
138
 
139
+ A deliberate one-off names the rule and the reason:
140
+
141
+ ```tsx
142
+ {/* eslint-disable-next-line design-system/no-radius-literal -- phone bezel, not a UI corner */}
143
+ ```
144
+
145
+ ## Tell your agent when to load the skill
146
+
147
+ In the product's CLAUDE.md or AGENTS.md:
148
+
149
+ ```
150
+ When shaping, building, reviewing or writing copy for user-facing UI, load
151
+ node_modules/@fracazo/design-system/skills/product-design/SKILL.md first.
152
+ Skip it for backend-only work, telemetry, generated files and tests with no
153
+ shipped UI.
154
+ ```
155
+
156
+ The skill names the request mode, routes to the reference that applies and
157
+ cites rules by stable ID. `DESIGN.md` is the narrative those rules point
158
+ back to.
159
+
96
160
  ## The two tiers, in one paragraph
97
161
 
98
162
  Primitives hold literals (`--brand`, `--band`, `--ink`) and live in the brand
@@ -102,7 +166,8 @@ primary, accent, ring and their foregrounds) or hold a brand-tuned literal
102
166
  in the brand file (the six that diverge in dark: secondary, muted, border,
103
167
  input, muted-foreground, accent-foreground). Dark border and input as
104
168
  translucent hairlines is a design decision, not duplication; never "fix" a
105
- divergent semantic by aliasing it.
169
+ divergent semantic by aliasing it. Change the brand file and the whole product
170
+ re-skins; the roles never move.
106
171
 
107
172
  ## Versioning
108
173
 
package/css/motion.css ADDED
@@ -0,0 +1,155 @@
1
+ /* =============================================================================
2
+ Design system motion (css/motion.css)
3
+
4
+ The animation vocabulary the package's components and the products use:
5
+ enter and exit keyframes driven by custom properties, the accordion
6
+ height keyframes, and the utilities that set those properties. Imported
7
+ by roles.css, so one import covers roles and motion. Class names and
8
+ values match tw-animate-css 1.4.0 for exactly the subset in use, so
9
+ dropping that import changes nothing on screen; the subset is the point.
10
+ Add a utility here only when a component or a product needs it.
11
+
12
+ Duration and easing read --tw-duration and --tw-ease first, so Tailwind's
13
+ duration-* and ease-* utilities tune a single animation; the defaults are
14
+ 150ms ease for enter and exit, 200ms ease-out for the accordion.
15
+ ============================================================================= */
16
+
17
+ /* Non-inherited so a child that animates never picks up its parent's offsets. */
18
+ @property --tw-animation-delay { syntax: "*"; inherits: false; initial-value: 0s; }
19
+ @property --tw-animation-direction { syntax: "*"; inherits: false; initial-value: normal; }
20
+ @property --tw-animation-duration { syntax: "*"; inherits: false; }
21
+ @property --tw-animation-fill-mode { syntax: "*"; inherits: false; initial-value: none; }
22
+ @property --tw-animation-iteration-count { syntax: "*"; inherits: false; initial-value: 1; }
23
+ @property --tw-enter-blur { syntax: "*"; inherits: false; initial-value: 0; }
24
+ @property --tw-enter-opacity { syntax: "*"; inherits: false; initial-value: 1; }
25
+ @property --tw-enter-rotate { syntax: "*"; inherits: false; initial-value: 0; }
26
+ @property --tw-enter-scale { syntax: "*"; inherits: false; initial-value: 1; }
27
+ @property --tw-enter-translate-x { syntax: "*"; inherits: false; initial-value: 0; }
28
+ @property --tw-enter-translate-y { syntax: "*"; inherits: false; initial-value: 0; }
29
+ @property --tw-exit-blur { syntax: "*"; inherits: false; initial-value: 0; }
30
+ @property --tw-exit-opacity { syntax: "*"; inherits: false; initial-value: 1; }
31
+ @property --tw-exit-rotate { syntax: "*"; inherits: false; initial-value: 0; }
32
+ @property --tw-exit-scale { syntax: "*"; inherits: false; initial-value: 1; }
33
+ @property --tw-exit-translate-x { syntax: "*"; inherits: false; initial-value: 0; }
34
+ @property --tw-exit-translate-y { syntax: "*"; inherits: false; initial-value: 0; }
35
+
36
+ @theme inline {
37
+ /* Fractions the numbered utilities resolve against (fade-in-0, zoom-in-95). */
38
+ --percentage-0: 0;
39
+ --percentage-5: .05;
40
+ --percentage-10: .1;
41
+ --percentage-15: .15;
42
+ --percentage-20: .2;
43
+ --percentage-25: .25;
44
+ --percentage-30: .3;
45
+ --percentage-35: .35;
46
+ --percentage-40: .4;
47
+ --percentage-45: .45;
48
+ --percentage-50: .5;
49
+ --percentage-55: .55;
50
+ --percentage-60: .6;
51
+ --percentage-65: .65;
52
+ --percentage-70: .7;
53
+ --percentage-75: .75;
54
+ --percentage-80: .8;
55
+ --percentage-85: .85;
56
+ --percentage-90: .9;
57
+ --percentage-95: .95;
58
+ --percentage-100: 1;
59
+ --percentage-translate-full: 1;
60
+
61
+ /* animate-in / animate-out */
62
+ --animate-in: enter var(--tw-animation-duration, var(--tw-duration, .15s)) var(--tw-ease, ease) var(--tw-animation-delay, 0s) var(--tw-animation-iteration-count, 1) var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none);
63
+ --animate-out: exit var(--tw-animation-duration, var(--tw-duration, .15s)) var(--tw-ease, ease) var(--tw-animation-delay, 0s) var(--tw-animation-iteration-count, 1) var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none);
64
+
65
+ @keyframes enter {
66
+ from {
67
+ opacity: var(--tw-enter-opacity, 1);
68
+ transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0));
69
+ filter: blur(var(--tw-enter-blur, 0));
70
+ }
71
+ }
72
+ @keyframes exit {
73
+ to {
74
+ opacity: var(--tw-exit-opacity, 1);
75
+ transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0));
76
+ filter: blur(var(--tw-exit-blur, 0));
77
+ }
78
+ }
79
+
80
+ /* Accordion content height; Radix supplies the variable. */
81
+ --animate-accordion-down: accordion-down var(--tw-animation-duration, var(--tw-duration, .2s)) var(--tw-ease, ease-out) var(--tw-animation-delay, 0s) var(--tw-animation-iteration-count, 1) var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none);
82
+ --animate-accordion-up: accordion-up var(--tw-animation-duration, var(--tw-duration, .2s)) var(--tw-ease, ease-out) var(--tw-animation-delay, 0s) var(--tw-animation-iteration-count, 1) var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none);
83
+
84
+ @keyframes accordion-down {
85
+ from { height: 0; }
86
+ to { height: var(--radix-accordion-content-height, auto); }
87
+ }
88
+ @keyframes accordion-up {
89
+ from { height: var(--radix-accordion-content-height, auto); }
90
+ to { height: 0; }
91
+ }
92
+ }
93
+
94
+ /* Enter */
95
+ @utility fade-in { --tw-enter-opacity: 0; }
96
+ @utility fade-in-* {
97
+ --tw-enter-opacity: calc(--value(number) / 100);
98
+ --tw-enter-opacity: --value(--percentage-*, [*]);
99
+ }
100
+ @utility zoom-in { --tw-enter-scale: 0; }
101
+ @utility zoom-in-* {
102
+ --tw-enter-scale: calc(--value(number) * 1%);
103
+ --tw-enter-scale: calc(--value(ratio));
104
+ --tw-enter-scale: --value(--percentage-*, [*]);
105
+ }
106
+ @utility blur-in { --tw-enter-blur: 20px; }
107
+ @utility blur-in-* {
108
+ --tw-enter-blur: calc(--value(number) * 1px);
109
+ --tw-enter-blur: --value(--blur-*, [*]);
110
+ }
111
+ @utility slide-in-from-top { --tw-enter-translate-y: -100%; }
112
+ @utility slide-in-from-top-* {
113
+ --tw-enter-translate-y: calc(--value(integer) * var(--spacing) * -1);
114
+ --tw-enter-translate-y: calc(--value(--percentage-*, --percentage-translate-*) * -100%);
115
+ --tw-enter-translate-y: calc(--value(ratio) * -100%);
116
+ --tw-enter-translate-y: calc(--value(--translate-*, [percentage], [length]) * -1);
117
+ }
118
+ @utility slide-in-from-bottom { --tw-enter-translate-y: 100%; }
119
+ @utility slide-in-from-bottom-* {
120
+ --tw-enter-translate-y: calc(--value(integer) * var(--spacing));
121
+ --tw-enter-translate-y: calc(--value(--percentage-*, --percentage-translate-*) * 100%);
122
+ --tw-enter-translate-y: calc(--value(ratio) * 100%);
123
+ --tw-enter-translate-y: --value(--translate-*, [percentage], [length]);
124
+ }
125
+ @utility slide-in-from-left { --tw-enter-translate-x: -100%; }
126
+ @utility slide-in-from-left-* {
127
+ --tw-enter-translate-x: calc(--value(integer) * var(--spacing) * -1);
128
+ --tw-enter-translate-x: calc(--value(--percentage-*, --percentage-translate-*) * -100%);
129
+ --tw-enter-translate-x: calc(--value(ratio) * -100%);
130
+ --tw-enter-translate-x: calc(--value(--translate-*, [percentage], [length]) * -1);
131
+ }
132
+ @utility slide-in-from-right { --tw-enter-translate-x: 100%; }
133
+ @utility slide-in-from-right-* {
134
+ --tw-enter-translate-x: calc(--value(integer) * var(--spacing));
135
+ --tw-enter-translate-x: calc(--value(--percentage-*, --percentage-translate-*) * 100%);
136
+ --tw-enter-translate-x: calc(--value(ratio) * 100%);
137
+ --tw-enter-translate-x: --value(--translate-*, [percentage], [length]);
138
+ }
139
+
140
+ /* Exit */
141
+ @utility fade-out { --tw-exit-opacity: 0; }
142
+ @utility fade-out-* {
143
+ --tw-exit-opacity: calc(--value(number) / 100);
144
+ --tw-exit-opacity: --value(--percentage-*, [*]);
145
+ }
146
+ @utility zoom-out { --tw-exit-scale: 0; }
147
+ @utility zoom-out-* {
148
+ --tw-exit-scale: calc(--value(number) * 1%);
149
+ --tw-exit-scale: calc(--value(ratio));
150
+ --tw-exit-scale: --value(--percentage-*, [*]);
151
+ }
152
+ @utility slide-out-to-top { --tw-exit-translate-y: -100%; }
153
+ @utility slide-out-to-bottom { --tw-exit-translate-y: 100%; }
154
+ @utility slide-out-to-left { --tw-exit-translate-x: -100%; }
155
+ @utility slide-out-to-right { --tw-exit-translate-x: 100%; }
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 {};