@nanisoft/prism-ui 0.7.0 → 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 (47) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +30 -0
  4. package/dist/components/index.d.ts +6 -0
  5. package/dist/components/index.d.ts.map +1 -1
  6. package/dist/components/index.js +3 -0
  7. package/dist/components/ui/breadcrumb.d.ts +6 -2
  8. package/dist/components/ui/breadcrumb.d.ts.map +1 -1
  9. package/dist/components/ui/breadcrumb.js +5 -3
  10. package/dist/components/ui/dialog.d.ts +12 -1
  11. package/dist/components/ui/dialog.d.ts.map +1 -1
  12. package/dist/components/ui/dialog.js +2 -2
  13. package/dist/components/ui/live-region.d.ts +78 -0
  14. package/dist/components/ui/live-region.d.ts.map +1 -0
  15. package/dist/components/ui/live-region.js +48 -0
  16. package/dist/components/ui/mark.d.ts +44 -0
  17. package/dist/components/ui/mark.d.ts.map +1 -0
  18. package/dist/components/ui/mark.js +79 -0
  19. package/dist/components/ui/pagination.d.ts +24 -6
  20. package/dist/components/ui/pagination.d.ts.map +1 -1
  21. package/dist/components/ui/pagination.js +23 -9
  22. package/dist/components/ui/product-switcher.d.ts +7 -0
  23. package/dist/components/ui/product-switcher.d.ts.map +1 -1
  24. package/dist/components/ui/product-switcher.js +2 -2
  25. package/dist/components/ui/slider.d.ts +1 -1
  26. package/dist/components/ui/slider.d.ts.map +1 -1
  27. package/dist/components/ui/tree.d.ts +115 -0
  28. package/dist/components/ui/tree.d.ts.map +1 -0
  29. package/dist/components/ui/tree.js +137 -0
  30. package/dist/fonts/Inter-OFL.txt +93 -0
  31. package/dist/fonts/inter-latin-400.woff2 +0 -0
  32. package/dist/fonts/inter-latin-500.woff2 +0 -0
  33. package/dist/fonts/inter-latin-600.woff2 +0 -0
  34. package/dist/styles.css +52 -0
  35. package/gates/README.md +98 -0
  36. package/gates/cli.mjs +133 -0
  37. package/gates/hidden-state.mjs +243 -0
  38. package/gates/index.mjs +81 -0
  39. package/gates/laws.mjs +170 -0
  40. package/gates/links.mjs +187 -0
  41. package/gates/pack-boundary.mjs +421 -0
  42. package/gates/pin.mjs +130 -0
  43. package/gates/retired-line.mjs +314 -0
  44. package/gates/run.mjs +320 -0
  45. package/gates/runtime-token-read.mjs +129 -0
  46. package/gates/stylesheet-ownership.mjs +207 -0
  47. package/package.json +16 -3
package/gates/run.mjs ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The kit's shared machinery, and the one place a consumer's design system is
3
+ * resolved.
4
+ *
5
+ * Three things live here because all six gates need them and a consumer must
6
+ * only write them once:
7
+ *
8
+ * 1. **Resolution of the design system.** A gate reads the token contract and
9
+ * the emitted base rules, and it reads them *through the component
10
+ * package's own dependency*, not through the consumer's. That is the whole
11
+ * reason a consumer no longer declares `@nanisoft/prism-tokens`: the file
12
+ * belongs to the package that owns it, and the package that owns it is the
13
+ * one the consumer pinned exactly. A second declaration of the token
14
+ * version in a consumer is a second fact to keep in step with a release.
15
+ * 2. **The CSS scanner**, shared by the ownership, token-read, hidden-state
16
+ * and pack-boundary gates, so four gates cannot disagree about what a
17
+ * declaration is. Comments are blanked rather than removed, so a finding
18
+ * names the line a reader has to edit.
19
+ * 3. **The report shape**, so every gate says what it read, names every
20
+ * exclusion, and fails when it read less than its floor.
21
+ *
22
+ * A gate that can read nothing must fail. `floor()` is that: it is called by
23
+ * every gate on the number it is about to be judged on, and it throws rather
24
+ * than warning, because a run that scanned an empty export directory and
25
+ * reported zero findings is indistinguishable from a clean repository.
26
+ */
27
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
28
+ import { createRequire } from 'node:module'
29
+ import path from 'node:path'
30
+
31
+ /** The component package's name. The one package a consumer of the kit pins. */
32
+ export const PACKAGE = '@nanisoft/prism-ui'
33
+
34
+ /** The token package's name, and the fact that it is the component package's dependency. */
35
+ export const TOKEN_PACKAGE = '@nanisoft/prism-tokens'
36
+
37
+ /**
38
+ * A gate read less than it needed to read.
39
+ *
40
+ * Its own class rather than a generic `Error`, so the report can name it as a
41
+ * coverage failure instead of a crash: the difference matters, because a crash
42
+ * reads as "the gate is broken" and a coverage failure reads as "the gate saw
43
+ * nothing", and only the second is true.
44
+ */
45
+ export class CoverageError extends Error {
46
+ constructor(message) {
47
+ super(message)
48
+ this.name = 'CoverageError'
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Fail the run when `actual` is below `floor`.
54
+ *
55
+ * `what` is the unit, because "read 0" is meaningless and "read 0 routes" is a
56
+ * diagnosis. The message says what a reader has to do, which is the whole
57
+ * reason this is a function and not a number in each gate.
58
+ */
59
+ export function floor(what, actual, minimum) {
60
+ if (actual >= minimum) return actual
61
+ throw new CoverageError(
62
+ `${what} ${actual} and this gate needs at least ${minimum}. A gate that read nothing reports\n` +
63
+ ` zero findings, and a zero-finding report is indistinguishable from a clean repository.`,
64
+ )
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ reading */
68
+
69
+ /** Directories no gate ever reads. Printed on every run, because an exclusion is arguable. */
70
+ export const SKIP_DIRECTORIES = [
71
+ '.git',
72
+ '.next',
73
+ '.source',
74
+ '.wrangler',
75
+ 'coverage',
76
+ 'node_modules',
77
+ 'out',
78
+ ]
79
+
80
+ /**
81
+ * Every file under `root`, as forward-slashed relative paths, depth first and
82
+ * sorted, with `skip` pruned rather than filtered afterwards.
83
+ *
84
+ * Sorted because a gate's output order is a reader's reading order and a
85
+ * filesystem's order is not one.
86
+ */
87
+ export function walk(root, { skip = SKIP_DIRECTORIES, extensions = null } = {}) {
88
+ const pruned = new Set(skip)
89
+ const found = []
90
+ const visit = (dir) => {
91
+ let entries
92
+ try {
93
+ entries = readdirSync(dir, { withFileTypes: true })
94
+ } catch {
95
+ return
96
+ }
97
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
98
+ if (pruned.has(entry.name)) continue
99
+ const full = path.join(dir, entry.name)
100
+ if (entry.isDirectory()) visit(full)
101
+ else if (extensions === null || extensions.some((pattern) => pattern.test(entry.name))) {
102
+ found.push(path.relative(root, full).split(path.sep).join('/'))
103
+ }
104
+ }
105
+ }
106
+ visit(root)
107
+ return found
108
+ }
109
+
110
+ /** Read a file under `root`, or return null when it is absent. */
111
+ export function read(root, relative) {
112
+ try {
113
+ return readFileSync(path.join(root, relative), 'utf8')
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
119
+ /** Read a JSON file under `root`, or return null when it is absent or malformed. */
120
+ export function readJson(root, relative) {
121
+ const text = read(root, relative)
122
+ if (text === null) return null
123
+ try {
124
+ return JSON.parse(text)
125
+ } catch (cause) {
126
+ throw new Error(`${relative} is not valid JSON: ${cause.message}`)
127
+ }
128
+ }
129
+
130
+ /** Fail the run when a declared root does not resolve, naming the file. */
131
+ export function requireRoot(root, relative) {
132
+ const full = path.join(root, relative)
133
+ if (!existsSync(full) || !statSync(full).isFile()) {
134
+ throw new CoverageError(
135
+ `${relative} does not resolve, so this gate cannot read what it was pointed at. ` +
136
+ `A renamed file empties a run and an emptied run reports a clean repository.`,
137
+ )
138
+ }
139
+ return full
140
+ }
141
+
142
+ /* ------------------------------------------------- the design system, resolved */
143
+
144
+ /**
145
+ * The consumer's installed design system, reached through its own export map.
146
+ *
147
+ * Everything a gate knows about tokens comes from here, so a gate never has to
148
+ * be told where the token package is and a consumer never has to declare it.
149
+ * `createRequire` is seeded with the *component* package's `package.json`, which
150
+ * is itself a published export, so the token package is resolved as a dependency
151
+ * of the thing that owns it rather than as a top-level lookup in the consumer.
152
+ */
153
+ export function designSystem(root) {
154
+ const fromConsumer = createRequire(path.join(root, 'package.json'))
155
+ let manifestPath
156
+ try {
157
+ manifestPath = fromConsumer.resolve(`${PACKAGE}/package.json`)
158
+ } catch (cause) {
159
+ throw new CoverageError(
160
+ `the pinned design system does not resolve from ${PACKAGE}/package.json (${cause.message}).\n` +
161
+ ` Every judgement about tokens and base rules is a guess without it. Run pnpm install first.`,
162
+ )
163
+ }
164
+ const packageRoot = path.dirname(manifestPath)
165
+ const require = createRequire(manifestPath)
166
+ const version = JSON.parse(readFileSync(manifestPath, 'utf8')).version
167
+
168
+ return {
169
+ version,
170
+ packageRoot,
171
+ /** The one emitted stylesheet, which carries the token blocks and the base layer. */
172
+ styles() {
173
+ try {
174
+ return readFileSync(require.resolve(`${PACKAGE}/styles.css`), 'utf8')
175
+ } catch (cause) {
176
+ throw new CoverageError(
177
+ `${PACKAGE}/styles.css does not resolve (${cause.message}), so the token contract and the\n` +
178
+ ' base rules cannot be read and every judgement about them would be a guess.',
179
+ )
180
+ }
181
+ },
182
+ /**
183
+ * The published per-pack block for one pack and mode, as the token package
184
+ * emits it. Resolved through the component package's dependency, which is
185
+ * why a consumer does not declare the token package itself.
186
+ */
187
+ tokenTheme(pack, mode) {
188
+ try {
189
+ const file = require.resolve(`${TOKEN_PACKAGE}/dist/themes/${pack}/${mode}.css`)
190
+ return properties(readFileSync(file, 'utf8'))
191
+ } catch (cause) {
192
+ throw new CoverageError(
193
+ `the token package's ${pack} ${mode} block does not resolve through ${PACKAGE} ` +
194
+ `(${cause.message}).\n A boundary resolves this file's values in both modes, so without it the\n` +
195
+ ' both-modes check cannot run at all.',
196
+ )
197
+ }
198
+ },
199
+ }
200
+ }
201
+
202
+ /* ------------------------------------------------------------------- the css */
203
+
204
+ /**
205
+ * Blank comments rather than removing them, so every line number a finding
206
+ * prints is still the line a reader has to edit.
207
+ */
208
+ export function blankComments(source) {
209
+ const out = source.split('')
210
+ for (let i = 0; i < out.length; i += 1) {
211
+ if (out[i] === '/' && out[i + 1] === '*') {
212
+ const close = source.indexOf('*/', i + 2)
213
+ const end = close === -1 ? out.length : close + 2
214
+ for (let k = i; k < end; k += 1) if (out[k] !== '\n') out[k] = ' '
215
+ i = end - 1
216
+ } else if (out[i] === '/' && out[i + 1] === '/') {
217
+ let end = source.indexOf('\n', i)
218
+ if (end === -1) end = source.length
219
+ for (let k = i; k < end; k += 1) out[k] = ' '
220
+ i = end - 1
221
+ }
222
+ }
223
+ return out.join('')
224
+ }
225
+
226
+ /**
227
+ * Split a stylesheet into `selector { declarations }`, keeping the line each
228
+ * block starts on. A declaration parser rather than a cascade resolution, and
229
+ * the difference is printed on every run: this cannot see a class-scoped rule
230
+ * that competes, an inline style prop, or a sheet it was not pointed at.
231
+ */
232
+ export function cssBlocks(source) {
233
+ const blanked = blankComments(source)
234
+ const blocks = []
235
+ for (const match of blanked.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
236
+ blocks.push({
237
+ selectors: splitSelectors(match[1]),
238
+ body: match[2],
239
+ line: blanked.slice(0, match.index).split('\n').length,
240
+ })
241
+ }
242
+ return blocks
243
+ }
244
+
245
+ /**
246
+ * A selector list, split on the commas that separate selectors.
247
+ *
248
+ * Not `split(',')`, because a comma inside `:where(a, b)` or `:is()` separates
249
+ * arguments rather than selectors, and splitting there makes the tail look like a
250
+ * bare-element selector: `.site :where(a, b)` would report `b)` as an unclassed
251
+ * element rule that declares a property the design system owns. A gate that
252
+ * reports a false positive on ordinary modern CSS is a gate that gets switched
253
+ * off, so the parentheses are tracked and the split only happens at depth zero.
254
+ */
255
+ export function splitSelectors(text) {
256
+ const parts = []
257
+ let depth = 0
258
+ let current = ''
259
+ for (const character of text) {
260
+ if (character === '(') depth += 1
261
+ else if (character === ')') depth = Math.max(0, depth - 1)
262
+ if (character === ',' && depth === 0) {
263
+ parts.push(current)
264
+ current = ''
265
+ continue
266
+ }
267
+ current += character
268
+ }
269
+ parts.push(current)
270
+ return parts.map((part) => part.trim()).filter(Boolean)
271
+ }
272
+
273
+ /** The custom properties a declaration list declares, `--name` included. */
274
+ export function properties(body) {
275
+ return new Map([...body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]))
276
+ }
277
+
278
+ /** Every custom property name a stylesheet declares. */
279
+ export function declaredProperties(css) {
280
+ return new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]))
281
+ }
282
+
283
+ /**
284
+ * Two colours are the same colour however they are written.
285
+ *
286
+ * A minifier shortens `#ffffff` to `#fff`, and a value read from an emitted
287
+ * sheet compared byte for byte against the contract reports a difference that is
288
+ * not one. Normalising is what lets a gate compare values rather than spellings.
289
+ */
290
+ export function sameColour(left, right) {
291
+ const expand = (value) => {
292
+ const hex = /^#([0-9a-f]{3,8})$/i.exec(String(value).trim())
293
+ if (!hex) return String(value).trim().toLowerCase()
294
+ const digits = hex[1]
295
+ return digits.length === 3 || digits.length === 4
296
+ ? `#${[...digits].map((digit) => digit + digit).join('')}`.toLowerCase()
297
+ : `#${digits}`.toLowerCase()
298
+ }
299
+ return expand(left) === expand(right)
300
+ }
301
+
302
+ /* ---------------------------------------------------------------- reporting */
303
+
304
+ /**
305
+ * One finding, formatted the way every gate formats one, so a reader who has
306
+ * seen one finding has seen every finding.
307
+ *
308
+ * `where` is the file and line a reader has to edit, `tag` is the class of the
309
+ * defect, and `body` is what it is. The law is not repeated per finding: it is
310
+ * printed once at the end, because a message repeated per finding is a message
311
+ * a reader learns to skip.
312
+ */
313
+ export function finding(where, tag, body) {
314
+ return `error ${where} [${tag}] ${body}`
315
+ }
316
+
317
+ /** A note the run prints, so coverage and exclusions are on every run. */
318
+ export function note(text) {
319
+ return text
320
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The token-read law's runtime form: no token is read at runtime.
3
+ *
4
+ * The clause was written about canvas, because a canvas reads a computed style
5
+ * from one element and paints it into pixels. The pack is a runtime attribute on
6
+ * an ancestor, so a scoped boundary hands that read the pack's light values on a
7
+ * dark page, and a painted pixel does not move when the pack beneath it changes.
8
+ * A reader with dark mode stored and a reader without it are served different
9
+ * colours from the same markup, and nothing in the rendered page says so.
10
+ *
11
+ * It generalises from canvas to any runtime token read, and it holds its shape
12
+ * from the original clause: **a read with a hard-coded fallback is a read that
13
+ * cannot fail, and a read that cannot fail hides its own failure.** The token
14
+ * build publishes the replacement, which is a token-driven inline replacement
15
+ * painted in the HTML: it resolves through the cascade, holds every pack, and
16
+ * ships no client code. So the fix is deletion rather than repair, and this gate
17
+ * asserts the deletion.
18
+ *
19
+ * What this gate does not do is forbid client code. Three of the four consumer
20
+ * repositories ship none and one ships exactly one component for a reason its own
21
+ * gate explains; whether a given repository has a client boundary is that
22
+ * repository's business, and a gate that forbade one would be a law about one
23
+ * site's structure pretending to be a law about all of them. What is universal is
24
+ * the read, because a read resolves once and paints a value that never follows the
25
+ * cascade.
26
+ *
27
+ * Coverage is asserted: the declared roots must hold at least as many source files
28
+ * as the consumer declares as its floor, so a renamed directory cannot empty this
29
+ * run and report a repository that reads nothing.
30
+ */
31
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
32
+ import path from 'node:path'
33
+
34
+ import { law } from './laws.mjs'
35
+ import { CoverageError, finding, floor } from './run.mjs'
36
+
37
+ /** Every way a program resolves a custom property from a live element. */
38
+ const RUNTIME_READ = /getPropertyValue|getComputedStyle|documentElement\.style\.|style\.setProperty/
39
+
40
+ /** The retired line's runtime read helpers, which a half-finished removal leaves behind. */
41
+ const RETIRED_READS = /prismCssVarKey|prismBrandPacks|PrismThemeModeProvider/
42
+
43
+ /** How a canvas or an inline replacement is painted, for the message that names the fix. */
44
+ const PAINTS = /getContext\(|<canvas|document\.createElement\(['"]canvas['"]\)/
45
+
46
+ export function run({ root, config }) {
47
+ const l = law('runtime-token-read')
48
+ const findings = []
49
+ const notes = []
50
+ const sourceRoots = config.sourceRoots ?? ['app', 'components', 'lib']
51
+ const minFiles = config.minFiles ?? 5
52
+
53
+ const missing = sourceRoots.filter((declared) => !existsSync(path.join(root, declared)))
54
+ if (missing.length > 0) {
55
+ throw new CoverageError(
56
+ `${missing.length} of ${sourceRoots.length} declared source roots do not resolve: ${missing.join(', ')}.\n` +
57
+ ' A gate that read nothing reports a repository that reads nothing, which is the cleanest possible\n' +
58
+ ' reading of this law and the one this gate must never give by accident.',
59
+ )
60
+ }
61
+
62
+ const skip = new Set(['node_modules', '.next', 'out', '.git', '.wrangler', '.source'])
63
+ const files = []
64
+ const visit = (dir) => {
65
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
66
+ if (skip.has(entry.name)) continue
67
+ const full = path.join(dir, entry.name)
68
+ if (entry.isDirectory() || statSync(full).isDirectory()) visit(full)
69
+ else if (/\.(tsx?|mjs|jsx?)$/.test(entry.name)) files.push(full)
70
+ }
71
+ }
72
+ for (const declared of sourceRoots) visit(path.join(root, declared))
73
+ floor('source file(s)', files.length, minFiles)
74
+
75
+ const relative = (file) => path.relative(root, file).split(path.sep).join('/')
76
+ let clientModules = 0
77
+
78
+ for (const file of files) {
79
+ const source = readFileSync(file, 'utf8')
80
+ const where = relative(file)
81
+ if (/^\s*['"]use client['"]/m.test(source)) clientModules += 1
82
+
83
+ source.split('\n').forEach((line, index) => {
84
+ const read = line.match(RUNTIME_READ)
85
+ if (read) {
86
+ const paints = PAINTS.test(source)
87
+ findings.push(
88
+ finding(
89
+ `${where}:${index + 1}`,
90
+ 'runtime-token-read',
91
+ `"${read[0]}" resolves a token from a live element. ${paints ? 'A canvas reads it once and paints\n' +
92
+ ' pixels, so a scoped boundary hands it the light values on a dark page and a painted pixel does not\n' +
93
+ ' move when the pack beneath it does. ' : ''}A read with a hard-coded fallback is a read that cannot\n` +
94
+ ' fail, and a read that cannot fail hides its own failure.',
95
+ ),
96
+ )
97
+ }
98
+ const retired = line.match(RETIRED_READS)
99
+ if (retired) {
100
+ findings.push(
101
+ finding(
102
+ `${where}:${index + 1}`,
103
+ 'retired-runtime-read',
104
+ `"${retired[0]}" is the retired line's runtime read helper. The theme is two attributes on the document\n` +
105
+ ' element and a blocking script the design system ships.',
106
+ ),
107
+ )
108
+ }
109
+ })
110
+ }
111
+
112
+ notes.push(
113
+ `${l.id}: ${findings.length} finding(s) across ${files.length} source file(s) read, floor ${minFiles}; roots: ${sourceRoots.join(', ')}`,
114
+ )
115
+ notes.push(
116
+ `${l.id}: ${clientModules} module(s) carry a client directive. Whether this repository has a client boundary at\n` +
117
+ ' all is its own business; what is universal is the read, because a read resolves once and a resolved\n' +
118
+ ' value does not follow the cascade.',
119
+ )
120
+ notes.push(
121
+ `${l.id}: every directory this run did not read, printed so an exclusion is arguable: ${[...skip].sort().join(', ')}`,
122
+ )
123
+ notes.push(
124
+ `${l.id}: the honest limit: this is a lexical scan. It cannot see a computed \`import()\` or a \`require\` assembled at\n` +
125
+ ' runtime, and it does not attempt to decide whether a read it found is load-bearing.',
126
+ )
127
+
128
+ return { law: l, findings, notes }
129
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Two laws about one sheet: what it may not own, and what it may not read.
3
+ *
4
+ * **Ownership.** Prism's base rules sit in `@layer base` and a consumer's sheet
5
+ * is unlayered, so an unlayered declaration outranks a layered one at any
6
+ * specificity regardless of import order, and a consumer that imported its sheet
7
+ * first would have exactly the same outcome. The layer is not the cause of these
8
+ * failures; it is the reason they were invisible, because nobody can tell from a
9
+ * screenshot that the site won.
10
+ *
11
+ * A site declaration **competes** when it can match the same element, for a
12
+ * property the design system's base declares, with shorthands expanded. Two looser
13
+ * definitions were tried and rejected: "any declaration of a property the base
14
+ * declares anywhere" is 1,478 rows across this family and says that every
15
+ * declaration in CSS touches something a reset already sets; "any declaration of a
16
+ * property the base declares" is 98 rows of which 55 match no element on the
17
+ * current line. The list below is the narrow one, and it needs no cascade to
18
+ * decide: an unlayered bare-element declaration for a property the base declares
19
+ * **is** the defect, whatever the cascade then does with it.
20
+ *
21
+ * A focus rule is a finding whatever it declares. The design system draws its
22
+ * ring on the component as a `box-shadow`, so a site `outline` does not compete
23
+ * with it; it draws a second band over the real one, and on anything that is not
24
+ * a Prism Component it is the only thing standing between a keyboard reader and
25
+ * no indicator at all.
26
+ *
27
+ * **The token read.** A custom property that resolves to nothing does not paint a
28
+ * wrong colour. The declaration it appears in is invalid at computed-value time,
29
+ * so a shorthand erases itself and a longhand reverts to its initial value: a page
30
+ * ground reverts to transparent, an inherited ink reverts to the browser default,
31
+ * and a hairline reverts to `border-style: none`, which is a box with no edge
32
+ * rather than a box with a wrong-coloured one. Ninety-eight of the 219 dead reads
33
+ * in the four repositories were shorthands. The reference is the design system's
34
+ * emitted stylesheet, read through its own export map, so a renamed token is a
35
+ * finding rather than a silent erasure.
36
+ *
37
+ * **The runtime form is the same law.** A read in JavaScript with a hard-coded
38
+ * fallback is a read that cannot fail, which is why the original clause was
39
+ * written about canvas: the pack is a runtime attribute on an ancestor, a canvas
40
+ * reads a computed style from one element, and a scoped boundary hands it the
41
+ * pack's light values on a dark page. The shipped form of that law is the gate
42
+ * that forbids the client read, and a repository that has no client code has
43
+ * nothing left to check.
44
+ *
45
+ * Coverage: the declared sheets must resolve and must hold at least as many
46
+ * declarations as the site declares as its floor. A floor on *findings* would fail
47
+ * a correct sheet, and a floor on nothing would pass an unread one, so the floor
48
+ * is on declarations read.
49
+ */
50
+ import { law } from './laws.mjs'
51
+ import {
52
+ CoverageError,
53
+ blankComments,
54
+ cssBlocks,
55
+ declaredProperties,
56
+ designSystem,
57
+ finding,
58
+ floor,
59
+ read,
60
+ } from './run.mjs'
61
+
62
+ /** The properties the design system's own base layer declares, so a bare-element rule competes. */
63
+ const COMPETING_PROPERTIES = [
64
+ 'background',
65
+ 'background-color',
66
+ 'color',
67
+ 'font-family',
68
+ 'outline',
69
+ 'outline-style',
70
+ 'border-color',
71
+ ]
72
+
73
+ /** Any focus selector. The design system owns the indicator; a consumer drawing one is a second one. */
74
+ const FOCUS_SELECTOR = /:focus(-visible)?\b/
75
+
76
+ /** A `color-mix()` that takes a custom property as an operand erases itself rather than repainting. */
77
+ const DEAD_OPERAND = /color-mix\([^)]*var\(/g
78
+
79
+ export function run({ root, config }) {
80
+ const l = law('stylesheet-ownership')
81
+ const readLaw = law('token-read')
82
+ const findings = []
83
+ const notes = []
84
+ const sheets = config.sheets ?? ['app/globals.css']
85
+ const supplied = config.supplied ?? {}
86
+ const minDeclarations = config.minDeclarations ?? 20
87
+
88
+ const missing = sheets.filter((sheet) => read(root, sheet) === null)
89
+ if (missing.length > 0) {
90
+ throw new CoverageError(
91
+ `${missing.length} of ${sheets.length} configured stylesheets do not resolve: ${missing.join(', ')}.\n` +
92
+ ' A gate that read nothing reports a clean sheet, so an unresolved root fails the run.',
93
+ )
94
+ }
95
+
96
+ const system = designSystem(root)
97
+ const emitted = system.styles()
98
+ const emittedProperties = declaredProperties(emitted)
99
+
100
+ let rules = 0
101
+ let declarations = 0
102
+ let competing = 0
103
+ let deadReads = 0
104
+
105
+ for (const sheet of sheets) {
106
+ const source = blankComments(read(root, sheet))
107
+ for (const block of cssBlocks(source)) {
108
+ rules += 1
109
+ for (const declaration of block.body.split(';')) {
110
+ if (/^[a-z-]+\s*:/.test(declaration.trim())) declarations += 1
111
+ }
112
+ for (const selector of block.selectors) {
113
+ if (FOCUS_SELECTOR.test(selector)) {
114
+ competing += 1
115
+ findings.push(
116
+ finding(
117
+ `${sheet}:${block.line}`,
118
+ 'focus-indicator',
119
+ `${selector} draws a focus indicator in this repository's own sheet. The design system draws its\n` +
120
+ ' ring on the component, and a plain anchor keeps the browser\'s own. A site rule is a second\n' +
121
+ " band over the first or the suppression of the second, and a shorthand whose colour token\n" +
122
+ ' stops resolving suppresses it rather than failing to draw one.',
123
+ ),
124
+ )
125
+ continue
126
+ }
127
+ // A bare-element selector: `body`, `a`, `*`, `html`. A class in the selector makes
128
+ // the rule this repository's own surface, which is a different question.
129
+ const hasClass = selector.includes('.') || selector.includes('[') || selector.includes(':')
130
+ if (hasClass) continue
131
+ for (const property of COMPETING_PROPERTIES) {
132
+ if (!new RegExp(`(?:^|[;{\\s])${property}\\s*:`, 'm').test(block.body)) continue
133
+ competing += 1
134
+ findings.push(
135
+ finding(
136
+ `${sheet}:${block.line}`,
137
+ 'competes-with-base',
138
+ `${selector} declares ${property}, which the design system's own base layer declares. The base is\n` +
139
+ " layered and this sheet is not, so this rule wins the cascade at any specificity and silently\n" +
140
+ " replaces the design system's.",
141
+ ),
142
+ )
143
+ }
144
+ }
145
+ const dead = [...block.body.matchAll(DEAD_OPERAND)]
146
+ competing += dead.length
147
+ if (dead.length > 0) {
148
+ findings.push(
149
+ finding(
150
+ `${sheet}:${block.line}`,
151
+ 'dead-operand',
152
+ 'color-mix() takes a var() as an operand. A custom property that resolves to nothing does not paint a\n' +
153
+ ' wrong colour: a shorthand with one dead operand erases the whole declaration, so the rule stops\n' +
154
+ ' existing.',
155
+ ),
156
+ )
157
+ }
158
+ }
159
+ }
160
+
161
+ floor('declarations read', declarations, minDeclarations)
162
+
163
+ for (const sheet of sheets) {
164
+ const source = blankComments(read(root, sheet))
165
+ const own = declaredProperties(source)
166
+ for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
167
+ const property = match[1]
168
+ if (own.has(property) || emittedProperties.has(property) || property in supplied) continue
169
+ deadReads += 1
170
+ const line = source.slice(0, match.index).split('\n').length
171
+ findings.push(
172
+ finding(
173
+ `${sheet}:${line}`,
174
+ 'dead-read',
175
+ `var(${property}) names a custom property nothing declares. The declaration is invalid at\n` +
176
+ ' computed-value time, so a shorthand erases itself and a longhand reverts to its initial value.\n' +
177
+ ' This is not a wrong colour; it is no declaration at all.',
178
+ ),
179
+ )
180
+ }
181
+ }
182
+
183
+ notes.push(
184
+ `${l.id}: ${sheets.length} sheet(s), ${rules} rule(s) and ${declarations} declaration(s) read, floor ${minDeclarations}`,
185
+ )
186
+ notes.push(`${l.id}: sheets read: ${sheets.join(', ')}`)
187
+ notes.push(
188
+ `${l.id}: ${competing} competing declaration(s); properties the design system's base declares: ${COMPETING_PROPERTIES.join(', ')}`,
189
+ )
190
+ notes.push(
191
+ `${readLaw.id}: ${deadReads} dead read(s). Every var() in this repository's own sheet names a property the\n` +
192
+ ` emitted stylesheet declares (${emittedProperties.size} properties), the sheet itself declares, or one of\n` +
193
+ ` ${Object.keys(supplied).length} the build supplies.`,
194
+ )
195
+ for (const [property, why] of Object.entries(supplied)) notes.push(`${readLaw.id}: supplied: ${property} is ${why}`)
196
+ notes.push(
197
+ `${l.id}: the reference is ${'`@nanisoft/prism-ui/styles.css`'}, resolved through the installed package's own export map,\n` +
198
+ ' so a renamed subpath is an error here rather than a run that reports every property as undeclared.',
199
+ )
200
+ notes.push(
201
+ `${l.id}: the honest limit, printed on every run: this is a text scan over selectors and declarations, not a\n` +
202
+ ' cascade resolution. It cannot see a class-scoped rule that competes, an inline style prop, a sheet it\n' +
203
+ " was not pointed at, or a change to the design system's base layer.",
204
+ )
205
+
206
+ return { law: l, findings, notes, also: [readLaw] }
207
+ }