doxa-maps/internal/check-reduced-motion.mjs
Doxa Maps 36f2818b37 Every animating library component now honours prefers-reduced-motion itself
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.
2026-09-29 01:38:07 +00:00

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.`)