@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/cli.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The consumer's gate chain. One program, one configuration file, one exit code.
|
|
4
|
+
*
|
|
5
|
+
* A consumer's repository holds data: its roots, its sheet, its pack map, its
|
|
6
|
+
* coverage floors, its destinations it knows are broken. It does not hold the
|
|
7
|
+
* wording of a rule, because a wording held in four places is four rules that will
|
|
8
|
+
* disagree, and the version they disagree at is the version nobody chose.
|
|
9
|
+
*
|
|
10
|
+
* pnpm prism-gates # every gate the configuration names
|
|
11
|
+
* pnpm prism-gates --gate=links # one gate
|
|
12
|
+
* pnpm prism-gates --gate=links --json # machine-readable, for a test
|
|
13
|
+
*
|
|
14
|
+
* `--json` exists because the assertion that matters about a gate is not that it
|
|
15
|
+
* passed: it is that it went red on a defect it can see. A test cannot watch a
|
|
16
|
+
* human-readable exit code and be sure it saw the failure it planted, so the
|
|
17
|
+
* machine form carries the findings and the coverage the run achieved.
|
|
18
|
+
*
|
|
19
|
+
* **The exit code is the contract.** 0 means every named gate ran and found
|
|
20
|
+
* nothing. 1 means a gate found something. 2 means a gate could not read what it
|
|
21
|
+
* was pointed at, which is a different answer from "clean" and is never reported
|
|
22
|
+
* as 0. A gate that read nothing and exited 0 is the failure this program exists
|
|
23
|
+
* to make impossible, and it is why the coverage floors live in the gates rather
|
|
24
|
+
* than in the consumer's expectations.
|
|
25
|
+
*/
|
|
26
|
+
import process from 'node:process'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
|
|
29
|
+
import { GATE_IDS, GATE_KIT_VERSION, gate, laws } from './index.mjs'
|
|
30
|
+
import { CoverageError, readJson } from './run.mjs'
|
|
31
|
+
|
|
32
|
+
const CONFIG_NAME = 'prism-gates.json'
|
|
33
|
+
|
|
34
|
+
const flag = (name) => process.argv.find((argument) => argument.startsWith(`--${name}=`))?.slice(name.length + 3)
|
|
35
|
+
const has = (name) => process.argv.includes(`--${name}`)
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const root = path.resolve(process.cwd())
|
|
39
|
+
const configPath = flag('config') ?? CONFIG_NAME
|
|
40
|
+
const json = has('json')
|
|
41
|
+
const only = flag('gate')
|
|
42
|
+
|
|
43
|
+
let config
|
|
44
|
+
try {
|
|
45
|
+
config = readJson(root, configPath)
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
return fail(`error prism-gates: ${cause.message}`)
|
|
48
|
+
}
|
|
49
|
+
if (!config) {
|
|
50
|
+
return fail(
|
|
51
|
+
`error prism-gates: ${path.join(root, configPath)} does not resolve.\n` +
|
|
52
|
+
' A gate chain with no configuration is a chain that runs nothing, and nothing looks like a pass.\n' +
|
|
53
|
+
' The configuration holds this repository\'s own data only: its roots, its sheet, its pack map, its\n' +
|
|
54
|
+
' coverage floors. The laws are not in it, because they are in the package.',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const named = only ? [only] : (config.gates ?? [])
|
|
59
|
+
if (named.length === 0) {
|
|
60
|
+
return fail(
|
|
61
|
+
`error prism-gates: ${configPath} names no gates, so this run enforced nothing.\n` +
|
|
62
|
+
` A chain that runs nothing exits the same as a chain that found nothing, which is the answer this\n` +
|
|
63
|
+
` program exists to stop giving. The kit ships: ${GATE_IDS.join(', ')}.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const results = []
|
|
68
|
+
let findings = 0
|
|
69
|
+
let unreadable = 0
|
|
70
|
+
|
|
71
|
+
for (const id of named) {
|
|
72
|
+
const settings = config[id] ?? {}
|
|
73
|
+
try {
|
|
74
|
+
const { run } = await gate(id)
|
|
75
|
+
const result = await run({ root, config: settings })
|
|
76
|
+
findings += result.findings.length
|
|
77
|
+
results.push({ gate: id, law: result.law.id, ok: result.findings.length === 0, findings: result.findings, notes: result.notes, also: (result.also ?? []).map((l) => l.id) })
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
unreadable += 1
|
|
80
|
+
const message =
|
|
81
|
+
cause instanceof CoverageError
|
|
82
|
+
? `error prism-gates: ${id}: ${cause.message}`
|
|
83
|
+
: `error prism-gates: ${id} could not run: ${cause.stack ?? cause.message}`
|
|
84
|
+
if (json) {
|
|
85
|
+
results.push({ gate: id, ok: false, unreadable: true, findings: [], notes: [message] })
|
|
86
|
+
} else {
|
|
87
|
+
console.error(message)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (json) {
|
|
93
|
+
console.log(
|
|
94
|
+
JSON.stringify(
|
|
95
|
+
{ gateKit: GATE_KIT_VERSION, laws: laws().map((l) => l.id), results },
|
|
96
|
+
null,
|
|
97
|
+
2,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
return findings > 0 || unreadable > 0 ? 1 : 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const result of results) {
|
|
104
|
+
if (result.unreadable) continue
|
|
105
|
+
for (const line of result.notes) console.log(line)
|
|
106
|
+
for (const line of result.findings) console.error(line)
|
|
107
|
+
if (result.findings.length > 0) {
|
|
108
|
+
const law = laws().find((l) => l.id === result.law)
|
|
109
|
+
console.error(`\n${law.title}`)
|
|
110
|
+
console.error(law.message)
|
|
111
|
+
console.error(` It is here because: ${law.why}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(
|
|
116
|
+
`\nprism-gates: gate kit ${GATE_KIT_VERSION}, ${named.length} gate(s) run, ${findings} finding(s), ` +
|
|
117
|
+
`${unreadable} unreadable`,
|
|
118
|
+
)
|
|
119
|
+
console.log(`prism-gates: the laws this run enforced: ${laws().map((l) => l.id).join(', ')}`)
|
|
120
|
+
console.log(
|
|
121
|
+
'prism-gates: the wording of every law above is in the package you pinned, not in this repository. A change\n' +
|
|
122
|
+
' to it reaches this repository in one release and cannot be declined here.',
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return findings > 0 || unreadable > 0 ? 1 : 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fail(message) {
|
|
129
|
+
console.error(message)
|
|
130
|
+
return 2
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
process.exitCode = await main()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hidden-state law: a CSS-authored hidden state can dismiss itself, and its
|
|
3
|
+
* exit is not a clock.
|
|
4
|
+
*
|
|
5
|
+
* The defect this replaces is not a bug report; it is a page nobody can read. A
|
|
6
|
+
* sheet carried
|
|
7
|
+
*
|
|
8
|
+
* .site [data-reveal] { opacity: 0; transform: translateY(12px); }
|
|
9
|
+
* .site [data-reveal].is-in { opacity: 1; transform: none; }
|
|
10
|
+
*
|
|
11
|
+
* and `is-in` was added by an IntersectionObserver in a client effect. That is the
|
|
12
|
+
* only exit. There is no timeout, so there is nothing to wait for and nothing to
|
|
13
|
+
* give up on: a reader with scripting disabled, a reader whose browser has no
|
|
14
|
+
* IntersectionObserver, a reader whose JavaScript failed to parse, and a crawler
|
|
15
|
+
* that never executes any of it all received `opacity: 0` and nothing else. The
|
|
16
|
+
* whole body of the page, below the header, was invisible.
|
|
17
|
+
*
|
|
18
|
+
* Exactly two shapes are allowed. An escapable condition, which is a media query
|
|
19
|
+
* or a `:not()` that the reader's own capability satisfies. Or a script-armed
|
|
20
|
+
* ancestor attribute, where the arming is written by exactly one script and by
|
|
21
|
+
* nothing else, so a reader whose scripting is off never receives it and the
|
|
22
|
+
* hidden state does not exist for them. A reader whose scripting fails midway
|
|
23
|
+
* might, and the `(scripting: none)` block is the guard for that case: it is a
|
|
24
|
+
* statement about capability rather than about health, and it does not care why
|
|
25
|
+
* the script did not finish. The two are complementary and neither is a timeout.
|
|
26
|
+
*
|
|
27
|
+
* **The clock is banned rather than shortened.** A fallback animation's clock
|
|
28
|
+
* starts at first style resolution rather than at scroll, so by the time the
|
|
29
|
+
* class lands there is nothing left to cancel, and a reader on a slow connection
|
|
30
|
+
* looks at a blank page for three seconds of a timer measuring the wrong
|
|
31
|
+
* interval. Cancellability was never the load-bearing property; having an exit
|
|
32
|
+
* that does not depend on a clock was.
|
|
33
|
+
*
|
|
34
|
+
* **A gate that only read the stylesheet would pass a site that renders nothing.**
|
|
35
|
+
* That is why a consumer that ships a hidden state also has a test that renders
|
|
36
|
+
* with scripting off, and this gate's job is to make the stylesheet half true
|
|
37
|
+
* enough for that test to be the only half left. A consumer with no hidden state
|
|
38
|
+
* runs this gate too and passes vacuously, and the run prints the rule count it
|
|
39
|
+
* read so the reader can tell the difference between a vacuous pass and a scan of
|
|
40
|
+
* nothing.
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
43
|
+
import path from 'node:path'
|
|
44
|
+
|
|
45
|
+
import { law } from './laws.mjs'
|
|
46
|
+
import { CoverageError, blankComments, cssBlocks, finding, floor, read } from './run.mjs'
|
|
47
|
+
|
|
48
|
+
/** A declaration that makes an element not painted. */
|
|
49
|
+
const HIDES = /^\s*(?:opacity\s*:\s*(?:0|0%)\b|visibility\s*:\s*hidden\b|display\s*:\s*none\b)/
|
|
50
|
+
|
|
51
|
+
/** A clock. Any of the three is a mechanism with its own failure mode. */
|
|
52
|
+
const CLOCK = /(?:^|[;\s])animation(?:-[a-z-]+)?\s*:|@keyframes/
|
|
53
|
+
|
|
54
|
+
export function run({ root, config }) {
|
|
55
|
+
const l = law('hidden-state')
|
|
56
|
+
const findings = []
|
|
57
|
+
const notes = []
|
|
58
|
+
const sheets = config.sheets ?? ['app/globals.css']
|
|
59
|
+
const minRules = config.minRules ?? 25
|
|
60
|
+
const marker = config.marker ?? 'data-reveal'
|
|
61
|
+
const armed = config.armed ?? 'data-reveal-armed'
|
|
62
|
+
const sourceRoots = config.sourceRoots ?? ['app', 'components', 'lib']
|
|
63
|
+
|
|
64
|
+
const missing = sheets.filter((sheet) => read(root, sheet) === null)
|
|
65
|
+
if (missing.length > 0) {
|
|
66
|
+
throw new CoverageError(
|
|
67
|
+
`${missing.length} of ${sheets.length} configured stylesheets do not resolve: ${missing.join(', ')}.\n` +
|
|
68
|
+
' A gate that read nothing reports a clean sheet, so an unresolved root fails the run.',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const markerSelector = new RegExp(`\\[${marker}\\b`)
|
|
73
|
+
const armedSelector = new RegExp(`\\[${armed}\\]`)
|
|
74
|
+
|
|
75
|
+
let rules = 0
|
|
76
|
+
let declarations = 0
|
|
77
|
+
let hiding = 0
|
|
78
|
+
|
|
79
|
+
for (const sheet of sheets) {
|
|
80
|
+
for (const block of cssBlocks(read(root, sheet))) {
|
|
81
|
+
rules += 1
|
|
82
|
+
for (const declaration of block.body.split(';')) {
|
|
83
|
+
if (/^[a-z-]+\s*:/.test(declaration.trim())) declarations += 1
|
|
84
|
+
}
|
|
85
|
+
const selectors = block.selectors.join(', ')
|
|
86
|
+
|
|
87
|
+
if (CLOCK.test(block.body)) {
|
|
88
|
+
findings.push(
|
|
89
|
+
finding(
|
|
90
|
+
`${sheet}:${block.line}`,
|
|
91
|
+
'clock',
|
|
92
|
+
`${selectors} carries an animation clock. A fallback animation's clock starts at first style\n` +
|
|
93
|
+
' resolution rather than at scroll, so by the time the class lands there is nothing left to\n' +
|
|
94
|
+
' cancel, and a slow connection sees a blank page for a timer that measures the wrong interval.',
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!block.body.split(';').some((declaration) => HIDES.test(declaration))) continue
|
|
100
|
+
if (!markerSelector.test(selectors)) {
|
|
101
|
+
// A rule that hides nothing through the marked selector is not the law's
|
|
102
|
+
// subject. It is still a rule this gate read, and it is still counted.
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
hiding += 1
|
|
106
|
+
|
|
107
|
+
if (!armedSelector.test(selectors)) {
|
|
108
|
+
findings.push(
|
|
109
|
+
finding(
|
|
110
|
+
`${sheet}:${block.line}`,
|
|
111
|
+
'unarmed',
|
|
112
|
+
`${selectors} hides content with no armed ancestor. A reader whose scripting is switched off never\n` +
|
|
113
|
+
` receives \`${armed}\`, so this declaration is the last thing they are shown.`,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
if (!/\(scripting\s*:\s*none\)/.test(read(root, sheet))) {
|
|
118
|
+
findings.push(
|
|
119
|
+
finding(
|
|
120
|
+
sheet,
|
|
121
|
+
'no-scripting-guard',
|
|
122
|
+
`the sheet hides content and declares no \`(scripting: none)\` guard. A reader whose scripting\n` +
|
|
123
|
+
' started and stopped would receive the arming attribute and nothing that withdraws it.',
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
floor('rule(s) read', rules, minRules)
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* A second writer of the arming attribute is a second owner of a state whose exit
|
|
134
|
+
* depends on every path withdrawing it, so the writer is counted.
|
|
135
|
+
*
|
|
136
|
+
* Counted by the WRITE, not by the name. A module that explains the mechanism in a
|
|
137
|
+
* comment names the attribute and writes nothing, and a gate that counted names
|
|
138
|
+
* would read its own subject's documentation as a second writer. The write is
|
|
139
|
+
* matched on a code line with the comments blanked, so a comment that shows the
|
|
140
|
+
* call as an example is a record and a call is a call.
|
|
141
|
+
*
|
|
142
|
+
* The withdrawal is counted separately and separately required: an arming with no
|
|
143
|
+
* withdrawal anywhere is a hidden state whose only exit is the scripting-media
|
|
144
|
+
* query, which does not see the reader whose scripting started and stopped.
|
|
145
|
+
*/
|
|
146
|
+
const sources = sourceFiles(root, sourceRoots)
|
|
147
|
+
const writer = new RegExp(`setAttribute\\(\\s*['"\`]${armed}['"\`]`)
|
|
148
|
+
const withdrawer = new RegExp(`removeAttribute\\(\\s*['"\`]${armed}['"\`]`)
|
|
149
|
+
const writers = sources.filter((file) =>
|
|
150
|
+
writer.test(blankComments(readFileSync(file, 'utf8'))),
|
|
151
|
+
)
|
|
152
|
+
const withdrawers = sources.filter((file) =>
|
|
153
|
+
withdrawer.test(blankComments(readFileSync(file, 'utf8'))),
|
|
154
|
+
)
|
|
155
|
+
const clientModules = sources.filter((file) =>
|
|
156
|
+
/^\s*['"]use client['"]/m.test(readFileSync(file, 'utf8')),
|
|
157
|
+
)
|
|
158
|
+
const relative = (file) => path.relative(root, file).split(path.sep).join('/')
|
|
159
|
+
if (writers.length === 0 && hiding > 0) {
|
|
160
|
+
findings.push(
|
|
161
|
+
finding(
|
|
162
|
+
sheets.join(', '),
|
|
163
|
+
'unwritten',
|
|
164
|
+
`a rule hides content through \`[${marker}]\` and no module writes \`${armed}\`. The attribute is the\n` +
|
|
165
|
+
' whole of the exit for a reader whose scripting works, so a sheet that hides with nothing to\n' +
|
|
166
|
+
' arm it hides for everyone.',
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (writers.length > 1) {
|
|
171
|
+
findings.push(
|
|
172
|
+
finding(
|
|
173
|
+
writers.slice(1).map(relative).join(', '),
|
|
174
|
+
'second-writer',
|
|
175
|
+
`more than one module writes \`${armed}\`, at ${writers.map(relative).join(' and ')}. The attribute has one\n` +
|
|
176
|
+
' writer, and a second writer is a second owner of a state whose exit depends on every path\n' +
|
|
177
|
+
' withdrawing it.',
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
if (withdrawers.length === 0 && writers.length > 0) {
|
|
182
|
+
findings.push(
|
|
183
|
+
finding(
|
|
184
|
+
writers.map(relative).join(', '),
|
|
185
|
+
'no-withdrawal',
|
|
186
|
+
`\`${armed}\` is written and never removed, so a reader whose scripting started and then\n` +
|
|
187
|
+
' stopped has the attribute and no path that takes it away. The (scripting: none) guard\n' +
|
|
188
|
+
" cannot see them, because their scripting is enabled.",
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
notes.push(
|
|
194
|
+
`${l.id}: ${findings.length} finding(s) across ${sheets.length} sheet(s), ${rules} rule(s) and ${declarations} declaration(s) read; floor ${minRules} rule(s)`,
|
|
195
|
+
)
|
|
196
|
+
notes.push(`${l.id}: sheets read: ${sheets.join(', ')}`)
|
|
197
|
+
notes.push(
|
|
198
|
+
`${l.id}: ${hiding} rule(s) hide content through \`[${marker}]\`; the escape shapes are an escapable condition\n` +
|
|
199
|
+
` or an armed ancestor \`[${armed}]\` written by exactly one script.`,
|
|
200
|
+
)
|
|
201
|
+
notes.push(
|
|
202
|
+
`${l.id}: ${sources.length} source file(s) read; ${writers.length} write the arming attribute` +
|
|
203
|
+
`${writers.length === 0 ? '' : ` (${writers.map(relative).join(', ')})`} and ${withdrawers.length} withdraw it` +
|
|
204
|
+
`${withdrawers.length === 0 ? '' : ` (${withdrawers.map(relative).join(', ')})`}. Counted by the call, not by the name, so a comment explaining the mechanism is not a writer.`,
|
|
205
|
+
)
|
|
206
|
+
if (hiding === 0) {
|
|
207
|
+
notes.push(
|
|
208
|
+
`${l.id}: this run passed vacuously. No rule in ${sheets.join(', ')} hides content through the marker, so this\n` +
|
|
209
|
+
' repository has nothing to exit and no runtime to exit it with. That is a real answer rather than a\n' +
|
|
210
|
+
' scan of nothing: the rule count above says how much was read, and a repository that acquired a\n' +
|
|
211
|
+
' hidden state would produce a finding here rather than a pass.',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
notes.push(`${l.id}: ${clientModules.length} module(s) carry a client directive.`)
|
|
215
|
+
notes.push(
|
|
216
|
+
`${l.id}: a stylesheet half is not enough. A consumer that ships a hidden state needs a test that renders\n` +
|
|
217
|
+
' with scripting switched off and asserts the content is present and the arming attribute is absent.',
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return { law: l, findings, notes }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every source file the declared roots hold, so a second writer is findable. */
|
|
224
|
+
function sourceFiles(root, roots) {
|
|
225
|
+
const found = []
|
|
226
|
+
const skip = new Set(['node_modules', '.next', 'out', '.git', '.wrangler', '.source'])
|
|
227
|
+
const visit = (dir) => {
|
|
228
|
+
let entries
|
|
229
|
+
try {
|
|
230
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
231
|
+
} catch {
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (skip.has(entry.name)) continue
|
|
236
|
+
const full = path.join(dir, entry.name)
|
|
237
|
+
if (entry.isDirectory() || statSync(full).isDirectory()) visit(full)
|
|
238
|
+
else if (/\.(tsx?|mjs)$/.test(entry.name)) found.push(full)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const declared of roots) visit(path.join(root, declared))
|
|
242
|
+
return found
|
|
243
|
+
}
|
package/gates/index.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate kit's registry: which law is which program, and which version of this
|
|
3
|
+
* kit a consumer is running.
|
|
4
|
+
*
|
|
5
|
+
* The registry exists so that "the laws" is a list rather than a folder. A gate
|
|
6
|
+
* that is not in `GATES` is a program nobody runs, and a law with no gate is a
|
|
7
|
+
* sentence, which is the thing this kit was written to remove. `check-gate-kit.mjs`
|
|
8
|
+
* asserts the two are the same set in both directions, and the assertion runs in
|
|
9
|
+
* this repository's own `check`, so the registry cannot rot without the build
|
|
10
|
+
* failing.
|
|
11
|
+
*
|
|
12
|
+
* `GATE_KIT_VERSION` is the version of the *contract*, not of the package. A
|
|
13
|
+
* consumer names it in its configuration so that a consumer's own gate chain can
|
|
14
|
+
* say which generation of the law it is holding itself to, and so that a law
|
|
15
|
+
* rewritten in a later release is visible as a version the consumer has not moved
|
|
16
|
+
* to rather than as a silent change of wording underneath it.
|
|
17
|
+
*/
|
|
18
|
+
import { LAW_IDS, law } from './laws.mjs'
|
|
19
|
+
|
|
20
|
+
/** The contract generation. Bump it when a law's wording or its gate's surface changes. */
|
|
21
|
+
export const GATE_KIT_VERSION = 1
|
|
22
|
+
|
|
23
|
+
/** Every gate, keyed by the id a consumer names. The value is the module's `run`. */
|
|
24
|
+
export const GATES = {
|
|
25
|
+
'retired-line': () => import('./retired-line.mjs'),
|
|
26
|
+
'stylesheet-ownership': () => import('./stylesheet-ownership.mjs'),
|
|
27
|
+
links: () => import('./links.mjs'),
|
|
28
|
+
'pack-boundary': () => import('./pack-boundary.mjs'),
|
|
29
|
+
'hidden-state': () => import('./hidden-state.mjs'),
|
|
30
|
+
'runtime-token-read': () => import('./runtime-token-read.mjs'),
|
|
31
|
+
pin: () => import('./pin.mjs'),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which gate holds which law, in both directions.
|
|
36
|
+
*
|
|
37
|
+
* The token-read law is held by the stylesheet-ownership gate because it reads the
|
|
38
|
+
* same sheet with the same parser and the same reference; splitting it would mean
|
|
39
|
+
* parsing a stylesheet twice to assert one property of it, and the two runs could
|
|
40
|
+
* disagree about what a declaration is. It is a separate law because its failure is
|
|
41
|
+
* a different failure: an unlayered declaration that wins the cascade is not the
|
|
42
|
+
* same defect as a read that erases itself.
|
|
43
|
+
*/
|
|
44
|
+
export const GATE_LAWS = {
|
|
45
|
+
'retired-line': ['retired-line'],
|
|
46
|
+
'stylesheet-ownership': ['stylesheet-ownership', 'token-read'],
|
|
47
|
+
links: ['links'],
|
|
48
|
+
'pack-boundary': ['pack-boundary'],
|
|
49
|
+
'hidden-state': ['hidden-state'],
|
|
50
|
+
'runtime-token-read': ['runtime-token-read'],
|
|
51
|
+
pin: ['pin'],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The gate ids, in the order a report prints them. */
|
|
55
|
+
export const GATE_IDS = Object.keys(GATES)
|
|
56
|
+
|
|
57
|
+
/** One gate by id, loaded. A throw naming the ones that exist, because a misspell must not pass. */
|
|
58
|
+
export async function gate(id) {
|
|
59
|
+
const load = GATES[id]
|
|
60
|
+
if (!load) {
|
|
61
|
+
throw new Error(`prism-gates: unknown gate "${id}". The kit ships: ${GATE_IDS.join(', ')}.`)
|
|
62
|
+
}
|
|
63
|
+
const module = await load()
|
|
64
|
+
if (typeof module.run !== 'function') {
|
|
65
|
+
throw new Error(`prism-gates: the gate "${id}" exports no run(), so it cannot be run rather than skipped.`)
|
|
66
|
+
}
|
|
67
|
+
return module
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every law the kit enforces, resolved, so a consumer can print them without
|
|
72
|
+
* knowing which program holds which.
|
|
73
|
+
*/
|
|
74
|
+
export function laws() {
|
|
75
|
+
return LAW_IDS.map(law)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The laws a named gate holds. */
|
|
79
|
+
export function lawsOf(gateId) {
|
|
80
|
+
return (GATE_LAWS[gateId] ?? []).map(law)
|
|
81
|
+
}
|
package/gates/laws.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The laws, defined once.
|
|
3
|
+
*
|
|
4
|
+
* For four years the cross-repository contract between the five NaniSoft
|
|
5
|
+
* repositories was a prose document mirrored in four files, and it decayed in
|
|
6
|
+
* the only way a mirror can: the two sites that mattered drifted into three
|
|
7
|
+
* implementations of one rule and one of the three rules was false. A prose law
|
|
8
|
+
* cannot fail, so it stays true until the day it is not, and nobody is told.
|
|
9
|
+
*
|
|
10
|
+
* So a law here is not a sentence a reader is asked to believe. It is the text a
|
|
11
|
+
* gate prints when the build fails, and it lives in the package every consumer
|
|
12
|
+
* pins exactly. A consumer's own repository holds data: its roots, its sheet, its
|
|
13
|
+
* pack map, its coverage floors. It does not hold the wording of a rule, because
|
|
14
|
+
* a wording held in four places is four rules that will disagree.
|
|
15
|
+
*
|
|
16
|
+
* **The test for what belongs here.** A clause earns a law when its violation is
|
|
17
|
+
* silent. Every entry below is silent by construction: an unlayered bare-element
|
|
18
|
+
* declaration of the page ground looks like a design decision in a screenshot, a
|
|
19
|
+
* `var()` that resolves to nothing erases the declaration rather than painting a
|
|
20
|
+
* wrong colour, a link that goes nowhere renders exactly like a link that works,
|
|
21
|
+
* and a boundary that resolves the light block on a dark page is pack-correct and
|
|
22
|
+
* looks right. None of them throws. That is the whole reason they are programs.
|
|
23
|
+
*
|
|
24
|
+
* **Why `stops` is here and not in a document.** A gate's failure message is read
|
|
25
|
+
* at the moment somebody's build is red, which is the only moment a rule about
|
|
26
|
+
* cross-repository conduct is ever going to be read by the person it is for. The
|
|
27
|
+
* consequence therefore belongs in the same string as the assertion, or it is
|
|
28
|
+
* prose again.
|
|
29
|
+
*
|
|
30
|
+
* Published at `@nanisoft/prism-ui/gates`. Not imported from `src/`, because a
|
|
31
|
+
* gate is a build-time program for a consumer's repository and must never enter a
|
|
32
|
+
* consumer's module graph or its bundle.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every law the kit can enforce, keyed by the id a consumer names in its
|
|
37
|
+
* configuration. Order is the order a reader meets them in, which is the order
|
|
38
|
+
* they were written in the contract this file replaces.
|
|
39
|
+
*/
|
|
40
|
+
export const LAWS = {
|
|
41
|
+
'retired-line': {
|
|
42
|
+
title: 'No trace of the retired component library survives.',
|
|
43
|
+
message:
|
|
44
|
+
'No trace of the retired line survives in a consumer: not a dependency, not an import, not a\n' +
|
|
45
|
+
' generated stylesheet, not a build step, and not a living instruction. A dependency line is the\n' +
|
|
46
|
+
' easy half; the lockfile is read as a graph because deleting a line does not empty a tree while\n' +
|
|
47
|
+
' another package still declares the library.',
|
|
48
|
+
why:
|
|
49
|
+
'The four repositories shipped the retired line together and each of them discovered it had to\n' +
|
|
50
|
+
' remove it separately. The rule survived the removal as prose in four files and nothing failed\n' +
|
|
51
|
+
' when one of them brought it back.',
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
'stylesheet-ownership': {
|
|
55
|
+
title: "A site stylesheet does not own a surface the design system owns.",
|
|
56
|
+
message:
|
|
57
|
+
'A site stylesheet must not own a surface the design system already owns. The cascade layer is\n' +
|
|
58
|
+
' not the cause of the failures this replaces; it is the reason they were invisible. Prism\'s base\n' +
|
|
59
|
+
' is layered and a consumer\'s sheet is not, so an unlayered declaration wins at any specificity\n' +
|
|
60
|
+
' whatever the cascade then does with it.',
|
|
61
|
+
why:
|
|
62
|
+
'An unlayered bare-element declaration of the page ground, the body ink, a focus outline or a\n' +
|
|
63
|
+
' hairline colour was live in all four repositories at once, and every one of them was a box\n' +
|
|
64
|
+
' with no edge, a white page in dark mode, or an element with no keyboard indicator, each of\n' +
|
|
65
|
+
' which is a plausible design in a screenshot.',
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
'token-read': {
|
|
69
|
+
title: 'A custom property that nothing declares is not a wrong colour; it is no declaration at all.',
|
|
70
|
+
message:
|
|
71
|
+
'Every custom property this sheet reads is declared, and that is what a shorthand with one dead\n' +
|
|
72
|
+
' operand does to itself: a declaration that is invalid at computed-value time is not a wrong\n' +
|
|
73
|
+
' colour, it is no declaration, so a page ground reverts to transparent and a hairline reverts to\n' +
|
|
74
|
+
' border-style: none. A read that cannot fail hides its own failure, which is the whole reason the\n' +
|
|
75
|
+
' runtime form of this law was written first.',
|
|
76
|
+
why:
|
|
77
|
+
'Roughly two hundred and twenty custom-property reads across the four repositories resolved to\n' +
|
|
78
|
+
' nothing on the new line, about half of them shorthands. The page ground, the body ink, the\n' +
|
|
79
|
+
' hairline and the focus outline all reverted, and a screenshot of unstyled body text is a\n' +
|
|
80
|
+
' plausible design.',
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
links: {
|
|
84
|
+
title: 'Every internal destination resolves, and every fragment names an element that exists.',
|
|
85
|
+
message:
|
|
86
|
+
'A reader followed a link on this site and arrived nowhere. Every address a reader has ever used\n' +
|
|
87
|
+
' has to keep working, and a broken internal link renders exactly like a working one, so nothing\n' +
|
|
88
|
+
' else in the build can see it.',
|
|
89
|
+
why:
|
|
90
|
+
'A migration rewrites the rendering layer and is forbidden from changing a word, so a route that\n' +
|
|
91
|
+
' quietly stopped being published is the one defect class the whole programme was at risk of\n' +
|
|
92
|
+
' shipping. This is the gate that outlived the parity baseline.',
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
'pack-boundary': {
|
|
96
|
+
title: 'A pack boundary lands on a mark, and a declared region set is the whole of what may carry a second pack.',
|
|
97
|
+
message:
|
|
98
|
+
'A pack boundary is a promise with two axes. It re-points the corner radius as well as the colour,\n' +
|
|
99
|
+
' so a boundary on anything but a fully rounded mark changes that shape, and a boundary with no\n' +
|
|
100
|
+
' mode class of its own resolves its pack in the wrong mode on half the pages a reader sees. A\n' +
|
|
101
|
+
' screenshot in one mode is not evidence for either.',
|
|
102
|
+
why:
|
|
103
|
+
'A boundary that carries no descendant form resolves the light block on a dark page, which is\n' +
|
|
104
|
+
' pack-correct, mode-inverted and indistinguishable from a design decision. And because radius\n' +
|
|
105
|
+
' is the one non-colour member of a pack block, a section ground would put the section index\n' +
|
|
106
|
+
' into that section\'s corner radius, which is an encoding nobody chose.',
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
'runtime-token-read': {
|
|
110
|
+
title: 'No token is read at runtime, and a read with a hard-coded fallback is a read that cannot fail.',
|
|
111
|
+
message:
|
|
112
|
+
'A token is resolved once at runtime and the resolved value does not follow the cascade, so a reader with\n' +
|
|
113
|
+
' a stored theme and a reader without one are served different colours from the same markup. The design\n' +
|
|
114
|
+
' system publishes the replacement, which is a token-driven inline replacement painted in the HTML: it\n' +
|
|
115
|
+
' resolves through the cascade, holds every pack, and ships no client code. The fix is deletion, not a\n' +
|
|
116
|
+
' better value.',
|
|
117
|
+
why:
|
|
118
|
+
'One site shipped a curve of numbers under a label reading as a live market feed, and the gate that should\n' +
|
|
119
|
+
' have seen it read prose while the dishonesty was arithmetic. A runtime token read is the same shape:\n' +
|
|
120
|
+
' the disagreement is between what is painted and what the cascade says, and only a program that reads\n' +
|
|
121
|
+
' the read can see it.',
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
'hidden-state': {
|
|
125
|
+
title: 'A CSS-authored hidden state is escapable, and its exit is not a clock.',
|
|
126
|
+
message:
|
|
127
|
+
'A reader with scripting switched off received opacity: 0 and no way out of it. A hidden state is\n' +
|
|
128
|
+
' allowed in exactly two shapes: an escapable condition, or a script-armed ancestor attribute. The\n' +
|
|
129
|
+
' arming attribute has one writer, the module that writes it withdraws it on every path including\n' +
|
|
130
|
+
' load, and an inlined listener covers the reader whose scripting started and stopped.',
|
|
131
|
+
why:
|
|
132
|
+
'A fallback animation\'s clock starts at first style resolution rather than at scroll, so by the\n' +
|
|
133
|
+
' time the class lands there is nothing left to cancel. Cancellability was never the\n' +
|
|
134
|
+
' load-bearing property; having an exit that does not depend on a clock was.',
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
pin: {
|
|
138
|
+
title: 'The design system is pinned exactly, and the token package is the component package\'s dependency rather than the site\'s.',
|
|
139
|
+
message:
|
|
140
|
+
'The pinned package is the whole cross-repository contract, so the pin is an exact version and the\n' +
|
|
141
|
+
' token package is not a dependency of this repository: `@nanisoft/prism-ui` declares it at an\n' +
|
|
142
|
+
' exact version, so a consumer cannot be handed a mismatched pair, and a second declaration of\n' +
|
|
143
|
+
' that number in a consumer is a second fact to keep in step with a release.',
|
|
144
|
+
why:
|
|
145
|
+
'The four repositories each declared the token version beside the component version, in a\n' +
|
|
146
|
+
' workspace configuration file as well as a manifest, so there were five places holding the\n' +
|
|
147
|
+
' same pair and no gate that could read all five. One site spent a release on the wrong line\n' +
|
|
148
|
+
' because nothing failed.',
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The ids, in the order they are declared. The order a report prints them in. */
|
|
153
|
+
export const LAW_IDS = Object.keys(LAWS)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One law by id, or a throw naming the ones that exist.
|
|
157
|
+
*
|
|
158
|
+
* A `throw` rather than a default, because a caller that misspells an id and
|
|
159
|
+
* receives an empty law gets a gate that runs and prints nothing, which is the
|
|
160
|
+
* failure this file exists to remove.
|
|
161
|
+
*/
|
|
162
|
+
export function law(id) {
|
|
163
|
+
const found = LAWS[id]
|
|
164
|
+
if (!found) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`prism-gates: unknown law "${id}". The kit enforces: ${LAW_IDS.join(', ')}.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
return { id, ...found }
|
|
170
|
+
}
|