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..148d5759
--- /dev/null
+++ b/hydra-gates/scripts/lib/test_gate_45_selector_parity.sh
@@ -0,0 +1,515 @@
+#!/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