@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.
Files changed (46) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +123 -2
  3. package/cli/main.mjs +141 -0
  4. package/cli/scaffold.mjs +406 -0
  5. package/data/recipes.json +61 -0
  6. package/dist/asset.d.ts +3 -0
  7. package/dist/css/fractalstyler.css +2453 -0
  8. package/dist/css/fractalstyler.min.css +2 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +6 -0
  11. package/dist/styles/_00_config.sass +29 -0
  12. package/dist/styles/_00_fonts.sass +58 -0
  13. package/dist/styles/_00_tokens.sass +211 -0
  14. package/dist/styles/_01_base.sass +58 -0
  15. package/dist/styles/_02_dimensions.sass +70 -0
  16. package/dist/styles/_03_typography.sass +170 -0
  17. package/dist/styles/_04_containers.sass +173 -0
  18. package/dist/styles/_05_layouts.sass +104 -0
  19. package/dist/styles/_06_shells.sass +135 -0
  20. package/dist/styles/_07_interactions.sass +212 -0
  21. package/dist/styles/_08_visuals.sass +136 -0
  22. package/dist/styles/_09_own.sass +174 -0
  23. package/dist/styles/colorpacks.sass +119 -0
  24. package/dist/styles/index.sass +14 -0
  25. package/dist/styles/themeplates.sass +159 -0
  26. package/docs/REGISTRY.api.md +477 -0
  27. package/docs/REGISTRY.md +14 -0
  28. package/docs/references/configurations-api.md +220 -0
  29. package/docs/references/configurations.md +258 -0
  30. package/lint/browser.mjs +79 -0
  31. package/lint/cli.mjs +212 -0
  32. package/lint/lib/agent-reporter.mjs +51 -0
  33. package/lint/lib/fuzzy.mjs +45 -0
  34. package/lint/lib/registry.mjs +107 -0
  35. package/lint/lib/sass-linter.mjs +71 -0
  36. package/lint/lib/svelte-linter.mjs +116 -0
  37. package/lint/lib/token-linter.mjs +97 -0
  38. package/package.json +112 -5
  39. package/registry.json +3999 -0
  40. package/scripts/class-vocab.js +197 -0
  41. package/scripts/update-registry.js +934 -0
  42. package/skills/fractal-styler/references/fractals.md +630 -0
  43. package/skills/fractal-styler/references/tokens.md +226 -0
  44. package/skills/fractalstyler/SKILL.md +59 -0
  45. package/skills/fractalstyler/references/fractals.md +630 -0
  46. package/skills/fractalstyler/references/tokens.md +226 -0
package/lint/cli.mjs ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // fractalstyler lint — the design system contract linter & agent guard.
4
+ //
5
+ // Verifies code against the styling system contract:
6
+ // - Svelte files: no scoped <style>, no style="..." attributes, all classes verified
7
+ // - Stylesheets: token purity (no raw hex, >2px budget, valid var(--tokens))
8
+ // - Sass files: valid interpolation, radius ladder usage
9
+ //
10
+ // Supports:
11
+ // Self mode: node lint/cli.mjs (inside fractalstyler repo)
12
+ // Consumer mode: node lint/cli.mjs <dir> (linting any project using fractalstyler)
13
+ // Flags: --json (agent/CI output), --own <file>, --registry <file>
14
+ // =============================================================================
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { loadRegistry } from './lib/registry.mjs';
20
+ import { lintSvelteFile } from './lib/svelte-linter.mjs';
21
+ import { TokenLinter } from './lib/token-linter.mjs';
22
+ import { lintUninterpolatedFunctions, lintHardcodedRadius } from './lib/sass-linter.mjs';
23
+ import { reportDiagnostics } from './lib/agent-reporter.mjs';
24
+
25
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+
27
+ function usage() {
28
+ console.log(`Usage:
29
+ fractalstyler lint # self mode: audit fractalstyler package
30
+ fractalstyler lint <dir> # consumer mode: audit project directory
31
+ Options:
32
+ --own <file> extension stylesheet (default <dir>/_09_own.sass)
33
+ --registry <f> registry.json override
34
+ --json machine-readable JSON diagnostics for agents and CI
35
+ --help show this help message`);
36
+ }
37
+
38
+ const SVELTE = /\.svelte$/;
39
+ const STYLESHEET = /\.(sass|scss|css)$/;
40
+
41
+ function walk(dir, filter, out = [], base = dir) {
42
+ if (!fs.existsSync(dir)) return out;
43
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ if (
45
+ entry.name === 'node_modules' ||
46
+ entry.name === '.svelte-kit' ||
47
+ entry.name === 'dist' ||
48
+ entry.name === '.git' ||
49
+ entry.name === 'reserve' ||
50
+ entry.name.startsWith('.')
51
+ )
52
+ continue;
53
+
54
+ const full = path.join(base, entry.name);
55
+ if (entry.isDirectory()) walk(full, filter, out, full);
56
+ else if (filter(entry.name)) out.push(full);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const rel = (root, file) => path.relative(root, file);
62
+
63
+ function harvestOwnClasses(content) {
64
+ const names = new Set();
65
+ for (const m of content.matchAll(/(?:^|[\s,{}])(\.)([a-zA-Z][a-zA-Z0-9_-]*)/g)) {
66
+ names.add(m[2]);
67
+ }
68
+ return names;
69
+ }
70
+
71
+ function collectLocalProperties(stylesheets) {
72
+ const names = new Set();
73
+ for (const file of stylesheets) {
74
+ try {
75
+ const content = fs.readFileSync(file, 'utf8');
76
+ for (const m of content.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
77
+ names.add(m[1]);
78
+ }
79
+ } catch {}
80
+ }
81
+ return names;
82
+ }
83
+
84
+ async function lintSelf(flags) {
85
+ const diagnostics = [];
86
+ const { classes, tokens, stems } = loadRegistry(PKG_ROOT, flags.registry);
87
+ const tokenLinter = new TokenLinter(PKG_ROOT, { tokens });
88
+
89
+ // 1. Lint Svelte files in src/
90
+ const svelteFiles = walk(path.join(PKG_ROOT, 'src'), (f) => SVELTE.test(f));
91
+ for (const file of svelteFiles) {
92
+ const source = fs.readFileSync(file, 'utf8');
93
+ const relPath = rel(PKG_ROOT, file);
94
+ const result = lintSvelteFile(relPath, source, { allowlist: classes, stems });
95
+ diagnostics.push(...result.diagnostics);
96
+ }
97
+
98
+ // 2. Lint Sass files in src/lib/styles
99
+ const sassFiles = walk(path.join(PKG_ROOT, 'src', 'lib', 'styles'), (f) => STYLESHEET.test(f));
100
+ for (const file of sassFiles) {
101
+ const source = fs.readFileSync(file, 'utf8');
102
+ const relPath = rel(PKG_ROOT, file);
103
+ diagnostics.push(...lintUninterpolatedFunctions(relPath, source));
104
+ }
105
+
106
+ return diagnostics;
107
+ }
108
+
109
+ async function lintConsumer(targetDir, flags) {
110
+ const diagnostics = [];
111
+ const targetRoot = path.resolve(targetDir);
112
+
113
+ // Load canonical registry from fractalstyler package
114
+ const { classes: canonicalClasses, tokens, stems } = loadRegistry(PKG_ROOT, flags.registry);
115
+
116
+ // Find consumer's own extension classes
117
+ const ownFile = flags.own
118
+ ? path.resolve(flags.own)
119
+ : path.join(targetRoot, 'src', 'lib', 'styles', '_09_own.sass');
120
+
121
+ const ownClasses = new Set();
122
+ if (fs.existsSync(ownFile)) {
123
+ const ownContent = fs.readFileSync(ownFile, 'utf8');
124
+ for (const cls of harvestOwnClasses(ownContent)) {
125
+ ownClasses.add(cls);
126
+ }
127
+ }
128
+
129
+ // Full allowlist = canonical registry union with own classes
130
+ const allowlist = new Set([...canonicalClasses, ...ownClasses]);
131
+
132
+ // Collect locally defined CSS variables across target
133
+ const stylesheets = walk(targetRoot, (f) => STYLESHEET.test(f));
134
+ const localProps = collectLocalProperties(stylesheets);
135
+
136
+ const tokenLinter = new TokenLinter(targetRoot, { tokens });
137
+
138
+ // Lint Svelte files
139
+ const svelteFiles = walk(targetRoot, (f) => SVELTE.test(f));
140
+ for (const file of svelteFiles) {
141
+ const source = fs.readFileSync(file, 'utf8');
142
+ const relPath = rel(targetRoot, file);
143
+ const result = lintSvelteFile(relPath, source, { allowlist, stems });
144
+ diagnostics.push(...result.diagnostics);
145
+ }
146
+
147
+ // Lint stylesheets (skip canonical system files if present inside target)
148
+ for (const file of stylesheets) {
149
+ const base = path.basename(file);
150
+ // Layer partials _00 to _08 belong to system, and so do the palette
151
+ // modules (raw hex ladders by design — they are where the hex lives);
152
+ // _09_own belongs to the user and stays linted.
153
+ if (
154
+ /^_(0[0-8]|config|tokens|base|dimensions|containers|layouts|shells|visuals|interactions|typography)/.test(base) ||
155
+ /^(colorpacks|themeplates|index)\b/.test(base)
156
+ ) {
157
+ continue;
158
+ }
159
+
160
+ const source = fs.readFileSync(file, 'utf8');
161
+ const relPath = rel(targetRoot, file);
162
+
163
+ diagnostics.push(...tokenLinter.lintFile(relPath, source, localProps));
164
+ diagnostics.push(...lintUninterpolatedFunctions(relPath, source));
165
+ diagnostics.push(...lintHardcodedRadius(relPath, source));
166
+ }
167
+
168
+ return diagnostics;
169
+ }
170
+
171
+ async function main() {
172
+ const args = process.argv.slice(2);
173
+ const flags = { json: false, own: null, registry: null };
174
+ const positional = [];
175
+
176
+ for (let i = 0; i < args.length; i++) {
177
+ const arg = args[i];
178
+ if (arg === '--help' || arg === '-h') {
179
+ usage();
180
+ process.exit(0);
181
+ } else if (arg === '--json') {
182
+ flags.json = true;
183
+ } else if (arg === '--own' && i + 1 < args.length) {
184
+ flags.own = args[++i];
185
+ } else if (arg === '--registry' && i + 1 < args.length) {
186
+ flags.registry = args[++i];
187
+ } else if (!arg.startsWith('-')) {
188
+ positional.push(arg);
189
+ }
190
+ }
191
+
192
+ const targetDir = positional[0] === 'lint' ? positional[1] : positional[0];
193
+
194
+ let diagnostics = [];
195
+ try {
196
+ if (!targetDir || targetDir === '.') {
197
+ diagnostics = await lintSelf(flags);
198
+ } else {
199
+ diagnostics = await lintConsumer(targetDir, flags);
200
+ }
201
+
202
+ reportDiagnostics(diagnostics, { json: flags.json });
203
+ const hasErrors = diagnostics.some((d) => d.severity === 'error');
204
+ process.exit(hasErrors ? 1 : 0);
205
+ } catch (err) {
206
+ console.error(`\x1b[31mError during lint: ${err.message}\x1b[0m`);
207
+ if (process.env.DEBUG) console.error(err);
208
+ process.exit(2);
209
+ }
210
+ }
211
+
212
+ main();
@@ -0,0 +1,51 @@
1
+ // =============================================================================
2
+ // Agent-first diagnostic formatter.
3
+ //
4
+ // Box-drawing output is deliberate: it survives terminal piping and gives
5
+ // coding agents an unambiguous, self-describing block per violation, with a
6
+ // prescriptive fix.
7
+ // =============================================================================
8
+
9
+ const LABEL = 'FRACTALSTYLER';
10
+
11
+ export function reportDiagnostics(diagnostics, options = {}) {
12
+ const { json = false, ok = 'All styling system contracts verified. 0 violations found.' } = options;
13
+
14
+ if (json) {
15
+ console.log(JSON.stringify(diagnostics, null, 2));
16
+ return;
17
+ }
18
+
19
+ if (diagnostics.length === 0) {
20
+ console.log(`\n\x1b[32m✔ ${ok}\x1b[0m\n`);
21
+ return;
22
+ }
23
+
24
+ console.log(
25
+ `\n\x1b[1m\x1b[31m[${LABEL}] Found ${diagnostics.length} system contract violation${diagnostics.length > 1 ? 's' : ''}:\x1b[0m\n`
26
+ );
27
+
28
+ for (const d of diagnostics) {
29
+ const isError = d.severity === 'error';
30
+ const badgeColor = isError ? '\x1b[31m' : '\x1b[33m';
31
+ const loc = d.line ? `${d.file}:${d.line}${d.column ? `:${d.column}` : ''}` : d.file;
32
+
33
+ console.log(`${badgeColor}┌─ [${LABEL} ${d.severity.toUpperCase()}: ${d.rule}] ─────────────────────────────────────────────\x1b[0m`);
34
+ console.log(`│ \x1b[1mLocation:\x1b[0m ${loc}`);
35
+ console.log(`│ \x1b[1mFound:\x1b[0m \x1b[36m${d.found}\x1b[0m`);
36
+ console.log(`├─ \x1b[1m[SYSTEM RATIONALE]\x1b[0m`);
37
+ console.log(`│ ${d.rationale}`);
38
+ console.log(`├─ \x1b[1m\x1b[32m[PRESCRIPTIVE AGENT FIX]\x1b[0m`);
39
+
40
+ const suggestionLines = String(d.suggestion || '').split('\n');
41
+ for (const sLine of suggestionLines) {
42
+ console.log(`│ \x1b[32m${sLine}\x1b[0m`);
43
+ }
44
+ console.log(`${badgeColor}└─────────────────────────────────────────────────────────────────────────────────\x1b[0m\n`);
45
+ }
46
+
47
+ const errors = diagnostics.filter((d) => d.severity === 'error').length;
48
+ const warnings = diagnostics.filter((d) => d.severity === 'warning').length;
49
+
50
+ console.log(`\x1b[1mResult: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}.\x1b[0m\n`);
51
+ }
@@ -0,0 +1,45 @@
1
+ // =============================================================================
2
+ // Levenshtein distance & fuzzy match suggestions.
3
+ // Zero dependencies. Used to suggest closest registered classes/tokens to agents.
4
+ // =============================================================================
5
+
6
+ function levenshtein(a, b) {
7
+ const m = a.length;
8
+ const n = b.length;
9
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
10
+
11
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
12
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
13
+
14
+ for (let i = 1; i <= m; i++) {
15
+ for (let j = 1; j <= n; j++) {
16
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
17
+ dp[i][j] = Math.min(
18
+ dp[i - 1][j] + 1, // deletion
19
+ dp[i][j - 1] + 1, // insertion
20
+ dp[i - 1][j - 1] + cost // substitution
21
+ );
22
+ }
23
+ }
24
+ return dp[m][n];
25
+ }
26
+
27
+ /**
28
+ * Finds closest matches in a candidates set or array.
29
+ * @param {string} target
30
+ * @param {Iterable<string>} candidates
31
+ * @param {number} maxResults
32
+ * @param {number} maxDistance
33
+ * @returns {string[]}
34
+ */
35
+ export function findClosestMatches(target, candidates, maxResults = 3, maxDistance = 4) {
36
+ const scored = [];
37
+ for (const cand of candidates) {
38
+ const dist = levenshtein(target, cand);
39
+ if (dist <= maxDistance) {
40
+ scored.push({ cand, dist });
41
+ }
42
+ }
43
+ scored.sort((a, b) => a.dist - b.dist);
44
+ return scored.slice(0, maxResults).map((s) => s.cand);
45
+ }
@@ -0,0 +1,107 @@
1
+ // =============================================================================
2
+ // Registry loader — the source of truth for every class and token check.
3
+ //
4
+ // Reads registry.json and pairs it with compiled stylesheet verification
5
+ // so loop-generated wildcard families (.gap-*, .pad-*, .radius-*) and exact
6
+ // selectors are both known without guesswork.
7
+ // =============================================================================
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import * as sass from 'sass';
12
+
13
+ /**
14
+ * Loads the style registry for verification.
15
+ * @param {string} rootDir Root of the fractalstyler package or consumer project.
16
+ * @param {string} [registryPath] Path to registry.json override.
17
+ */
18
+ export function loadRegistry(rootDir, registryPath) {
19
+ const defaultRegistry = path.join(rootDir, 'registry.json');
20
+ const regFile = registryPath || (fs.existsSync(defaultRegistry) ? defaultRegistry : path.join(rootDir, 'src', 'lib', 'data', 'registry.json'));
21
+
22
+ if (!fs.existsSync(regFile)) {
23
+ throw new Error(`registry.json not found at ${regFile} — run "pnpm registry" first.`);
24
+ }
25
+
26
+ const data = JSON.parse(fs.readFileSync(regFile, 'utf8'));
27
+ const classes = new Set();
28
+ const tokens = new Set();
29
+ const ownLayerClasses = new Set();
30
+ const layerIds = new Set();
31
+ const layerOf = new Map();
32
+ const fileOfLayer = new Map();
33
+ const stems = [];
34
+
35
+ // 1. Process registry.json (handles both array format and { layers: [...] } format)
36
+ if (Array.isArray(data)) {
37
+ for (const entry of data) {
38
+ const rawClass = (entry.class || '').replace(/^\./, '').trim();
39
+ if (!rawClass) continue;
40
+
41
+ if (rawClass.includes('*')) {
42
+ stems.push(rawClass.replace(/\*.*$/, ''));
43
+ } else {
44
+ classes.add(rawClass);
45
+ }
46
+
47
+ const layer = entry.layer || 'L0';
48
+ layerIds.add(layer);
49
+ layerOf.set(rawClass, layer);
50
+ if (entry.file) fileOfLayer.set(layer, entry.file);
51
+
52
+ if (layer.toLowerCase().includes('own') || (entry.file && entry.file.includes('own'))) {
53
+ ownLayerClasses.add(rawClass);
54
+ }
55
+ }
56
+ } else if (data.layers && Array.isArray(data.layers)) {
57
+ for (const layer of data.layers) {
58
+ const isOwn = /own/i.test(layer.id || '') || /own/i.test(layer.file || '');
59
+ layerIds.add(layer.id);
60
+ if (!fileOfLayer.has(layer.id)) fileOfLayer.set(layer.id, layer.file || layer.id);
61
+
62
+ for (const item of layer.items || []) {
63
+ if (item.kind === 'class') {
64
+ const name = item.name.replace(/^\./, '');
65
+ if (name.includes('*')) {
66
+ stems.push(name.replace(/\*.*$/, ''));
67
+ } else {
68
+ classes.add(name);
69
+ }
70
+ if (!layerOf.has(name)) layerOf.set(name, layer.id);
71
+ if (isOwn) ownLayerClasses.add(name);
72
+ } else if (item.kind === 'token') {
73
+ tokens.add(item.name);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ // 2. Also harvest compiled stylesheet if index.sass exists (for exact wildcard matches)
80
+ const indexSass = path.join(rootDir, 'src', 'lib', 'styles', 'index.sass');
81
+ if (fs.existsSync(indexSass)) {
82
+ try {
83
+ const compiled = sass.compile(indexSass, { style: 'expanded' }).css;
84
+ for (const m of compiled.matchAll(/\.([a-zA-Z][\w-]*)/g)) {
85
+ classes.add(m[1]);
86
+ }
87
+ for (const m of compiled.matchAll(/(--[a-zA-Z0-9_-]+):/g)) {
88
+ tokens.add(m[1]);
89
+ }
90
+ } catch (e) {
91
+ // If standalone compile fails in consumer mode, fallback to registry.json
92
+ }
93
+ }
94
+
95
+ // 3. Fallback token extraction from _00_tokens.sass if tokens set is empty
96
+ if (tokens.size === 0) {
97
+ const tokenFile = path.join(rootDir, 'src', 'lib', 'styles', '_00_tokens.sass');
98
+ if (fs.existsSync(tokenFile)) {
99
+ const tokenContent = fs.readFileSync(tokenFile, 'utf8');
100
+ for (const m of tokenContent.matchAll(/(--[a-zA-Z0-9_-]+):/g)) {
101
+ tokens.add(m[1]);
102
+ }
103
+ }
104
+ }
105
+
106
+ return { classes, tokens, ownLayerClasses, layerIds, layerOf, fileOfLayer, stems };
107
+ }
@@ -0,0 +1,71 @@
1
+ // =============================================================================
2
+ // Sass declaration linters for fractalstyler.
3
+ //
4
+ // 1. fs/sass-interpolation — Sass parses custom-property values literally.
5
+ // Functions like list.nth(...) or map.get(...) inside `--name: ...`
6
+ // must be wrapped in `#{...}` to be evaluated rather than emitted verbatim.
7
+ //
8
+ // 2. fs/hardcoded-radius — Corners should route through the radius ladder
9
+ // `var(--radius-*)` so theme shape modifiers can restyle them.
10
+ // =============================================================================
11
+
12
+ const SASS_NAMESPACED = /\b(?:map|list|math|color|string|meta|selector|sass)\.[a-z-]+\s*\(/;
13
+ const SASS_CORE =
14
+ /\b(?:if|nth|append|join|zip|index|list-separator|is-bracketed|length|set-nth|unit|unitless|percentage|floor|ceil|abs|random|quote|unquote|str-length|str-insert|str-index|str-slice|to-upper-case|to-lower-case|type-of|comparable|call|get-function|inspect|variable-exists|function-exists|global-variable-exists|feature-exists|keywords|mix|lighten|darken|saturate|desaturate|grayscale|invert|opacify|fade-in|transparentize|fade-out|adjust-hue|scale-color|change-color|adjust-color|ie-hex-str|red|green|blue|hue|saturation|lightness|alpha|opacity)\s*\(/;
15
+
16
+ export function lintUninterpolatedFunctions(relPath, content) {
17
+ const diagnostics = [];
18
+ content.split('\n').forEach((line, idx) => {
19
+ const cleanLine = line.replace(/\/\/.*$/, '').replace(/\/\*[\s\S]*?\*\//, '');
20
+ const decl = cleanLine.match(/(^|\s)(--[a-zA-Z0-9_-]+)\s*:\s*(.+)$/);
21
+ if (!decl) return;
22
+
23
+ const value = decl[3].replace(/#\{[\s\S]*?\}/g, '');
24
+ const hit = value.match(SASS_NAMESPACED) || value.match(SASS_CORE);
25
+ if (!hit) return;
26
+
27
+ diagnostics.push({
28
+ rule: 'fs/sass-interpolation',
29
+ severity: 'error',
30
+ file: relPath,
31
+ line: idx + 1,
32
+ column: line.indexOf(decl[2]) + 1,
33
+ found: `${decl[2]}: ${decl[3].trim()}`,
34
+ rationale:
35
+ 'Sass parses custom-property values literally: this function call is NOT evaluated — its source text ships to the browser as the value. Only #{…} interpolation is evaluated inside a custom property.',
36
+ suggestion: `Wrap the function call in interpolation: ${decl[2]}: #{${decl[3].trim()}} — or compute the value into a Sass variable first.`
37
+ });
38
+ });
39
+ return diagnostics;
40
+ }
41
+
42
+ const RADIUS_OK = /^(?:0(?:px)?|inherit|initial|unset|var\(--radius[^)]*\))$/;
43
+
44
+ export function lintHardcodedRadius(relPath, content) {
45
+ const diagnostics = [];
46
+ content.split('\n').forEach((line, idx) => {
47
+ const cleanLine = line.replace(/\/\/.*$/, '').replace(/\/\*[\s\S]*?\*\//, '');
48
+ const decl = cleanLine.match(/(^|\s)(border-radius)\s*:\s*(.+)$/);
49
+ if (!decl) return;
50
+
51
+ const bad = decl[3]
52
+ .replace(/#\{[^}]*\}/g, ' ')
53
+ .trim()
54
+ .split(/\s+/)
55
+ .filter((token) => token && !RADIUS_OK.test(token));
56
+ if (bad.length === 0) return;
57
+
58
+ diagnostics.push({
59
+ rule: 'fs/hardcoded-radius',
60
+ severity: 'warning',
61
+ file: relPath,
62
+ line: idx + 1,
63
+ column: line.indexOf(decl[2]) + 1,
64
+ found: `border-radius: ${decl[3].trim()}`,
65
+ rationale:
66
+ 'Literal border-radius values are unreachable by the theme shape axis. Data attributes rewrite the --radius-* ladder.',
67
+ suggestion: `Route the corner through the ladder — var(--radius-xs/sm/md/bs/lg/xl) or system radius classes.`
68
+ });
69
+ });
70
+ return diagnostics;
71
+ }
@@ -0,0 +1,116 @@
1
+ // =============================================================================
2
+ // Svelte contract scanner for fractalstyler.
3
+ //
4
+ // Zero-dependency regex scanner that runs via CLI or in CI. Enforces:
5
+ // fs/no-in-component-styles — no scoped <style> blocks.
6
+ // fs/no-inline-styles — no style="..." attributes.
7
+ // fs/unknown-class — every class used in markup must exist in registry.
8
+ // =============================================================================
9
+
10
+ import { findClosestMatches } from './fuzzy.mjs';
11
+
12
+ const CLASS_ATTR = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
13
+ const CLASS_DIRECTIVE = /\sclass:([a-zA-Z0-9_-]+)/g;
14
+ const STYLE_BLOCK = /<style[\s>]/;
15
+ const STYLE_ATTR = /\sstyle\s*=/;
16
+
17
+ /**
18
+ * Lints a Svelte file for styling system compliance.
19
+ * @param {string} relPath
20
+ * @param {string} source
21
+ * @param {{ allowlist?: Set<string>, stems?: string[] }} [options]
22
+ * @returns {{ diagnostics: Array, classes: Set<string> }}
23
+ */
24
+ export function lintSvelteFile(relPath, source, options = {}) {
25
+ const diagnostics = [];
26
+ const classes = new Set();
27
+ const { allowlist, stems = [] } = options;
28
+
29
+ // Rule 1: No scoped <style> blocks
30
+ if (STYLE_BLOCK.test(source)) {
31
+ const line = source.slice(0, source.search(STYLE_BLOCK)).split('\n').length;
32
+ diagnostics.push({
33
+ rule: 'fs/no-in-component-styles',
34
+ severity: 'error',
35
+ file: relPath,
36
+ line,
37
+ found: '<style> block detected',
38
+ rationale:
39
+ 'Components must remain pure markup & logic. Scoped styles fragment the design system, block global theme changes, and leak ad-hoc CSS.',
40
+ suggestion:
41
+ 'Remove the <style> block and express the design using fractalstyler classes. If a class is missing, add it to the designated layer in src/lib/styles or your _09_own.sass.'
42
+ });
43
+ }
44
+
45
+ // Strip <script> content first so JS assignments like `style = ...` are not flagged
46
+ const scriptStart = source.indexOf('<script');
47
+ let markup = source;
48
+ if (scriptStart !== -1) {
49
+ const scriptEnd = source.indexOf('</script>', scriptStart);
50
+ if (scriptEnd !== -1) {
51
+ markup = source.slice(0, scriptStart) + source.slice(scriptEnd + '</script>'.length);
52
+ }
53
+ }
54
+
55
+ // Rule 2: No inline style="..." attributes
56
+ if (STYLE_ATTR.test(markup)) {
57
+ const match = markup.match(STYLE_ATTR);
58
+ const line = markup.slice(0, match.index).split('\n').length;
59
+ diagnostics.push({
60
+ rule: 'fs/no-inline-styles',
61
+ severity: 'error',
62
+ file: relPath,
63
+ line,
64
+ found: match[0].trim(),
65
+ rationale:
66
+ 'Inline styles are unconstrained ad-hoc CSS: invisible to the registry, unreachable by theme switches, and unaccountable to the design system.',
67
+ suggestion:
68
+ 'Express the styling intent using canonical classes (e.g. .w-full, .pad-sm, .gap-md). Never patch elements with inline CSS.'
69
+ });
70
+ }
71
+
72
+ // Harvest classes
73
+ for (const m of markup.matchAll(CLASS_ATTR)) {
74
+ const value = m[1] ?? m[2] ?? '';
75
+ for (const name of value.split(/\s+/)) {
76
+ // Skip interpolated fragments (class="x {expr} y")
77
+ if (name && !/[{()}]/.test(name) && !name.startsWith('{')) {
78
+ classes.add(name);
79
+ }
80
+ }
81
+ }
82
+ for (const m of markup.matchAll(CLASS_DIRECTIVE)) {
83
+ classes.add(m[1]);
84
+ }
85
+
86
+ // Rule 3: Check unknown classes against allowlist if provided
87
+ if (allowlist && allowlist.size > 0) {
88
+ for (const cls of classes) {
89
+ // Check exact match, modifier, or stem match
90
+ const isAllowed =
91
+ allowlist.has(cls) ||
92
+ stems.some((stem) => cls.startsWith(stem)) ||
93
+ /-(mob|desk)$/.test(cls);
94
+
95
+ if (!isAllowed) {
96
+ const closest = findClosestMatches(cls, allowlist, 3, 4);
97
+ const suggestion =
98
+ closest.length > 0
99
+ ? `Class ".${cls}" is not in the system registry. Did you mean one of these?\n - .${closest.join('\n - .')}`
100
+ : `Class ".${cls}" is not in the system registry. Compose using existing classes in REGISTRY.md, or add it to the appropriate system partial in src/lib/styles (or _09_own.sass).`;
101
+
102
+ diagnostics.push({
103
+ rule: 'fs/unknown-class',
104
+ severity: 'error',
105
+ file: relPath,
106
+ found: `.${cls}`,
107
+ rationale:
108
+ `The closed-world styling contract forbids ad-hoc or hallucinated classes. Every class must resolve to the verified system registry.`,
109
+ suggestion
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ return { diagnostics, classes };
116
+ }