@fracazo/design-system 0.1.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 ADDED
@@ -0,0 +1,12 @@
1
+ # DESIGN.md
2
+
3
+ The written authority for products built on `@fracazo/design-system`: what
4
+ the reader's job is, how a page is composed, what the system refuses to
5
+ ship. Authored in the next phase. Until then the machine-readable parts
6
+ already exist and are enforced:
7
+
8
+ - The brand contract at the top of `css/roles.css`, held by `ds-check-brand`.
9
+ - The two ESLint guardrails in `@fracazo/design-system/eslint`.
10
+
11
+ Prose rules will be added here only when a correction recurs. The
12
+ stylesheet does the visual work; this file exists for judgment.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Fracazo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @fracazo/design-system
2
+
3
+ Roles, a brand contract and guardrails for a warm, evidence-led product
4
+ 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.
7
+
8
+ Built for [BirthGuide](https://birthguide.com.au) and
9
+ [birthplans.app](https://www.birthplans.app), designed to start the next
10
+ product from.
11
+
12
+ ## What is in the package
13
+
14
+ | Path | What it is |
15
+ |---|---|
16
+ | `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
+ | `ds-check-brand` | Holds a brand file to the contract: nothing missing, nothing extra |
18
+ | `ds-build-brand-css` | Composes the plain-CSS token file a product serves publicly (e.g. `/brand.css`) |
19
+ | `@fracazo/design-system/eslint` | Two guardrails: no raw colours and no arbitrary fluid type sizes in a `className` |
20
+ | `demo/index.html` | A showcase page that renders the roles in both modes off a served `/brand.css` |
21
+ | `DESIGN.md` | The written authority: judgment, composition, the rejection list. Authored next |
22
+
23
+ Brand files live in each product repo, not here. The contract is what keeps
24
+ them honest.
25
+
26
+ ## Consume it
27
+
28
+ Install (Tailwind v4 is a peer dependency):
29
+
30
+ ```bash
31
+ pnpm add @fracazo/design-system
32
+ ```
33
+
34
+ Import the roles, then exactly one brand file, at the top of your global
35
+ stylesheet. Order matters: roles first.
36
+
37
+ ```css
38
+ @import "tailwindcss";
39
+ @import "@fracazo/design-system/roles.css";
40
+ @import "./system/brands/my-product.css";
41
+ ```
42
+
43
+ Write the brand file to the contract printed at the top of `roles.css`: one
44
+ `@theme inline` block for the typeface mapping, one `:root, .force-light`
45
+ block with every light value, one `.dark` block with every theme-varying
46
+ value. Then wire the checks into your lint chain:
47
+
48
+ ```json
49
+ {
50
+ "scripts": {
51
+ "brand:contract": "ds-check-brand src/system/brands/my-product.css",
52
+ "brand:build": "ds-build-brand-css --brand src/system/brands/my-product.css --out public/brand.css --name MyProduct --url https://my-product.example",
53
+ "brand:check": "ds-build-brand-css --brand src/system/brands/my-product.css --out public/brand.css --name MyProduct --url https://my-product.example --check",
54
+ "lint": "eslint && pnpm brand:check && pnpm brand:contract"
55
+ }
56
+ }
57
+ ```
58
+
59
+ And the ESLint guardrails, with your own exemptions for renderers that
60
+ genuinely cannot use CSS variables (PDF and email builders, OG images):
61
+
62
+ ```js
63
+ import { designSystemGuardrails } from '@fracazo/design-system/eslint'
64
+
65
+ export default defineConfig([
66
+ // ...your base config
67
+ designSystemGuardrails({
68
+ files: ['src/**/*.{ts,tsx}'],
69
+ ignores: ['src/components/pdf/**', 'src/lib/email.ts'],
70
+ }),
71
+ ])
72
+ ```
73
+
74
+ ## The two tiers, in one paragraph
75
+
76
+ Primitives hold literals (`--brand`, `--band`, `--ink`) and live in the brand
77
+ file. Semantics either alias a primitive via `var()` in `roles.css` (the
78
+ eight that are identical in light and dark: foreground, card, popover,
79
+ primary, accent, ring and their foregrounds) or hold a brand-tuned literal
80
+ in the brand file (the six that diverge in dark: secondary, muted, border,
81
+ input, muted-foreground, accent-foreground). Dark border and input as
82
+ translucent hairlines is a design decision, not duplication; never "fix" a
83
+ divergent semantic by aliasing it.
84
+
85
+ ## Versioning
86
+
87
+ Semantic versioning by hand. A new role or a renamed role is a minor bump
88
+ and a note in the changelog; a changed contract that a brand file must
89
+ satisfy anew is a major bump.
90
+
91
+ ## Licence
92
+
93
+ MIT.
package/css/roles.css ADDED
@@ -0,0 +1,206 @@
1
+ /* =============================================================================
2
+ Design system roles (src/system/roles.css)
3
+
4
+ The brand-agnostic half of the system: every role the app can name, and
5
+ no brand literal anywhere in this file. Values come from one brand file
6
+ that lives in the product repo and satisfies the contract below. The
7
+ product stylesheet imports this file first, then exactly one brand.
8
+
9
+ Two tiers. Primitives (--brand, --band, --ink ...) hold literals and live
10
+ in the brand file. Semantics either alias a primitive via var() here (the
11
+ eight below, identical in light and dark) or hold a brand-tuned literal in
12
+ the brand file (the six divergent ones: secondary, muted, border, input,
13
+ muted-foreground, accent-foreground). Dark border/input as translucent
14
+ hairlines is a design decision, not duplication; never "fix" a divergent
15
+ semantic by aliasing it.
16
+
17
+ Brand contract. ds-check-brand reads these lists and fails the lint when
18
+ a brand file defines less or more.
19
+
20
+ brand-contract:theme
21
+ --font-sans --font-mono
22
+ brand-contract:light
23
+ --radius
24
+ --card-shadow --card-shadow-hover --card-shadow-selected
25
+ --background --primary-foreground --secondary --secondary-foreground
26
+ --muted --muted-foreground --accent-foreground --destructive
27
+ --border --input
28
+ --band --band-2 --surface --surface-2 --ink --ink-2 --ink-3
29
+ --line --line-2 --brand --brand-ink --brand-soft --dark-accent
30
+ --headline-accent
31
+ --highlight --highlight-ink --highlight-soft --highlight-deep
32
+ --dark --dark-2 --dark-3 --dark-4 --dark-line --dark-soft
33
+ --dark-ink --dark-ink-2 --dark-muted --dark-soft-2 --dark-faint
34
+ --dark-faint-2 --dark-brand
35
+ --chip-1-soft --chip-1-ink --chip-2-soft --chip-2-ink
36
+ --chip-3-soft --chip-3-ink --glow-1 --glow-2
37
+ --status-want --status-want-soft --status-ifnec --status-ifnec-soft
38
+ --status-no --status-no-soft
39
+ --bar-shadow --warm-shadow-sm --warm-shadow-md --warm-shadow-lg
40
+ brand-contract:dark
41
+ --card-shadow --card-shadow-hover --card-shadow-selected
42
+ --background --primary-foreground --secondary --secondary-foreground
43
+ --muted --muted-foreground --accent-foreground --destructive
44
+ --border --input
45
+ --band --band-2 --surface --surface-2 --ink --ink-2 --ink-3
46
+ --line --line-2 --brand --brand-ink --brand-soft --dark-accent
47
+ --headline-accent
48
+ --highlight-soft --highlight-deep
49
+ --dark --dark-2 --dark-line --dark-soft
50
+ --chip-1-soft --chip-1-ink --chip-2-soft --chip-2-ink
51
+ --chip-3-soft --chip-3-ink --glow-1 --glow-2
52
+ --status-want --status-want-soft --status-no --status-no-soft
53
+ --warm-shadow-sm --warm-shadow-md --warm-shadow-lg
54
+ brand-contract:end
55
+
56
+ Roles absent from the dark list are mode-constant by design: the radius
57
+ base, the NEW badge (--highlight, --highlight-ink), the phone bezel
58
+ (--dark-3, --dark-4), the on-dark text ramp (--dark-ink ... --dark-brand)
59
+ and the bottom-bar shadow. Always-dark surfaces never change theme, so
60
+ neither does their text. The status "if necessary" pair aliases the
61
+ highlight primitive in light and follows it into dark by itself.
62
+ ============================================================================= */
63
+
64
+ @custom-variant dark (&:is(.dark *));
65
+
66
+ @theme inline {
67
+ /* shadcn semantics */
68
+ --color-background: var(--background);
69
+ --color-foreground: var(--foreground);
70
+ --color-ring: var(--ring);
71
+ --color-input: var(--input);
72
+ --color-border: var(--border);
73
+ --color-destructive: var(--destructive);
74
+ --color-accent-foreground: var(--accent-foreground);
75
+ --color-accent: var(--accent);
76
+ --color-muted-foreground: var(--muted-foreground);
77
+ --color-muted: var(--muted);
78
+ --color-secondary-foreground: var(--secondary-foreground);
79
+ --color-secondary: var(--secondary);
80
+ --color-primary-foreground: var(--primary-foreground);
81
+ --color-primary: var(--primary);
82
+ --color-popover-foreground: var(--popover-foreground);
83
+ --color-popover: var(--popover);
84
+ --color-card-foreground: var(--card-foreground);
85
+ --color-card: var(--card);
86
+
87
+ /* Radius ramp, relative to the brand's --radius base */
88
+ --radius-sm: calc(var(--radius) - 4px);
89
+ --radius-md: calc(var(--radius) - 2px);
90
+ --radius-lg: var(--radius);
91
+ --radius-xl: calc(var(--radius) + 4px);
92
+ --radius-2xl: calc(var(--radius) + 8px);
93
+ --radius-3xl: calc(var(--radius) + 12px);
94
+ --radius-4xl: calc(var(--radius) + 16px);
95
+ /* Named token for the 20px radius used ~34× as rounded-[20px]. Absolute
96
+ value (not on the relative --radius calc ramp) so it resolves to exactly
97
+ 20px, identical to the arbitrary value it replaces. Utility: rounded-20. */
98
+ --radius-20: 20px;
99
+
100
+ /* ── Fluid type roles (SPEC_017): conversion-surface headline scale ───
101
+ Byte-identical to the arbitrary clamp() literals they replace.
102
+ text-section-title carries its line-height via the companion token;
103
+ text-lede deliberately does not, so per-use leading utilities
104
+ (e.g. leading-relaxed) still apply where they exist. */
105
+ --text-display: clamp(2.25rem, 5vw, 3.4rem);
106
+ --text-display--line-height: 0.9;
107
+ --text-section-title: clamp(1.875rem, 4.1vw, 3.0625rem);
108
+ --text-section-title--line-height: 1.05;
109
+ --text-lede: clamp(1.0625rem, 1.6vw, 1.1875rem);
110
+
111
+ /* ── Band rhythm (SPEC_017): landing section verticals ────────────────
112
+ band = section py; band-gap = section header to content mt. */
113
+ --spacing-band: clamp(4.5rem, 9vw, 7.75rem);
114
+ --spacing-band-gap: clamp(2.25rem, 4vw, 3.25rem);
115
+
116
+ /* Shadow-border utilities: shadow-card, shadow-card-hover, shadow-card-selected */
117
+ --shadow-card: var(--card-shadow);
118
+ --shadow-card-hover: var(--card-shadow-hover);
119
+ --shadow-card-selected: var(--card-shadow-selected);
120
+
121
+ /* ── Primitive palette roles ──────────────────────────────────────────
122
+ Theme-aware: each utility maps to a custom property the brand file
123
+ defines under :root / .force-light (light) and overrides in .dark.
124
+ Use the .force-light class to pin a subtree to light, e.g. document
125
+ previews that represent printed white paper. */
126
+ --color-band: var(--band);
127
+ --color-band-2: var(--band-2);
128
+ --color-surface: var(--surface);
129
+ --color-surface-2: var(--surface-2);
130
+ --color-ink: var(--ink);
131
+ --color-ink-2: var(--ink-2);
132
+ --color-ink-3: var(--ink-3);
133
+ --color-line: var(--line);
134
+ --color-line-2: var(--line-2);
135
+ --color-brand: var(--brand);
136
+ --color-brand-ink: var(--brand-ink);
137
+ --color-brand-soft: var(--brand-soft);
138
+ --color-dark-accent: var(--dark-accent);
139
+ --color-headline-accent: var(--headline-accent);
140
+ --color-highlight: var(--highlight);
141
+ --color-highlight-ink: var(--highlight-ink);
142
+ --color-highlight-soft: var(--highlight-soft);
143
+ --color-highlight-deep: var(--highlight-deep);
144
+ --color-dark: var(--dark);
145
+ --color-dark-2: var(--dark-2);
146
+ --color-dark-3: var(--dark-3);
147
+ --color-dark-4: var(--dark-4);
148
+ --color-dark-line: var(--dark-line);
149
+ --color-dark-ink: var(--dark-ink);
150
+ --color-dark-ink-2: var(--dark-ink-2);
151
+ --color-dark-muted: var(--dark-muted);
152
+ --color-dark-soft: var(--dark-soft);
153
+ --color-dark-soft-2: var(--dark-soft-2);
154
+ --color-dark-faint: var(--dark-faint);
155
+ --color-dark-faint-2: var(--dark-faint-2);
156
+ --color-dark-brand: var(--dark-brand);
157
+
158
+ /* Stat chips + hero glow blobs (SPEC_016): theme-varying accent tints
159
+ that let the landing components drop their hand-authored dark: pairs. */
160
+ --color-chip-1-soft: var(--chip-1-soft);
161
+ --color-chip-1-ink: var(--chip-1-ink);
162
+ --color-chip-2-soft: var(--chip-2-soft);
163
+ --color-chip-2-ink: var(--chip-2-ink);
164
+ --color-chip-3-soft: var(--chip-3-soft);
165
+ --color-chip-3-ink: var(--chip-3-ink);
166
+ --color-glow-1: var(--glow-1);
167
+ --color-glow-2: var(--glow-2);
168
+
169
+ /* Traffic-light status ramp (Want / If necessary / Don't want) for
170
+ on-screen preference UI. PDFs and previews keep their own hex palette
171
+ in constants.ts. */
172
+ --color-status-want: var(--status-want);
173
+ --color-status-want-soft: var(--status-want-soft);
174
+ --color-status-ifnec: var(--status-ifnec);
175
+ --color-status-ifnec-soft: var(--status-ifnec-soft);
176
+ --color-status-no: var(--status-no);
177
+ --color-status-no-soft: var(--status-no-soft);
178
+
179
+ /* Warm soft shadows for landing cards: shadow-warm-sm / -md / -lg */
180
+ --shadow-warm-sm: var(--warm-shadow-sm);
181
+ --shadow-warm-md: var(--warm-shadow-md);
182
+ --shadow-warm-lg: var(--warm-shadow-lg);
183
+
184
+ /* Floating bottom-bar shadow (upward): shadow-bar */
185
+ --shadow-bar: var(--bar-shadow);
186
+ }
187
+
188
+ /* The eight aliasing semantics, declared once. They recompute on any element
189
+ that redefines the primitive they point at, which is what lets a
190
+ .force-light subtree inside a dark page resolve to light values. */
191
+ :root,
192
+ .force-light {
193
+ color-scheme: light;
194
+ --foreground: var(--ink);
195
+ --card: var(--surface);
196
+ --card-foreground: var(--ink);
197
+ --popover: var(--surface);
198
+ --popover-foreground: var(--ink);
199
+ --primary: var(--brand);
200
+ --accent: var(--brand-soft);
201
+ --ring: var(--brand);
202
+ }
203
+
204
+ .dark {
205
+ color-scheme: dark;
206
+ }
@@ -0,0 +1,101 @@
1
+ <!doctype html>
2
+ <!-- Showcase template from @fracazo/design-system. Serve it from a product
3
+ that publishes its tokens at /brand.css; the copy and the illustrative
4
+ second skin are BirthGuide and birthplans.app, edit them for yours. -->
5
+ <meta charset="utf-8">
6
+ <title>brand.css demo</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400..700&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/brand.css">
11
+ <style>
12
+ body { margin: 0; font-family: 'Hanken Grotesk', system-ui, sans-serif; }
13
+ .grid { display: grid; grid-template-columns: 1fr 1fr; min-height: 100vh; }
14
+ .panel { padding: 30px 36px 34px; background: var(--background); color: var(--ink); }
15
+
16
+ /* Illustrative birthplans.app skin: same roles, periwinkle values.
17
+ Exactly the override contract documented in brand.css's header. */
18
+ .skin-bp { --brand: #7280cf; --brand-ink: #5c6cbe; --brand-soft: #e9ecf8; --headline-accent: var(--brand-ink); }
19
+ .dark.skin-bp { --brand: #96a4ea; --brand-ink: #aeb9f0; --brand-soft: #343c66; --headline-accent: #c3cbf5; }
20
+
21
+ .tag { font-size: 12px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--warm-muted); }
22
+ h2 { margin: 14px 0 0; font-size: var(--text-section-title); line-height: var(--text-section-title--line-height); font-weight: 600; letter-spacing: -0.02em; }
23
+ h2 em { font-style: normal; color: var(--headline-accent); }
24
+ .lede { margin: 12px 0 0; max-width: 46ch; font-size: var(--text-lede); line-height: 1.55; color: var(--ink-2); }
25
+ .row { display: flex; align-items: center; gap: 12px; margin-top: 20px; flex-wrap: wrap; }
26
+ .btn { display: inline-block; padding: 11px 20px; border-radius: 999px; background: var(--brand); color: var(--primary-foreground); font-weight: 600; font-size: 15px; }
27
+ .chip { display: inline-block; padding: 7px 14px; border-radius: 999px; background: var(--brand-soft); color: var(--brand-ink); font-weight: 600; font-size: 13px; }
28
+ .card { margin-top: 22px; max-width: 400px; padding: 16px 18px; background: var(--card); border-radius: var(--radius-lg); box-shadow: var(--card-shadow); }
29
+ .card b { display: block; font-size: 14.5px; }
30
+ .card span { display: block; margin-top: 3px; font-size: 13px; color: var(--warm-muted); }
31
+ .swatches { display: flex; gap: 8px; margin-top: 22px; flex-wrap: wrap; }
32
+ .sw { width: 74px; }
33
+ .sw i { display: block; height: 40px; border-radius: 8px; border: 1px solid var(--line); }
34
+ .sw small { display: block; margin-top: 4px; font-size: 10.5px; color: var(--warm-muted); font-family: ui-monospace, monospace; }
35
+ </style>
36
+ <div class="grid">
37
+ <div class="panel">
38
+ <span class="tag">BirthGuide &middot; light</span>
39
+ <h2>Feel <em>calm and prepared</em> for labour and birth.</h2>
40
+ <p class="lede">Every value on this page resolves from brand.css: colours, type roles, radii and shadows. Nothing is styled directly.</p>
41
+ <div class="row"><span class="btn">Explore the program</span><span class="chip">Evidence-based</span></div>
42
+ <div class="card"><b>Card on surface</b><span>shadow-card over the cream page, radius from the ramp</span></div>
43
+ <div class="swatches">
44
+ <div class="sw"><i style="background:var(--brand)"></i><small>brand</small></div>
45
+ <div class="sw"><i style="background:var(--brand-ink)"></i><small>brand-ink</small></div>
46
+ <div class="sw"><i style="background:var(--brand-soft)"></i><small>brand-soft</small></div>
47
+ <div class="sw"><i style="background:var(--sand)"></i><small>sand</small></div>
48
+ <div class="sw"><i style="background:var(--surface)"></i><small>surface</small></div>
49
+ <div class="sw"><i style="background:var(--ink)"></i><small>ink</small></div>
50
+ <div class="sw"><i style="background:var(--amber)"></i><small>amber</small></div>
51
+ </div>
52
+ </div>
53
+ <div class="panel dark">
54
+ <span class="tag">BirthGuide &middot; dark</span>
55
+ <h2>Feel <em>calm and prepared</em> for labour and birth.</h2>
56
+ <p class="lede">The .dark block swaps every token; the markup and the roles are identical to the light panel.</p>
57
+ <div class="row"><span class="btn">Explore the program</span><span class="chip">Evidence-based</span></div>
58
+ <div class="card"><b>Card on surface</b><span>dark shadows lean on black and hairlines</span></div>
59
+ <div class="swatches">
60
+ <div class="sw"><i style="background:var(--brand)"></i><small>brand</small></div>
61
+ <div class="sw"><i style="background:var(--brand-ink)"></i><small>brand-ink</small></div>
62
+ <div class="sw"><i style="background:var(--brand-soft)"></i><small>brand-soft</small></div>
63
+ <div class="sw"><i style="background:var(--sand)"></i><small>sand</small></div>
64
+ <div class="sw"><i style="background:var(--surface)"></i><small>surface</small></div>
65
+ <div class="sw"><i style="background:var(--ink)"></i><small>ink</small></div>
66
+ <div class="sw"><i style="background:var(--amber)"></i><small>amber</small></div>
67
+ </div>
68
+ </div>
69
+ <div class="panel skin-bp">
70
+ <span class="tag">birthplans.app skin &middot; light (illustrative)</span>
71
+ <h2>Your plan, <em>ready to share</em> in minutes.</h2>
72
+ <p class="lede">Same stylesheet, same roles. Only the brand primitives are overridden, exactly as the brand.css header documents.</p>
73
+ <div class="row"><span class="btn">Build your plan</span><span class="chip">Free basic plan</span></div>
74
+ <div class="card"><b>Card on surface</b><span>warm neutrals carry across products unchanged</span></div>
75
+ <div class="swatches">
76
+ <div class="sw"><i style="background:var(--brand)"></i><small>brand</small></div>
77
+ <div class="sw"><i style="background:var(--brand-ink)"></i><small>brand-ink</small></div>
78
+ <div class="sw"><i style="background:var(--brand-soft)"></i><small>brand-soft</small></div>
79
+ <div class="sw"><i style="background:var(--sand)"></i><small>sand</small></div>
80
+ <div class="sw"><i style="background:var(--surface)"></i><small>surface</small></div>
81
+ <div class="sw"><i style="background:var(--ink)"></i><small>ink</small></div>
82
+ <div class="sw"><i style="background:var(--amber)"></i><small>amber</small></div>
83
+ </div>
84
+ </div>
85
+ <div class="panel dark skin-bp">
86
+ <span class="tag">birthplans.app skin &middot; dark (illustrative)</span>
87
+ <h2>Your plan, <em>ready to share</em> in minutes.</h2>
88
+ <p class="lede">The dark skin overrides the same four primitives again; everything else rides the shared espresso palette.</p>
89
+ <div class="row"><span class="btn">Build your plan</span><span class="chip">Free basic plan</span></div>
90
+ <div class="card"><b>Card on surface</b><span>one system, two products, both modes</span></div>
91
+ <div class="swatches">
92
+ <div class="sw"><i style="background:var(--brand)"></i><small>brand</small></div>
93
+ <div class="sw"><i style="background:var(--brand-ink)"></i><small>brand-ink</small></div>
94
+ <div class="sw"><i style="background:var(--brand-soft)"></i><small>brand-soft</small></div>
95
+ <div class="sw"><i style="background:var(--sand)"></i><small>sand</small></div>
96
+ <div class="sw"><i style="background:var(--surface)"></i><small>surface</small></div>
97
+ <div class="sw"><i style="background:var(--ink)"></i><small>ink</small></div>
98
+ <div class="sw"><i style="background:var(--amber)"></i><small>amber</small></div>
99
+ </div>
100
+ </div>
101
+ </div>
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // Brand token stylesheet generator.
4
+ //
5
+ // Composes a plain-CSS token file (the public API a product serves, e.g. at
6
+ // /brand.css) from the package's roles.css and the product's brand file.
7
+ // Commit the output; never edit it by hand.
8
+ //
9
+ // ds-build-brand-css --brand src/system/brands/birthguide.css \
10
+ // --out public/brand.css \
11
+ // --name BirthGuide --url https://birthguide.com.au
12
+ // ds-build-brand-css ... --check exit 1 when the output is stale
13
+ //
14
+ // Published: the roles' aliasing semantics, the brand's values in both
15
+ // modes, and the mode-constant type, spacing and radius tokens re-homed
16
+ // onto :root so they resolve without Tailwind. Not published: the
17
+ // --color-*/--shadow-* @theme aliases (Tailwind utility mappings) and the
18
+ // brand's font mapping (a framework variable; document the family in the
19
+ // brand file instead).
20
+ // =============================================================================
21
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ function arg(name) {
24
+ const i = process.argv.indexOf(`--${name}`);
25
+ return i === -1 ? undefined : process.argv[i + 1];
26
+ }
27
+ const brandPath = arg('brand');
28
+ const outPath = arg('out');
29
+ const name = arg('name') ?? 'Brand';
30
+ const url = arg('url') ?? '';
31
+ const check = process.argv.includes('--check');
32
+ if (!brandPath || !outPath) {
33
+ console.error('usage: ds-build-brand-css --brand <brand.css> --out <path> [--name <Name>] [--url <site>] [--check]');
34
+ process.exit(2);
35
+ }
36
+ const ROLES = path.resolve(import.meta.dirname, '../../css/roles.css');
37
+ const roles = readFileSync(ROLES, 'utf8');
38
+ const brand = readFileSync(path.resolve(brandPath), 'utf8');
39
+ function block(source, file, re, label) {
40
+ const m = source.match(re);
41
+ if (!m) {
42
+ throw new Error(`brand.css generator: the ${label} block was not found in ${file}. ` +
43
+ 'Its shape changed; update the generator to match.');
44
+ }
45
+ return m[1];
46
+ }
47
+ const LIGHT = /^:root,\n\.force-light \{\n([\s\S]*?)\n\}/m;
48
+ const DARK = /^\.dark \{\n([\s\S]*?)\n\}/m;
49
+ const THEME = /^@theme inline \{\n([\s\S]*?)\n\}/m;
50
+ const rolesLight = block(roles, 'roles.css', LIGHT, ':root/.force-light');
51
+ const rolesDark = block(roles, 'roles.css', DARK, '.dark');
52
+ const rolesTheme = block(roles, 'roles.css', THEME, '@theme inline');
53
+ const brandLight = block(brand, brandPath, LIGHT, ':root/.force-light');
54
+ const brandDark = block(brand, brandPath, DARK, '.dark');
55
+ const modeConstantTokens = rolesTheme
56
+ .split('\n')
57
+ .filter((line) => /^\s*--(?:text|spacing|radius)-[\w-]+:/.test(line))
58
+ .join('\n');
59
+ if (!modeConstantTokens.includes('--text-display')) {
60
+ throw new Error('brand.css generator: no --text-* tokens matched in the roles @theme block.');
61
+ }
62
+ const contract = block(roles, 'roles.css', /(brand-contract:theme[\s\S]*?)\n\s*brand-contract:end/, 'brand contract')
63
+ .split('\n')
64
+ .map((l) => l.replace(/^\s{3}/, ''))
65
+ .join('\n');
66
+ const header = `/* =============================================================================
67
+ ${name} brand tokens${url ? `\n ${url.replace(/\/$/, '')}/brand.css` : ''}
68
+
69
+ GENERATED by @fracazo/design-system (ds-build-brand-css) from the
70
+ package's roles.css and this product's brand file. Never edit by hand:
71
+ edit the brand file and rebuild.
72
+
73
+ Two tiers: primitives hold literals (--brand, --band, --ink); semantics
74
+ reference them via var() (--primary, --border). Light values live on
75
+ :root, dark overrides on .dark; set the .dark class on <html> from
76
+ prefers-color-scheme before first paint, or mirror the .dark block inside
77
+ a prefers-color-scheme media query. .force-light pins a subtree to light
78
+ regardless of mode, e.g. document previews that represent printed paper.
79
+
80
+ Skinning: the roles block below is shared by every product on this
81
+ system; a brand supplies every property in the contract with its own
82
+ values, light and dark. The roles stay identical; only the values change.
83
+
84
+ ${contract.split('\n').join('\n ')}
85
+ ============================================================================= */
86
+ `;
87
+ const output = `${header}
88
+ /* ── Roles: the aliasing semantics, shared by every brand ──────────────── */
89
+ :root,
90
+ .force-light {
91
+ ${rolesLight}
92
+ }
93
+
94
+ .dark {
95
+ ${rolesDark}
96
+ }
97
+
98
+ /* ── Brand: ${name} values ────────────────────────────────────────────── */
99
+ :root,
100
+ .force-light {
101
+ ${brandLight}
102
+ }
103
+
104
+ .dark {
105
+ ${brandDark}
106
+ }
107
+
108
+ /* ── Type, band rhythm and radius (mode-constant; consumed as Tailwind
109
+ theme tokens inside the app, plain custom properties out here) ──────── */
110
+ :root {
111
+ ${modeConstantTokens}
112
+ }
113
+ `;
114
+ const out = path.resolve(outPath);
115
+ if (check) {
116
+ const current = existsSync(out) ? readFileSync(out, 'utf8') : null;
117
+ if (current !== output) {
118
+ console.error(`${outPath} is stale against the brand file. Rebuild it and commit the result.`);
119
+ process.exit(1);
120
+ }
121
+ console.log(`${outPath} is current.`);
122
+ }
123
+ else {
124
+ writeFileSync(out, output);
125
+ console.log(`written: ${out}`);
126
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // Brand contract check.
4
+ //
5
+ // roles.css declares the custom properties a brand file must define, in a
6
+ // machine-readable comment block (brand-contract:theme / :light / :dark).
7
+ // This holds every brand file you pass to that contract: nothing missing,
8
+ // nothing extra. Wire it into your lint chain.
9
+ //
10
+ // ds-check-brand src/system/brands/birthguide.css [more brand files]
11
+ // =============================================================================
12
+ import { readFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ const ROLES = path.resolve(import.meta.dirname, '../../css/roles.css');
15
+ const roles = readFileSync(ROLES, 'utf8');
16
+ function contractList(section) {
17
+ const re = new RegExp(`brand-contract:${section}\\n([\\s\\S]*?)\\n\\s*brand-contract:`);
18
+ const m = roles.match(re);
19
+ if (!m)
20
+ throw new Error(`roles.css: brand-contract:${section} block not found`);
21
+ return new Set(m[1].match(/--[\w-]+/g) ?? []);
22
+ }
23
+ // Custom properties declared inside the first block whose selector matches.
24
+ function declared(css, selectorRe) {
25
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '');
26
+ const blockRe = /([^{}]+)\{([^{}]*)\}/g;
27
+ for (const m of stripped.matchAll(blockRe)) {
28
+ const selector = (m[1].split(';').pop() ?? '').trim();
29
+ if (!selectorRe.test(selector))
30
+ continue;
31
+ return new Set([...m[2].matchAll(/(--[\w-]+)\s*:/g)].map((d) => d[1]));
32
+ }
33
+ return new Set();
34
+ }
35
+ function diff(expected, actual) {
36
+ const missing = [...expected].filter((p) => !actual.has(p));
37
+ const extra = [...actual].filter((p) => !expected.has(p));
38
+ return { missing, extra };
39
+ }
40
+ const contract = {
41
+ theme: contractList('theme'),
42
+ light: contractList('light'),
43
+ dark: contractList('dark'),
44
+ };
45
+ const brandFiles = process.argv.slice(2);
46
+ if (brandFiles.length === 0) {
47
+ console.error('usage: ds-check-brand <brand.css> [more brand files]');
48
+ process.exit(2);
49
+ }
50
+ let failed = false;
51
+ for (const file of brandFiles) {
52
+ const css = readFileSync(path.resolve(file), 'utf8');
53
+ const checks = [
54
+ ['@theme inline', contract.theme, declared(css, /^@theme\b/)],
55
+ [':root, .force-light', contract.light, declared(css, /^:root,\s*\.force-light$/)],
56
+ ['.dark', contract.dark, declared(css, /^\.dark$/)],
57
+ ];
58
+ for (const [label, expected, actual] of checks) {
59
+ const { missing, extra } = diff(expected, actual);
60
+ if (missing.length || extra.length) {
61
+ failed = true;
62
+ console.error(`${file} ${label}:`);
63
+ for (const p of missing)
64
+ console.error(` missing ${p}`);
65
+ for (const p of extra)
66
+ console.error(` extra ${p} (not a role; declare it in roles.css first)`);
67
+ }
68
+ }
69
+ }
70
+ if (failed)
71
+ process.exit(1);
72
+ console.log(`brand contract: ${brandFiles.length} brand file(s) satisfy roles.css ` +
73
+ `(${contract.light.size} light, ${contract.dark.size} dark, ${contract.theme.size} theme).`);
@@ -0,0 +1,21 @@
1
+ type Restriction = {
2
+ selector: string;
3
+ message: string;
4
+ };
5
+ export declare const noArbitraryColour: Restriction[];
6
+ export declare const noArbitraryTypeClamp: Restriction[];
7
+ export interface GuardrailOptions {
8
+ /** Glob(s) the rules apply to. Default: src/**\/*.{ts,tsx}. */
9
+ files?: string[];
10
+ /** Glob(s) exempt from the rules: renderers that genuinely cannot use CSS variables. */
11
+ ignores?: string[];
12
+ }
13
+ /** A flat-config block: spread it into your eslint.config array. */
14
+ export declare function designSystemGuardrails({ files, ignores, }?: GuardrailOptions): {
15
+ files: string[];
16
+ ignores: string[];
17
+ rules: {
18
+ 'no-restricted-syntax': (string | Restriction)[];
19
+ };
20
+ };
21
+ export {};
@@ -0,0 +1,51 @@
1
+ // =============================================================================
2
+ // ESLint guardrails.
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.
16
+ //
17
+ // import { designSystemGuardrails } from '@fracazo/design-system/eslint'
18
+ // export default defineConfig([
19
+ // ...,
20
+ // designSystemGuardrails({
21
+ // files: ['src/**/*.{ts,tsx}'],
22
+ // ignores: ['src/components/pdf/**'], // renderers that cannot use CSS vars
23
+ // }),
24
+ // ])
25
+ //
26
+ // A deliberate one-off carries `// eslint-disable-next-line
27
+ // no-restricted-syntax -- <reason>` so the exception is visible in review.
28
+ // =============================================================================
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.';
33
+ const forClassName = (regex, message) => [
34
+ { selector: `JSXAttribute[name.name='className'] Literal[value=/${regex}/]`, message },
35
+ {
36
+ selector: `JSXAttribute[name.name='className'] TemplateElement[value.cooked=/${regex}/]`,
37
+ message,
38
+ },
39
+ ];
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 = [], } = {}) {
44
+ return {
45
+ files,
46
+ ignores,
47
+ rules: {
48
+ 'no-restricted-syntax': ['error', ...noArbitraryColour, ...noArbitraryTypeClamp],
49
+ },
50
+ };
51
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@fracazo/design-system",
3
+ "version": "0.1.0",
4
+ "description": "Roles, brand contract and guardrails for a warm, evidence-led product design system. Each product supplies a brand file; the system stays the same.",
5
+ "license": "MIT",
6
+ "author": "Alex Fracazo",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/fracazo/design-system.git"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=20.11"
14
+ },
15
+ "files": [
16
+ "css",
17
+ "dist",
18
+ "demo",
19
+ "README.md",
20
+ "DESIGN.md"
21
+ ],
22
+ "exports": {
23
+ "./roles.css": "./css/roles.css",
24
+ "./eslint": {
25
+ "types": "./dist/guardrails/eslint.d.ts",
26
+ "default": "./dist/guardrails/eslint.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "bin": {
31
+ "ds-check-brand": "dist/guardrails/check-brand.js",
32
+ "ds-build-brand-css": "dist/guardrails/build-brand-css.js"
33
+ },
34
+ "peerDependencies": {
35
+ "tailwindcss": "^4"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22",
39
+ "typescript": "^5"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "check": "tsc -p tsconfig.json --noEmit"
44
+ }
45
+ }