17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.
The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.
CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.
WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
· SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
visibly working, no smooth spin.
· LegendProgressBar's only motion is a hover filter, so it simply stops; the
bar's FILL is geometry, not animation, and still reports its value.
The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.
check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.
Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.
Registered in all three places and verified by re-reading the parsed
package.json.
68 lines
3.1 KiB
JavaScript
68 lines
3.1 KiB
JavaScript
/**
|
|
* check-reduced-motion.mjs — a library component that animates must carry its own reduced-motion rule.
|
|
*
|
|
* ★ WHY IT CANNOT BE DONE ONCE, AT THE PAGE. A @media (prefers-reduced-motion) rule in the host
|
|
* document does not cross a shadow boundary, and these components render inside one. Measured while
|
|
* writing this: NO generated leaf page ships such a rule at all — 0 occurrences across the whole
|
|
* built tree — so there is not even a page rule to inherit. Every animating component owns its own.
|
|
*
|
|
* ★ WHAT COUNTS AS ANIMATING: a transition:, an animation:, or an @keyframes in the component's
|
|
* source. Motion declared in a template literal, a concatenated string or a <style> block all count
|
|
* — the mechanism varies across this library and the reader does not care which one was used.
|
|
*
|
|
* ★ THE RULE MUST LIVE WHERE THE STYLES DO. A component whose CSS is injected through
|
|
* useShadowStyles but whose reduced-motion rule sits in a <style> block has written a rule that
|
|
* never reaches the shadow root. That is the defect this repo already knows about from the other
|
|
* direction (composables/useShadowStyles.js), so it is asserted rather than trusted.
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
const ROOT = new URL('../library/', import.meta.url).pathname
|
|
const files = []
|
|
const walk = (d) => {
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
|
const p = join(d, e.name)
|
|
if (e.isDirectory()) walk(p)
|
|
else if (e.name.endsWith('.vue')) files.push(p)
|
|
}
|
|
}
|
|
walk(ROOT)
|
|
|
|
const MOTION = /transition\s*:|animation\s*:|@keyframes/
|
|
const fail = []
|
|
let animating = 0
|
|
|
|
for (const p of files) {
|
|
const src = readFileSync(p, 'utf8')
|
|
if (!MOTION.test(src)) continue
|
|
// A component whose only "motion" is the reduced-motion rule itself is not animating.
|
|
const withoutRule = src.replace(/@media\s*\(prefers-reduced-motion[^{]*\{[\s\S]*?\n\}/g, '')
|
|
if (!MOTION.test(withoutRule)) continue
|
|
animating++
|
|
const rel = p.split('/library/')[1]
|
|
|
|
if (!src.includes('prefers-reduced-motion')) {
|
|
fail.push(`${rel}: ships motion and carries NO prefers-reduced-motion rule`)
|
|
continue
|
|
}
|
|
// If the component injects through useShadowStyles, the rule must be in the injected CSS —
|
|
// a rule stranded in a <style> block would never reach the shadow root.
|
|
const injects = /useShadowStyles\s*\(/.test(src)
|
|
if (injects) {
|
|
const styleAt = src.search(/^<style/m)
|
|
const ruleIdxs = [...src.matchAll(/prefers-reduced-motion/g)].map((m) => m.index)
|
|
const anyInjected = ruleIdxs.some((i) => styleAt === -1 || i < styleAt)
|
|
if (!anyInjected) {
|
|
fail.push(`${rel}: injects via useShadowStyles but its reduced-motion rule is only in a <style> block, which is dropped in a shadow root`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fail.length) {
|
|
console.error('REDUCED-MOTION: FAIL')
|
|
for (const f of fail) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`REDUCED-MOTION: PASS — ${animating} animating library component(s), each carrying its own rule where its styles are injected.`)
|