@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +30 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/ui/breadcrumb.d.ts +6 -2
- package/dist/components/ui/breadcrumb.d.ts.map +1 -1
- package/dist/components/ui/breadcrumb.js +5 -3
- package/dist/components/ui/dialog.d.ts +12 -1
- package/dist/components/ui/dialog.d.ts.map +1 -1
- package/dist/components/ui/dialog.js +2 -2
- package/dist/components/ui/live-region.d.ts +78 -0
- package/dist/components/ui/live-region.d.ts.map +1 -0
- package/dist/components/ui/live-region.js +48 -0
- package/dist/components/ui/mark.d.ts +44 -0
- package/dist/components/ui/mark.d.ts.map +1 -0
- package/dist/components/ui/mark.js +79 -0
- package/dist/components/ui/pagination.d.ts +24 -6
- package/dist/components/ui/pagination.d.ts.map +1 -1
- package/dist/components/ui/pagination.js +23 -9
- package/dist/components/ui/product-switcher.d.ts +7 -0
- package/dist/components/ui/product-switcher.d.ts.map +1 -1
- package/dist/components/ui/product-switcher.js +2 -2
- package/dist/components/ui/slider.d.ts +1 -1
- package/dist/components/ui/slider.d.ts.map +1 -1
- package/dist/components/ui/tree.d.ts +115 -0
- package/dist/components/ui/tree.d.ts.map +1 -0
- package/dist/components/ui/tree.js +137 -0
- package/dist/fonts/Inter-OFL.txt +93 -0
- package/dist/fonts/inter-latin-400.woff2 +0 -0
- package/dist/fonts/inter-latin-500.woff2 +0 -0
- package/dist/fonts/inter-latin-600.woff2 +0 -0
- package/dist/styles.css +52 -0
- package/gates/README.md +98 -0
- package/gates/cli.mjs +133 -0
- package/gates/hidden-state.mjs +243 -0
- package/gates/index.mjs +81 -0
- package/gates/laws.mjs +170 -0
- package/gates/links.mjs +187 -0
- package/gates/pack-boundary.mjs +421 -0
- package/gates/pin.mjs +130 -0
- package/gates/retired-line.mjs +314 -0
- package/gates/run.mjs +320 -0
- package/gates/runtime-token-read.mjs +129 -0
- package/gates/stylesheet-ownership.mjs +207 -0
- package/package.json +16 -3
package/gates/links.mjs
ADDED
|
@@ -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
|
+
}
|