@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
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The links law, and the one gate that outlived the migration.
3
+ *
4
+ * Four kinds of destination, four different failures, and only two of them are
5
+ * things this gate can decide:
6
+ *
7
+ * 1. An `href` beginning with `/` must equal an emitted route. This is the one
8
+ * that breaks quietly, because a broken internal link renders exactly like a
9
+ * working one and a crawler finds it a month later.
10
+ * 2. An `href` beginning with `#` must name an `id` the same document emits, so
11
+ * a rebuilt section cannot orphan a deep link.
12
+ * 3. An `href` beginning with `mailto:` or `tel:` is a destination the reader's
13
+ * own client handles, and is listed rather than resolved.
14
+ * 4. An absolute `href` to another origin is a destination off this site, and is
15
+ * listed rather than resolved. This gate cannot know that a sibling site
16
+ * exists, and a check that guessed would be a check that reported a network
17
+ * failure as a content failure.
18
+ *
19
+ * Extraction goes through a document parser rather than a regular expression.
20
+ * Two titles on one of these sites serialise an ampersand, and a regex would
21
+ * store the escaped form and then report it as a permanent difference against
22
+ * itself.
23
+ *
24
+ * **An exemption list is a finding when it matches nothing.** A consumer that
25
+ * declares a destination it knows is broken has to say why, and the declaration
26
+ * is site data rather than a law: it is one site's content defect. What is a law
27
+ * is that the declaration must match a real destination on this build, because an
28
+ * exemption list that outlives its cause is a lie and a gate that accepts a new
29
+ * link because the list is long enough to seem plausible is not a gate.
30
+ */
31
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
32
+ import { createRequire } from 'node:module'
33
+ import path from 'node:path'
34
+
35
+ import { law } from './laws.mjs'
36
+ import { CoverageError, finding, floor } from './run.mjs'
37
+
38
+ /**
39
+ * The document parser, resolved from the consumer's own dependency.
40
+ *
41
+ * The consumer owns its parser because the consumer's tests already do; a gate
42
+ * that carried its own would add a copy of jsdom to four repositories so that
43
+ * four gates could read four documents.
44
+ */
45
+ function loadParser(root) {
46
+ try {
47
+ return createRequire(path.join(root, 'package.json'))('jsdom').JSDOM
48
+ } catch (cause) {
49
+ throw new CoverageError(
50
+ `jsdom does not resolve from this repository (${cause.message}), so no document can be parsed\n` +
51
+ ' and every destination in this run would be unanswerable.',
52
+ )
53
+ }
54
+ }
55
+
56
+ /** Every emitted HTML file under `outDir`, as `[absolute path, route]` pairs, sorted. */
57
+ export function emittedRoutes(root, outDir) {
58
+ const out = path.join(root, outDir)
59
+ if (!existsSync(out)) {
60
+ throw new CoverageError(
61
+ `${outDir} does not resolve, so there is no export to read and a reader's destinations cannot be\n` +
62
+ ' checked at all. Run the build first.',
63
+ )
64
+ }
65
+ const files = []
66
+ const walk = (dir) => {
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ const full = path.join(dir, entry.name)
69
+ if (entry.isDirectory()) walk(full)
70
+ else if (entry.name.endsWith('.html')) {
71
+ const relative = path.relative(out, full).split(path.sep).join('/')
72
+ files.push([full, relative === 'index.html' ? '/' : `/${relative.replace(/\.html$/, '')}`])
73
+ }
74
+ }
75
+ }
76
+ walk(out)
77
+ return files
78
+ }
79
+
80
+ export function run({ root, config }) {
81
+ const l = law('links')
82
+ const outDir = config.outDir ?? 'out'
83
+ const minRoutes = config.minRoutes ?? 6
84
+ const minAnchors = config.minAnchors ?? 0
85
+ const knownBroken = config.knownBroken ?? {}
86
+
87
+ const JSDOM = loadParser(root)
88
+ const files = emittedRoutes(root, outDir)
89
+ floor('route(s) read', files.length, minRoutes)
90
+
91
+ const emitted = new Set(files.map(([, route]) => route))
92
+ const resolves = (target) => {
93
+ const wanted = target.replace(/\/$/, '') || '/'
94
+ return emitted.has(wanted) || emitted.has(`${wanted}/`)
95
+ }
96
+
97
+ const findings = []
98
+ const internal = new Set()
99
+ const fragments = new Set()
100
+ const external = new Set()
101
+ const schemes = new Set()
102
+ const discharged = new Set()
103
+ let anchors = 0
104
+
105
+ for (const [file, route] of files) {
106
+ const document = new JSDOM(readFileSync(file, 'utf8')).window.document
107
+ const ids = new Set([...document.querySelectorAll('[id]')].map((element) => element.id))
108
+ for (const anchor of document.querySelectorAll('a[href]')) {
109
+ anchors += 1
110
+ const href = anchor.getAttribute('href') ?? ''
111
+ if (href === '') {
112
+ findings.push(
113
+ finding(route, 'empty-destination', 'an anchor with no href is a shape a reader has to guess at.'),
114
+ )
115
+ } else if (href.startsWith('/')) {
116
+ internal.add(href)
117
+ const target = href.split('#')[0] || route
118
+ const known = knownBroken[target]
119
+ if (known) {
120
+ discharged.add(target)
121
+ } else if (!resolves(target)) {
122
+ findings.push(
123
+ finding(
124
+ route,
125
+ 'internal-destination',
126
+ `"${href}" is not an emitted route. Every address a reader has ever used has to keep\n` +
127
+ ' working, and a link that renders is a link nobody notices is broken.',
128
+ ),
129
+ )
130
+ }
131
+ if (href.includes('#')) fragments.add(`${target}${href.slice(href.indexOf('#'))}`)
132
+ } else if (href.startsWith('#')) {
133
+ fragments.add(`${route}${href}`)
134
+ if (!ids.has(href.slice(1))) {
135
+ findings.push(
136
+ finding(route, 'fragment', `"${href}" names an id this document does not emit.`),
137
+ )
138
+ }
139
+ } else if (/^(mailto:|tel):/.test(href)) {
140
+ schemes.add(`${href.split(':')[0]}:`)
141
+ } else if (/^https?:\/\//.test(href)) {
142
+ external.add(new URL(href).host)
143
+ } else {
144
+ findings.push(
145
+ finding(route, 'unclassified-destination', `"${href}" is neither internal, a fragment nor absolute.`),
146
+ )
147
+ }
148
+ }
149
+ }
150
+
151
+ floor('anchor(s) read', anchors, minAnchors)
152
+
153
+ for (const [destination] of Object.entries(knownBroken)) {
154
+ if (discharged.has(destination)) continue
155
+ findings.push(
156
+ finding(
157
+ destination,
158
+ 'stale-exemption',
159
+ `this destination is declared as known-broken and this build emits no link to it. An exemption\n` +
160
+ ' that fires on nothing is indistinguishable from a rule that found nothing, so close it and\n' +
161
+ ' delete the entry in the same commit.',
162
+ ),
163
+ )
164
+ }
165
+
166
+ const notes = [
167
+ `${l.id}: ${findings.length} finding(s) across ${files.length} route(s) and ${anchors} anchor(s) read;` +
168
+ ` floors ${minRoutes} route(s), ${minAnchors} anchor(s)`,
169
+ `${l.id}: ${emitted.size} emitted route(s): ${[...emitted].sort().join(', ')}`,
170
+ `${l.id}: destinations resolved on this site: ${[...internal].sort().join(', ') || 'none'}`,
171
+ `${l.id}: fragments resolved: ${[...fragments].sort().join(', ') || 'none'}`,
172
+ `${l.id}: destinations off this site, listed and not resolved: ${[...external].sort().join(', ') || 'none'}`,
173
+ ]
174
+ if (schemes.size > 0) {
175
+ notes.push(`${l.id}: reader-handled schemes, listed and not resolved: ${[...schemes].sort().join(', ')}`)
176
+ }
177
+ notes.push(
178
+ `${l.id}: an off-site host is listed, not resolved. This gate cannot know that another origin exists, and a\n` +
179
+ ' network failure reported as a content failure is a check that teaches people to ignore it.',
180
+ )
181
+ for (const [destination] of Object.entries(knownBroken)) {
182
+ const state = discharged.has(destination) ? 'and still linked' : 'AND NO LONGER LINKED'
183
+ notes.push(`${l.id}: declared broken, ${state}: ${destination}`)
184
+ }
185
+
186
+ return { law: l, findings, notes }
187
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The pack-boundary law: a boundary lands on a mark, and a declared region set is
3
+ * the whole of what may carry a second pack.
4
+ *
5
+ * **A screenshot cannot check this, in either mode, and that is the finding.** A
6
+ * boundary is correct only if it resolves to its own pack in the mode the document
7
+ * is in, which is two facts multiplied. A boundary carrying `data-pack="mint"` on a
8
+ * dark document with no `dark` class of its own matches the light block, so the
9
+ * mark paints the light pack on a dark page: pale marks on a dark page, which looks
10
+ * like a design decision rather than a bug. A screenshot in the wrong mode looks
11
+ * correct, so a single-mode screenshot is not evidence and this gate does not accept
12
+ * one.
13
+ *
14
+ * **Both modes are read from the emitted CSS, not from a browser.** The failure
15
+ * being guarded is a selector shape, and a selector shape is knowable by reading
16
+ * the stylesheet the build emitted against the markup the build emitted. For every
17
+ * pack the page uses, the gate resolves the pack's light and dark blocks out of the
18
+ * emitted stylesheet, asserts that the light block is a bare `[data-pack=x]` and
19
+ * that the dark block carries **both** the compound and the descendant form, and
20
+ * then matches every boundary in the document against both. A dark block that is
21
+ * compound-only is the defect this catches, and it is invisible to a review and to
22
+ * a screenshot, because the page it produces is pack-correct and mode-inverted.
23
+ *
24
+ * The values are then compared against the token package's own published per-pack
25
+ * files, resolved through the component package's dependency, so the gate is not
26
+ * merely self-consistent: a boundary resolves the contract's light values in light
27
+ * mode and its dark values in dark mode, and the two are asserted to differ, which
28
+ * is what makes a both-modes check worth running.
29
+ *
30
+ * **The document element is not a boundary.** `<html data-pack="sky">` is the
31
+ * page's ground, which is the page's theme and the one attribute the whole document
32
+ * is built around; it is the axis the scoped boundary law talks about *beneath*. So
33
+ * the gate reads it, confirms it names the declared ground, and then judges every
34
+ * other element by the scoped law.
35
+ *
36
+ * **Which half is the law and which half is the site's data.** The law is the set
37
+ * of judgements below: where a boundary may land, what a region set means, that
38
+ * both modes resolve, and that the values agree with the contract. All of it is
39
+ * here. What belongs to the site is the map (`pack-map.json`, with the reason each
40
+ * region exists), the ground, the route the map describes, the slot a boundary is
41
+ * allowed to land on, and the resolver that names a region from a DOM node, because
42
+ * naming a region means knowing this site's own structure. That resolver is a
43
+ * module the site declares rather than a table in this file, for the same reason
44
+ * `pack-map.json` is data: it is a fact about one page, and a fact about one page
45
+ * in a file about all pages would be wrong the moment the page changes.
46
+ *
47
+ * The honest limit, printed on every run: this is a selector match over emitted CSS
48
+ * and emitted markup, not a layout engine. It cannot see a runtime that sets
49
+ * `data-pack` after paint, and it cannot see a token a boundary reads that the
50
+ * stylesheet does not declare.
51
+ */
52
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
53
+ import { createRequire } from 'node:module'
54
+ import path from 'node:path'
55
+ import { pathToFileURL } from 'node:url'
56
+
57
+ import { law } from './laws.mjs'
58
+ import {
59
+ CoverageError,
60
+ cssBlocks,
61
+ designSystem,
62
+ finding,
63
+ floor,
64
+ properties,
65
+ readJson,
66
+ sameColour,
67
+ } from './run.mjs'
68
+
69
+ /** The roles a boundary must move, and the two a reader would notice it not moving. */
70
+ const ROLES = ['background', 'card', 'foreground', 'border']
71
+
72
+ /** Elements whose corner radius a reader can see change when the pack moves. */
73
+ const SHAPES = ['rect', 'section', 'article', 'a', 'div', 'li', 'svg', 'g', 'path', 'circle']
74
+
75
+ export async function run({ root, config }) {
76
+ const l = law('pack-boundary')
77
+ const findings = []
78
+ const notes = []
79
+ const outDir = config.outDir ?? 'out'
80
+ const mapPath = config.map ?? 'scripts/pack-map.json'
81
+ const landing = config.landing ?? '/'
82
+ const markSlot = config.markSlot ?? 'product-mark'
83
+
84
+ const map = readJson(root, mapPath)
85
+ if (!map) {
86
+ throw new CoverageError(
87
+ `${mapPath} does not resolve or is not JSON, so there is no map and every judgement about what a page\n` +
88
+ ' may carry would be a guess. A map that says zero is a decision; a page with no map is a drift.',
89
+ )
90
+ }
91
+
92
+ const regionOf = await regionResolver(root, config, mapPath)
93
+ const out = path.join(root, outDir)
94
+ if (!existsSync(path.join(out, '_next'))) {
95
+ throw new CoverageError(
96
+ `the export has no ${outDir}/_next, so the emitted stylesheet cannot be read and every judgement about\n` +
97
+ ' which mode a boundary resolves in would be a guess. Run the build first.',
98
+ )
99
+ }
100
+
101
+ const cssFiles = []
102
+ const walk = (dir) => {
103
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
104
+ const full = path.join(dir, entry.name)
105
+ if (entry.isDirectory()) walk(full)
106
+ else if (entry.name.endsWith('.css')) cssFiles.push(full)
107
+ }
108
+ }
109
+ walk(path.join(out, '_next'))
110
+ floor('emitted stylesheet(s)', cssFiles.length, 1)
111
+ const css = cssFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
112
+
113
+ /** Every rule whose selector list mentions a boundary, keyed `pack:mode`. */
114
+ const rules = new Map()
115
+ for (const block of cssBlocks(css)) {
116
+ for (const selector of block.selectors) {
117
+ const boundary = /\[data-pack="?([a-z]+)"?\]/.exec(selector)
118
+ if (!boundary) continue
119
+ const pack = boundary[1]
120
+ const key = `${pack}:${selector.includes('.dark') ? 'dark' : 'light'}`
121
+ if (!rules.has(key)) rules.set(key, { selectors: [], values: properties(block.body) })
122
+ const rule = rules.get(key)
123
+ if (!rule.selectors.includes(selector)) rule.selectors.push(selector)
124
+ for (const [property, value] of properties(block.body)) rule.values.set(property, value)
125
+ }
126
+ }
127
+
128
+ const system = designSystem(root)
129
+ const JSDOM = createRequire(path.join(root, 'package.json'))('jsdom').JSDOM
130
+ const documents = []
131
+ const walkHtml = (dir) => {
132
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
133
+ const full = path.join(dir, entry.name)
134
+ if (entry.isDirectory()) walkHtml(full)
135
+ else if (entry.name.endsWith('.html')) {
136
+ const relative = path.relative(out, full).split(path.sep).join('/')
137
+ documents.push([full, relative === 'index.html' ? '/' : `/${relative.replace(/\.html$/, '')}`])
138
+ }
139
+ }
140
+ }
141
+ walkHtml(out)
142
+ floor('emitted document(s)', documents.length, 1)
143
+
144
+ const byRoute = new Map()
145
+ const actual = new Map()
146
+ const reported = new Set()
147
+ let boundaries = 0
148
+ let marks = 0
149
+
150
+ const report = (key, where, tag, body) => {
151
+ if (reported.has(key)) return
152
+ reported.add(key)
153
+ findings.push(finding(where, tag, body))
154
+ }
155
+
156
+ for (const [file, route] of documents) {
157
+ const document = new JSDOM(readFileSync(file, 'utf8')).window.document
158
+
159
+ const ground = document.documentElement.getAttribute('data-pack')
160
+ if (ground !== map.ground) {
161
+ findings.push(
162
+ finding(
163
+ route,
164
+ 'ground',
165
+ `the document element carries data-pack="${ground}" and the map declares "${map.ground}".\n` +
166
+ ' The ground is one fact with one owner.',
167
+ ),
168
+ )
169
+ }
170
+ if (map.defaultMode === 'dark' && !document.documentElement.classList.contains('dark')) {
171
+ findings.push(
172
+ finding(
173
+ route,
174
+ 'mode',
175
+ 'the document element carries no dark class, so a reader with no stored theme gets light mode,\n' +
176
+ ' which is not this site\'s default.',
177
+ ),
178
+ )
179
+ }
180
+
181
+ const regions = new Map()
182
+ for (const element of document.querySelectorAll('[data-pack]')) {
183
+ if (element === document.documentElement) continue
184
+ boundaries += 1
185
+ const pack = element.getAttribute('data-pack') ?? ''
186
+ const region = regionOf(element, document)
187
+ if (region === null || region === undefined) {
188
+ report(
189
+ `unnameable:${element.tagName}`,
190
+ route,
191
+ 'unnamed-region',
192
+ `a data-pack="${pack}" boundary in a band the site's own resolver cannot name cannot be held to\n` +
193
+ ' the map. A region this gate cannot name is a region it cannot hold to the map.',
194
+ )
195
+ continue
196
+ }
197
+ if (!regions.has(region)) regions.set(region, [])
198
+ regions.get(region).push(pack)
199
+
200
+ const tag = element.tagName.toLowerCase()
201
+ if (element.getAttribute('data-slot') === markSlot) marks += 1
202
+ else {
203
+ report(
204
+ `off-mark:${element.tagName}`,
205
+ route,
206
+ 'boundary-off-a-mark',
207
+ `a data-pack="${pack}" boundary in ${region} sits on a <${tag}>, not on a mark. A boundary\n` +
208
+ ' re-points --radius as well as colour, so anything that is not a fully rounded shape changes\n' +
209
+ ' shape with its pack.',
210
+ )
211
+ }
212
+ if (SHAPES.includes(tag)) {
213
+ report(
214
+ `shape:${tag}`,
215
+ route,
216
+ 'boundary-shape',
217
+ `a <${tag}> carries data-pack="${pack}" in ${region}.`,
218
+ )
219
+ }
220
+ if (/\brounded-(?!full\b)[a-z0-9-]+/.test(element.getAttribute('class') ?? '')) {
221
+ report(
222
+ `radius:${region}`,
223
+ route,
224
+ 'boundary-radius',
225
+ `the boundary in ${region} carries a radius utility other than rounded-full, so its corner radius\n` +
226
+ ' is computed from the pack it carries.',
227
+ )
228
+ }
229
+ if (/\bdark\b/.test(element.getAttribute('class') ?? '')) {
230
+ report(
231
+ `hard-mode:${region}`,
232
+ route,
233
+ 'hard-coded-mode',
234
+ `a boundary in ${region} carries a dark class of its own, so it is mode-correct for half the\n` +
235
+ ' readers and inverted for the other half. A boundary wears the mode of the nearest ancestor\n' +
236
+ ' carrying it, and the server is the only thing that can know it.',
237
+ )
238
+ }
239
+
240
+ for (const mode of ['light', 'dark']) {
241
+ const rule = rules.get(`${pack}:${mode}`)
242
+ if (!rule) {
243
+ report(`no-block:${pack}:${mode}`, route, `no-${mode}-block`, `the emitted stylesheet declares no [data-pack=${pack}] block for ${mode}.`)
244
+ continue
245
+ }
246
+ if (mode === 'dark') {
247
+ if (!rule.selectors.some((selector) => selector === `.dark [data-pack=${pack}]`)) {
248
+ report(
249
+ `descendant:${pack}`,
250
+ route,
251
+ 'mode-inverted',
252
+ `the dark block for "${pack}" is ${JSON.stringify(rule.selectors)}, which has no descendant\n` +
253
+ ' form. A boundary is an attribute on an element with no mode class of its own, so without\n' +
254
+ ' `.dark [data-pack=x]` a server-rendered boundary on a dark page matches the light block:\n' +
255
+ ' pack-correct, mode-inverted, and it looks like a design decision.',
256
+ )
257
+ }
258
+ if (!rule.selectors.some((selector) => selector === `[data-pack=${pack}].dark`)) {
259
+ report(
260
+ `compound:${pack}`,
261
+ route,
262
+ 'compound-form-missing',
263
+ `the dark block for "${pack}" drops the compound form, which is published for a boundary that must\n` +
264
+ ' hold a fixed mode.',
265
+ )
266
+ }
267
+ }
268
+ const published = system.tokenTheme(pack, mode)
269
+ for (const role of ROLES) {
270
+ const emittedValue = rule.values.get(`--${role}`)
271
+ const expected = published.get(`--${role}`)
272
+ if (emittedValue === undefined) {
273
+ report(
274
+ `unresolved:${pack}:${role}`,
275
+ route,
276
+ 'unresolved-role',
277
+ `the ${mode} block for "${pack}" does not declare --${role}.`,
278
+ )
279
+ } else if (expected !== undefined && !sameColour(emittedValue, expected)) {
280
+ report(
281
+ `contract:${pack}:${mode}:${role}`,
282
+ route,
283
+ 'contract-mismatch',
284
+ `the ${mode} block for "${pack}" declares --${role}: ${emittedValue}, and the published token contract\n` +
285
+ ` declares ${expected}.`,
286
+ )
287
+ }
288
+ }
289
+ if (mode === 'dark') {
290
+ const light = rules.get(`${pack}:light`)?.values
291
+ if (light && light.get('--card') === rule.values.get('--card')) {
292
+ report(
293
+ `identical:${pack}`,
294
+ route,
295
+ 'modes-identical',
296
+ `the light and dark blocks for "${pack}" declare the same --card, so a boundary resolves to the same\n` +
297
+ ' values in both modes and this check proves nothing about modes.',
298
+ )
299
+ }
300
+ }
301
+ }
302
+ }
303
+ byRoute.set(route, regions)
304
+ for (const [region, packs] of regions) {
305
+ if (!actual.has(region)) actual.set(region, { packs: new Set(), routes: [] })
306
+ actual.get(region).packs = new Set([...actual.get(region).packs, ...packs])
307
+ actual.get(region).routes.push(route)
308
+ }
309
+ }
310
+
311
+ const multiset = (packs) => JSON.stringify([...packs].sort())
312
+ for (const [route, regions] of [...byRoute.entries()].sort()) {
313
+ for (const [region, packs] of [...regions.entries()].sort()) {
314
+ const expected = map.regions?.[region]
315
+ if (!expected) {
316
+ findings.push(
317
+ finding(
318
+ route,
319
+ 'undeclared-region',
320
+ `${region} carries ${multiset(packs)} and the map does not declare it. A map that says zero is a\n` +
321
+ ' decision; a map that says nothing is a drift.',
322
+ ),
323
+ )
324
+ continue
325
+ }
326
+ if (multiset(packs) !== multiset(expected.packs)) {
327
+ findings.push(
328
+ finding(
329
+ route,
330
+ 'map-mismatch',
331
+ `${region} carries ${multiset(packs)} and the map declares ${multiset(expected.packs)}.`,
332
+ ),
333
+ )
334
+ }
335
+ }
336
+ }
337
+
338
+ const onLanding = byRoute.get(landing) ?? new Map()
339
+ for (const region of Object.keys(map.regions ?? {})) {
340
+ if (onLanding.has(region)) continue
341
+ findings.push(
342
+ finding(region, 'missing-on-landing', 'the map declares this region and the landing does not carry it.'),
343
+ )
344
+ }
345
+ if (!byRoute.has(landing)) {
346
+ findings.push(finding(landing, 'missing-landing', `this export has no ${landing} document, so the map could not be checked.`))
347
+ }
348
+
349
+ // Both halves: the derived set is what the page does, the declared set is what
350
+ // the page may do. A page that grows a third region carrying a second pack is
351
+ // the difference between them.
352
+ const derivedSecond = [...onLanding]
353
+ .filter(([, packs]) => packs.some((pack) => pack !== map.ground))
354
+ .map(([region]) => region)
355
+ .sort()
356
+ const allowedSecond = [...(map.secondPackRegions ?? [])].sort()
357
+ if (JSON.stringify(derivedSecond) !== JSON.stringify(allowedSecond)) {
358
+ findings.push(
359
+ finding(
360
+ 'pack-map',
361
+ 'second-pack-regions',
362
+ `the regions carrying a pack that is not the ground are ${JSON.stringify(derivedSecond)}, and the map\n` +
363
+ ` allows ${JSON.stringify(allowedSecond)}. A boundary moves the corner radius beneath it, so a third\n` +
364
+ ' region carrying a second pack is a third set of corners that mean something other than radius.',
365
+ ),
366
+ )
367
+ }
368
+
369
+ notes.push(
370
+ `${l.id}: ${findings.length} finding(s) across ${documents.length} document(s) and ${boundaries} boundary/boundaries read`,
371
+ )
372
+ notes.push(`${l.id}: the ground is "${map.ground}"; ${marks} of ${boundaries} boundary/boundaries sit on a mark`)
373
+ notes.push(`${l.id}: regions read, with the packs each carries and how many routes carry it:`)
374
+ for (const [region, entry] of [...actual.entries()].sort()) {
375
+ notes.push(`${l.id}: ${region} ${multiset([...entry.packs])} on ${entry.routes.length} route(s)`)
376
+ }
377
+ notes.push(
378
+ `${l.id}: ${derivedSecond.length} region(s) carry a pack that is not the ground: ${derivedSecond.join(', ')}; the map allows ${allowedSecond.join(', ')}`,
379
+ )
380
+ notes.push(
381
+ `${l.id}: both modes were resolved for every boundary, from ${cssFiles.length} emitted stylesheet(s) and from the\n` +
382
+ ' published token contract resolved through the component package, and each boundary was matched against both.',
383
+ )
384
+ notes.push(
385
+ `${l.id}: the region resolver is this repository's own (${config.regionResolver ?? 'none declared'}), because naming a\n` +
386
+ " region means knowing this site's structure. The law above is not per site.",
387
+ )
388
+ notes.push(
389
+ `${l.id}: the honest limit: this is a selector match over emitted CSS and emitted markup, not a layout engine.\n` +
390
+ ' It cannot see a runtime that sets data-pack after paint, and a screenshot in one mode is not evidence.',
391
+ )
392
+
393
+ return { law: l, findings, notes }
394
+ }
395
+
396
+ /**
397
+ * The site's own region resolver.
398
+ *
399
+ * Required rather than defaulted, because a default would be this file's guess at
400
+ * one page's structure, and a guess that silently named nothing would turn every
401
+ * boundary on every site into an unnamed region and read as a broken map rather
402
+ * than as a missing declaration.
403
+ */
404
+ async function regionResolver(root, config, mapPath) {
405
+ const declared = config.regionResolver
406
+ if (!declared) {
407
+ throw new CoverageError(
408
+ `no region resolver is declared, so a boundary cannot be named and the map cannot be checked.\n` +
409
+ ` Add "regionResolver" to the gate's configuration: a module beside ${mapPath} that exports regionOf(element, document).`,
410
+ )
411
+ }
412
+ const file = path.join(root, declared)
413
+ if (!existsSync(file)) {
414
+ throw new CoverageError(`${declared} does not resolve, so no boundary on any route can be named.`)
415
+ }
416
+ const module = await import(pathToFileURL(file).href)
417
+ if (typeof module.regionOf !== 'function') {
418
+ throw new CoverageError(`${declared} does not export regionOf, so no boundary on any route can be named.`)
419
+ }
420
+ return module.regionOf
421
+ }