@fractaldesign/fractalstyler 0.0.0-stage → 0.9.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/LICENSE +19 -0
- package/README.md +123 -2
- package/cli/main.mjs +141 -0
- package/cli/scaffold.mjs +406 -0
- package/data/recipes.json +61 -0
- package/dist/asset.d.ts +3 -0
- package/dist/css/fractalstyler.css +2453 -0
- package/dist/css/fractalstyler.min.css +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/styles/_00_config.sass +29 -0
- package/dist/styles/_00_fonts.sass +58 -0
- package/dist/styles/_00_tokens.sass +211 -0
- package/dist/styles/_01_base.sass +58 -0
- package/dist/styles/_02_dimensions.sass +70 -0
- package/dist/styles/_03_typography.sass +170 -0
- package/dist/styles/_04_containers.sass +173 -0
- package/dist/styles/_05_layouts.sass +104 -0
- package/dist/styles/_06_shells.sass +135 -0
- package/dist/styles/_07_interactions.sass +212 -0
- package/dist/styles/_08_visuals.sass +136 -0
- package/dist/styles/_09_own.sass +174 -0
- package/dist/styles/colorpacks.sass +119 -0
- package/dist/styles/index.sass +14 -0
- package/dist/styles/themeplates.sass +159 -0
- package/docs/REGISTRY.api.md +477 -0
- package/docs/REGISTRY.md +14 -0
- package/docs/references/configurations-api.md +220 -0
- package/docs/references/configurations.md +258 -0
- package/lint/browser.mjs +79 -0
- package/lint/cli.mjs +212 -0
- package/lint/lib/agent-reporter.mjs +51 -0
- package/lint/lib/fuzzy.mjs +45 -0
- package/lint/lib/registry.mjs +107 -0
- package/lint/lib/sass-linter.mjs +71 -0
- package/lint/lib/svelte-linter.mjs +116 -0
- package/lint/lib/token-linter.mjs +97 -0
- package/package.json +112 -5
- package/registry.json +3999 -0
- package/scripts/class-vocab.js +197 -0
- package/scripts/update-registry.js +934 -0
- package/skills/fractal-styler/references/fractals.md +630 -0
- package/skills/fractal-styler/references/tokens.md +226 -0
- package/skills/fractalstyler/SKILL.md +59 -0
- package/skills/fractalstyler/references/fractals.md +630 -0
- package/skills/fractalstyler/references/tokens.md +226 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// update-registry — regenerates every registry surface from the stylesheet.
|
|
4
|
+
//
|
|
5
|
+
// Ground truth is src/lib/styles/_00…_09 (colorpacks.sass and themeplates.sass
|
|
6
|
+
// are raw Sass variable palettes and emit no classes — they are not indexed).
|
|
7
|
+
// This script parses them structurally and emits:
|
|
8
|
+
// registry.json (class inventory)
|
|
9
|
+
// REGISTRY.md (grep cheatsheet + tokens)
|
|
10
|
+
// docs/REGISTRY.md (pointer mirror)
|
|
11
|
+
// skills/fractal-styler/references/fractals.md (agent class reference)
|
|
12
|
+
// skills/fractal-styler/references/tokens.md (agent token reference)
|
|
13
|
+
//
|
|
14
|
+
// Every entry is a CONCRETE class. The old wildcard families (.gap-*) are
|
|
15
|
+
// gone: the eight steps (xs sm md bs lg xl 2xl 3xl) are expanded from the
|
|
16
|
+
// $gap/$pad/$mar family maps in _00_config.sass, so the registry lists the
|
|
17
|
+
// classes that actually compile.
|
|
18
|
+
//
|
|
19
|
+
// node scripts/update-registry.js
|
|
20
|
+
// ============================================================================
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = path.dirname(__filename);
|
|
27
|
+
const rootDir = path.resolve(__dirname, '..');
|
|
28
|
+
const stylesDir = path.join(rootDir, 'src', 'lib', 'styles');
|
|
29
|
+
|
|
30
|
+
// --- The system map ---------------------------------------------------------
|
|
31
|
+
// Layer assignment mirrors AGENTS.md's progressive discovery index.
|
|
32
|
+
const FILES = [
|
|
33
|
+
{ file: '_01_base.sass', layer: 'L0' },
|
|
34
|
+
{ file: '_02_dimensions.sass', layer: 'L1' },
|
|
35
|
+
{ file: '_03_typography.sass', layer: 'L5' },
|
|
36
|
+
{ file: '_04_containers.sass', layer: 'L2' },
|
|
37
|
+
{ file: '_05_layouts.sass', layer: 'L3' },
|
|
38
|
+
{ file: '_06_shells.sass', layer: 'L4' },
|
|
39
|
+
{ file: '_07_interactions.sass', layer: 'L5' },
|
|
40
|
+
{ file: '_08_visuals.sass', layer: 'L5' },
|
|
41
|
+
{ file: '_09_own.sass', layer: 'Custom' }
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const LAYERS = [
|
|
45
|
+
['L0', 'Tokens & Base', 'Raw values and native resets. Never hardcode a literal a token covers.'],
|
|
46
|
+
['L1', 'Dimensions', 'Loop-generated space families (gap/rgap/cgap, pad/px/py/pt/pr/pb/pl, mar/mx/my/mt/mr/mb/ml × the eight steps), radius channels, full/zero sizing, viewport heights. All token-routed.'],
|
|
47
|
+
['L2', 'Containers', 'Flow and alignment. .box (flex column), .row (flex row), .grid — alignment modifiers nest under their base, never standalone. x* is ALWAYS the horizontal/inline axis, y* the vertical/block axis.'],
|
|
48
|
+
['L3', 'Layouts', 'Grids that step only through divisors (3→1, 4→2→1, 6→3→2→1), column spans, card-grid, prose, frame ratios, the reel.'],
|
|
49
|
+
['L4', 'Shells', 'Application scaffolding: the .app-shell canon, header, role-bound rails, footer, content sections.'],
|
|
50
|
+
['L5', 'Visuals & Interactions', 'Typography roles, inputs, links, the button quartet, surface fills, ink, borders, elevation shadows, visibility.'],
|
|
51
|
+
['Custom', 'Own', 'Sanctioned extension point (_09_own.sass). Project-specific additions that cannot be expressed via canonical layers.']
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Sass value parsing — the $maps in _00_config.sass
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/** Read a top-level `$name: value` from Sass source. Returns the raw value text. */
|
|
59
|
+
function readSassVariable(content, name) {
|
|
60
|
+
const re = new RegExp(`\\$${name}:\\s*`);
|
|
61
|
+
const m = re.exec(content);
|
|
62
|
+
if (!m) return null;
|
|
63
|
+
const rest = content.slice(m.index + m[0].length);
|
|
64
|
+
if (rest.startsWith('(')) {
|
|
65
|
+
// Map/list literal: read to the balanced closing paren.
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let quote = null;
|
|
68
|
+
for (let i = 0; i < rest.length; i++) {
|
|
69
|
+
const ch = rest[i];
|
|
70
|
+
if (quote) {
|
|
71
|
+
if (ch === '\\') i++;
|
|
72
|
+
else if (ch === quote) quote = null;
|
|
73
|
+
} else if (ch === "'" || ch === '"') quote = ch;
|
|
74
|
+
else if (ch === '(') depth++;
|
|
75
|
+
else if (ch === ')') {
|
|
76
|
+
depth--;
|
|
77
|
+
if (depth === 0) return rest.slice(0, i + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Single-line value.
|
|
83
|
+
return rest.split(/\r?\n/)[0].trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Split a value string at top-level separators (sep = ',' or ' '). */
|
|
87
|
+
function splitTop(value, sep) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
let depth = 0;
|
|
90
|
+
let quote = null;
|
|
91
|
+
let buf = '';
|
|
92
|
+
for (let i = 0; i < value.length; i++) {
|
|
93
|
+
const ch = value[i];
|
|
94
|
+
if (quote) {
|
|
95
|
+
buf += ch;
|
|
96
|
+
if (ch === '\\') {
|
|
97
|
+
buf += value[++i] ?? '';
|
|
98
|
+
} else if (ch === quote) quote = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === "'" || ch === '"') {
|
|
102
|
+
quote = ch;
|
|
103
|
+
buf += ch;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === '(') {
|
|
107
|
+
depth++;
|
|
108
|
+
buf += ch;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === ')') {
|
|
112
|
+
depth--;
|
|
113
|
+
buf += ch;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (depth === 0 && (sep === ',' ? ch === ',' : /\s/.test(ch))) {
|
|
117
|
+
if (buf.trim()) parts.push(buf.trim());
|
|
118
|
+
buf = '';
|
|
119
|
+
if (sep === ' ') continue; // collapse whitespace runs
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
buf += ch;
|
|
123
|
+
}
|
|
124
|
+
if (buf.trim()) parts.push(buf.trim());
|
|
125
|
+
return parts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const unquote = (s) => s.replace(/^['"]|['"]$/g, '').trim();
|
|
129
|
+
|
|
130
|
+
/** Parse a Sass map/list literal into JS (nested maps become objects). */
|
|
131
|
+
function parseSassValue(raw) {
|
|
132
|
+
let text = String(raw).trim();
|
|
133
|
+
if (text.startsWith('(') && text.endsWith(')')) text = text.slice(1, -1).trim();
|
|
134
|
+
if (!text) return [];
|
|
135
|
+
const items = splitTop(text, ',');
|
|
136
|
+
if (items.length === 1 && !/^\s*['"]?[\w-]+['"]?\s*:/.test(items[0])) {
|
|
137
|
+
// Space-separated list (e.g. $steps).
|
|
138
|
+
return splitTop(items[0], ' ').map(unquote);
|
|
139
|
+
}
|
|
140
|
+
const map = {};
|
|
141
|
+
for (const item of items) {
|
|
142
|
+
const m = /^(['"]?[\w-]+['"]?)\s*:\s*([\s\S]+)$/.exec(item);
|
|
143
|
+
if (!m) continue;
|
|
144
|
+
const key = unquote(m[1]);
|
|
145
|
+
const val = m[2].trim();
|
|
146
|
+
map[key] = val.startsWith('(') ? parseSassValue(val) : unquote(val);
|
|
147
|
+
}
|
|
148
|
+
return map;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// Indented-Sass structural parser
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
const countIndent = (line) => (line.match(/^\t*/)[0] || '').length;
|
|
156
|
+
|
|
157
|
+
/** Strip a trailing `// comment` — whitespace-guarded so http:// URLs survive. */
|
|
158
|
+
function splitTrailingComment(text) {
|
|
159
|
+
const m = /^(.*?)\s+\/\/\s*(.+)$/.exec(text);
|
|
160
|
+
return m ? [m[1].trim(), m[2].trim()] : [text.trim(), null];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isAtRule = (t) => t.startsWith('@');
|
|
164
|
+
const isMixin = (t) => t.startsWith('=') || t.startsWith('+');
|
|
165
|
+
|
|
166
|
+
function isSelector(t) {
|
|
167
|
+
if (isAtRule(t) || isMixin(t)) return false;
|
|
168
|
+
if (t.startsWith('//') || t.startsWith('/*')) return false;
|
|
169
|
+
// A declaration is `prop: value` (including custom props `--x: y`).
|
|
170
|
+
// Selectors here never have a bare `ident:` head (`:root`, `::view-*`,
|
|
171
|
+
// `*`, `.foo`, `&.bar`, `a.nav-link`, `p, li`, `[data-mode]`, `> *` …).
|
|
172
|
+
if (/^[-\w]+\s*:/.test(t) && !t.startsWith(':')) return false;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse indented Sass into a rule tree.
|
|
178
|
+
* Nodes: { type: 'rule', selectors, comments, trailing, children }
|
|
179
|
+
* { type: 'media', params, children }
|
|
180
|
+
* { type: 'skip', why, children } — @each/@for/@keyframes/@font-face/mixins
|
|
181
|
+
* { type: 'decl', text, trailing }
|
|
182
|
+
*/
|
|
183
|
+
function parseSassTree(content) {
|
|
184
|
+
let lines = content.split(/\r?\n/);
|
|
185
|
+
// A comment block at the very top of the file is a file banner (the
|
|
186
|
+
// `====`-framed layer headers), not a description of the first class
|
|
187
|
+
// below it — drop it before parsing, so e.g. `.box` falls through to its
|
|
188
|
+
// curated description instead of inheriting the _04 banner.
|
|
189
|
+
let top = 0;
|
|
190
|
+
while (top < lines.length && (!lines[top].trim() || lines[top].trim().startsWith('//'))) top++;
|
|
191
|
+
lines = lines.slice(top);
|
|
192
|
+
let idx = 0;
|
|
193
|
+
let pendingComments = [];
|
|
194
|
+
|
|
195
|
+
const takeComments = () => {
|
|
196
|
+
const comments = pendingComments;
|
|
197
|
+
pendingComments = [];
|
|
198
|
+
return comments;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function parseChildren(indent) {
|
|
202
|
+
const children = [];
|
|
203
|
+
while (idx < lines.length) {
|
|
204
|
+
const raw = lines[idx];
|
|
205
|
+
if (!raw.trim()) {
|
|
206
|
+
idx++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const lineIndent = countIndent(raw);
|
|
210
|
+
if (lineIndent < indent) break;
|
|
211
|
+
const [t, trailing] = splitTrailingComment(raw.trim());
|
|
212
|
+
if (!t) {
|
|
213
|
+
idx++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (t.startsWith('//')) {
|
|
217
|
+
pendingComments.push(trailing ?? t.replace(/^\/\/\s?/, ''));
|
|
218
|
+
idx++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (isAtRule(t)) {
|
|
222
|
+
const name = /^@([\w-]+)/.exec(t)[1];
|
|
223
|
+
const comments = takeComments();
|
|
224
|
+
idx++;
|
|
225
|
+
const bodyless = ['use', 'forward', 'import', 'include', 'warn', 'error', 'debug', 'charset'];
|
|
226
|
+
if (bodyless.includes(name)) {
|
|
227
|
+
children.push({ type: 'skip', why: name, params: t, comments, children: [] });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// @media frames carry a media context; everything else (@each, @for,
|
|
231
|
+
// @keyframes, @font-face) emits no registry classes — skip its body.
|
|
232
|
+
const type = name === 'media' ? 'media' : 'skip';
|
|
233
|
+
children.push({ type, why: name, params: t, comments, children: parseChildren(lineIndent + 1) });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (isMixin(t)) {
|
|
237
|
+
const comments = takeComments();
|
|
238
|
+
idx++;
|
|
239
|
+
children.push({ type: 'mixin', name: t.slice(1).trim(), comments, children: parseChildren(lineIndent + 1) });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (isSelector(t)) {
|
|
243
|
+
const comments = takeComments();
|
|
244
|
+
const selectors = [t];
|
|
245
|
+
idx++;
|
|
246
|
+
// Multi-line selector lists: `img,` / `video,` / `svg`.
|
|
247
|
+
while (idx < lines.length) {
|
|
248
|
+
const raw2 = lines[idx];
|
|
249
|
+
if (!raw2.trim()) {
|
|
250
|
+
idx++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const t2 = splitTrailingComment(raw2.trim())[0];
|
|
254
|
+
if (countIndent(raw2) === lineIndent && t2 && isSelector(t2)) {
|
|
255
|
+
selectors.push(t2);
|
|
256
|
+
idx++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
children.push({ type: 'rule', selectors, comments, trailing, children: parseChildren(lineIndent + 1) });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Declaration (or stray line): record with its trailing comment.
|
|
265
|
+
children.push({ type: 'decl', text: t, trailing, comments: takeComments() });
|
|
266
|
+
idx++;
|
|
267
|
+
}
|
|
268
|
+
return children;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return parseChildren(0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// Entry extraction
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
const CLASS_RE = /\.(-?[_a-zA-Z][\w-]*)/g;
|
|
279
|
+
const classesIn = (sel) => [...sel.matchAll(CLASS_RE)].map((m) => m[1]);
|
|
280
|
+
|
|
281
|
+
/** Compact a media query for property strings: (min-width: 1025px) → @min-width:1025px */
|
|
282
|
+
function compactMedia(params) {
|
|
283
|
+
const m = /@media\s*\((.+)\)/.exec(params) || /^\((.+)\)$/.exec(params);
|
|
284
|
+
const body = (m ? m[1] : params).replace(/\s+/g, '');
|
|
285
|
+
return `@${body}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Shorten data: URLs and cap a declaration for table cells. */
|
|
289
|
+
function cleanDecl(text) {
|
|
290
|
+
let t = text.replace(/url\(\s*["']?data:[^)]*\)/gi, 'url(data:…)');
|
|
291
|
+
if (t.length > 72) t = `${t.slice(0, 69)}…`;
|
|
292
|
+
return t;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Collect the declarations of a rule node. Direct declarations come first;
|
|
297
|
+
* declarations inside nested @media blocks are kept with their media context.
|
|
298
|
+
* Nested rule blocks are skipped (they become their own entries).
|
|
299
|
+
*/
|
|
300
|
+
function collectDecls(node, media = []) {
|
|
301
|
+
const out = [];
|
|
302
|
+
const walk = (children, ctx) => {
|
|
303
|
+
for (const child of children) {
|
|
304
|
+
if (child.type === 'decl') out.push({ text: cleanDecl(child.text), media: ctx });
|
|
305
|
+
else if (child.type === 'media') walk(child.children, [...ctx, compactMedia(child.params)]);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
walk(node.children ?? [], media);
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function propertyString(decls) {
|
|
313
|
+
if (!decls.length) return '';
|
|
314
|
+
const base = decls.filter((d) => !d.media.length).map((d) => d.text);
|
|
315
|
+
const parts = [];
|
|
316
|
+
if (base.length) parts.push(base.slice(0, 3).join('; ') + (base.length > 3 ? '; …' : ''));
|
|
317
|
+
const mediaGroups = new Map();
|
|
318
|
+
for (const d of decls.filter((x) => x.media.length)) {
|
|
319
|
+
const key = d.media.join(' ');
|
|
320
|
+
if (!mediaGroups.has(key)) mediaGroups.set(key, []);
|
|
321
|
+
mediaGroups.get(key).push(d.text);
|
|
322
|
+
}
|
|
323
|
+
for (const [key, texts] of mediaGroups) parts.push(`${key}: ${texts.slice(0, 2).join('; ')}`);
|
|
324
|
+
const property = parts.join(' · ');
|
|
325
|
+
return property.length > 168 ? `${property.slice(0, 165)}…` : property;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Curated descriptions for classes whose source carries no comment and whose
|
|
329
|
+
// declarations alone would not explain intent. Comments in the source still
|
|
330
|
+
// win: this map only fills gaps.
|
|
331
|
+
const CURATED = {
|
|
332
|
+
'.bdr': 'Debug outline — flags an element on screen with a red border',
|
|
333
|
+
'.mode-wipe': 'On <html>: disables view-transition animations while a color-mode wipe runs',
|
|
334
|
+
'.wfull': 'Full width (100%)',
|
|
335
|
+
'.hfull': 'Full height (100%)',
|
|
336
|
+
'.full': 'Full width and height (100% both axes)',
|
|
337
|
+
'.minw0': 'Zero min-width — lets a flex child shrink below its content',
|
|
338
|
+
'.minh0': 'Zero min-height — lets a flex child shrink below its content',
|
|
339
|
+
'.min0': 'Zero min-width and min-height — prevents flex blowouts',
|
|
340
|
+
'.h-sm': 'Control height step sm (var(--height-sm))',
|
|
341
|
+
'.h-md': 'Control height step md (var(--height-md))',
|
|
342
|
+
'.h-bs': 'Control height step bs (var(--height-bs))',
|
|
343
|
+
'.h-lg': 'Control height step lg (var(--height-lg))',
|
|
344
|
+
'.hfull-vh': 'Full viewport height (min-height: 100vh)',
|
|
345
|
+
'.hfull-vh-fitted': 'Viewport height fitted between header and footer (var(--fit-height))',
|
|
346
|
+
'.box': 'Flex column container — the vertical structural foundation',
|
|
347
|
+
'.row': 'Flex row container — the horizontal structural foundation',
|
|
348
|
+
'.grid': 'CSS grid container (columns composed via .grid-N / alignment below)',
|
|
349
|
+
'.wrap': 'Allow flex wrapping',
|
|
350
|
+
'.grow': 'Flex grow — take remaining space (flex: 1 1 0%)',
|
|
351
|
+
'.shrink-0': 'Never shrink below content size',
|
|
352
|
+
'.relative': 'position: relative (anchor for absolute children)',
|
|
353
|
+
'.absolute': 'position: absolute',
|
|
354
|
+
'.fixed': 'position: fixed',
|
|
355
|
+
'.sticky': 'position: sticky',
|
|
356
|
+
'.card': 'Panel card: padded, bordered, panel background; arrow svg and footer behaviors included',
|
|
357
|
+
'.chip': 'Small inline label — tag/badge duty (xs type, md radius)',
|
|
358
|
+
'.prose-section': 'Vertical prose section (flex column, lg gap)',
|
|
359
|
+
'.nav-section': 'Vertical nav section (flex column, xs gap)',
|
|
360
|
+
'.page-header': 'Page header block (flex column, sm gap, 2xl bottom pad)',
|
|
361
|
+
'.inputs-wrapper': 'Form field group (flex row, xs gap; labels at sm muted)',
|
|
362
|
+
'.checkbox-wrapper': 'Checkbox row (flex, centered, xs gap; span at sm weight 500)',
|
|
363
|
+
'.card-grid': 'Auto-fit card grid — column count negotiates, min track var(--card-min)',
|
|
364
|
+
'.prose': 'Reading measure — max-width var(--prose-clamp), centered, typographic rhythm',
|
|
365
|
+
'.grid-1': 'Single-column grid (no responsive steps)',
|
|
366
|
+
'.grid-2': 'Responsive 2-column grid: 1 → 2 columns at 1025px',
|
|
367
|
+
'.grid-3': 'Responsive 3-column grid: 1 → 3 columns at 1025px (3→1 golden rule)',
|
|
368
|
+
'.grid-4': 'Responsive 4-column grid: 1 → 2 → 4 columns (never 3+1)',
|
|
369
|
+
'.grid-6': 'Responsive 6-column grid: 1 → 3 → 6 columns (both golden rules hold)',
|
|
370
|
+
'.cspan-1': 'Span 1 grid column',
|
|
371
|
+
'.cspan-2': 'Span 2 grid columns',
|
|
372
|
+
'.cspan-3': 'Span 3 grid columns',
|
|
373
|
+
'.cspan-4': 'Span 4 grid columns',
|
|
374
|
+
'.cspan-5': 'Span 5 grid columns',
|
|
375
|
+
'.cspan-6': 'Span 6 grid columns',
|
|
376
|
+
'.cspan-7': 'Span 7 grid columns',
|
|
377
|
+
'.cspan-8': 'Span 8 grid columns',
|
|
378
|
+
'.app-shell': 'Application shell root — flex column, 100vh, hosts header/main/footer and the drawer state',
|
|
379
|
+
'.app-header': 'Sticky application header (var(--header-height))',
|
|
380
|
+
'.app-main': 'Main region between header and footer — flex row, fitted to 100dvh minus chrome',
|
|
381
|
+
'.main-section': 'Primary content column inside .app-main (flex: 1, min-width: 0)',
|
|
382
|
+
'.sidebar-left': 'Left rail — nav role; off-canvas drawer below 1025px via .open on .app-shell',
|
|
383
|
+
'.sidebar-right': 'Right rail — TOC role; hidden below 1281px',
|
|
384
|
+
'.app-footer': 'Application footer (var(--footer-height))',
|
|
385
|
+
'.content-section': 'Page content block — responsive padding and narrow width modes',
|
|
386
|
+
'.input': 'Text input — themed field with focus ring',
|
|
387
|
+
'.select': 'Select dropdown — themed field with chevron and focus ring',
|
|
388
|
+
'.link': 'Themed link — theme color, alt on hover',
|
|
389
|
+
'.link-plain': 'Plain link — inherits ink, theme color on hover',
|
|
390
|
+
'.link-parent': 'Link block — hovers color its .link-title child instead of itself',
|
|
391
|
+
'button': 'Element base for every button — the quartet root (align, height, focus, disabled states)',
|
|
392
|
+
'a.nav-link': 'Interactive form of .nav-link — padded, curved, hover fill',
|
|
393
|
+
'a.primary': 'Primary call-to-action anchor — theme fill, sm elevation, modern corner optional',
|
|
394
|
+
'a.outline': 'Outline call-to-action anchor — transparent fill, ink border, sm elevation',
|
|
395
|
+
'.text-body': 'Body copy at base size (line-height 1.5)',
|
|
396
|
+
'.text-body-lg': 'Body copy at large size (line-height 1.5)',
|
|
397
|
+
'.text-body-sm': 'Body copy at small size (line-height 1.5)',
|
|
398
|
+
'.page-title': 'Page title — 3xl, 700, tight tracking',
|
|
399
|
+
'.page-desc': 'Page description — lg, airy',
|
|
400
|
+
'.page-heading-xl': 'Page heading, xl rung — 600, tight tracking',
|
|
401
|
+
'.page-heading-lg': 'Page heading, lg rung — 600, tight tracking',
|
|
402
|
+
'.page-heading-bs': 'Page heading, base rung — 600, tight tracking',
|
|
403
|
+
'.card-title': 'Card title — lg, 600',
|
|
404
|
+
'.card-desc': 'Card description — md',
|
|
405
|
+
'.breadcrumb': 'Breadcrumb rail (flex, offset -0.5rem)',
|
|
406
|
+
'.breadcrumb-link': 'Breadcrumb link — muted, uppercased, theme color on hover',
|
|
407
|
+
'.breadcrumb-spacer': 'Breadcrumb spacer (non-link segment, same type treatment)',
|
|
408
|
+
'.nav-link': 'Nav link type — md, 1.2 rhythm',
|
|
409
|
+
'.nav-link-sm': 'Nav link type, small rung',
|
|
410
|
+
'.nav-header': 'Nav group header — sm, 500, padded',
|
|
411
|
+
'.header-nav': 'Header nav block — uppercased md links, theme color on hover',
|
|
412
|
+
'.mono': 'Monospace font stack (var(--font-mono))',
|
|
413
|
+
'.sans': 'Sans font stack (var(--font-sans))',
|
|
414
|
+
'.truncate': 'One-line ellipsis truncation',
|
|
415
|
+
'.bg': 'Bare background — page base fill (var(--bg))',
|
|
416
|
+
'.surface': 'Bare background — surface fill (var(--bg-surface))',
|
|
417
|
+
'.panel': 'Bare background — panel fill (var(--bg-panel))',
|
|
418
|
+
'.sunken': 'Bare background — sunken fill (var(--bg-sunken))',
|
|
419
|
+
'.raised': 'Bare background — raised fill (var(--bg-raised))',
|
|
420
|
+
'.extra': 'Bare background — extra fill (var(--bg-extra))',
|
|
421
|
+
'.border': 'Full border, standard weight (var(--border))',
|
|
422
|
+
'.border-subtle': 'Full border, subtle weight (var(--border-subtle))',
|
|
423
|
+
'.border-strong': 'Full border, strong weight (var(--border-strong))',
|
|
424
|
+
'.hide-mobile': 'Display none at ≤1024px',
|
|
425
|
+
'.hide-desktop': 'Display none at ≥1025px',
|
|
426
|
+
'.site-logomotif': 'Site logo motif — 48px, scales 1.2 on hover',
|
|
427
|
+
'.logotype': 'Logotype — 28px wordmark',
|
|
428
|
+
'.project-circle': 'Project dot — 16px circle',
|
|
429
|
+
'.accordion': 'Accordion group — vertical stack of .accordion-item',
|
|
430
|
+
'.accordion-item': 'Accordion item — bordered, curved shell hosting trigger and content',
|
|
431
|
+
'.accordion-content': 'Collapsible region of an .accordion-item — 0fr→1fr grid transition',
|
|
432
|
+
'.accordion-trigger': 'Accordion trigger button — full-width, hover/focus states',
|
|
433
|
+
'.hero-name': 'Hero display name — text-5xl doubled, 800, tight tracking'
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
function describe(entry, node, decls) {
|
|
437
|
+
const first = decls.find((d) => !d.media.length)?.text || decls[0]?.text || '';
|
|
438
|
+
if (entry.trailing) return entry.trailing;
|
|
439
|
+
if (node.trailing) return node.trailing;
|
|
440
|
+
// Leading comments describe their selector — but `--- section ---` divider
|
|
441
|
+
// banners are structure, not description.
|
|
442
|
+
const comment = node.comments?.find((c) => !/^[-=*]{2,}/.test(c));
|
|
443
|
+
if (comment) {
|
|
444
|
+
const text = comment.replace(/^[-=*]{2,}\s*|\s*[-=*]{2,}$/g, '').trim() || comment;
|
|
445
|
+
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
|
|
446
|
+
}
|
|
447
|
+
if (CURATED[entry.class]) return CURATED[entry.class];
|
|
448
|
+
switch (entry.kind) {
|
|
449
|
+
case 'modifier':
|
|
450
|
+
return `${first} — modifier of ${entry.parent}`;
|
|
451
|
+
case 'child':
|
|
452
|
+
return `${first} — child of ${entry.parent}`;
|
|
453
|
+
case 'variant':
|
|
454
|
+
return `${first} — attribute variant of ${entry.parent}`;
|
|
455
|
+
case 'compound':
|
|
456
|
+
return `${first} — combination rule (${entry.class.replace(/\./g, '')})`;
|
|
457
|
+
default:
|
|
458
|
+
return first || `${entry.class} rule`;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function exampleFor(cls, kind) {
|
|
463
|
+
const names = classesIn(cls);
|
|
464
|
+
if (kind === 'element' || /^(button)/.test(cls)) {
|
|
465
|
+
if (/\[data-/.test(cls)) return `<button ${cls.replace(/^button/, '')}>…</button>`;
|
|
466
|
+
if (cls === 'button') return '<button>…</button>';
|
|
467
|
+
return `<button class="${names.join(' ')}">…</button>`;
|
|
468
|
+
}
|
|
469
|
+
if (/^a[.\s[]/.test(cls)) return `<a class="${names.join(' ')}" href="…">…</a>`;
|
|
470
|
+
if (kind === 'compound') {
|
|
471
|
+
const segments = cls.split(/\s+/).map((seg) => {
|
|
472
|
+
const segNames = classesIn(seg);
|
|
473
|
+
return seg.replace(CLASS_RE, '').trim() || (segNames.length ? segNames.join(' ') : seg);
|
|
474
|
+
});
|
|
475
|
+
return segments.map((s) => `<div class="${s}">`).join('') + '…' + '</div>'.repeat(segments.length);
|
|
476
|
+
}
|
|
477
|
+
return `<div class="${names.join(' ')}">…</div>`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Bare element selectors that are API in their own right, and the partial that
|
|
482
|
+
* authors them. `button` also appears in _01_base as a bare reset — the entry
|
|
483
|
+
* belongs to the button quartet in _07, not to the reset.
|
|
484
|
+
*/
|
|
485
|
+
const ELEMENT_ENTRIES = { button: '_07_interactions.sass' };
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Walk the rule tree and emit registry entries.
|
|
489
|
+
* ctx: { selPath: string|null, owner: string|null, media: [] }
|
|
490
|
+
*/
|
|
491
|
+
function walkRules(children, ctx, file, layer, out, seen) {
|
|
492
|
+
for (const node of children) {
|
|
493
|
+
if (node.type === 'mixin') continue; // token mixins handled separately
|
|
494
|
+
if (node.type === 'skip') continue; // @each/@for/@keyframes/@font-face bodies
|
|
495
|
+
if (node.type === 'media') {
|
|
496
|
+
walkRules(node.children, { ...ctx, media: [...ctx.media, compactMedia(node.params)] }, file, layer, out, seen);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (node.type !== 'rule') continue;
|
|
500
|
+
|
|
501
|
+
// The frame path children qualify against.
|
|
502
|
+
let framePath = null;
|
|
503
|
+
let owner = ctx.owner;
|
|
504
|
+
|
|
505
|
+
// Flatten the selector group: multi-line lists and one-line comma lists.
|
|
506
|
+
const parts = [];
|
|
507
|
+
for (const rawSel of node.selectors) {
|
|
508
|
+
for (const part of splitTop(rawSel, ',')) {
|
|
509
|
+
const p = part.trim().replace(/,+$/, '').trim();
|
|
510
|
+
if (p) parts.push(p);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const sel of parts) {
|
|
515
|
+
const selClasses = classesIn(sel);
|
|
516
|
+
const qualified = sel.startsWith('&') && ctx.selPath ? ctx.selPath + sel.slice(1) : sel;
|
|
517
|
+
|
|
518
|
+
if (framePath === null) framePath = qualified;
|
|
519
|
+
|
|
520
|
+
if (!selClasses.length) {
|
|
521
|
+
if (sel.startsWith('&[')) {
|
|
522
|
+
// Attribute variant (`&[data-…]`) — no new class, still an entry.
|
|
523
|
+
push({ class: qualified, kind: 'variant', parent: ctx.selPath, file, layer }, node, out, seen);
|
|
524
|
+
owner = qualified;
|
|
525
|
+
} else if (ELEMENT_ENTRIES[sel] === file && !ctx.selPath) {
|
|
526
|
+
// Bare element frame authored here. `button` is the quartet root — an API entry.
|
|
527
|
+
push({ class: sel, kind: 'element', parent: null, file, layer }, node, out, seen);
|
|
528
|
+
owner = sel;
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (sel.startsWith('&')) {
|
|
534
|
+
// Modifier / state / variant of the parent.
|
|
535
|
+
const rest = sel.slice(1);
|
|
536
|
+
const ownClasses = classesIn(rest.startsWith('.') ? rest : `.${rest}`);
|
|
537
|
+
// `&:hover` and friends introduce no new class — skip the entry,
|
|
538
|
+
// keep the frame for nested children (e.g. `&:hover .link-title`).
|
|
539
|
+
// An attribute test (`&[data-…]`) is always a variant — entry.
|
|
540
|
+
const isNew =
|
|
541
|
+
rest.trim().startsWith('[') ||
|
|
542
|
+
ownClasses.some((c) => !classesIn(ctx.selPath || '').includes(c));
|
|
543
|
+
if (!isNew) continue;
|
|
544
|
+
let kind = 'modifier';
|
|
545
|
+
if (rest.trim().startsWith('[')) kind = 'variant';
|
|
546
|
+
else if (/\s/.test(rest.trim())) kind = 'compound';
|
|
547
|
+
push({ class: qualified, kind, parent: ctx.selPath, file, layer }, node, out, seen);
|
|
548
|
+
owner = qualified;
|
|
549
|
+
} else if (ctx.selPath) {
|
|
550
|
+
// Plain descendant under a parent frame.
|
|
551
|
+
const topClasses = classesIn(ctx.selPath);
|
|
552
|
+
if (!selClasses.some((c) => !topClasses.includes(c))) continue;
|
|
553
|
+
if (/\s/.test(sel.trim())) {
|
|
554
|
+
// e.g. `.app-shell.open .sidebar-left` — a state/combination rule.
|
|
555
|
+
push({ class: qualified, kind: 'compound', parent: null, file, layer }, node, out, seen);
|
|
556
|
+
} else if (selClasses.length === 1 && sel.trim() === `.${selClasses[0]}`) {
|
|
557
|
+
push({ class: `.${selClasses[0]}`, kind: 'child', parent: ctx.selPath, file, layer }, node, out, seen);
|
|
558
|
+
owner = `.${selClasses[0]}`;
|
|
559
|
+
} else {
|
|
560
|
+
// Scoped child element with its own class (e.g. `svg.arrow` under .card).
|
|
561
|
+
const last = selClasses[selClasses.length - 1];
|
|
562
|
+
push({ class: `.${last}`, kind: 'child', parent: ctx.selPath, file, layer }, node, out, seen);
|
|
563
|
+
owner = `.${last}`;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
// Top-level selector.
|
|
567
|
+
if (selClasses.length > 1) {
|
|
568
|
+
push({ class: qualified, kind: 'compound', parent: null, file, layer }, node, out, seen);
|
|
569
|
+
} else {
|
|
570
|
+
const cls = selClasses[0];
|
|
571
|
+
const el = /^([a-zA-Z][\w-]*)\.[\w-]+/.exec(sel);
|
|
572
|
+
// `a.nav-link` keeps its element prefix (distinct from the type
|
|
573
|
+
// class `.nav-link`); `html.mode-wipe` is a root utility — drop it.
|
|
574
|
+
const name = el ? (el[1] === 'html' ? `.${cls}` : `${el[1]}.${cls}`) : `.${cls}`;
|
|
575
|
+
push({ class: name, kind: 'class', parent: null, file, layer }, node, out, seen);
|
|
576
|
+
owner = name;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
walkRules(
|
|
582
|
+
node.children,
|
|
583
|
+
{ selPath: framePath ?? ctx.selPath, owner, media: ctx.media },
|
|
584
|
+
file,
|
|
585
|
+
layer,
|
|
586
|
+
out,
|
|
587
|
+
seen
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function push(entry, node, out, seen) {
|
|
593
|
+
if (seen.has(entry.class)) return;
|
|
594
|
+
seen.add(entry.class);
|
|
595
|
+
const decls = collectDecls(node);
|
|
596
|
+
entry.property = propertyString(decls);
|
|
597
|
+
entry.description = describe(entry, node, decls);
|
|
598
|
+
entry.example = exampleFor(entry.class, entry.kind);
|
|
599
|
+
out.push(entry);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ============================================================================
|
|
603
|
+
// Loop-generated families from _00_config.sass
|
|
604
|
+
// ============================================================================
|
|
605
|
+
|
|
606
|
+
const FAMILY_DESC = {
|
|
607
|
+
gap: 'Gap on both axes (flex/grid)',
|
|
608
|
+
rgap: 'Row gap (block axis)',
|
|
609
|
+
cgap: 'Column gap (inline axis)',
|
|
610
|
+
pad: 'Padding, all four sides',
|
|
611
|
+
px: 'Inline (horizontal) padding',
|
|
612
|
+
py: 'Block (vertical) padding',
|
|
613
|
+
pt: 'Top padding',
|
|
614
|
+
pr: 'Right padding',
|
|
615
|
+
pb: 'Bottom padding',
|
|
616
|
+
pl: 'Left padding',
|
|
617
|
+
mar: 'Margin, all four sides',
|
|
618
|
+
mx: 'Inline (horizontal) margin',
|
|
619
|
+
my: 'Block (vertical) margin',
|
|
620
|
+
mt: 'Top margin',
|
|
621
|
+
mr: 'Right margin',
|
|
622
|
+
mb: 'Bottom margin',
|
|
623
|
+
ml: 'Left margin'
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
function familyEntries(config, out, seen) {
|
|
627
|
+
const groups = [
|
|
628
|
+
['$gap-families', config.gapFamilies],
|
|
629
|
+
['$pad-families', config.padFamilies],
|
|
630
|
+
['$mar-families', config.marFamilies]
|
|
631
|
+
];
|
|
632
|
+
for (const [varName, families] of groups) {
|
|
633
|
+
if (!families) continue;
|
|
634
|
+
for (const [famName, cssProp] of Object.entries(families)) {
|
|
635
|
+
for (const step of config.steps) {
|
|
636
|
+
const cls = `.${famName}-${step}`;
|
|
637
|
+
if (seen.has(cls)) continue;
|
|
638
|
+
seen.add(cls);
|
|
639
|
+
out.push({
|
|
640
|
+
class: cls,
|
|
641
|
+
kind: 'family',
|
|
642
|
+
layer: 'L1',
|
|
643
|
+
file: '_02_dimensions.sass',
|
|
644
|
+
property: `${cssProp}: var(--space-${step})`,
|
|
645
|
+
description: `${FAMILY_DESC[famName] ?? `${famName} spacing`} at the ${step} step — rides var(--space-${step}) (generated from ${varName} × $steps in _00_config.sass)`,
|
|
646
|
+
example: `<div class="${famName}-${step}">`
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
for (const [name, ratio] of Object.entries(config.frameRatios || {})) {
|
|
652
|
+
const cls = `.frame-${name}`;
|
|
653
|
+
if (seen.has(cls)) continue;
|
|
654
|
+
seen.add(cls);
|
|
655
|
+
out.push({
|
|
656
|
+
class: cls,
|
|
657
|
+
kind: 'family',
|
|
658
|
+
layer: 'L3',
|
|
659
|
+
file: '_05_layouts.sass',
|
|
660
|
+
property: `aspect-ratio: ${ratio}; overflow: hidden`,
|
|
661
|
+
description: `Aspect-ratio media frame (${ratio.replace(/\s/g, '')}) — img/video/iframe/svg fill and cover (generated from $frame-ratios in _00_config.sass)`,
|
|
662
|
+
example: `<div class="frame-${name}"><img …></div>`
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ============================================================================
|
|
668
|
+
// Token extraction from _00_tokens.sass (+ shade tokens from _00_config.sass)
|
|
669
|
+
// ============================================================================
|
|
670
|
+
|
|
671
|
+
function collectTokens() {
|
|
672
|
+
const content = fs.readFileSync(path.join(stylesDir, '_00_tokens.sass'), 'utf8');
|
|
673
|
+
const tree = parseSassTree(content);
|
|
674
|
+
const tokens = [];
|
|
675
|
+
const seen = new Set();
|
|
676
|
+
|
|
677
|
+
const add = (name, value, scope) => {
|
|
678
|
+
if (seen.has(name)) return; // first definition wins (light before dark)
|
|
679
|
+
seen.add(name);
|
|
680
|
+
tokens.push({ name, value, scope });
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const walk = (children, scope) => {
|
|
684
|
+
for (const node of children) {
|
|
685
|
+
if (node.type === 'mixin') {
|
|
686
|
+
const inner = /^light-theme-tokens$/.test(node.name) ? 'light' : /^dark-theme-tokens$/.test(node.name) ? 'dark' : null;
|
|
687
|
+
if (inner) walk(node.children, inner);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (node.type === 'rule') {
|
|
691
|
+
const isRoot = node.selectors.some((s) => s.trim() === ':root');
|
|
692
|
+
walk(node.children, isRoot && !scope ? 'root' : scope);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (node.type === 'decl' && scope) {
|
|
696
|
+
const m = /^(--[\w-]+)\s*:\s*(.+)$/.exec(node.text);
|
|
697
|
+
if (m) add(m[1], m[2].trim(), scope);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
walk(tree, null);
|
|
702
|
+
|
|
703
|
+
// --shade-* tokens are loop-generated in _00_config.sass from $shadow-specs.
|
|
704
|
+
const configSrc = fs.readFileSync(path.join(stylesDir, '_00_config.sass'), 'utf8');
|
|
705
|
+
const specs = parseSassValue(readSassVariable(configSrc, 'shadow-specs'));
|
|
706
|
+
if (specs && typeof specs === 'object') {
|
|
707
|
+
for (const [step, spec] of Object.entries(specs)) {
|
|
708
|
+
const v = (k) => spec[k];
|
|
709
|
+
const value = `var(--shadow-rim), 0 ${v('y1')} ${v('b1')} rgb(var(--shadow-color) / ${v('a1')}), 0 ${v('y2')} ${v('b2')} rgb(var(--shadow-color) / ${v('a2')})`;
|
|
710
|
+
add(`--shade-${step}`, value, 'root');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return tokens;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ============================================================================
|
|
717
|
+
// Markdown surfaces
|
|
718
|
+
// ============================================================================
|
|
719
|
+
|
|
720
|
+
function generateMarkdownRegistry(registry, tokens) {
|
|
721
|
+
let md = `# Fractalstyler2 — Complete Class & Token Registry\n\n`;
|
|
722
|
+
md += `Status: **LOCKED & CANONICAL** \n`;
|
|
723
|
+
md += `Updated: **${new Date().toISOString().split('T')[0]}** \n`;
|
|
724
|
+
md += `Architecture: **L0 (Tokens) $\\rightarrow$ L1 (Dimensions) $\\rightarrow$ L2 (Containers) $\\rightarrow$ L3 (Layouts) $\\rightarrow$ L4 (Shells) $\\rightarrow$ L5 (Visuals & Interactions)**\n\n`;
|
|
725
|
+
md += `This document is the **single, definitive, grepable master registry** for all CSS classes, tokens, modifiers, and canonical markup structures in \`fractalstyler2\`.\n\n`;
|
|
726
|
+
md += `Every entry is a concrete class — the space families are expanded from the family maps in \`_00_config.sass\` across the eight steps \`xs sm md bs lg xl 2xl 3xl\`. There are no wildcard families, no literal ladders and no \`-mob\`/\`-desk\` bands.\n\n`;
|
|
727
|
+
md += `---\n\n## Quick Grep Cheatsheet\n\n`;
|
|
728
|
+
md += `Format: \`CLASS_NAME | LAYER | CSS PROPERTY / BEHAVIOR | FILE SOURCE | EXAMPLE\`\n\n\`\`\`\n`;
|
|
729
|
+
|
|
730
|
+
for (const item of registry) {
|
|
731
|
+
const clsPad = item.class.padEnd(30, ' ');
|
|
732
|
+
const layerPad = item.layer.padEnd(2, ' ');
|
|
733
|
+
const propPad = (item.property || item.description).padEnd(66, ' ');
|
|
734
|
+
const filePad = item.file.padEnd(22, ' ');
|
|
735
|
+
md += `${clsPad} | ${layerPad} | ${propPad} | ${filePad} | ${item.example}\n`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
md += `\`\`\`\n\n---\n\n## Semantic Token Variables\n\n`;
|
|
739
|
+
md += `Light values are the marker-free default; \`[data-mode='dark']\` remaps them via \`=dark-theme-tokens\`.\n\n\`\`\`css\n`;
|
|
740
|
+
for (const token of tokens) {
|
|
741
|
+
md += ` ${token.name}: ${token.value}; /* ${token.scope} */\n`;
|
|
742
|
+
}
|
|
743
|
+
md += `\`\`\`\n`;
|
|
744
|
+
return md;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function generateSkillReference(registry, tokens) {
|
|
748
|
+
let md = `# Fractalstyler2 — Class Reference\n\n`;
|
|
749
|
+
md += `GENERATED FILE — do not edit. Emitted by \`scripts/update-registry.js\` from the\n`;
|
|
750
|
+
md += `parsed stylesheet. ${registry.length} classes across ${tokens.length} tokens.\n\n`;
|
|
751
|
+
md += `**The classes below are the entire public API.** The system defines no\n`;
|
|
752
|
+
md += `authoring mixins and no SASS functions — \`+stack\`, \`+surface\`, \`space()\` and\n`;
|
|
753
|
+
md += `friends do not exist. Compose in markup.\n\n`;
|
|
754
|
+
md += `Every class is concrete. Space families (\`.gap\` \`.rgap\` \`.cgap\` \`.pad\` \`.px\` \`.py\`\n`;
|
|
755
|
+
md += `\`.pt\` \`.pr\` \`.pb\` \`.pl\` \`.mar\` \`.mx\` \`.my\` \`.mt\` \`.mr\` \`.mb\` \`.ml\`) each ride the\n`;
|
|
756
|
+
md += `eight steps: \`xs sm md bs lg xl 2xl 3xl\`, token-routed through \`var(--space-*)\`.\n`;
|
|
757
|
+
md += `Modifiers nest under their base (\`.box.xcenter\`), children carry their own\n`;
|
|
758
|
+
md += `name (\`.accordion-content\`), and \`Parent.Child\` notation marks scoped children.\n\n`;
|
|
759
|
+
|
|
760
|
+
for (const [layer, title, blurb] of LAYERS) {
|
|
761
|
+
const items = registry.filter((r) => r.layer === layer);
|
|
762
|
+
if (!items.length) continue;
|
|
763
|
+
md += `\n---\n\n## ${layer} — ${title}\n\n${blurb}\n\n`;
|
|
764
|
+
md += `| Class | Applies | Source |\n|:---|:---|:---|\n`;
|
|
765
|
+
for (const i of items) {
|
|
766
|
+
const applies = (i.description || i.property || '').replace(/\|/g, '\\|');
|
|
767
|
+
md += `| \`${i.class}\` | ${applies} | \`${i.file}\` |\n`;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
md += `\n---\n\n## Semantic Tokens\n\n\`\`\`css\n`;
|
|
772
|
+
for (const t of tokens) md += ` ${t.name}: ${t.value}; /* ${t.scope} */\n`;
|
|
773
|
+
md += `\`\`\`\n`;
|
|
774
|
+
return md;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function generateTokenReference(tokens) {
|
|
778
|
+
const find = (name) => tokens.find((t) => t.name === name);
|
|
779
|
+
const group = (prefix) => tokens.filter((t) => t.name.startsWith(prefix));
|
|
780
|
+
const TYPE_TOKENS = /^--text-(xs|sm|md|bs|lg|xl|2xl|3xl|4xl|5xl)$/;
|
|
781
|
+
// Curated token -> class mappings where the class name is not a prefix twin.
|
|
782
|
+
const TOKEN_CLASS = {
|
|
783
|
+
'--white-fixed': '`.text-white`',
|
|
784
|
+
'--black-fixed': '`.text-black`',
|
|
785
|
+
'--theme-color': '`.text-theme`',
|
|
786
|
+
'--success': '`.text-success` `.bg-success`',
|
|
787
|
+
'--warning': '`.text-warning` `.bg-warning`',
|
|
788
|
+
'--danger': '`.text-danger` `.bg-danger`',
|
|
789
|
+
'--info': '`.text-info` `.bg-info`',
|
|
790
|
+
'--orangepop1': '`.orange1`',
|
|
791
|
+
'--orangepop2': '`.orange2`',
|
|
792
|
+
'--orangepop3': '`.orange3`',
|
|
793
|
+
'--orangepop4': '`.orange4`',
|
|
794
|
+
'--orangepop5': '`.orange5`',
|
|
795
|
+
'--orangepop6': '`.orange6`'
|
|
796
|
+
};
|
|
797
|
+
const table = (rows, valueOf) => {
|
|
798
|
+
let out = `| Token | Class | Value |\n|:---|:---|:---|\n`;
|
|
799
|
+
for (const t of rows) {
|
|
800
|
+
let cls;
|
|
801
|
+
if (t.name.startsWith('--space-')) cls = `\`.gap${t.name.slice(7)}\` \`.pad${t.name.slice(7)}\` \`.mar${t.name.slice(7)}\` + sides`;
|
|
802
|
+
else if (TYPE_TOKENS.test(t.name)) cls = `\`.text${t.name.slice(6)}\``;
|
|
803
|
+
else if (/^--text-(primary|secondary|muted|inverse)$/.test(t.name)) cls = `.${t.name.slice(2)}`;
|
|
804
|
+
else if (t.name.startsWith('--radius-')) cls = `\`.radius${t.name.slice(8)}\``;
|
|
805
|
+
else if (t.name.startsWith('--height-')) cls = `\`.h${t.name.slice(8)}\``;
|
|
806
|
+
else if (/^--shadow-(xs|sm|md|bs|lg|xl|2xl|3xl)$/.test(t.name)) cls = `\`.shadow${t.name.slice(8)}\``;
|
|
807
|
+
else cls = TOKEN_CLASS[t.name] ?? '\u2014';
|
|
808
|
+
out += `| \`${t.name}\` | ${cls} | \`${(valueOf ? valueOf(t) : t.value).replace(/\|/g, '\\|')}\` |\n`;
|
|
809
|
+
}
|
|
810
|
+
return out;
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
let md = `# Fractalstyler2 \u2014 Token Reference\n\n`;
|
|
814
|
+
md += `GENERATED FILE \u2014 do not edit. Emitted by \`scripts/update-registry.js\`\n`;
|
|
815
|
+
md += `from \`_00_tokens.sass\` (light/dark mixins + \`:root\`) and the shadow specs in\n`;
|
|
816
|
+
md += `\`_00_config.sass\`.\n\n`;
|
|
817
|
+
md += `Every \`--space-*\` step is a class family: if \`--space-md\` exists, so do\n`;
|
|
818
|
+
md += `\`.gap-md\`, \`.pad-md\` and \`.mar-md\` (plus the \`.px\`/\`.py\`/\`.pt\`\u2026 sides).\n`;
|
|
819
|
+
md += `There is no second vocabulary to learn.\n\n`;
|
|
820
|
+
|
|
821
|
+
md += `## Type Scale\n\nFixed rem rungs up to base, then compounded by \`--text-scaling\` (1.25).\n\n`;
|
|
822
|
+
md += table(group('--text-').filter((t) => TYPE_TOKENS.test(t.name)));
|
|
823
|
+
md += '\n' + table([find('--text-scaling')].filter(Boolean));
|
|
824
|
+
|
|
825
|
+
md += `\n## Space Scale\n\n\`--unit-space\` (0.25rem) times a step multiplier. Every step feeds the\n`;
|
|
826
|
+
md += `gap/pad/mar families in L1.\n\n`;
|
|
827
|
+
md += table([find('--unit-space')].filter(Boolean));
|
|
828
|
+
md += '\n' + table(group('--space-'));
|
|
829
|
+
|
|
830
|
+
md += `\n## Radius\n\nSix channels. Compositions read the tokens, never a literal radius.\n\n`;
|
|
831
|
+
md += table(group('--radius-'));
|
|
832
|
+
|
|
833
|
+
md += `\n## Control & Component Metrics\n\nHeights for inputs, selects and buttons; avatar and switch channels.\n\n`;
|
|
834
|
+
md += table(tokens.filter((t) => /^--(height|control|avatar|switch)/.test(t.name)));
|
|
835
|
+
|
|
836
|
+
md += `\n## Motion Language\n\nDurations, easings and the composed \`--motion*\` channels every transition reads.\n\n`;
|
|
837
|
+
md += table(tokens.filter((t) => /^--(speed|trans|motion)/.test(t.name)));
|
|
838
|
+
|
|
839
|
+
md += `\n## Colour Roles\n\nName the role, never the hex. Values shown are the light default;\n`;
|
|
840
|
+
md += `\`[data-mode='dark']\` remaps them via \`=dark-theme-tokens\`.\n\n`;
|
|
841
|
+
md += table(
|
|
842
|
+
tokens.filter(
|
|
843
|
+
(t) =>
|
|
844
|
+
/^--(white-fixed|black-fixed|bg|state|border|theme|success|warning|danger|info|ring)/.test(t.name) ||
|
|
845
|
+
/^--text-(primary|secondary|muted|inverse)$/.test(t.name)
|
|
846
|
+
)
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
md += `\n## Elevation\n\n\`--shadow-*\` channels are what the \`.shadow-*\` classes read; \`--shade-*\` are\n`;
|
|
850
|
+
md += `loop-generated from \`$shadow-specs\` in \`_00_config.sass\` (rim + key + ambient).\n\n`;
|
|
851
|
+
md += table(
|
|
852
|
+
group('--shadow-')
|
|
853
|
+
.concat(group('--shade-'), [find('--shadow-color'), find('--shadow-rim')].filter(Boolean))
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
md += `\n## Fonts, Layout Constants & Page Chrome\n\nFont stacks plus the page geometry the shells and viewport classes read.\n\n`;
|
|
857
|
+
md += table(
|
|
858
|
+
tokens.filter((t) =>
|
|
859
|
+
/^--(font|timeless|header-height|footer-height|fit-height|prose-clamp|content-clamp|app-inline|card-min|sidebar-width|z-)/.test(
|
|
860
|
+
t.name
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
md += `\n## Pop Colors\n\nNamed accent literals. The \`.orange1\`\u2026\`.orange6\` ink classes read\n`;
|
|
866
|
+
md += `\`--orangepop*\`; the green set is reserved for future use.\n\n`;
|
|
867
|
+
md += table(group('--greenpop').concat(group('--orangepop')));
|
|
868
|
+
|
|
869
|
+
md += `\n## Modes & Attribute Axes\n\n\`data-mode\` on the root selects the token mixin: \`light\` (default) or \`dark\`.\n\n`;
|
|
870
|
+
md += `| Attribute | Where | Values | Effect |\n|:---|:---|:---|:---|\n`;
|
|
871
|
+
md += `| \`data-mode\` | \`<html>\` | \`light\` \\| \`dark\` | Swaps \`=light-theme-tokens\` / \`=dark-theme-tokens\` |\n`;
|
|
872
|
+
md += `| \`data-shape\` | \`.card\`, \`button\` | \`modern\` \\| \`curved\` \\| \`round\` | Corner treatment of the component |\n`;
|
|
873
|
+
md += `| \`data-variant\` | \`button\` | \`primary\` \\| \`secondary\` \\| \`soft\` \\| \`outline\` \\| \`ghost\` \\| \`link\` \\| \`destructive\` | Paint variant of the button quartet |\n`;
|
|
874
|
+
md += `| \`data-size\` | \`button\` | \`sm\` \\| \`md\` \\| \`bs\` \\| \`lg\` | Metrics variant of the button quartet |\n`;
|
|
875
|
+
return md;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ============================================================================
|
|
879
|
+
// Execute
|
|
880
|
+
// ============================================================================
|
|
881
|
+
|
|
882
|
+
const configSrc = fs.readFileSync(path.join(stylesDir, '_00_config.sass'), 'utf8');
|
|
883
|
+
const config = {
|
|
884
|
+
steps: parseSassValue(readSassVariable(configSrc, 'steps')),
|
|
885
|
+
gapFamilies: parseSassValue(readSassVariable(configSrc, 'gap-families')),
|
|
886
|
+
padFamilies: parseSassValue(readSassVariable(configSrc, 'pad-families')),
|
|
887
|
+
marFamilies: parseSassValue(readSassVariable(configSrc, 'mar-families')),
|
|
888
|
+
frameRatios: parseSassValue(readSassVariable(configSrc, 'frame-ratios')),
|
|
889
|
+
breakpoints: parseSassValue(readSassVariable(configSrc, 'breakpoints'))
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const registry = [];
|
|
893
|
+
const seen = new Set();
|
|
894
|
+
|
|
895
|
+
// 1. Loop-generated families first (they own the L1 space ladder and L3 frames).
|
|
896
|
+
familyEntries(config, registry, seen);
|
|
897
|
+
|
|
898
|
+
// 2. Explicit classes from every partial, in layer order.
|
|
899
|
+
for (const { file, layer } of FILES) {
|
|
900
|
+
const content = fs.readFileSync(path.join(stylesDir, file), 'utf8');
|
|
901
|
+
const tree = parseSassTree(content);
|
|
902
|
+
walkRules(tree, { selPath: null, owner: null, media: [] }, file, layer, registry, seen);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const tokens = collectTokens();
|
|
906
|
+
|
|
907
|
+
fs.writeFileSync(path.join(rootDir, 'REGISTRY.md'), generateMarkdownRegistry(registry, tokens), 'utf8');
|
|
908
|
+
fs.mkdirSync(path.join(rootDir, 'docs'), { recursive: true });
|
|
909
|
+
fs.writeFileSync(
|
|
910
|
+
path.join(rootDir, 'docs', 'REGISTRY.md'),
|
|
911
|
+
`---\nid: registry-master\ntitle: Master Class & Token Registry\ntype: design\ntags: [registry, classes, tokens, grep, reference, complete]\nsummary: The complete, grepable master registry of all CSS classes, tokens, modifiers, and canonical markup structures in Fractalstyler2.\nupdated: ${new Date().toISOString().split('T')[0]}\n---\n\n# Master Class & Token Registry\n\nThis document is mirrored from [\`REGISTRY.md\`](../REGISTRY.md) at the repository root.\n\nPlease refer to [**\`../REGISTRY.md\`**](../REGISTRY.md) for the single, definitive, grepable master registry.\n`,
|
|
912
|
+
'utf8'
|
|
913
|
+
);
|
|
914
|
+
fs.writeFileSync(path.join(rootDir, 'registry.json'), JSON.stringify(registry, null, 2), 'utf8');
|
|
915
|
+
|
|
916
|
+
fs.mkdirSync(path.join(rootDir, 'skills', 'fractal-styler', 'references'), { recursive: true });
|
|
917
|
+
fs.writeFileSync(
|
|
918
|
+
path.join(rootDir, 'skills', 'fractal-styler', 'references', 'fractals.md'),
|
|
919
|
+
generateSkillReference(registry, tokens),
|
|
920
|
+
'utf8'
|
|
921
|
+
);
|
|
922
|
+
fs.writeFileSync(
|
|
923
|
+
path.join(rootDir, 'skills', 'fractal-styler', 'references', 'tokens.md'),
|
|
924
|
+
generateTokenReference(tokens),
|
|
925
|
+
'utf8'
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
const byLayer = {};
|
|
929
|
+
for (const e of registry) byLayer[e.layer] = (byLayer[e.layer] || 0) + 1;
|
|
930
|
+
const layerSummary = Object.entries(byLayer)
|
|
931
|
+
.map(([l, n]) => `${l}: ${n}`)
|
|
932
|
+
.join(', ');
|
|
933
|
+
console.log(`✔ Registry updated: ${registry.length} classes (${layerSummary}) across ${tokens.length} tokens.`);
|
|
934
|
+
console.log(` Emit: registry.json, REGISTRY.md, docs/REGISTRY.md, skills/fractal-styler/references/{fractals,tokens}.md`);
|