@fracazo/design-system 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/DESIGN.md +366 -8
  2. package/README.md +77 -11
  3. package/css/motion.css +155 -0
  4. package/css/roles.css +3 -0
  5. package/dist/guardrails/eslint.d.ts +72 -5
  6. package/dist/guardrails/eslint.js +197 -29
  7. package/dist/guardrails/init.d.ts +2 -0
  8. package/dist/guardrails/init.js +65 -0
  9. package/dist/guardrails/intake.d.ts +2 -0
  10. package/dist/guardrails/intake.js +131 -0
  11. package/package.json +8 -3
  12. package/skills/product-design/SKILL.md +142 -0
  13. package/skills/product-design/coverage-gaps.md +41 -0
  14. package/skills/product-design/exemplars/calm-the-offering-cards.md +33 -0
  15. package/skills/product-design/exemplars/clamp-drift-to-named-roles.md +37 -0
  16. package/skills/product-design/exemplars/concentric-radii-and-button-optics.md +36 -0
  17. package/skills/product-design/exemplars/dialog-close-focus-visible.md +36 -0
  18. package/skills/product-design/exemplars/hero-glow-seam.md +34 -0
  19. package/skills/product-design/intake/2026-09-07.md +413 -0
  20. package/skills/product-design/references/components.md +42 -0
  21. package/skills/product-design/references/copy.md +25 -0
  22. package/skills/product-design/references/intake.md +66 -0
  23. package/skills/product-design/references/motion.md +22 -0
  24. package/skills/product-design/references/rules.md +319 -0
  25. package/skills/product-design/references/surfaces.md +50 -0
  26. package/skills/product-design/references/tokens.md +54 -0
  27. package/skills/product-design/references/type-and-space.md +42 -0
  28. package/skills/product-design/references/verification.md +35 -0
  29. package/template/CLAUDE.md +47 -0
  30. package/template/README.md +16 -0
  31. package/template/eslint.config.mjs +20 -0
  32. package/template/gitignore +44 -0
  33. package/template/next.config.ts +7 -0
  34. package/template/package.json +40 -0
  35. package/template/pnpm-workspace.yaml +14 -0
  36. package/template/postcss.config.mjs +7 -0
  37. package/template/src/app/globals.css +66 -0
  38. package/template/src/app/layout.tsx +55 -0
  39. package/template/src/app/page.tsx +59 -0
  40. package/template/src/components/ThemeSync.tsx +21 -0
  41. package/template/src/system/brands/starter.css +143 -0
  42. package/template/tsconfig.json +34 -0
@@ -0,0 +1,14 @@
1
+ # pnpm 11 reads project settings from here.
2
+ #
3
+ # allowBuilds: these packages ship install scripts and are approved to build.
4
+ # Without this pnpm fails with ERR_PNPM_IGNORED_BUILDS on install.
5
+ allowBuilds:
6
+ esbuild: true
7
+ msw: true
8
+ sharp: true
9
+ unrs-resolver: true
10
+
11
+ # The design system is ours, so a fresh release may be installed the day it
12
+ # ships; pnpm's minimum-release-age gate otherwise refuses it.
13
+ minimumReleaseAgeExclude:
14
+ - '@fracazo/design-system'
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,66 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Tokens: the package's roles.css declares every role the app can name and
4
+ the motion vocabulary the components use; the brand file supplies the
5
+ values. Import order matters: roles first, then exactly one brand. The
6
+ @source line makes Tailwind scan the package's compiled components, so it
7
+ generates the utilities they use. Everything below the imports is
8
+ app-level CSS. */
9
+ @import "@fracazo/design-system/roles.css";
10
+ @import "../system/brands/starter.css";
11
+ @source "../../node_modules/@fracazo/design-system/dist";
12
+
13
+ @layer base {
14
+ * {
15
+ @apply border-border outline-ring/50;
16
+ }
17
+ body {
18
+ /* font-sans is applied here, not on <html>: next/font sets the font
19
+ variable on <body>, so it only resolves at or below that element. */
20
+ @apply bg-background text-foreground font-sans;
21
+ padding-bottom: env(safe-area-inset-bottom);
22
+ }
23
+ h1, h2, h3, h4 {
24
+ font-weight: 600;
25
+ letter-spacing: -0.02em;
26
+ text-wrap: balance;
27
+ }
28
+ p {
29
+ text-wrap: pretty;
30
+ }
31
+ /* Tailwind v4 resets cursor to default; interactive elements get the pointer. */
32
+ button:not(:disabled),
33
+ [role="button"]:not(:disabled),
34
+ a[href],
35
+ summary {
36
+ cursor: pointer;
37
+ }
38
+ /* iOS zooms into inputs smaller than 16px on focus. */
39
+ input:not([type="checkbox"]):not([type="radio"]),
40
+ select,
41
+ textarea {
42
+ font-size: 16px;
43
+ }
44
+ /* Tap targets on coarse pointers: at least 44px, 48px for the primary controls. */
45
+ @media (pointer: coarse) {
46
+ [data-slot="radio-group-item"],
47
+ [data-slot="checkbox"] {
48
+ min-width: 44px;
49
+ min-height: 44px;
50
+ }
51
+ [data-slot="button"],
52
+ [data-slot="input"] {
53
+ min-height: 48px;
54
+ }
55
+ }
56
+ }
57
+
58
+ @media (prefers-reduced-motion: reduce) {
59
+ *,
60
+ *::before,
61
+ *::after {
62
+ animation-duration: 0.01ms !important;
63
+ animation-iteration-count: 1 !important;
64
+ transition-duration: 0.01ms !important;
65
+ }
66
+ }
@@ -0,0 +1,55 @@
1
+ import type { Metadata, Viewport } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import { ThemeSync } from "@/components/ThemeSync";
4
+ import "./globals.css";
5
+
6
+ // The brand file maps --font-sans to --font-geist-sans. Swap the family here
7
+ // and the variable name there together. `display: "optional"` keeps text from
8
+ // blocking on the webfont: if it is not ready within the browser's window,
9
+ // the metric-matched fallback stays for this page view.
10
+ const sans = Geist({
11
+ variable: "--font-geist-sans",
12
+ subsets: ["latin"],
13
+ display: "optional",
14
+ });
15
+
16
+ const mono = Geist_Mono({
17
+ variable: "--font-geist-mono",
18
+ subsets: ["latin"],
19
+ display: "optional",
20
+ });
21
+
22
+ export const metadata: Metadata = {
23
+ title: "Starter",
24
+ description: "A product on @fracazo/design-system. Replace this.",
25
+ };
26
+
27
+ // viewport-fit=cover lets env(safe-area-inset-*) resolve on iOS, so fixed
28
+ // bottom bars clear the home indicator.
29
+ export const viewport: Viewport = {
30
+ width: "device-width",
31
+ initialScale: 1,
32
+ viewportFit: "cover",
33
+ };
34
+
35
+ export default function RootLayout({
36
+ children,
37
+ }: Readonly<{
38
+ children: React.ReactNode;
39
+ }>) {
40
+ return (
41
+ <html lang="en" suppressHydrationWarning>
42
+ <body className={`${sans.variable} ${mono.variable} antialiased`}>
43
+ {/* Follow the OS colour scheme before first paint, no flash. */}
44
+ <script
45
+ dangerouslySetInnerHTML={{
46
+ __html:
47
+ "(function(){try{if(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.classList.add('dark')}}catch(e){}})();",
48
+ }}
49
+ />
50
+ <ThemeSync />
51
+ {children}
52
+ </body>
53
+ </html>
54
+ );
55
+ }
@@ -0,0 +1,59 @@
1
+ import { Button } from "@fracazo/design-system/ui/button";
2
+ import { Input } from "@fracazo/design-system/ui/input";
3
+ import { Label } from "@fracazo/design-system/ui/label";
4
+
5
+ /**
6
+ * A one-screen proof that the system is wired: the roles resolve through the
7
+ * brand file, the package's components render, both themes work. Replace it
8
+ * with the product's first surface; nothing here is meant to survive.
9
+ */
10
+ export default function Home() {
11
+ return (
12
+ <main>
13
+ <section className="mx-auto max-w-3xl px-6 py-band">
14
+ <p className="font-mono text-xs font-semibold uppercase tracking-[0.16em] text-ink-3">
15
+ Starter
16
+ </p>
17
+ <h1 className="mt-3 text-display">
18
+ A new product on the <span className="text-headline-accent">system</span>
19
+ </h1>
20
+ <p className="mt-5 max-w-prose text-lede text-ink-2">
21
+ The roles come from the package, the values from one brand file.
22
+ Replace every value in <code className="font-mono text-sm">src/system/brands/starter.css</code>,
23
+ then delete this page.
24
+ </p>
25
+ <div className="mt-8 flex flex-wrap gap-3">
26
+ <Button>Primary action</Button>
27
+ <Button variant="outline">Secondary</Button>
28
+ <Button variant="ghost">Back</Button>
29
+ </div>
30
+ </section>
31
+
32
+ <section className="bg-band py-band">
33
+ <div className="mx-auto max-w-3xl px-6">
34
+ <h2 className="text-section-title">A form on a band</h2>
35
+ <div className="mt-band-gap rounded-20 bg-card p-6 shadow-card">
36
+ <div className="grid gap-2">
37
+ <Label htmlFor="email">Email</Label>
38
+ <Input id="email" type="email" placeholder="you@example.com" />
39
+ </div>
40
+ <div className="mt-5 flex flex-wrap gap-2">
41
+ <span className="rounded-full bg-status-want-soft px-3 py-1 text-xs font-medium text-status-want">Want</span>
42
+ <span className="rounded-full bg-status-ifnec-soft px-3 py-1 text-xs font-medium text-highlight-ink">If necessary</span>
43
+ <span className="rounded-full bg-status-no-soft px-3 py-1 text-xs font-medium text-status-no">Don&apos;t want</span>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </section>
48
+
49
+ <footer className="bg-dark py-band text-dark-ink-2">
50
+ <div className="mx-auto max-w-3xl px-6">
51
+ <h4 className="text-dark-faint-2">Always-dark surface</h4>
52
+ <p className="mt-2 text-dark-soft">
53
+ Text here comes from the on-dark ramp, which does not change with the theme.
54
+ </p>
55
+ </div>
56
+ </footer>
57
+ </main>
58
+ );
59
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ /**
6
+ * Keeps the `.dark` class on <html> in sync with the operating system's
7
+ * colour scheme while the page is open. First paint is handled by the inline
8
+ * script in the root layout, so there is no flash; this only reacts to live
9
+ * changes. There is no manual toggle: the product follows system settings.
10
+ */
11
+ export function ThemeSync() {
12
+ useEffect(() => {
13
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
14
+ const apply = (dark: boolean) => document.documentElement.classList.toggle('dark', dark)
15
+ apply(mq.matches)
16
+ const onChange = (event: MediaQueryListEvent) => apply(event.matches)
17
+ mq.addEventListener('change', onChange)
18
+ return () => mq.removeEventListener('change', onChange)
19
+ }, [])
20
+ return null
21
+ }
@@ -0,0 +1,143 @@
1
+ /* =============================================================================
2
+ Brand: STARTER (src/system/brands/starter.css)
3
+
4
+ Every role @fracazo/design-system's roles.css declares, with achromatic
5
+ placeholder values so the starter renders and passes `pnpm brand:contract`
6
+ on day one. Nothing here is a brand. Replace every value, light and dark,
7
+ with the product's own, keep the property names exactly as they are, and
8
+ rename this file to the product. The contract at the top of roles.css is
9
+ the list; `ds-check-brand` fails lint if a property is missing or extra.
10
+
11
+ Two tiers. Primitives (--brand, --band, --ink ...) hold literals here.
12
+ Eight semantics alias them in roles.css and are not redeclared. The six
13
+ below (secondary, muted, border, input, muted-foreground,
14
+ accent-foreground) hold their own literals because a good dark theme
15
+ tunes them away from the primitives; keep that freedom.
16
+ ============================================================================= */
17
+
18
+ @theme inline {
19
+ /* Typeface. next/font sets these variables on <body> (see layout.tsx); the
20
+ mapping stays inline so font-sans resolves on the element carrying it. */
21
+ --font-sans: var(--font-geist-sans);
22
+ --font-mono: var(--font-geist-mono);
23
+ }
24
+
25
+ :root,
26
+ .force-light {
27
+ /* Light values: page, semantics, primitives, status ramp, shadows. */
28
+ --radius: 0.625rem;
29
+ --card-shadow: 0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px -1px oklch(0 0 0 / 0.06), 0 2px 4px 0 oklch(0 0 0 / 0.04);
30
+ --card-shadow-hover: 0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 2px -1px oklch(0 0 0 / 0.08), 0 2px 4px 0 oklch(0 0 0 / 0.06);
31
+ --card-shadow-selected: 0 0 0 1.5px var(--primary), 0 1px 3px -1px oklch(0 0 0 / 0.06), 0 2px 6px 0 oklch(0 0 0 / 0.04);
32
+ --background: oklch(0.985 0 0);
33
+ --primary-foreground: oklch(0.985 0 0);
34
+ --secondary: oklch(0.94 0 0);
35
+ --secondary-foreground: oklch(0.25 0 0);
36
+ --muted: oklch(0.94 0 0);
37
+ --muted-foreground: oklch(0.45 0 0);
38
+ --accent-foreground: oklch(0.35 0 0);
39
+ --destructive: oklch(0.577 0.245 27.325);
40
+ --border: oklch(0.9 0 0);
41
+ --input: oklch(0.9 0 0);
42
+ --band: oklch(0.95 0 0);
43
+ --band-2: oklch(0.93 0 0);
44
+ --surface: oklch(1 0 0);
45
+ --surface-2: oklch(0.975 0 0);
46
+ --ink: oklch(0.25 0 0);
47
+ --ink-2: oklch(0.45 0 0);
48
+ --ink-3: oklch(0.62 0 0);
49
+ --line: oklch(0.9 0 0);
50
+ --line-2: oklch(0.93 0 0);
51
+ --brand: oklch(0.5 0 0);
52
+ --brand-ink: oklch(0.35 0 0);
53
+ --brand-soft: oklch(0.93 0 0);
54
+ --dark-accent: oklch(0.72 0 0);
55
+ --headline-accent: var(--brand-ink);
56
+ --highlight: oklch(0.8 0 0);
57
+ --highlight-ink: oklch(0.25 0 0);
58
+ --highlight-soft: oklch(0.95 0 0);
59
+ --highlight-deep: oklch(0.6 0 0);
60
+ --dark: oklch(0.22 0 0);
61
+ --dark-2: oklch(0.27 0 0);
62
+ --dark-3: oklch(0.17 0 0);
63
+ --dark-4: oklch(0.12 0 0);
64
+ --dark-line: oklch(0.33 0 0);
65
+ --dark-soft: oklch(0.65 0 0);
66
+ --dark-ink: oklch(0.94 0 0);
67
+ --dark-ink-2: oklch(0.82 0 0);
68
+ --dark-muted: oklch(0.72 0 0);
69
+ --dark-soft-2: oklch(0.6 0 0);
70
+ --dark-faint: oklch(0.55 0 0);
71
+ --dark-faint-2: oklch(0.52 0 0);
72
+ --dark-brand: oklch(0.8 0 0);
73
+ --chip-1-soft: oklch(0.94 0 0);
74
+ --chip-1-ink: oklch(0.4 0 0);
75
+ --chip-2-soft: oklch(0.94 0 0);
76
+ --chip-2-ink: oklch(0.4 0 0);
77
+ --chip-3-soft: oklch(0.94 0 0);
78
+ --chip-3-ink: oklch(0.4 0 0);
79
+ --glow-1: oklch(0.95 0 0);
80
+ --glow-2: oklch(0.96 0 0);
81
+ --status-want: oklch(0.606 0.117 158);
82
+ --status-want-soft: oklch(0.945 0.012 158);
83
+ --status-ifnec: var(--highlight);
84
+ --status-ifnec-soft: var(--highlight-soft);
85
+ --status-no: oklch(0.616 0.156 22);
86
+ --status-no-soft: oklch(0.949 0.017 22);
87
+ --bar-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
88
+ --warm-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05), 0 4px 16px -8px oklch(0 0 0 / 0.12);
89
+ --warm-shadow-md: 0 2px 4px oklch(0 0 0 / 0.04), 0 18px 40px -20px oklch(0 0 0 / 0.26);
90
+ --warm-shadow-lg: 0 30px 80px -36px oklch(0 0 0 / 0.42);
91
+ }
92
+
93
+ .dark {
94
+ /* Dark values. Roles absent here are mode-constant by design. */
95
+ --card-shadow: 0 0 0 1px oklch(1 0 0 / 0.06), 0 1px 2px -1px oklch(0 0 0 / 0.4), 0 2px 4px 0 oklch(0 0 0 / 0.3);
96
+ --card-shadow-hover: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.5), 0 2px 4px 0 oklch(0 0 0 / 0.35);
97
+ --card-shadow-selected: 0 0 0 1.5px var(--primary), 0 1px 3px -1px oklch(0 0 0 / 0.4), 0 2px 6px 0 oklch(0 0 0 / 0.3);
98
+ --background: oklch(0.22 0 0);
99
+ --primary-foreground: oklch(0.2 0 0);
100
+ --secondary: oklch(0.33 0 0);
101
+ --secondary-foreground: oklch(0.94 0 0);
102
+ --muted: oklch(0.33 0 0);
103
+ --muted-foreground: oklch(0.72 0 0);
104
+ --accent-foreground: oklch(0.94 0 0);
105
+ --destructive: oklch(0.704 0.191 22.216);
106
+ --border: oklch(1 0 0 / 10%);
107
+ --input: oklch(1 0 0 / 15%);
108
+ --band: oklch(0.27 0 0);
109
+ --band-2: oklch(0.3 0 0);
110
+ --surface: oklch(0.27 0 0);
111
+ --surface-2: oklch(0.245 0 0);
112
+ --ink: oklch(0.94 0 0);
113
+ --ink-2: oklch(0.8 0 0);
114
+ --ink-3: oklch(0.72 0 0);
115
+ --line: oklch(0.355 0 0);
116
+ --line-2: oklch(0.325 0 0);
117
+ --brand: oklch(0.75 0 0);
118
+ --brand-ink: oklch(0.82 0 0);
119
+ --brand-soft: oklch(0.38 0 0);
120
+ --dark-accent: oklch(0.75 0 0);
121
+ --headline-accent: oklch(0.9 0 0);
122
+ --highlight-soft: oklch(0.34 0 0);
123
+ --highlight-deep: oklch(0.74 0 0);
124
+ --dark: oklch(0.2 0 0);
125
+ --dark-2: oklch(0.26 0 0);
126
+ --dark-line: oklch(0.33 0 0);
127
+ --dark-soft: oklch(0.65 0 0);
128
+ --chip-1-soft: oklch(0.34 0 0);
129
+ --chip-1-ink: oklch(0.83 0 0);
130
+ --chip-2-soft: oklch(0.34 0 0);
131
+ --chip-2-ink: oklch(0.83 0 0);
132
+ --chip-3-soft: oklch(0.34 0 0);
133
+ --chip-3-ink: oklch(0.83 0 0);
134
+ --glow-1: oklch(0.4 0 0);
135
+ --glow-2: oklch(0.38 0 0);
136
+ --status-want: oklch(0.72 0.13 158);
137
+ --status-want-soft: oklch(0.34 0.04 158);
138
+ --status-no: oklch(0.70 0.17 22);
139
+ --status-no-soft: oklch(0.34 0.05 22);
140
+ --warm-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.4), 0 4px 16px -8px oklch(0 0 0 / 0.55);
141
+ --warm-shadow-md: 0 2px 6px oklch(0 0 0 / 0.38), 0 20px 44px -22px oklch(0 0 0 / 0.7);
142
+ --warm-shadow-lg: 0 36px 90px -38px oklch(0 0 0 / 0.8);
143
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }