From a8c8ba9c136a0ec57ceabac06e599bc4adfda600 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 21 Sep 2026 11:09:12 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(gate-45):=20a=20reduced-motion=20block?= =?UTF-8?q?=20that=20exists=20is=20not=20a=20fallback=20=E2=80=94=20judge?= =?UTF-8?q?=20every=20motion=20selector=20under=20the=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-45 asked one question per file: is there a `@media (prefers-reduced-motion …)` block in it? thematiq css/systems/nldesign/theme.css had one, so the gate said PASS at 6dcbbaf9 — where c495c99c had added `:not(.action-button)` to four `!important` motion selectors while the reset kept the bare `.button-vue, button, .button`. Equal specificity before (reset wins on source order), strictly less after (reset loses on every button in the app). PASS again at 67caeb85, once the selectors were mirrored back. Same verdict on the broken and the fixed file. The checker now flattens the stylesheet into rules, resolves nesting (`&`), and requires every selector carrying motion to be overridden by a rule inside a reduced-motion block under the cascade's actual rules: equal importance → more specific wins, then source order; unequal → `!important` wins. The repo-wide universal reset the pre-pass detects is therefore a `*` guard fed into the same check instead of an early exit: it covers every plain motion declaration and no `!important` one, which is what thematiq's own comment above its reset block says. Recognised without a matching selector: `no-preference` motion, and a duration token zeroed inside the reduced-motion block (the atom-design convention). Brace-less .sass falls back to the presence question. Measured with the package runner at full scope: theme.css @ 6dcbbaf9 FAIL — 3 finding(s), naming exactly the three narrowed selectors theme.css @ 67caeb85 PASS thematiq @ 7466a5a, openregister @ cabd4106, portaliq @ aa6dc05 PASS (no fleet noise) test_gate_45_selector_parity.sh pins the thematiq shape as the failing arm and every correct idiom as a control (mirrored selectors, `!important` guard over plain motion, `*` over plain vs `!important` motion, `no-preference`, duration token, `&`-nesting, `:is(.a, .b)`, `html`-prefixed guard, the repo-wide reset in another file). test_gate_45_stylesheet_scope.sh, test_gate_a11y_markup_scope.sh, test_gate_45_to_55_acceptance.sh and tests/test-hydra-gates-bin.sh stay green. Co-Authored-By: Claude Fable 5.1 --- .../lib/test_gate_45_selector_parity.sh | 376 ++++++++++++++++++ hydra-gates/scripts/run-hydra-gates.sh | 220 +++++++++- .../gate-acceptance/COVERED-ELSEWHERE.md | 2 +- 3 files changed, 578 insertions(+), 20 deletions(-) create mode 100644 hydra-gates/scripts/lib/test_gate_45_selector_parity.sh diff --git a/hydra-gates/scripts/lib/test_gate_45_selector_parity.sh b/hydra-gates/scripts/lib/test_gate_45_selector_parity.sh new file mode 100644 index 00000000..12127aac --- /dev/null +++ b/hydra-gates/scripts/lib/test_gate_45_selector_parity.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# +# test_gate_45_selector_parity.sh — gate-45 must judge the SELECTORS that carry +# motion, not the presence of a reduced-motion block somewhere in the file. +# +# WHAT THIS GUARDS (thematiq#604, 2026-09-17) +# -------------------------------------------- +# Until 2026-09-21 the checker asked one question per file: is there a +# `@media (prefers-reduced-motion …)` block in it? thematiq's +# css/systems/nldesign/theme.css had one, so the gate said PASS — at `6dcbbaf9`, +# where `c495c99c` had added `:not(.action-button)` to four `!important` motion +# selectors while the reset kept the bare `.button-vue, button, .button`. Equal +# specificity before (reset wins on source order); strictly less after (reset +# loses on every button). The gate said PASS again at `67caeb85`, after the +# selectors were mirrored back. +# +# Measured 2026-09-21 with the package runner at full scope, one variable: +# theme.css @ 6dcbbaf9 (reset no longer names the motion selectors) PASS <- the defect +# theme.css @ 67caeb85 (reset names them again) PASS +# +# Same verdict on the broken and the fixed file: the gate did not observe the +# property it is named after. The arms below are that file's shape reduced to +# its cascade essentials, plus the anti-widening controls for every correct +# idiom the fleet writes — the change turns a file-level question into a +# per-selector one, and that is precisely the kind of change that turns a gate +# into a noise generator if the controls are not pinned. +# +# P1 the thematiq shape: !important motion on `.btn:not(.x)`, reset on bare `.btn` → FAIL, names the selector +# P2 CONTROL: the same file with the reset mirroring the selectors → PASS +# P3 a LESS specific guard covers a plain motion when the guard is !important → PASS (.card covers .card:hover) +# P3b …and does NOT when both are plain: `.card:hover` beats `.card` by specificity → FAIL +# P4 a universal `*` reset covers plain motion (ARM 6 of the scope suite) → PASS +# P4b …and does NOT cover !important motion: `*` has specificity 0 → FAIL +# P5 `@media (prefers-reduced-motion: no-preference) { …motion… }` is guarded by construction → PASS +# P6 a duration token zeroed inside the reduced-motion block covers its users → PASS +# P7 the markup arm judges per selector too: a scoped +VUE + ( + cd "$1" || exit 1 + git init -q . + git add -A + git -c user.email=t@t -c user.name=t commit -qm init + ) >/dev/null 2>&1 +} + +# Written to a FILE, not a variable: `$(_run45 …)` is a subshell and an +# assignment made inside it never reaches the caller. +_LAST_LOG_PTR="${_tmp}/last-log-path" +_run45() { # _run45 -> echoes the gate-45 verdict line + local logs="${_tmp}/logs.$$.${RANDOM}" + mkdir -p "${logs}" + printf '%s' "${logs}/hydra-gate-prefers-reduced-motion.log" > "${_LAST_LOG_PTR}" + ( + cd "$1" || exit 1 + git add -A >/dev/null 2>&1 + git -c user.email=t@t -c user.name=t commit -qm wip >/dev/null 2>&1 + HYDRA_GATE_LOG_DIR="${logs}" bash "${_runner}" . 2>/dev/null + ) | grep -E '^\[gate-45\]' || true +} + +_assert() { # _assert