trykeyline 0.0.0-stage → 0.1.1
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/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Inventory the design values a codebase actually uses, with no dependencies.
|
|
4
|
+
*
|
|
5
|
+
* node scan.mjs [root] a markdown summary, top values first
|
|
6
|
+
* node scan.mjs [root] --json everything, for when the summary isn't enough
|
|
7
|
+
* node scan.mjs [root] --ignore src/content --ignore docs
|
|
8
|
+
* leave out paths holding prose or examples, not UI
|
|
9
|
+
*
|
|
10
|
+
* This is evidence, not a verdict. It counts what's written in the source —
|
|
11
|
+
* custom properties, Tailwind utilities, raw CSS values, motion configs — so that
|
|
12
|
+
* setup can see which values are the system and which are strays. Frequency is
|
|
13
|
+
* the signal: a size used 400 times is a role, a size used twice is a question.
|
|
14
|
+
*
|
|
15
|
+
* It reads text, not a compiled stylesheet, so it can't resolve a value computed
|
|
16
|
+
* at runtime or a class built by string concatenation. When the summary and the
|
|
17
|
+
* code disagree, the code wins — open the file the summary points at.
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
20
|
+
import { join, relative, extname, basename } from "node:path";
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const json = argv.includes("--json");
|
|
24
|
+
const ignores = [];
|
|
25
|
+
const positional = [];
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
if (argv[i] === "--ignore") ignores.push(argv[++i] ?? "");
|
|
28
|
+
else if (!argv[i].startsWith("-")) positional.push(argv[i]);
|
|
29
|
+
}
|
|
30
|
+
const root = positional[0] ?? ".";
|
|
31
|
+
if (!existsSync(root)) {
|
|
32
|
+
console.error(`scan: no such directory: ${root}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SKIP_DIRS = new Set([
|
|
37
|
+
"node_modules", ".git", ".next", ".nuxt", ".output", ".svelte-kit", ".turbo",
|
|
38
|
+
".vercel", ".cache", "dist", "build", "out", "coverage", "vendor",
|
|
39
|
+
"storybook-static", ".claude", "public", "static",
|
|
40
|
+
]);
|
|
41
|
+
const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss", ".styl"]);
|
|
42
|
+
const MARKUP = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte", ".astro", ".html", ".mdx"]);
|
|
43
|
+
|
|
44
|
+
/* ── Walk ─────────────────────────────────────────────────────────────────── */
|
|
45
|
+
|
|
46
|
+
const files = [];
|
|
47
|
+
(function walk(dir) {
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.name.startsWith(".") && e.isDirectory() && e.name !== ".storybook") continue;
|
|
56
|
+
const path = join(dir, e.name);
|
|
57
|
+
if (ignores.some((ig) => ig && relative(root, path).startsWith(ig))) continue;
|
|
58
|
+
if (e.isDirectory()) {
|
|
59
|
+
if (!SKIP_DIRS.has(e.name)) walk(path);
|
|
60
|
+
} else if (STYLE.has(extname(e.name)) || MARKUP.has(extname(e.name))) {
|
|
61
|
+
if (/\.min\.|\.d\.ts$|\.test\.|\.spec\.|\.stories\./.test(e.name)) continue;
|
|
62
|
+
if (statSync(path).size > 1_000_000) continue;
|
|
63
|
+
files.push(path);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})(root);
|
|
67
|
+
|
|
68
|
+
/* ── Tallies ──────────────────────────────────────────────────────────────── */
|
|
69
|
+
|
|
70
|
+
const tallies = {};
|
|
71
|
+
function count(bucket, value, file) {
|
|
72
|
+
const v = String(value).trim().replace(/\s+/g, " ");
|
|
73
|
+
if (!v) return;
|
|
74
|
+
tallies[bucket] ??= new Map();
|
|
75
|
+
const entry = tallies[bucket].get(v) ?? { n: 0, files: new Set() };
|
|
76
|
+
entry.n++;
|
|
77
|
+
entry.files.add(relative(root, file));
|
|
78
|
+
tallies[bucket].set(v, entry);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Custom property definitions: name → the values it's given, and where. */
|
|
82
|
+
const props = new Map();
|
|
83
|
+
const propUses = new Map();
|
|
84
|
+
/**
|
|
85
|
+
* Every class-like word in markup, variant prefixes stripped, so a Tailwind v4
|
|
86
|
+
* `@theme` token (`--color-ink-soft`) can be credited with its utility uses
|
|
87
|
+
* (`text-ink-soft`, `hover:border-ink-soft/50`) and not only its `var()` uses.
|
|
88
|
+
*/
|
|
89
|
+
const classWords = new Map();
|
|
90
|
+
|
|
91
|
+
const signals = {};
|
|
92
|
+
function signal(name, file) {
|
|
93
|
+
signals[name] ??= new Set();
|
|
94
|
+
signals[name].add(relative(root, file));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tailwind's scale names, so `text-sm` counts as a size and `text-red-500` doesn't. */
|
|
98
|
+
const TW_SIZE = "xs|sm|base|md|lg|xl|[2-9]xl";
|
|
99
|
+
const TW_PREFIX = String.raw`(?<![\w-])(?:[a-z0-9-]+:)*!?`;
|
|
100
|
+
const tw = (body) => new RegExp(TW_PREFIX + body + String.raw`(?![\w-])`, "g");
|
|
101
|
+
|
|
102
|
+
const RULES = [
|
|
103
|
+
// [bucket, regex, group index, only in]
|
|
104
|
+
["color.literal", /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\([^)]*\)/g, 0, "all"],
|
|
105
|
+
["font-size", /font-size\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
106
|
+
["font-size", tw(`text-(${TW_SIZE})`), 1, "markup"],
|
|
107
|
+
["font-size", tw(String.raw`text-\[(\d[\d.]*(?:px|rem|em))\]`), 1, "markup"],
|
|
108
|
+
["font-weight", /font-weight\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
109
|
+
["font-weight", tw("font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)"), 1, "markup"],
|
|
110
|
+
["font-weight", tw(String.raw`font-\[(\d+)\]`), 1, "markup"],
|
|
111
|
+
["line-height", /line-height\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
112
|
+
["line-height", tw(String.raw`leading-(none|tight|snug|normal|relaxed|loose|\d+|\[[^\]]+\])`), 1, "markup"],
|
|
113
|
+
["letter-spacing", /letter-spacing\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
114
|
+
["letter-spacing", tw(String.raw`tracking-(tighter|tight|normal|wide|wider|widest|\[[^\]]+\])`), 1, "markup"],
|
|
115
|
+
["font-family", /font-family\s*:\s*([^;}\n]+)/g, 1, "all"],
|
|
116
|
+
["font-style", /font-style\s*:\s*(italic|oblique)/g, 1, "all"],
|
|
117
|
+
["font-style", tw("(italic)"), 1, "markup"],
|
|
118
|
+
["radius", /border-radius\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
119
|
+
["radius", tw(String.raw`rounded(?:-[trblse]{1,2})?(?:-(none|xs|sm|md|lg|xl|2xl|3xl|full|\[[^\]]+\]))?`), 0, "markup"],
|
|
120
|
+
["z-index", /z-index\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
121
|
+
["z-index", tw(String.raw`-?z-(\d+|\[[^\]]+\])`), 0, "markup"],
|
|
122
|
+
["shadow", /(?<![(\w-])box-shadow\s*:\s*([^;}\n]+)/g, 1, "all"],
|
|
123
|
+
["shadow", tw(String.raw`shadow(?:-(xs|sm|md|lg|xl|2xl|inner|none|\[[^\]]+\]))?`), 0, "markup"],
|
|
124
|
+
["backdrop", /(?<![(\w-])backdrop-filter\s*:\s*([^;}\n]+)/g, 1, "all"],
|
|
125
|
+
["backdrop", tw(String.raw`backdrop-(?:blur|saturate)(?:-[a-z0-9\[\].%]+)?`), 0, "markup"],
|
|
126
|
+
["max-width", /(?<!\(\s*)max-width\s*:\s*([^;}"'\n]+)/g, 1, "all"],
|
|
127
|
+
["max-width", tw(String.raw`max-w-([a-z0-9]+|\[[^\]]+\])`), 1, "markup"],
|
|
128
|
+
["spacing", tw(String.raw`(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|gap-x|gap-y|space-x|space-y)-(\d+(?:\.5)?|px|\[[^\]]+\])`), 1, "markup"],
|
|
129
|
+
["duration", /(?:transition|animation)(?:-duration)?\s*:[^;}\n]*?(\d*\.?\d+m?s)\b/g, 1, "all"],
|
|
130
|
+
["duration", tw(String.raw`duration-(\d+|\[[^\]]+\])`), 1, "markup"],
|
|
131
|
+
["duration", /\bduration\s*:\s*(\d*\.?\d+)\b/g, 1, "markup"],
|
|
132
|
+
["easing", /cubic-bezier\([^)]*\)/g, 0, "all"],
|
|
133
|
+
["easing", /\b(?:ease|ease-in|ease-out|ease-in-out|linear)(?=[\s;,"'])/g, 0, "style"],
|
|
134
|
+
["easing", /\bease\s*:\s*(\[[^\]]*\]|"[^"]*"|'[^']*')/g, 1, "markup"],
|
|
135
|
+
["spring", /stiffness\s*:\s*(\d+)[^}]*?damping\s*:\s*(\d+)(?:[^}]*?mass\s*:\s*([\d.]+))?/g, "spring", "markup"],
|
|
136
|
+
["breakpoint", /@media[^{]*?\((?:min|max)-width\s*:\s*([^)]+)\)/g, 1, "style"],
|
|
137
|
+
["breakpoint", /(?<![\w-])(sm|md|lg|xl|2xl):(?=[a-z!-])/g, 1, "markup"],
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const SIGNALS = [
|
|
141
|
+
["theme: prefers-color-scheme", /prefers-color-scheme/],
|
|
142
|
+
["theme: data-theme attribute", /data-theme/],
|
|
143
|
+
["theme: .dark class / dark: variant", /\.dark\b|(?<![\w-])dark:/],
|
|
144
|
+
["theme: color-scheme declared", /color-scheme\s*:/],
|
|
145
|
+
["a11y: prefers-reduced-motion", /prefers-reduced-motion|useReducedMotion|motion-reduce:|motion-safe:/],
|
|
146
|
+
["a11y: prefers-reduced-transparency", /prefers-reduced-transparency/],
|
|
147
|
+
["a11y: prefers-contrast", /prefers-contrast/],
|
|
148
|
+
["a11y: :focus-visible", /:focus-visible|focus-visible:/],
|
|
149
|
+
["a11y: bare :focus (check it isn't the ring)", /:focus(?![-\w])/],
|
|
150
|
+
["a11y: outline removed", /outline\s*:\s*(none|0)\b|outline-none/],
|
|
151
|
+
["tailwind v4 @theme block", /@theme\b/],
|
|
152
|
+
["css layers", /@layer\b/],
|
|
153
|
+
["font: @font-face", /@font-face/],
|
|
154
|
+
["font: next/font", /from\s+["']next\/font\//],
|
|
155
|
+
["font: Google Fonts link", /fonts\.googleapis\.com/],
|
|
156
|
+
["numerals: tabular-nums", /tabular-nums|font-variant-numeric/],
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
const text = readFileSync(file, "utf8");
|
|
161
|
+
const kind = STYLE.has(extname(file)) ? "style" : "markup";
|
|
162
|
+
|
|
163
|
+
for (const [bucket, re, group, only] of RULES) {
|
|
164
|
+
if (only !== "all" && only !== kind) continue;
|
|
165
|
+
for (const m of text.matchAll(re)) {
|
|
166
|
+
if (group === "spring") count(bucket, `stiffness ${m[1]}, damping ${m[2]}${m[3] ? `, mass ${m[3]}` : ""}`, file);
|
|
167
|
+
else if (m[group] !== undefined) count(bucket, m[group], file);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A declaration starts after `{`, `;` or whitespace and ends at `;` or `}` — so a BEM
|
|
172
|
+
// modifier in a selector (`.btn--primary:hover {`) is neither a property nor a value.
|
|
173
|
+
for (const m of text.matchAll(/(?<=[{;\s])(--[\w-]+)\s*:\s*([^;{}]+)(?=[;}])/g)) {
|
|
174
|
+
// `@property --x` blocks and Tailwind's `--tw-*` internals are noise.
|
|
175
|
+
if (m[1].startsWith("--tw-")) continue;
|
|
176
|
+
const entry = props.get(m[1]) ?? { values: [], files: new Set() };
|
|
177
|
+
if (entry.values.length < 4 && !entry.values.includes(m[2].trim())) entry.values.push(m[2].trim());
|
|
178
|
+
entry.files.add(relative(root, file));
|
|
179
|
+
props.set(m[1], entry);
|
|
180
|
+
}
|
|
181
|
+
if (kind === "markup") {
|
|
182
|
+
for (const m of text.matchAll(/(?<![\w-])(?:[a-z0-9-]+:)*!?(-?[a-z][a-z0-9-]*(?:-[a-z0-9.]+)+)(?:\/\d+)?(?![\w-])/g)) {
|
|
183
|
+
classWords.set(m[1], (classWords.get(m[1]) ?? 0) + 1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const m of text.matchAll(/var\(\s*(--[\w-]+)/g)) {
|
|
187
|
+
propUses.set(m[1], (propUses.get(m[1]) ?? 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const [name, re] of SIGNALS) if (re.test(text)) signal(name, file);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Tailwind v4 namespaces: `--<namespace>-<name>` becomes utilities ending in
|
|
195
|
+
* `-<name>`. A heuristic — it can over-credit a name that's also a plain word —
|
|
196
|
+
* but it's the difference between "unused" and "used in 40 files".
|
|
197
|
+
*/
|
|
198
|
+
const NAMESPACES = ["color", "font", "text", "radius", "shadow", "ease", "animate", "container", "spacing", "tracking", "leading", "breakpoint", "blur", "inset-shadow", "drop-shadow"];
|
|
199
|
+
function utilityUses(prop) {
|
|
200
|
+
const ns = NAMESPACES.find((n) => prop.startsWith(`--${n}-`));
|
|
201
|
+
if (!ns) return 0;
|
|
202
|
+
const name = prop.slice(ns.length + 3);
|
|
203
|
+
let n = 0;
|
|
204
|
+
for (const [word, c] of classWords) if (word.endsWith(`-${name}`) && word !== name) n += c;
|
|
205
|
+
return n;
|
|
206
|
+
}
|
|
207
|
+
const uses = (prop) => (propUses.get(prop) ?? 0) + utilityUses(prop);
|
|
208
|
+
|
|
209
|
+
/* ── Stack ────────────────────────────────────────────────────────────────── */
|
|
210
|
+
|
|
211
|
+
const stack = {};
|
|
212
|
+
const pkgPath = join(root, "package.json");
|
|
213
|
+
if (existsSync(pkgPath)) {
|
|
214
|
+
try {
|
|
215
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
216
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
217
|
+
const KNOWN = [
|
|
218
|
+
"next", "react", "vue", "nuxt", "svelte", "@sveltejs/kit", "astro", "solid-js", "@angular/core",
|
|
219
|
+
"tailwindcss", "sass", "styled-components", "@emotion/react", "@vanilla-extract/css", "@stitches/react",
|
|
220
|
+
"@pandacss/dev", "@mui/material", "@chakra-ui/react", "@mantine/core", "@radix-ui/themes",
|
|
221
|
+
"motion", "framer-motion", "gsap", "@react-spring/web", "react-aria-components", "@headlessui/react",
|
|
222
|
+
"bootstrap", "class-variance-authority", "tailwind-merge",
|
|
223
|
+
];
|
|
224
|
+
for (const name of KNOWN) if (deps[name]) stack[name] = deps[name];
|
|
225
|
+
if (Object.keys(deps).some((d) => d.startsWith("@radix-ui/react-"))) stack["@radix-ui primitives"] = "yes";
|
|
226
|
+
} catch {
|
|
227
|
+
stack["package.json"] = "unreadable";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const f of ["components.json", "tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs", "theme.json", "tokens.json"]) {
|
|
231
|
+
if (existsSync(join(root, f))) stack[f] = "present";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ── Token files: where the system is declared ────────────────────────────── */
|
|
235
|
+
|
|
236
|
+
const defsPerFile = new Map();
|
|
237
|
+
for (const [, entry] of props) for (const f of entry.files) defsPerFile.set(f, (defsPerFile.get(f) ?? 0) + 1);
|
|
238
|
+
const tokenFiles = [...defsPerFile].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
239
|
+
|
|
240
|
+
/* ── Output ───────────────────────────────────────────────────────────────── */
|
|
241
|
+
|
|
242
|
+
const sorted = (bucket) => [...(tallies[bucket] ?? new Map())].sort((a, b) => b[1].n - a[1].n);
|
|
243
|
+
|
|
244
|
+
if (json) {
|
|
245
|
+
const out = {
|
|
246
|
+
root,
|
|
247
|
+
filesScanned: files.length,
|
|
248
|
+
stack,
|
|
249
|
+
tokenFiles: Object.fromEntries(tokenFiles),
|
|
250
|
+
customProperties: Object.fromEntries(
|
|
251
|
+
[...props].map(([k, v]) => [k, { values: v.values, definedIn: [...v.files], varUses: propUses.get(k) ?? 0, utilityUses: utilityUses(k) }]),
|
|
252
|
+
),
|
|
253
|
+
values: Object.fromEntries(
|
|
254
|
+
Object.keys(tallies).map((b) => [b, sorted(b).map(([v, e]) => ({ value: v, count: e.n, files: e.files.size }))]),
|
|
255
|
+
),
|
|
256
|
+
signals: Object.fromEntries(Object.entries(signals).map(([k, v]) => [k, [...v]])),
|
|
257
|
+
};
|
|
258
|
+
console.log(JSON.stringify(out, null, 2));
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const lines = [];
|
|
263
|
+
const say = (s = "") => lines.push(s);
|
|
264
|
+
|
|
265
|
+
say(`# Design inventory — ${basename(join(process.cwd(), root))}`);
|
|
266
|
+
say();
|
|
267
|
+
say(`${files.length} source files scanned. Counts are occurrences in source text; "files" is how many files use the value.`);
|
|
268
|
+
say();
|
|
269
|
+
|
|
270
|
+
say("## Stack");
|
|
271
|
+
say();
|
|
272
|
+
if (Object.keys(stack).length === 0) say("No package.json signals found.");
|
|
273
|
+
for (const [k, v] of Object.entries(stack)) say(`- ${k} ${v === "present" || v === "yes" ? "" : v}`.trimEnd());
|
|
274
|
+
say();
|
|
275
|
+
|
|
276
|
+
say("## Where tokens are declared");
|
|
277
|
+
say();
|
|
278
|
+
if (tokenFiles.length === 0) say("No custom property declarations found — values are likely inline or in a JS theme object.");
|
|
279
|
+
for (const [f, n] of tokenFiles) say(`- \`${f}\` — ${n} custom properties`);
|
|
280
|
+
say();
|
|
281
|
+
|
|
282
|
+
const TOP = 15;
|
|
283
|
+
/** Long values (a four-layer shadow) are clipped; --json has them whole. */
|
|
284
|
+
const clip = (v, n = 90) => (v.length > n ? `${v.slice(0, n)}…` : v).replace(/\|/g, "\\|");
|
|
285
|
+
const SECTIONS = [
|
|
286
|
+
["Colour literals", "color.literal", "A long tail here means colours bypass the tokens."],
|
|
287
|
+
["Font sizes", "font-size", "Many sizes with low counts usually means no ladder."],
|
|
288
|
+
["Font weights", "font-weight"],
|
|
289
|
+
["Line heights", "line-height"],
|
|
290
|
+
["Letter spacing", "letter-spacing"],
|
|
291
|
+
["Font families", "font-family"],
|
|
292
|
+
["Italic", "font-style", "Relevant if the system reserves or bans italic."],
|
|
293
|
+
["Max widths (measures)", "max-width"],
|
|
294
|
+
["Spacing utilities", "spacing"],
|
|
295
|
+
["Radii", "radius"],
|
|
296
|
+
["Z-index", "z-index"],
|
|
297
|
+
["Shadows", "shadow"],
|
|
298
|
+
["Backdrop filters", "backdrop"],
|
|
299
|
+
["Durations", "duration", "Bare numbers from JS configs are usually seconds."],
|
|
300
|
+
["Easing", "easing"],
|
|
301
|
+
["Springs", "spring"],
|
|
302
|
+
["Breakpoints", "breakpoint"],
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
for (const [title, bucket, note] of SECTIONS) {
|
|
306
|
+
const rows = sorted(bucket);
|
|
307
|
+
if (rows.length === 0) continue;
|
|
308
|
+
say(`## ${title}`);
|
|
309
|
+
say();
|
|
310
|
+
if (note) say(`_${note}_`), say();
|
|
311
|
+
say("| Value | Count | Files |");
|
|
312
|
+
say("|---|---|---|");
|
|
313
|
+
for (const [v, e] of rows.slice(0, TOP)) say(`| \`${clip(v)}\` | ${e.n} | ${e.files.size} |`);
|
|
314
|
+
if (rows.length > TOP) say(`| … ${rows.length - TOP} more distinct values | | |`);
|
|
315
|
+
say();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (props.size > 0) {
|
|
319
|
+
say("## Custom properties");
|
|
320
|
+
say();
|
|
321
|
+
say("_Most used first. A property defined but never used is a candidate for removal; one used everywhere is a role._");
|
|
322
|
+
say();
|
|
323
|
+
say("| Property | Uses (var · utility) | Values seen | Defined in |");
|
|
324
|
+
say("|---|---|---|---|");
|
|
325
|
+
const byUse = [...props].sort((a, b) => uses(b[0]) - uses(a[0]));
|
|
326
|
+
for (const [k, v] of byUse.slice(0, 60)) {
|
|
327
|
+
const values = v.values.map((x) => `\`${clip(x.replace(/\s+/g, " "), 48)}\``).join(" · ");
|
|
328
|
+
say(`| \`${k}\` | ${propUses.get(k) ?? 0} · ${utilityUses(k)} | ${values} | ${[...v.files].slice(0, 2).join(", ")} |`);
|
|
329
|
+
}
|
|
330
|
+
if (props.size > 60) say(`| … ${props.size - 60} more | | | |`);
|
|
331
|
+
say();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
say("## Signals");
|
|
335
|
+
say();
|
|
336
|
+
if (Object.keys(signals).length === 0) say("None found.");
|
|
337
|
+
for (const [k, v] of Object.entries(signals).sort()) say(`- ${k} — ${v.size} file${v.size === 1 ? "" : "s"}`);
|
|
338
|
+
say();
|
|
339
|
+
|
|
340
|
+
console.log(lines.join("\n"));
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# The `DESIGN.md` format
|
|
2
|
+
|
|
3
|
+
One file at the project root. It is the contract between the project and every Keyline command: they read it before touching an interface, and write decisions back to it when a decision changes.
|
|
4
|
+
|
|
5
|
+
It is written for two readers at once — a person skimming it to learn the system, and a model looking up one row. That settles most format questions: tables for anything looked up, a sentence of reasoning for anything decided, and **stable section headings**, because each command finds its section by its heading.
|
|
6
|
+
|
|
7
|
+
## Rules
|
|
8
|
+
|
|
9
|
+
- **The project's names, always.** The `Name here` column holds whatever the codebase already calls the thing — a Tailwind class, a CSS custom property, a theme key, a component prop. The `Role` column holds the canonical role from `roles.md`. Never rename a project's tokens to match the canonical roles; the mapping is the whole point.
|
|
10
|
+
- **Where the project has no name for a role it needs**, propose one in the project's existing naming style and mark it `proposed`. Detect the style from what's there — `--color-text-muted` implies `--color-text-<tier>`; `ink.soft` implies `ink.<adjective>`; a project with evocative names (`dek`, `keyline`, `whisper`) gets evocative proposals. Don't import a naming scheme the project doesn't use.
|
|
11
|
+
- **Values as the code states them.** `14px`, `0.875rem`, `text-sm` — whichever the source uses, with the resolved pixel value in parentheses where it's a scale step or a unit conversion. A value that differs per theme gets a column per theme.
|
|
12
|
+
- **Contrast is written down, not implied.** Every text tier carries its measured ratio against the surface it's checked on, per theme. Setup computes them with `scripts/contrast.mjs`.
|
|
13
|
+
- **Leave out what isn't there.** No dark theme, no dark column. No prose, no `lead` row. An empty row reads as a requirement.
|
|
14
|
+
- **Decisions carry a reason and a date.** Anything that moves a dial off its preset, or knowingly breaks a law, goes under `## Decisions`. A command that meets a decision respects it; a command that would break a law without one asks first and then records the answer here.
|
|
15
|
+
- **Changes a law forced are recorded too.** A change made because of a law that a later session might undo — a white page replacing a grey one, a removed animation — goes under `## Decisions` marked *law*, so it isn't "fixed" back.
|
|
16
|
+
- **The decisions log reads as the current state, not a diary.** Each entry is dated and marked by who decided — *user decision*, *client feedback*, or *law* — newest first. A new decision on a subject replaces the entry it supersedes rather than sitting below it; git history keeps the old one. A session reads the log before changing anything it covers, so a decided value isn't "fixed" back.
|
|
17
|
+
- **The detector reads tables, not prose.** `scripts/check.mjs` takes the preset from the `Preset` row under `## Profile`, durations from every table row under `## Motion` (the duration ladder and the named exemptions), and z-index values from `## Layout` rows whose role starts `z.` or whose `Name here` is a z utility. A value written only in a sentence, or only as `var(--…)`, is invisible to it, so these tables state values. **Every value in those tables is approved**, so record the ladder the code mostly uses, not every value it contains: a duration or z-index used once or twice, or a few ms from a neighbour, is an open issue to snap, not a rung. The detector warns when a `duration.*` role is no longer than the role for a smaller change.
|
|
18
|
+
- **Findings are not fixes.** Setup lists what violates a law under `## Open issues` and leaves the code alone unless asked. The list is how a later session knows what's known.
|
|
19
|
+
|
|
20
|
+
## Template
|
|
21
|
+
|
|
22
|
+
Copy this shape. Drop any table or row that doesn't apply; add project roles under the canonical role they refine.
|
|
23
|
+
|
|
24
|
+
````markdown
|
|
25
|
+
# Design system
|
|
26
|
+
|
|
27
|
+
> Keyline reads this before touching UI, and writes back to it when a decision changes. Edit it by hand whenever you like — it is the source of truth for intent, the code is the source of truth for values. Set up YYYY-MM-DD.
|
|
28
|
+
|
|
29
|
+
## Profile
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Product | One line: what it is and who it's for |
|
|
34
|
+
| Reader | What the reader is mostly doing — reading, working, or being persuaded |
|
|
35
|
+
| Preset | `editorial` · `product` · `expressive` · `glass` |
|
|
36
|
+
| Areas | Routes or directories on a different preset, e.g. `app/(marketing)/** → expressive` |
|
|
37
|
+
| Stack | Framework, styling approach, component library |
|
|
38
|
+
| Tokens live in | Paths, e.g. `src/styles/tokens.css`, `tailwind.config.ts` |
|
|
39
|
+
| Themes | `light`, `dark` — and the mechanism: attribute, class, media query |
|
|
40
|
+
| Motion | CSS only, or the library in use |
|
|
41
|
+
|
|
42
|
+
## Type
|
|
43
|
+
|
|
44
|
+
**Families** — `family.text`: Inter (self-hosted, `next/font`), `family.mono`: JetBrains Mono.
|
|
45
|
+
|
|
46
|
+
| Role | Name here | Size | Leading | Tracking | Weight | Tier |
|
|
47
|
+
|---|---|---|---|---|---|---|
|
|
48
|
+
| `type.title` | `text-title` | 20px | 1.25 | −0.02em | 600 | `text.primary` |
|
|
49
|
+
| `type.body` | `text-base` | 15px | 1.6 | 0 | 400 | `text.primary` |
|
|
50
|
+
| ↳ `pull-quote` (project role) | `.pull` | 18px | 1.4 | 0 | 400 italic | `text.primary` — separated by a 3px rule and the italic voice |
|
|
51
|
+
|
|
52
|
+
**Weights** — `weight.regular` 400, `weight.strong` 600.
|
|
53
|
+
|
|
54
|
+
## Colour
|
|
55
|
+
|
|
56
|
+
**Text** — checked against `surface.page` unless noted.
|
|
57
|
+
|
|
58
|
+
| Role | Name here | Light | Ratio | Dark | Ratio |
|
|
59
|
+
|---|---|---|---|---|---|
|
|
60
|
+
| `text.primary` | `--ink` | `#16161a` | 17.4:1 | `#ececef` | 16.1:1 |
|
|
61
|
+
|
|
62
|
+
**Surfaces**, **Lines and fills**, **Signal** — same columns, without ratios except for `focus` (3:1 against its worst surface).
|
|
63
|
+
|
|
64
|
+
**Exceptions** — each chromatic spend beyond the accent, with what it's for and why that reason can't spread.
|
|
65
|
+
|
|
66
|
+
## Layout
|
|
67
|
+
|
|
68
|
+
| Role | Name here | Value |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `measure.reading` | `max-w-prose` | 65ch (≈ 620px) |
|
|
71
|
+
|
|
72
|
+
**Radius ladder**
|
|
73
|
+
|
|
74
|
+
| Role | Name here | Value |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `radius.control` | `rounded-md` | 6px |
|
|
77
|
+
| `radius.card` | `rounded-xl` | 12px |
|
|
78
|
+
|
|
79
|
+
**z-index ladder** — low to high, rows `z.below`, `z.raised`, `z.popover`, `z.dismiss`, `z.nav`, `z.overlay`, `z.toast`, `z.skip-link` as used.
|
|
80
|
+
|
|
81
|
+
| Role | Name here | Value |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `z.popover` | `z-popover` | 20 |
|
|
84
|
+
| `z.overlay` | `z-overlay` | 60 |
|
|
85
|
+
| `z.toast` | `z-toast` | 70 |
|
|
86
|
+
|
|
87
|
+
**Breakpoints**
|
|
88
|
+
|
|
89
|
+
| Role | Name here | Value | What changes |
|
|
90
|
+
|---|---|---|---|
|
|
91
|
+
| `bp.rail` | `lg` | 1024px | The table of contents moves into the margin rail |
|
|
92
|
+
|
|
93
|
+
Plus any forks (places where touch and pointer get different components).
|
|
94
|
+
|
|
95
|
+
## Surfaces
|
|
96
|
+
|
|
97
|
+
| Level | Surface | Edge | Shadow | Layer |
|
|
98
|
+
|---|---|---|---|---|
|
|
99
|
+
| `elevation.floating` | `bg-popover` | `border-subtle` | `shadow-md` | `z-popover` |
|
|
100
|
+
|
|
101
|
+
Plus `material.translucent` tiers, if any, and their fallbacks.
|
|
102
|
+
|
|
103
|
+
## Motion
|
|
104
|
+
|
|
105
|
+
| Role | Name here | Value | Used for |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| `duration.state` | `--dur-fast` | 150ms | hover colour, icon swaps |
|
|
108
|
+
| `ease.standard` | `--ease-out` | `cubic-bezier(0.2, 0, 0, 1)` | everything but colour |
|
|
109
|
+
|
|
110
|
+
**Named exemptions** — motion off the ladder on purpose: set pieces, exits, spring opacity, crossfade pairs.
|
|
111
|
+
|
|
112
|
+
| Exemption | Values | Why |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| Greeting | 900ms | Set piece: plays once a session, so it is not state (motion law 4) |
|
|
115
|
+
| Menu exit | 120ms | Closing runs faster than opening |
|
|
116
|
+
|
|
117
|
+
Plus springs by character, and the reduced-motion policy in one sentence.
|
|
118
|
+
|
|
119
|
+
## Patterns
|
|
120
|
+
|
|
121
|
+
**Nouns** — one per kind of thing: task (never "issue"), note, project, member. **Row second line** — `{type} from {origin}`. **Destructive** — delete, remove, sign out: last in menu, no icon; projects and accounts need a typed word. **Shortcuts** — ⌘K palette, J/K, F filter, Escape one layer. **Remembered views** — tasks and meetings per page; folders pinned to folders.
|
|
122
|
+
|
|
123
|
+
## Decisions
|
|
124
|
+
|
|
125
|
+
- YYYY-MM-DD — Card titles at 500, not 600: they read heavy beside the pane title. (User decision; the type guide dial.)
|
|
126
|
+
- YYYY-MM-DD — Three weights, not two: the billing screens need a 600 heading weight above the 500 that lifts lines inside rows. (Client feedback; the type guide dial override.)
|
|
127
|
+
- YYYY-MM-DD — Page on white `surface.page`, not the grey `bg-neutral-50`: the text tiers failed against the grey. (Law; the colour guide.)
|
|
128
|
+
|
|
129
|
+
## Open issues
|
|
130
|
+
|
|
131
|
+
- `text.tertiary` (`--ink-faint`) is 3.1:1 on `surface.subtle` in light — fails 4.5:1. Used in 14 files.
|
|
132
|
+
````
|
|
133
|
+
|
|
134
|
+
## When the file already exists
|
|
135
|
+
|
|
136
|
+
Read it first; never regenerate over it. Setup's job on a second run is to **reconcile**: re-scan, show what drifted (a token added, a value changed, an issue fixed), and propose edits to the file — keeping every `Decisions` entry and every name the user chose, even where the scan would have proposed something else.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Reading a codebase's design system
|
|
2
|
+
|
|
3
|
+
Where each kind of project keeps its decisions, and what to read once `scan.mjs` has pointed you at the right files. The scan counts; this is how to interpret what it counted.
|
|
4
|
+
|
|
5
|
+
## Where the tokens live
|
|
6
|
+
|
|
7
|
+
| Stack signal | Look in | Notes |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Tailwind v4 (`@theme` in the scan's signals) | The CSS file with the `@theme` block — usually `app/globals.css`, `src/index.css`, `src/styles/*.css` | Tokens are custom properties in namespaces (`--color-*`, `--text-*`, `--radius-*`, `--ease-*`) and are used as utilities, so a token with no `var()` uses can still be everywhere. The scan's "utility" count is the one to read |
|
|
10
|
+
| Tailwind v3 (`tailwind.config.*` present) | `theme.extend` in the config, plus any `@layer base` custom properties | Values in `theme` (not `extend`) *replace* Tailwind's defaults — note which scales were replaced, because that's a deliberate ladder |
|
|
11
|
+
| shadcn/ui (`components.json`) | The CSS variables block it generated (`--background`, `--foreground`, `--muted-foreground`, `--border`, `--ring`, …) and `components/ui/*` | The names are already semantic, and many map one-to-one onto canonical roles. `--muted-foreground` is almost always `text.secondary` or `text.tertiary` — check its contrast before deciding which |
|
|
12
|
+
| CSS custom properties only | Whichever file the scan lists first under "Where tokens are declared" | Look for a `:root` block and a theme block (`[data-theme="dark"]`, `.dark`, or a media query) |
|
|
13
|
+
| Sass (`sass` dependency) | `_variables.scss`, `_tokens.scss`, `abstracts/`, `settings/` | Sass variables compile away, so the scan's custom-property table will be thin. Read the partials directly |
|
|
14
|
+
| CSS-in-JS (`styled-components`, `@emotion/react`, `@stitches/react`, `@vanilla-extract/css`, `@pandacss/dev`) | The theme object: `theme.ts`, `stitches.config.ts`, `*.css.ts`, `panda.config.ts` | Values live in JS, so the scan finds them as literals. Tokens are the object's keys |
|
|
15
|
+
| A component library (`@mui/material`, `@chakra-ui/react`, `@mantine/core`, `@radix-ui/themes`) | The `createTheme` / `extendTheme` call, or the `Theme` provider's props | The library supplies a full system; the project's decisions are the *overrides*. Map the library's roles, then record the overrides as the project's dials |
|
|
16
|
+
| Design tokens JSON (`tokens.json`, Style Dictionary, Tokens Studio) | The JSON source, not the generated output | The generated CSS is downstream. Point `DESIGN.md` at the source file |
|
|
17
|
+
|
|
18
|
+
## Fonts
|
|
19
|
+
|
|
20
|
+
- **`next/font`** — the import in the root layout names the family, the weights loaded and the CSS variable it's exposed as. Weights loaded is a strong hint at weights *intended*.
|
|
21
|
+
- **`@font-face`** — the `font-weight` ranges declared are what exists; anything else is faux-bolded by the browser.
|
|
22
|
+
- **Google Fonts `<link>`** — the `wght@` list in the URL.
|
|
23
|
+
- **System stack only** — the project has chosen not to ship a font. Record it as a decision, not an absence.
|
|
24
|
+
|
|
25
|
+
A family loaded at five weights and used at two is worth a line in open issues: the other three are download cost with no job.
|
|
26
|
+
|
|
27
|
+
## Themes
|
|
28
|
+
|
|
29
|
+
| Signal | Mechanism |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `data-theme` attribute | An explicit toggle that can override the OS — the more robust setup |
|
|
32
|
+
| `.dark` class / `dark:` variant | The same, via a class. Check whether Tailwind's dark variant is set to the class or to the media query |
|
|
33
|
+
| `prefers-color-scheme` only | Follows the OS with no override. A toggle can't be added without changing the mechanism |
|
|
34
|
+
| `color-scheme` missing | Native scrollbars and form controls won't re-tint. An open issue if there's a dark theme |
|
|
35
|
+
|
|
36
|
+
Look for the pre-paint script as well: a theme set by React after hydration flashes the wrong theme on every load.
|
|
37
|
+
|
|
38
|
+
## Motion
|
|
39
|
+
|
|
40
|
+
- **CSS** — `transition` and `animation` declarations, `@keyframes`, and any duration or easing custom properties.
|
|
41
|
+
- **Motion / Framer Motion** — `transition={{ … }}` props, `variants` objects, shared presets (often a `motion.ts` or `transitions.ts`). Durations there are **seconds**, not milliseconds; the scan reports them as bare numbers.
|
|
42
|
+
- **GSAP, React Spring** — timelines and `config` objects. React Spring's `tension`/`friction` map roughly to stiffness/damping.
|
|
43
|
+
- **Reduced motion** — `prefers-reduced-motion` in CSS, `useReducedMotion()`, `MotionConfig reducedMotion`, Tailwind's `motion-safe:`/`motion-reduce:`. Note *what* happens under it — removed entirely, or replaced with a fade? The motion guide treats those very differently.
|
|
44
|
+
|
|
45
|
+
## Reading a component
|
|
46
|
+
|
|
47
|
+
The scan can't tell you how values combine. Three components read end to end tell you most of it:
|
|
48
|
+
|
|
49
|
+
1. **A page** — the reading measure, the heading sizes, the section rhythm, what the `h1` looks like.
|
|
50
|
+
2. **A card or list row** — the radius, the border, the surface, the hover state, the text tiers inside it.
|
|
51
|
+
3. **A menu, popover or dialog** — the elevation, the z-index, the open and close motion, focus handling.
|
|
52
|
+
|
|
53
|
+
Between them you will see the roles in use, which is what the mapping needs. A value that looks like a token but is never used together with its siblings is often a leftover.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Design System Setup
|
|
2
|
+
|
|
3
|
+
Keyline's domain guides know how a type ladder, a colour system or a motion system should behave. They don't know *your* project: what it calls its greys, which font it ships, whether it has a dark theme, whether its reader is skimming a dashboard or reading an essay. This guide finds out once and writes it down in `DESIGN.md` at the project root, so every later session and every other command starts from the same facts.
|
|
4
|
+
|
|
5
|
+
The job is **translation, not replacement.** A project that calls its quietest text `whisper` keeps calling it `whisper`; setup records that `whisper` is `text.tertiary`, the lowest tier that must still be readable, so the colour guide can check it clears 4.5:1 and the type guide can put captions on it.
|
|
6
|
+
|
|
7
|
+
The contract lives in `system/`: `roles.md` (the canonical roles, and which may share a token), `presets.md` (the four starting characters and how to choose), `design-md.md` (the file's format, a template, and how to reconcile).
|
|
8
|
+
|
|
9
|
+
## 0. If `DESIGN.md` already exists
|
|
10
|
+
|
|
11
|
+
Don't regenerate it. Rescan and **reconcile**: report what drifted (new tokens, changed values, issues fixed or new), propose edits, and keep every name and `Decisions` entry the user chose. A second run is an audit, not a rewrite; the rest of this file is the first run.
|
|
12
|
+
|
|
13
|
+
## 1. Scan
|
|
14
|
+
|
|
15
|
+
From the project root:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node <keyline>/scripts/scan.mjs . --ignore src/content
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`--ignore` takes a path prefix and repeats; use it for directories of prose, documentation or code samples, or their example values count as the project's. The output is a markdown inventory — the stack, where custom properties are declared, every value by frequency, and signals for theming, reduced motion, focus handling and font loading. `--json` gives everything.
|
|
22
|
+
|
|
23
|
+
The scan reads source text, so it can't see a value computed at runtime, a class assembled from strings, or a theme passed through a provider. Open the top token file and the root layout, and read a page, a card and a menu end to end. `system/detection.md` lists where each stack keeps its tokens.
|
|
24
|
+
|
|
25
|
+
**Frequency is the signal.** A size used in 40 files is a role; a size used once is a stray or a genuine one-off, and deserves a look rather than an assumption.
|
|
26
|
+
|
|
27
|
+
## 2. Map what's there onto the roles
|
|
28
|
+
|
|
29
|
+
For each canonical role in `roles.md`: which of the project's names does that job, and with what value? Map by usage, not by name — a `gray-500` that every caption uses *is* `text.tertiary`.
|
|
30
|
+
|
|
31
|
+
Five places projects have quietly broken a law:
|
|
32
|
+
|
|
33
|
+
- **One token doing two jobs.** A grey used for borders and text: a border needs 3:1, text 4.5:1, and one value rarely serves both. Record the coupling as an open issue; don't split it yet.
|
|
34
|
+
- **A job with no token.** Captions at six greys, durations typed inline. Propose a name in the project's style (`design-md.md` says how to detect it) and mark it `proposed`. Where a whole domain has no tokens — no type tokens, no motion tokens — record that as one open issue, not one per role; that domain's refining pass proposes a full set in the project's style, marked `proposed`.
|
|
35
|
+
- **Roles the list doesn't have.** A `keyline`, a `pull-quote`, a `kicker`: record each as a project role under its nearest canonical parent, with what separates it — size, weight, leading, colour or a rule. If nothing does, that's a finding.
|
|
36
|
+
- **Boxes as the default.** Every section, stat and list in a card, cards inside cards, a bento grid of unlike sections, tinted strips behind labels, uppercase eyebrows, several chip shapes, a second hue that calls for attention (a hue family answering what, where or who under `product` is not one), success colour on done steps where completion isn't the news (a sequence the reader passes through, not a board or queue). Count the boxes per main screen and list each that holds more than one object (depth law 2, layout law 3). If the user has a reference interface they've already approved, screenshot each main screen beside it; the gaps are open issues.
|
|
37
|
+
- **Values on no ladder.** Twenty-three font sizes, fourteen radii. Cluster them by usage, propose the ladder, list the strays that could snap to a neighbour unseen.
|
|
38
|
+
|
|
39
|
+
Compute contrast; don't estimate it:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
node <keyline>/scripts/contrast.mjs --ladder "#ffffff" "#16161a" "#4b4b52" "#6b6b73"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`--ladder` takes a surface then the text tiers, top first, and checks each clears 4.5:1 **and** that they strictly descend — every tier passing while two are indistinguishable means the hierarchy is gone. Run it per theme against the worst surface each tier lands on; use `--min 3` for icons, borders and the focus ring.
|
|
46
|
+
|
|
47
|
+
## 3. Choose the preset
|
|
48
|
+
|
|
49
|
+
From what the reader is mostly doing — reading, working, or being persuaded — propose `editorial`, `product` or `expressive` (`presets.md`). Offer `glass` only when the user wants modimihir.com's exact system rather than their own; it fills the dials from the tables in each `glass/<domain>/guide.md`, under the project's existing names wherever it has them.
|
|
50
|
+
|
|
51
|
+
State the evidence: *"Mostly long-form pages under `app/blog`, two weights in use, one accent — this reads as `editorial`."* Mixed projects get a base preset plus areas: `product`, with `app/(marketing)/** → expressive`.
|
|
52
|
+
|
|
53
|
+
Code that contradicts the preset — a `product` codebase with five weights — doesn't change the preset. It's a decision to record with its reason, or an issue to list.
|
|
54
|
+
|
|
55
|
+
## 4. Ask only what the code can't answer
|
|
56
|
+
|
|
57
|
+
At most four questions, each with your recommendation first:
|
|
58
|
+
|
|
59
|
+
1. **The preset**, if the evidence doesn't settle it or you're proposing something the codebase doesn't yet look like.
|
|
60
|
+
2. **Themes**, if only one exists: is a second planned? It changes how tokens should be structured now.
|
|
61
|
+
3. **Couplings**, where splitting one changes what users see — "captions and borders share `gray-500`; split them?"
|
|
62
|
+
4. **Names**, only when proposing several at once and the project's style is ambiguous.
|
|
63
|
+
|
|
64
|
+
Don't ask what you can find out, and don't ask for approval of the whole mapping — write it and let the user correct the file.
|
|
65
|
+
|
|
66
|
+
**When you can't stop to ask** — a headless or scheduled run, or a user who said to go ahead — take your recommendation for each question, write the file, and list each one under `## Decisions` marked *(?) assumed*, with the alternative, so the user can overturn it in one edit. A later command treats an *(?) assumed* entry like any decision until the user changes it.
|
|
67
|
+
|
|
68
|
+
## 5. Write `DESIGN.md`
|
|
69
|
+
|
|
70
|
+
Follow `system/design-md.md`: the project's names in `Name here`, canonical roles in `Role`, values as the source states them, measured contrast for every text tier, per-theme columns only where themes exist, nothing for roles the project lacks. Then:
|
|
71
|
+
|
|
72
|
+
- **`## Decisions`** — every dial set differently from the preset, with its reason, or a question mark if unknown.
|
|
73
|
+
- **`## Open issues`** — every law the code breaks, one line each with evidence (a ratio, a count, a file). Cite each law by the right number (`commands/init.md`). **Don't fix anything** unless asked; a setup run that also rewrites forty components can't be reviewed.
|
|
74
|
+
|
|
75
|
+
Tell the user briefly: the preset and why, how many roles mapped cleanly, what you proposed names for, and the three most important open issues.
|
|
76
|
+
|
|
77
|
+
## 6. Generating tokens, only when asked
|
|
78
|
+
|
|
79
|
+
Apart from the `glass` preset's drop-in files under `glass/<domain>/assets/`, the set ships no token files, because the right file depends on the stack. Asked for tokens — for a new project, or to fill `proposed` rows — generate them **in the project's existing mechanism**: a Tailwind v4 `@theme` block, a `tailwind.config` extension, CSS custom properties, a JS theme object, a design-tokens JSON. Match the existing layout and naming; never add a second token system beside the first.
|
|
80
|
+
|
|
81
|
+
With no tokens yet, `presets.md` and each domain guide's `## Dials` section give defaults. Say they're defaults, choose names with the user, and record the choices in `DESIGN.md`.
|
|
82
|
+
|
|
83
|
+
## Checklist
|
|
84
|
+
|
|
85
|
+
- [ ] An existing `DESIGN.md` was reconciled, not overwritten; every user-chosen name and decision survived.
|
|
86
|
+
- [ ] The scan ignored prose and example directories, and its findings were checked against the code.
|
|
87
|
+
- [ ] Every role is mapped by **usage**, not by what the token is called.
|
|
88
|
+
- [ ] The project's names are unchanged; new names are `proposed` and in the project's style.
|
|
89
|
+
- [ ] Project roles sit under a canonical parent, each with the signal that separates it.
|
|
90
|
+
- [ ] Tokens doing two jobs are listed as issues, not silently split.
|
|
91
|
+
- [ ] Boxes per main screen counted; every card holding more than one object, every nested card and every tile grid listed as an issue.
|
|
92
|
+
- [ ] Every text tier has a measured ratio per theme against its worst surface, and the ladder strictly descends.
|
|
93
|
+
- [ ] The preset is justified by evidence about the reader; mixed projects list areas by path.
|
|
94
|
+
- [ ] `glass` was offered only when the user wants modimihir.com's exact system.
|
|
95
|
+
- [ ] At most four questions, each about something the code couldn't answer.
|
|
96
|
+
- [ ] Open issues carry evidence; nothing was fixed without being asked.
|
|
97
|
+
- [ ] Generated tokens use the project's existing mechanism and naming.
|