@dorsk/tsumikit 0.81.0 → 0.82.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 (102) hide show
  1. package/README.md +180 -2
  2. package/dist/breakpoints.d.ts +15 -0
  3. package/dist/breakpoints.js +14 -0
  4. package/dist/components/atoms/Badge.svelte +1 -0
  5. package/dist/components/atoms/Button.svelte +16 -5
  6. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  7. package/dist/components/atoms/Input.svelte +1 -0
  8. package/dist/components/atoms/Slider.svelte +1 -1
  9. package/dist/components/atoms/Text.svelte +4 -4
  10. package/dist/components/atoms/Text.svelte.d.ts +1 -1
  11. package/dist/components/layouts/AppShell.svelte +14 -0
  12. package/dist/components/layouts/Backdrop.svelte +152 -0
  13. package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
  14. package/dist/components/layouts/NavSection.svelte +1 -1
  15. package/dist/components/molecules/AttachmentList.svelte +1 -1
  16. package/dist/components/molecules/CapBar.svelte +1 -1
  17. package/dist/components/molecules/Composer.svelte +1 -1
  18. package/dist/components/molecules/Dropzone.svelte +1 -1
  19. package/dist/components/molecules/EmptyState.svelte +13 -6
  20. package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
  21. package/dist/components/molecules/Field.svelte +1 -1
  22. package/dist/components/molecules/FilterInput.svelte +5 -3
  23. package/dist/components/molecules/Meter.svelte +1 -1
  24. package/dist/components/molecules/Metric.svelte +3 -1
  25. package/dist/components/molecules/Modal.svelte +4 -4
  26. package/dist/components/molecules/Pagination.svelte +26 -2
  27. package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
  28. package/dist/components/molecules/Prose.svelte +1 -0
  29. package/dist/components/molecules/RadioGroup.svelte +2 -2
  30. package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
  31. package/dist/components/molecules/SectionHeader.svelte +1 -1
  32. package/dist/components/molecules/SegmentedControl.svelte +55 -22
  33. package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
  34. package/dist/components/molecules/SortableList.svelte +1 -1
  35. package/dist/components/molecules/StatusBar.svelte +2 -2
  36. package/dist/components/molecules/Tabs.svelte +32 -0
  37. package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
  38. package/dist/components/molecules/ThemePicker.svelte +3 -3
  39. package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
  40. package/dist/components/organisms/DataTable.svelte +2 -2
  41. package/dist/dev-audit.d.ts +19 -0
  42. package/dist/dev-audit.js +259 -0
  43. package/dist/index.d.ts +6 -1
  44. package/dist/index.js +6 -1
  45. package/dist/media.svelte.d.ts +8 -0
  46. package/dist/media.svelte.js +26 -0
  47. package/dist/pending.svelte.d.ts +16 -0
  48. package/dist/pending.svelte.js +75 -0
  49. package/dist/stores/toast.svelte.d.ts +22 -0
  50. package/dist/stores/toast.svelte.js +54 -2
  51. package/dist/styles/app.css +2 -1
  52. package/dist/styles/font.css +19 -0
  53. package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
  54. package/dist/styles/fonts/inter.woff2 +0 -0
  55. package/dist/styles/themes.css +58 -58
  56. package/dist/styles/tokens.css +15 -9
  57. package/dist/url-state.svelte.d.ts +22 -0
  58. package/dist/url-state.svelte.js +64 -0
  59. package/lint/breakpoints.mjs +33 -0
  60. package/lint/cli.mjs +99 -0
  61. package/lint/context.mjs +242 -0
  62. package/lint/index.mjs +97 -0
  63. package/lint/rules/breakpoints.mjs +25 -0
  64. package/lint/rules/img-alt.mjs +13 -0
  65. package/lint/rules/motion.mjs +85 -0
  66. package/lint/rules/no-raw-color.mjs +59 -0
  67. package/lint/rules/no-raw-control.mjs +23 -0
  68. package/lint/rules/no-raw-font.mjs +64 -0
  69. package/lint/rules/one-primary.mjs +74 -0
  70. package/lint/rules/spacing-scale.mjs +66 -0
  71. package/package.json +12 -2
  72. package/principles/01-visibility-of-status.md +32 -0
  73. package/principles/02-one-primary-action.md +31 -0
  74. package/principles/03-emphasise-by-de-emphasising.md +31 -0
  75. package/principles/04-labels-last-resort.md +30 -0
  76. package/principles/05-tint-the-greys.md +30 -0
  77. package/principles/06-depth-from-shade.md +30 -0
  78. package/principles/07-one-accent.md +31 -0
  79. package/principles/08-spacing-scale.md +30 -0
  80. package/principles/09-group-with-space.md +31 -0
  81. package/principles/10-real-typeface-fixed-scale.md +33 -0
  82. package/principles/11-mono-only-for-code.md +30 -0
  83. package/principles/12-measure.md +29 -0
  84. package/principles/13-imagery-sets-the-mood.md +32 -0
  85. package/principles/14-instant-calm-feedback.md +32 -0
  86. package/principles/15-undo-over-confirm.md +30 -0
  87. package/principles/16-empty-states.md +31 -0
  88. package/principles/17-errors-in-plain-words.md +31 -0
  89. package/principles/18-match-the-real-world.md +31 -0
  90. package/principles/19-consistency-and-standards.md +32 -0
  91. package/principles/20-error-prevention.md +31 -0
  92. package/principles/21-recognition-over-recall.md +31 -0
  93. package/principles/22-flexibility-and-shortcuts.md +31 -0
  94. package/principles/23-url-is-state.md +31 -0
  95. package/principles/24-links-are-links.md +31 -0
  96. package/principles/25-visible-focus.md +32 -0
  97. package/principles/26-generous-targets.md +31 -0
  98. package/principles/27-cheap-motion-respect-preferences.md +32 -0
  99. package/principles/28-less-but-better.md +31 -0
  100. package/principles/29-colour-is-not-the-only-cue.md +31 -0
  101. package/principles/30-help-where-needed.md +31 -0
  102. package/principles/README.md +62 -0
package/lint/cli.mjs ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { RULES, lintPaths, selectRules } from './index.mjs';
4
+
5
+ const HELP = `tsumikit-lint [options] [paths...]
6
+
7
+ Flags drift from the kit's design rules in .svelte and .css files.
8
+ Defaults to ./src when no path is given.
9
+
10
+ Options:
11
+ --json machine-readable output
12
+ --rule <id> run only this rule (repeatable, comma-separated)
13
+ --disable <id> skip this rule (repeatable, comma-separated)
14
+ --list-rules print the rule ids and exit
15
+ -h, --help show this help
16
+
17
+ Suppress a line with a comment on the line above:
18
+ /* tsumikit-lint-ignore spacing-scale */ (css)
19
+ <!-- tsumikit-lint-ignore no-raw-control --> (markup)
20
+
21
+ Rules:
22
+ ${RULES.map((r) => ` ${r.id.padEnd(16)} ${r.description}`).join('\n')}
23
+ `;
24
+
25
+ function parseArgs(argv) {
26
+ const opts = { json: false, only: [], disable: [], paths: [] };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const arg = argv[i];
29
+ const next = () => {
30
+ if (i + 1 >= argv.length) throw new Error(`${arg} needs a value`);
31
+ return argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
32
+ };
33
+ if (arg === '--json') opts.json = true;
34
+ else if (arg === '--rule') opts.only.push(...next());
35
+ else if (arg.startsWith('--rule=')) opts.only.push(...arg.slice(7).split(','));
36
+ else if (arg === '--disable') opts.disable.push(...next());
37
+ else if (arg.startsWith('--disable=')) opts.disable.push(...arg.slice(10).split(','));
38
+ else if (arg === '--list-rules') opts.list = true;
39
+ else if (arg === '-h' || arg === '--help') opts.help = true;
40
+ else if (arg.startsWith('-')) throw new Error(`unknown option ${arg}`);
41
+ else opts.paths.push(arg);
42
+ }
43
+ return opts;
44
+ }
45
+
46
+ function formatText({ files, findings }) {
47
+ const lines = findings.map(
48
+ (f) => `${f.file}:${f.line}:${f.column} ${f.rule.padEnd(16)} ${f.message}`
49
+ );
50
+ const byRule = new Map();
51
+ for (const f of findings) byRule.set(f.rule, (byRule.get(f.rule) ?? 0) + 1);
52
+ const summary = findings.length
53
+ ? `${findings.length} problem${findings.length === 1 ? '' : 's'} in ${files} file${files === 1 ? '' : 's'} (${[...byRule].map(([r, n]) => `${r}: ${n}`).join(', ')})`
54
+ : `no problems in ${files} file${files === 1 ? '' : 's'}`;
55
+ return [...lines, '', summary].join('\n');
56
+ }
57
+
58
+ async function main() {
59
+ let opts;
60
+ try {
61
+ opts = parseArgs(process.argv.slice(2));
62
+ } catch (e) {
63
+ process.stderr.write(`tsumikit-lint: ${e.message}\n\n${HELP}`);
64
+ return 2;
65
+ }
66
+ if (opts.help) {
67
+ process.stdout.write(HELP);
68
+ return 0;
69
+ }
70
+ if (opts.list) {
71
+ process.stdout.write(`${RULES.map((r) => r.id).join('\n')}\n`);
72
+ return 0;
73
+ }
74
+ let rules;
75
+ try {
76
+ rules = selectRules({ only: opts.only, disable: opts.disable });
77
+ } catch (e) {
78
+ process.stderr.write(`tsumikit-lint: ${e.message}\n`);
79
+ return 2;
80
+ }
81
+ const paths = opts.paths.length ? opts.paths : existsSync('src') ? ['src'] : ['.'];
82
+ for (const p of paths) {
83
+ if (!existsSync(p)) {
84
+ process.stderr.write(`tsumikit-lint: no such path ${p}\n`);
85
+ return 2;
86
+ }
87
+ }
88
+ const result = await lintPaths(paths, { rules });
89
+ if (opts.json) {
90
+ process.stdout.write(
91
+ `${JSON.stringify({ files: result.files, errorCount: result.findings.length, findings: result.findings }, null, 2)}\n`
92
+ );
93
+ } else {
94
+ process.stdout.write(`${formatText(result)}\n`);
95
+ }
96
+ return result.findings.length ? 1 : 0;
97
+ }
98
+
99
+ process.exitCode = await main();
@@ -0,0 +1,242 @@
1
+ import { parse } from 'svelte/compiler';
2
+
3
+ /**
4
+ * @typedef {import('svelte/compiler').AST.Fragment} Fragment
5
+ * @typedef {import('svelte/compiler').AST.ElementLike} ElementLike
6
+ * @typedef {import('svelte/compiler').AST.Attribute} Attribute
7
+ * @typedef {import('svelte/compiler').AST.StyleDirective} StyleDirective
8
+ * @typedef {Fragment['nodes'][number]} MarkupNode
9
+ * @typedef {import('svelte/compiler').AST.CSS.Declaration} Declaration
10
+ * @typedef {import('svelte/compiler').AST.CSS.Atrule} Atrule
11
+ * @typedef {import('svelte/compiler').AST.CSS.Rule} CssRule
12
+ * @typedef {{ property: string, value: string, start: number }} DeclarationLike
13
+ * @typedef {{ file: string, line: number, column: number, rule: string, message: string }} Finding
14
+ * @typedef {ReturnType<typeof createContext>} Context
15
+ * @typedef {{ id: string, description: string, check: (ctx: Context) => void }} LintRule
16
+ */
17
+
18
+ const IGNORE_RE = /tsumikit-lint-ignore(?:\s+([\w-]+(?:[\s,]+[\w-]+)*))?/g;
19
+
20
+ /** @param {string} source */
21
+ function lineStarts(source) {
22
+ const starts = [0];
23
+ for (let i = 0; i < source.length; i++) {
24
+ if (source.charCodeAt(i) === 10) starts.push(i + 1);
25
+ }
26
+ return starts;
27
+ }
28
+
29
+ /** @param {number[]} starts @param {number} offset */
30
+ function positionOf(starts, offset) {
31
+ let lo = 0;
32
+ let hi = starts.length - 1;
33
+ while (lo < hi) {
34
+ const mid = (lo + hi + 1) >> 1;
35
+ if (starts[mid] <= offset) lo = mid;
36
+ else hi = mid - 1;
37
+ }
38
+ return { line: lo + 1, column: offset - starts[lo] + 1 };
39
+ }
40
+
41
+ /**
42
+ * Collects `tsumikit-lint-ignore [rule ...]` comments. A comment suppresses the
43
+ * named rules (all rules when none is given) on the following line, and on its
44
+ * own line when code precedes it.
45
+ * @param {string} source
46
+ * @param {number[]} starts
47
+ */
48
+ function collectIgnores(source, starts) {
49
+ /** @type {Map<number, 'all' | Set<string>>} */
50
+ const ignores = new Map();
51
+ /** @param {number} line @param {'all' | string[]} rules */
52
+ const add = (line, rules) => {
53
+ const existing = ignores.get(line);
54
+ if (existing === 'all') return;
55
+ if (rules === 'all') ignores.set(line, 'all');
56
+ else ignores.set(line, new Set([...(existing ?? []), ...rules]));
57
+ };
58
+ for (const m of source.matchAll(IGNORE_RE)) {
59
+ const { line } = positionOf(starts, m.index);
60
+ const rules = m[1] ? m[1].split(/[\s,]+/).filter(Boolean) : 'all';
61
+ const lineText = source.slice(starts[line - 1], m.index);
62
+ if (/\S/.test(lineText.replace(/(\/\*|<!--|\/\/)\s*$/, ''))) add(line, rules);
63
+ add(line + 1, rules);
64
+ }
65
+ return ignores;
66
+ }
67
+
68
+ /**
69
+ * Parses a .svelte or .css source into a lint context. CSS is wrapped in a
70
+ * <style> element so the Svelte CSS parser can be reused; offsets are shifted
71
+ * back when reporting.
72
+ * @param {string} file
73
+ * @param {string} source
74
+ */
75
+ export function createContext(file, source) {
76
+ const isCss = /\.css$/i.test(file);
77
+ const shift = isCss ? '<style>'.length : 0;
78
+ const parsed = parse(isCss ? `<style>${source}</style>` : source, { modern: true });
79
+ const starts = lineStarts(source);
80
+ const ignores = collectIgnores(source, starts);
81
+ /** @type {Finding[]} */
82
+ const findings = [];
83
+ const parseSource = isCss ? `<style>${source}</style>` : source;
84
+
85
+ return {
86
+ file,
87
+ source,
88
+ isCss,
89
+ css: parsed.css,
90
+ fragment: isCss ? null : parsed.fragment,
91
+ /** @param {{ start: number, end: number }} node */
92
+ text(node) {
93
+ return parseSource.slice(node.start, node.end);
94
+ },
95
+ /** @param {number} offset */
96
+ loc(offset) {
97
+ return positionOf(starts, Math.max(0, offset - shift));
98
+ },
99
+ /** @param {string} rule @param {number} offset @param {string} message */
100
+ report(rule, offset, message) {
101
+ const { line, column } = this.loc(offset);
102
+ const ignored = ignores.get(line);
103
+ if (ignored === 'all' || ignored?.has(rule)) return;
104
+ findings.push({ file, line, column, rule, message });
105
+ },
106
+ findings
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Visits every CSS declaration with its enclosing selectors and at-rules.
112
+ * `cb(declaration, { selectors, atrules })` where `selectors` are the selector
113
+ * texts of enclosing rules (outermost first) and `atrules` the enclosing
114
+ * at-rule nodes.
115
+ * @param {Context} ctx
116
+ * @param {(declaration: Declaration, scope: { selectors: string[], atrules: Atrule[] }) => void} cb
117
+ */
118
+ export function walkDeclarations(ctx, cb) {
119
+ if (!ctx.css) return;
120
+ /**
121
+ * @param {Array<Declaration | CssRule | Atrule>} nodes
122
+ * @param {string[]} selectors
123
+ * @param {Atrule[]} atrules
124
+ */
125
+ const visit = (nodes, selectors, atrules) => {
126
+ for (const node of nodes) {
127
+ if (node.type === 'Declaration') cb(node, { selectors, atrules });
128
+ else if (node.type === 'Rule') {
129
+ visit(node.block.children, [...selectors, ctx.text(node.prelude)], atrules);
130
+ } else if (node.type === 'Atrule' && node.block) {
131
+ visit(node.block.children, selectors, [...atrules, node]);
132
+ }
133
+ }
134
+ };
135
+ visit(ctx.css.children, [], []);
136
+ }
137
+
138
+ /** @param {Context} ctx @param {(node: Atrule) => void} cb */
139
+ export function walkAtrules(ctx, cb) {
140
+ if (!ctx.css) return;
141
+ /** @param {Array<Declaration | CssRule | Atrule>} nodes */
142
+ const visit = (nodes) => {
143
+ for (const node of nodes) {
144
+ if (node.type === 'Atrule') {
145
+ cb(node);
146
+ if (node.block) visit(node.block.children);
147
+ } else if (node.type === 'Rule') visit(node.block.children);
148
+ }
149
+ };
150
+ visit(ctx.css.children);
151
+ }
152
+
153
+ const CHILD_KEYS = ['fragment', 'consequent', 'alternate', 'body', 'fallback', 'pending', 'then', 'catch'];
154
+
155
+ /**
156
+ * Visits every markup node (elements, components, blocks) depth-first.
157
+ * @param {Context} ctx
158
+ * @param {(node: MarkupNode) => void} cb
159
+ */
160
+ export function walkMarkup(ctx, cb) {
161
+ if (!ctx.fragment) return;
162
+ /** @param {MarkupNode | Fragment | null | undefined} node */
163
+ const visit = (node) => {
164
+ if (!node || typeof node !== 'object') return;
165
+ if (node.type === 'Fragment') {
166
+ for (const child of node.nodes) visit(child);
167
+ return;
168
+ }
169
+ cb(node);
170
+ const children = /** @type {Partial<Record<string, Fragment | null>>} */ (/** @type {unknown} */ (node));
171
+ for (const key of CHILD_KEYS) {
172
+ if (children[key]) visit(children[key]);
173
+ }
174
+ };
175
+ visit(ctx.fragment);
176
+ }
177
+
178
+ /**
179
+ * Joined text of a static attribute value, or null when any part is dynamic.
180
+ * @param {Attribute['value'] | StyleDirective['value']} value
181
+ */
182
+ function staticText(value) {
183
+ if (value === true) return '';
184
+ let out = '';
185
+ for (const v of Array.isArray(value) ? value : [value]) {
186
+ if (v.type !== 'Text') return null;
187
+ out += v.data;
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Static text of an attribute, or null when it is dynamic or absent.
194
+ * @param {ElementLike} node
195
+ * @param {string} name
196
+ */
197
+ export function staticAttribute(node, name) {
198
+ const attr = node.attributes.find((a) => a.type === 'Attribute' && a.name === name);
199
+ if (!attr || attr.type !== 'Attribute') return null;
200
+ return staticText(attr.value);
201
+ }
202
+
203
+ /** @param {ElementLike} node @param {string} name */
204
+ export function hasAttribute(node, name) {
205
+ return node.attributes.some((a) => a.type === 'Attribute' && a.name === name);
206
+ }
207
+
208
+ /** @param {ElementLike} node */
209
+ export function hasSpread(node) {
210
+ return node.attributes.some((a) => a.type === 'SpreadAttribute');
211
+ }
212
+
213
+ /**
214
+ * Declarations written inline on an element (`style="…"` and `style:prop`
215
+ * directives) with static values.
216
+ * @param {ElementLike} node
217
+ * @returns {DeclarationLike[]}
218
+ */
219
+ export function inlineDeclarations(node) {
220
+ /** @type {DeclarationLike[]} */
221
+ const out = [];
222
+ for (const attr of node.attributes) {
223
+ if (attr.type === 'Attribute' && attr.name === 'style') {
224
+ const text = staticText(attr.value);
225
+ if (text == null) continue;
226
+ for (const chunk of text.split(';')) {
227
+ const i = chunk.indexOf(':');
228
+ if (i < 0) continue;
229
+ out.push({
230
+ property: chunk.slice(0, i).trim(),
231
+ value: chunk.slice(i + 1).trim(),
232
+ start: attr.start
233
+ });
234
+ }
235
+ } else if (attr.type === 'StyleDirective' && attr.value !== true) {
236
+ const value = staticText(attr.value);
237
+ if (value == null) continue;
238
+ out.push({ property: attr.name, value, start: attr.start });
239
+ }
240
+ }
241
+ return out;
242
+ }
package/lint/index.mjs ADDED
@@ -0,0 +1,97 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { createContext } from './context.mjs';
4
+ import breakpoints from './rules/breakpoints.mjs';
5
+ import imgAlt from './rules/img-alt.mjs';
6
+ import motion from './rules/motion.mjs';
7
+ import noRawColor from './rules/no-raw-color.mjs';
8
+ import noRawControl from './rules/no-raw-control.mjs';
9
+ import noRawFont from './rules/no-raw-font.mjs';
10
+ import onePrimary from './rules/one-primary.mjs';
11
+ import spacingScale from './rules/spacing-scale.mjs';
12
+
13
+ /**
14
+ * @typedef {import('./context.mjs').Finding} Finding
15
+ * @typedef {import('./context.mjs').LintRule} LintRule
16
+ */
17
+
18
+ export { BREAKPOINTS, isAllowedBreakpointWidth } from './breakpoints.mjs';
19
+
20
+ /** @type {LintRule[]} */
21
+ export const RULES = [
22
+ noRawColor,
23
+ spacingScale,
24
+ noRawControl,
25
+ onePrimary,
26
+ noRawFont,
27
+ breakpoints,
28
+ motion,
29
+ imgAlt
30
+ ];
31
+
32
+ export const RULE_IDS = RULES.map((r) => r.id);
33
+
34
+ const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', 'dist', 'build', '.git', 'coverage']);
35
+
36
+ /** @param {{ only?: string[], disable?: string[] }} [options] */
37
+ export function selectRules({ only = [], disable = [] } = {}) {
38
+ const unknown = [...only, ...disable].filter((id) => !RULE_IDS.includes(id));
39
+ if (unknown.length) throw new Error(`unknown rule(s): ${unknown.join(', ')}`);
40
+ return RULES.filter((r) => (only.length ? only.includes(r.id) : true) && !disable.includes(r.id));
41
+ }
42
+
43
+ /**
44
+ * Lints one source string; returns `{ findings, error }`.
45
+ * @param {string} file
46
+ * @param {string} source
47
+ * @param {LintRule[]} [rules]
48
+ */
49
+ export function lintSource(file, source, rules = RULES) {
50
+ let ctx;
51
+ try {
52
+ ctx = createContext(file, source);
53
+ } catch (err) {
54
+ const e = /** @type {import('svelte/compiler').CompileError} */ (err);
55
+ return {
56
+ findings: [{ file, line: e.start?.line ?? 1, column: e.start?.column ?? 1, rule: 'parse-error', message: e.message }],
57
+ error: e
58
+ };
59
+ }
60
+ for (const rule of rules) rule.check(ctx);
61
+ ctx.findings.sort((a, b) => a.line - b.line || a.column - b.column);
62
+ return { findings: ctx.findings, error: null };
63
+ }
64
+
65
+ /** @param {string[]} paths */
66
+ export async function collectFiles(paths) {
67
+ /** @type {string[]} */
68
+ const files = [];
69
+ /** @param {string} path */
70
+ const visit = async (path) => {
71
+ const info = await stat(path);
72
+ if (info.isDirectory()) {
73
+ for (const entry of await readdir(path, { withFileTypes: true })) {
74
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
75
+ await visit(join(path, entry.name));
76
+ }
77
+ } else if (/\.(svelte|css)$/i.test(path)) files.push(path);
78
+ };
79
+ for (const p of paths) await visit(p);
80
+ return files.sort();
81
+ }
82
+
83
+ /**
84
+ * @param {string[]} paths
85
+ * @param {{ rules?: LintRule[], cwd?: string }} [options]
86
+ */
87
+ export async function lintPaths(paths, { rules = RULES, cwd = process.cwd() } = {}) {
88
+ const files = await collectFiles(paths);
89
+ /** @type {Finding[]} */
90
+ const findings = [];
91
+ for (const file of files) {
92
+ const source = await readFile(file, 'utf8');
93
+ const result = lintSource(relative(cwd, file) || file, source, rules);
94
+ findings.push(...result.findings);
95
+ }
96
+ return { files: files.length, findings };
97
+ }
@@ -0,0 +1,25 @@
1
+ import { describeBreakpoints, isAllowedBreakpointWidth } from '../breakpoints.mjs';
2
+ import { walkAtrules } from '../context.mjs';
3
+
4
+ const FEATURE = /\(([^()]*width[^()]*)\)/gi;
5
+ const LENGTH = /-?\d*\.?\d+(?:px|rem|em)\b/g;
6
+
7
+ export default /** @type {import('../context.mjs').LintRule} */ ({
8
+ id: 'breakpoints',
9
+ description: `media-query widths outside the kit's breakpoints (${describeBreakpoints()})`,
10
+ check(ctx) {
11
+ walkAtrules(ctx, (node) => {
12
+ if (node.name !== 'media' || !node.prelude) return;
13
+ for (const feature of node.prelude.matchAll(FEATURE)) {
14
+ for (const width of feature[1].match(LENGTH) ?? []) {
15
+ if (isAllowedBreakpointWidth(width)) continue;
16
+ ctx.report(
17
+ 'breakpoints',
18
+ node.start,
19
+ `media width ${width} is not a kit breakpoint; use ${describeBreakpoints()} (or the max-width form one unit below)`
20
+ );
21
+ }
22
+ }
23
+ });
24
+ }
25
+ });
@@ -0,0 +1,13 @@
1
+ import { hasAttribute, hasSpread, walkMarkup } from '../context.mjs';
2
+
3
+ export default /** @type {import('../context.mjs').LintRule} */ ({
4
+ id: 'img-alt',
5
+ description: '<img> without alt',
6
+ check(ctx) {
7
+ walkMarkup(ctx, (node) => {
8
+ if (node.type !== 'RegularElement' || node.name !== 'img') return;
9
+ if (hasAttribute(node, 'alt') || hasSpread(node)) return;
10
+ ctx.report('img-alt', node.start, '<img> without alt; add alt text, or alt="" when decorative');
11
+ });
12
+ }
13
+ });
@@ -0,0 +1,85 @@
1
+ import { walkDeclarations } from '../context.mjs';
2
+
3
+ export const ANIMATABLE = new Set([
4
+ 'transform',
5
+ 'translate',
6
+ 'scale',
7
+ 'rotate',
8
+ 'opacity',
9
+ 'color',
10
+ 'background-color',
11
+ 'background',
12
+ 'border-color',
13
+ 'outline-color',
14
+ 'text-decoration-color',
15
+ 'fill',
16
+ 'stroke',
17
+ 'box-shadow',
18
+ 'filter',
19
+ 'backdrop-filter',
20
+ 'visibility',
21
+ 'display',
22
+ 'overlay',
23
+ 'none'
24
+ ]);
25
+
26
+ const TIMING = /^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end|steps|cubic-bezier|var|allow-discrete|normal)$|^-?\d*\.?\d+m?s$|^-?\d*\.?\d+$/;
27
+
28
+ /** @param {string} value */
29
+ function splitTopLevel(value) {
30
+ const parts = [];
31
+ let depth = 0;
32
+ let current = '';
33
+ for (const ch of value) {
34
+ if (ch === '(') depth++;
35
+ if (ch === ')') depth--;
36
+ if (ch === ',' && depth === 0) {
37
+ parts.push(current);
38
+ current = '';
39
+ } else current += ch;
40
+ }
41
+ parts.push(current);
42
+ return parts.map((p) => p.trim()).filter(Boolean);
43
+ }
44
+
45
+ /** @param {string} value */
46
+ export function transitionedProperties(value) {
47
+ const out = [];
48
+ for (const item of splitTopLevel(value)) {
49
+ for (const word of item.split(/\s+/)) {
50
+ const name = word.replace(/\(.*$/, '');
51
+ if (TIMING.test(name) || word.startsWith('var(')) continue;
52
+ out.push(word.toLowerCase());
53
+ break;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export default /** @type {import('../context.mjs').LintRule} */ ({
60
+ id: 'motion',
61
+ description: 'transition/animation on properties other than transform/opacity/color/background-color/box-shadow/filter',
62
+ check(ctx) {
63
+ walkDeclarations(ctx, (d, { atrules }) => {
64
+ if (d.property === 'transition' || d.property === 'transition-property') {
65
+ for (const prop of transitionedProperties(d.value)) {
66
+ if (ANIMATABLE.has(prop) || prop.startsWith('--')) continue;
67
+ ctx.report(
68
+ 'motion',
69
+ d.start,
70
+ prop === 'all'
71
+ ? 'transition: all animates layout; list transform/opacity/color/background-color/box-shadow/filter explicitly'
72
+ : `transition on "${prop}" animates layout; animate only transform/opacity/color/background-color/box-shadow/filter`
73
+ );
74
+ }
75
+ }
76
+ if (atrules.some((a) => a.name === 'keyframes') && !ANIMATABLE.has(d.property) && !/^(--|animation-)/.test(d.property)) {
77
+ ctx.report(
78
+ 'motion',
79
+ d.start,
80
+ `keyframe animates "${d.property}"; animate only transform/opacity/color/background-color/box-shadow/filter`
81
+ );
82
+ }
83
+ });
84
+ }
85
+ });
@@ -0,0 +1,59 @@
1
+ import { inlineDeclarations, walkDeclarations, walkMarkup } from '../context.mjs';
2
+
3
+ /**
4
+ * @typedef {import('../context.mjs').Context} Context
5
+ * @typedef {import('../context.mjs').DeclarationLike} DeclarationLike
6
+ * @typedef {import('../context.mjs').Atrule} Atrule
7
+ */
8
+
9
+ const THEME_SELECTOR = /:root|:host|\[data-theme|\[data-look|@theme/;
10
+ const HEX = /#[0-9a-f]{3,8}\b/i;
11
+ const FUNCTIONS = /\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(([^()]*(?:\([^()]*\)[^()]*)*)\)/gi;
12
+
13
+ /** @param {string} value */
14
+ function stripUrls(value) {
15
+ return value.replace(/url\((?:[^)(]|\([^)(]*\))*\)/gi, 'url()');
16
+ }
17
+
18
+ /** @param {string} value */
19
+ export function rawColorIn(value) {
20
+ const text = stripUrls(value);
21
+ const hex = HEX.exec(text);
22
+ if (hex) return hex[0];
23
+ for (const m of text.matchAll(FUNCTIONS)) {
24
+ if (!/var\(|\bfrom\b/.test(m[2])) return m[0];
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * @param {Context} ctx
31
+ * @param {DeclarationLike} declaration
32
+ * @param {string[]} selectors
33
+ * @param {Atrule[]} atrules
34
+ */
35
+ function check(ctx, declaration, selectors, atrules) {
36
+ if (declaration.property.startsWith('--')) return;
37
+ if (selectors.some((s) => THEME_SELECTOR.test(s))) return;
38
+ if (atrules.some((a) => a.name === 'property')) return;
39
+ const hit = rawColorIn(declaration.value);
40
+ if (hit) {
41
+ ctx.report(
42
+ 'no-raw-color',
43
+ declaration.start,
44
+ `raw color ${hit} in "${declaration.property}"; use a theme token (var(--…)) or define it in a theme block`
45
+ );
46
+ }
47
+ }
48
+
49
+ export default /** @type {import('../context.mjs').LintRule} */ ({
50
+ id: 'no-raw-color',
51
+ description: 'hex/rgb/hsl literals outside a theme block',
52
+ check(ctx) {
53
+ walkDeclarations(ctx, (d, { selectors, atrules }) => check(ctx, d, selectors, atrules));
54
+ walkMarkup(ctx, (node) => {
55
+ if (node.type !== 'RegularElement' && node.type !== 'Component') return;
56
+ for (const d of inlineDeclarations(node)) check(ctx, d, [], []);
57
+ });
58
+ }
59
+ });
@@ -0,0 +1,23 @@
1
+ import { staticAttribute, walkMarkup } from '../context.mjs';
2
+
3
+ /** @type {Partial<Record<string, string>>} */
4
+ const CONTROLS = {
5
+ button: 'Button',
6
+ input: 'Input / Checkbox / Switch / Radio',
7
+ select: 'Select',
8
+ textarea: 'Textarea'
9
+ };
10
+
11
+ export default /** @type {import('../context.mjs').LintRule} */ ({
12
+ id: 'no-raw-control',
13
+ description: 'raw <button>/<input>/<select>/<textarea> instead of the atoms',
14
+ check(ctx) {
15
+ walkMarkup(ctx, (node) => {
16
+ if (node.type !== 'RegularElement') return;
17
+ const atom = CONTROLS[node.name];
18
+ if (!atom) return;
19
+ if (node.name === 'input' && staticAttribute(node, 'type') === 'hidden') return;
20
+ ctx.report('no-raw-control', node.start, `raw <${node.name}>; use the ${atom} atom`);
21
+ });
22
+ }
23
+ });