/* ============================================================================
 * theme/anim.css — the motion layer for the Greek portraits (theme/icons.js).
 *
 * ---------------------------------------------------------------------------
 * 🔴 WIRING — WHERE THIS FILE IS LOADED FROM. Read this before touching it.
 *
 *   index.html                 <link rel="stylesheet" href="theme/anim.css"
 *                                    data-fm-theme="anim" />   (last of three)
 *   theme/boot.js              await import('./icons-anim.js') — the module
 *                              that creates the `gk-a-*` hooks every rule here
 *                              needs. Without it this sheet matches NOTHING.
 *
 * Both were added 2026-07-28. Before that neither existed: this file and
 * icons-anim.js were complete, tested, and referenced BY NOTHING — no <link>,
 * no import, no injection anywhere in the console. The figures never moved and
 * nothing reported that they didn't. If you are reading this because motion
 * stopped, check those two call sites FIRST; grep is the whole diagnosis:
 *
 *     grep -rn "icons-anim\|anim\.css" webconsole/index.html webconsole/theme/*.js
 *
 * ---------------------------------------------------------------------------
 * 🔴 WHAT THIS IS. theme/icons.js draws the figures. This file makes a FEW of
 * them move, and theme/icons-anim.js decides WHICH few and WHEN. Nothing is
 * redrawn: the only elements this file ever gives a `transform` or an `opacity`
 * to are the bare `<g class="gk-a-…">` HOOKS that icons-anim.js wrapped around
 * elements which already existed. A hook carries no geometry of its own, so a
 * transform on a hook is pure motion.
 *
 * ---------------------------------------------------------------------------
 * 🔴🔴 THE ONE RULE THAT MUST NEVER BE BROKEN AGAIN, AND THE BUG THAT WROTE IT.
 *
 *   A rule in this file may set `animation` / `animation-play-state`.
 *   A rule in this file may set `transform` / `opacity` ONLY on a hook.
 *   NO rule in this file may EVER set `transform: none` or `opacity: <x>` on a
 *   selector that can reach artwork — and `.gk-icon *` reaches all of it.
 *
 * icons.js positions its figures with SVG `transform=` PRESENTATION ATTRIBUTES
 * (H_FIT, HELM_FIT, SEAL_FIT, and the per-figure fits and mirrors on the 18 px
 * marks). A CSS `transform` OVERRIDES a presentation attribute. So the six
 * "stop the motion" rules in this file used to read
 *
 *     … { animation: none !important; transform: none !important; }
 *
 * against `.gk-icon *`, and every one of them silently UNDID THE DRAWING it was
 * protecting. MEASURED in a browser on #/guide, default state, no reduced
 * motion, no kill-switch — i.e. what shipped:
 *
 *   • 53 sidebar marks, 49-50 of which carry a fit transform (it varies with
 *     which rail item is active): ALL of them flattened to `none`. 24-26 are
 *     mirrored with `translate(32.9 0)scale(-1 1)…`, so they lost their
 *     mirroring and FACED THE WRONG WAY. Ink-box error up to 1.49 px of
 *     position and 2.34 px of size inside an 18 px box.
 *   • under prefers-reduced-motion the 48 px header portrait lost
 *     `translate(10.2 8.6)scale(.885)`: 4.157 px of position and 4.784 px of
 *     size, i.e. ~+13 % on a 48 px figure.
 *   • the 84 px login seal lost BOTH nested fits (SEAL_FIT scale 1.2273 and
 *     H_FIT scale .885). 28 of its 139 elements changed transform and the worst
 *     ink-box error was 76.65 px — most of the seal.
 *   • `opacity: inherit` on `.gk-icon *` made group opacity COMPOUND — the
 *     header portrait's chain of six 0.95 groups resolved to 0.7351 instead of
 *     1.0, a 26.5 % fade — and it also DESTROYED explicit per-group opacity:
 *     the seal's 0.92 group read back as 1.
 *   • the same under forced-colors, under the kill-switch, and under every DENY
 *     container: a 96 px portrait planted in `td`, `pre`, `.fm-queue`,
 *     `.fm-timeline`, `.fm-verifier`, `.rp-card`, `.fm-log` or
 *     `[data-fm-noanim]` was up to 17.26 px out of position and 19.34 px out of
 *     size — all eight measured.
 *
 * AFTER: 54 icons per route x 10 routes x {default, reduced motion,
 * kill-switch} = 0.0000 px of position, 0.0000 px of size, 0 transform
 * mismatches, 0 opacity mismatches.
 *
 * 🔴 THE FIX PRINCIPLE: TO STOP MOTION, CANCEL THE ANIMATION, NOT THE TRANSFORM.
 * `animation: none` removes the animation and the element falls back to its base
 * style — which, for artwork, IS the presentation attribute. It cannot move a
 * figure by a single sub-pixel, and that is now proven by measurement rather
 * than asserted: see THE ACCEPTANCE TEST below.
 *
 * 🔴 THE ACCEPTANCE TEST, and it is not optional. A figure that this file has
 * blocked must be PIXEL-IDENTICAL to the same figure with anim.css absent from
 * the page entirely. Harness: snapshot getComputedStyle().transform and
 * getBoundingClientRect() for every element of the subtree, set
 * `link[href*=anim.css].sheet.disabled = true`, snapshot again, diff. The
 * passing number is 0.0000 px of position and 0.0000 px of size, on every
 * figure, in the default state, under prefers-reduced-motion, under the
 * kill-switch and under the mobile breakpoint. Any non-zero is this bug coming
 * back. Re-measured 2026-07-28 when this sheet was first actually LINKED (see
 * WIRING below): 0.0000 / 0.0000 in all four states, 636 elements per state.
 *
 * 🔴 THE HOOK SELECTOR IS THE ONLY IDIOM IN THIS FILE. Every stop-rule below
 * targets
 *
 *     [class^="gk-a-"], [class*=" gk-a-"]
 *
 * and NEVER `.gk-icon *`. Two forms because a hook's class attribute is
 * `"gk-a-<name> gk-ph<n> gk-sk<n>"` — the prefix form matches today's output and
 * the space form survives a reordering. Hooks are the only animated elements
 * inside a figure, which is checkable at any time with
 * `[...svg.getAnimations({subtree:true})].every(a =>
 *      /(^|\s)gk-a-/.test(a.effect.target.getAttribute('class') || ''))`.
 * Scoping to hooks also keeps the blast radius inside the drawing: the old
 * `[data-fm-noanim] *` and `.fm-hell-navmark *` rules reached every descendant
 * of those containers, so they also stopped `.fm-spinner`, `.fm-dot.pulse` and
 * the terminal caret — console UI that has nothing to do with this theme.
 *
 * ---------------------------------------------------------------------------
 * 🔴 NO NETWORK. No url(), no @import, no @font-face, no image, no external
 * host. There is not a single URL in this file. It is inert CSS.
 *
 * ---------------------------------------------------------------------------
 * 🔴 THE CONSTRAINT THAT OUTRANKS THE EFFECT.
 *
 * This is the console for 5 VPS and 3 PCs with root-capable controls. A moving
 * figure beside a failing run competes with the thing that actually matters, so
 * motion is confined to the CHROME — the login seal, page headers, product
 * headers, empty states — and it is barred from tables, logs, job timelines,
 * verifier reports, code and the queue. That bar is enforced TWICE and
 * independently:
 *
 *   1. in JS: icons-anim.js refuses to stamp `data-gk-anim` on any icon with a
 *      forbidden ancestor, and every animation below requires that attribute;
 *   2. in CSS: the HARD BLOCK section at the bottom of this file, which is a
 *      plain `animation: none !important` on every HOOK under every forbidden
 *      container and does not depend on JS running, being loaded, or being
 *      correct. 🔴 It is `animation: none` and NOTHING ELSE — see the rule at
 *      the top of this header. A block that repositions the thing it blocks is
 *      not a block, it is a second bug.
 *
 * If either layer alone is working, nothing moves where it must not. The 18 px
 * sidebar marks are covered by BOTH the tier gate (`.gk-icon--mark` never gets
 * a rule) and the hard block, because 42 figures animating permanently in an
 * operator's peripheral vision is a continuous CPU cost with no benefit.
 *
 * ---------------------------------------------------------------------------
 * 🔴 THE SIX OFF-SWITCHES, in order of authority. Any one of them stops
 * everything; none of them can be defeated by the others.
 *
 *   prefers-reduced-motion: reduce   -> `animation: none` on every hook, and on
 *        NOTHING but the hooks. That is what makes the next sentence true: a
 *        hook is a bare <g> with no transform and no opacity of its own, so
 *        cancelling its animation drops it to the identity it was born with and
 *        leaves every fit, mirror and group opacity in the artwork untouched.
 *        With motion off you get exactly the drawing icons.js emitted — 0.0000
 *        px, measured, not assumed.
 *   :root[data-ornament="off"]       -> THE kill-switch. This is what
 *   :root.hel-no-ornament               FMTheme.setOrnament('none') writes, and
 *        it is what actually fires. Drops the animation in the same paint as it
 *        drops the ornament.
 *   :root[data-fm-hellenic="none"]   -> kept only as a by-hand spelling. 🔴 It
 *        is NOT what the kill-switch writes and on its own it never fires —
 *        hellenic.js REMOVES that attribute rather than setting it to "none".
 *        This file used to list it as the kill-switch and carry it alone. See
 *        section 4.
 *   @media (max-width: 720px)        -> mobile: the header portrait this layer
 *        exists to move is display:none below that width. Same breakpoint as
 *        hellenic.js. See section 4.
 *   forced-colors: active            -> the operator's own palette wins.
 *   no [data-gk-anim] on the <svg>   -> the default. An unbound icon is inert.
 *
 * ---------------------------------------------------------------------------
 * PERFORMANCE. Every animated property here is `transform` or `opacity`, so no
 * rule in this file can trigger layout or reflow. `transform-box: fill-box` is
 * required on each hook: the figures sit inside two nested fit transforms
 * (H_FIT / HELM_FIT and SEAL_FIT), so a view-box origin would resolve in the
 * wrong coordinate space. fill-box resolves against the hook's own bounding
 * box, which is the same in every tier and every fit.
 *
 * There is deliberately NO `will-change`. An SVG sub-tree is rasterised on the
 * main thread whatever we promise, so `will-change` here buys a compositor
 * layer that is never used and costs memory per icon.
 *
 * ---------------------------------------------------------------------------
 * IDLE LOOPS vs TELLS — the trick that makes a game portrait feel alive.
 *
 *   idle  slow, low-amplitude, continuous. Sub-degree rotations and sub-unit
 *         translations, 2.9 s to 11 s. You should not be able to point at it.
 *   tell  a blink, a glance, an ear-flick, a head-turn. RARE: the active
 *         window is 0.6 % to 4 % of a 6 s–24 s cycle, so a tell lands every
 *         several seconds and never on a beat.
 *
 * 🔴 DE-SYNCHRONISATION. 42 portraits blinking in unison is uncanny and reads
 * as a rendering bug, so every hook gets its OWN period and its OWN start
 * offset. Each carries two classes from icons-anim.js — a LANE `.gk-ph0`..
 * `.gk-ph6` and a SKEW `.gk-sk0`..`.gk-sk10` — and the four values they set
 * combine as:
 *
 *     period = base * --gk-k * --gk-ks        (7 x 11 = 77 distinct periods)
 *     delay  = --gk-lag + --gk-skew           (77 distinct negative offsets)
 *
 * The lane sets the coarse pair, the skew the fine one; icons-anim.js draws
 * both from ONE counter, so the pair is unique for 77 consecutive hooks.
 *
 * 🔴 IT TOOK THREE GOES, AND EACH FAILURE WAS MEASURED IN A BROWSER OVER TEN
 * SIMULATED MINUTES, NOT REASONED ABOUT. Keep all three fixes:
 *
 *   1. SEVEN LANES, BECAUSE SEVEN IS PRIME. An eye costs TWO hooks (the lid
 *      cluster and the pupil nested in it), so consecutive eyes advance the
 *      counter by two. With six lanes, Argos Panoptes' 1st and 4th eyes landed
 *      on lane 0 together — unison, on the one character whose entire point is
 *      that they never all sleep at once. A prime lane count cannot collide
 *      under a stride of two until it has used every lane.
 *   2. ONE COUNTER, NOT TWO. Lane per hook and skew per icon, advancing at
 *      different rates, re-align on their own: measured overlap 1.00 between
 *      Panoptes and Hermes.
 *   3. SKEW MUST PERTURB THE PERIOD, NOT ONLY THE OFFSET. Two hooks on the
 *      same lane share a period exactly, and a constant offset between two
 *      identical periods either always coincides or never does — one pair
 *      landed with eye A's first blink on eye B's second, overlap 0.14. Once
 *      `--gk-ks` makes every period distinct the worst pair on a 14-eye page
 *      measures 0.055, which is the chance overlap of two 2.2 % duty cycles.
 *
 * 🔴 NO INLINE STYLE ANYWHERE. Phase is carried by a class, not by a style
 * attribute written from JS, so this whole layer survives a `style-src` CSP
 * with no 'unsafe-inline'.
 * ==========================================================================*/

/* --------------------------------------------------------------------------
 * 1. THE GATE
 *
 * Animations are declared under `[data-gk-anim]` (any value) and PAUSED under
 * `[data-gk-anim="pause"]`. That split is deliberate: pausing rather than
 * un-declaring keeps the Animation objects alive with playState "paused", so
 * (a) an icon scrolled back into view resumes mid-stride instead of snapping
 * to frame 0, and (b) the pause is provable from the console with
 * `svg.getAnimations({subtree:true}).every(a => a.playState === 'paused')`.
 * A paused CSS animation is not ticked, sampled or composited — it is 0 work.
 * ------------------------------------------------------------------------*/

/* 🔴 HOOKS ONLY — `animation-play-state` on `.gk-icon *` would be harmless, but
 * there is exactly one targeting idiom in this file so that no future edit can
 * reintroduce a wide selector and then quietly add a geometry property to it. */
.gk-icon[data-gk-anim="pause"] [class^="gk-a-"],
.gk-icon[data-gk-anim="pause"] [class*=" gk-a-"] { animation-play-state: paused !important; }

/* Every hook shares this. `fill-box` is the load-bearing line — see the header.
 *
 * 🔴 THE DELAY IS THE SUM OF THE LANE OFFSET AND THE SKEW OFFSET, and the
 * duration (section 3) is the product of the lane and skew multipliers. Both
 * halves are needed — see the de-synchronisation note in the header. */
.gk-icon[data-gk-anim] [class^="gk-a-"],
.gk-icon[data-gk-anim] [class*=" gk-a-"] {
  transform-box: fill-box;
  animation-iteration-count: infinite;
  animation-fill-mode: none;
  animation-delay: calc(var(--gk-lag, 0s) + var(--gk-skew, 0s));
}

/* --------------------------------------------------------------------------
 * 2. LANE — the coarse half of the phase pair. SEVEN, AND SEVEN IS PRIME:
 * see the header. `--gk-k` stretches the period, `--gk-lag` starts the cycle
 * already part-way through, so nothing is synchronised even on frame one.
 * Both are plain custom properties set by a class — nothing is ever written
 * into a style attribute.
 * ------------------------------------------------------------------------*/

.gk-ph0 { --gk-k: 1;     --gk-lag: -0.7s;  }
.gk-ph1 { --gk-k: 1.27;  --gk-lag: -3.9s;  }
.gk-ph2 { --gk-k: 0.83;  --gk-lag: -7.3s;  }
.gk-ph3 { --gk-k: 1.49;  --gk-lag: -11.1s; }
.gk-ph4 { --gk-k: 0.94;  --gk-lag: -15.4s; }
.gk-ph5 { --gk-k: 1.13;  --gk-lag: -19.8s; }
.gk-ph6 { --gk-k: 0.71;  --gk-lag: -24.5s; }

/* --------------------------------------------------------------------------
 * 2b. SKEW — the second half of the phase pair.
 *
 * icons-anim.js hands out lane and skew from ONE counter: lane = n % 7,
 * skew = floor(n / 7) % 11. Two hooks therefore share a lane only when their
 * indices differ by a multiple of 7, and share BOTH only when they differ by a
 * multiple of 77.
 *
 * 🔴 THERE MUST BE EXACTLY ELEVEN OF THESE, AND SKEW_LANES IN icons-anim.js
 * MUST MATCH. An undeclared class is not an error in CSS — `--gk-skew` and
 * `--gk-ks` simply fall back to their defaults, silently collapsing every skew
 * above the last declared one onto skew 0. That is precisely how Argos
 * Panoptes and Chronos measured overlap 1.00 with the counter already fixed:
 * the JS said eleven and this section said five.
 *
 * `--gk-ks` is the period multiplier and it is what actually breaks a lock:
 * two hooks with identical periods cannot be separated by an offset alone.
 * The values stay inside +/-9 % so an idle stays slow and a tell stays rare.
 * ------------------------------------------------------------------------*/

.gk-sk0  { --gk-skew: 0s;      --gk-ks: 1; }
.gk-sk1  { --gk-skew: -1.7s;   --gk-ks: 1.031; }
.gk-sk2  { --gk-skew: -3.3s;   --gk-ks: 0.967; }
.gk-sk3  { --gk-skew: -5.1s;   --gk-ks: 1.083; }
.gk-sk4  { --gk-skew: -6.7s;   --gk-ks: 0.941; }
.gk-sk5  { --gk-skew: -8.3s;   --gk-ks: 1.117; }
.gk-sk6  { --gk-skew: -9.9s;   --gk-ks: 0.913; }
.gk-sk7  { --gk-skew: -11.6s;  --gk-ks: 1.049; }
.gk-sk8  { --gk-skew: -13.1s;  --gk-ks: 0.979; }
.gk-sk9  { --gk-skew: -14.9s;  --gk-ks: 1.061; }
.gk-sk10 { --gk-skew: -16.3s;  --gk-ks: 0.997; }

/* --------------------------------------------------------------------------
 * 3. THE HOOKS
 *
 * Each hook is a <g> that icons-anim.js wrapped around elements ALREADY in the
 * drawing. The comment on each names the icons.js geometry it wraps, so a
 * change to that geometry can be traced here.
 * ------------------------------------------------------------------------*/

/* --- TELL: the blink -------------------------------------------------------
 * Wraps a detected eye cluster: the two lid arcs (which share a start point),
 * the iris ring and the pupil disc — HR_EYE_M / HR_EYE_F in icons.js, and the
 * three ornament eyes of Argos Panoptes, and the hound's eye. Collapsing the
 * cluster vertically about its own centre is what a lid closing looks like.
 * Two blinks per cycle: at ~13 s base that is one roughly every 6 s, and the
 * closed phase is 78 ms — which is a real blink, not a wink. */
.gk-icon[data-gk-anim] .gk-a-eye {
  transform-origin: 50% 50%;
  animation-name: gk-blink;
  animation-duration: calc(13s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-blink {
  0%, 40.6%   { transform: scaleY(1); }
  41.0%       { transform: scaleY(0.08); }
  41.7%       { transform: scaleY(1); }
  96.4%       { transform: scaleY(1); }
  96.8%       { transform: scaleY(0.08); }
  97.5%, 100% { transform: scaleY(1); }
}

/* --- TELL: the glance ------------------------------------------------------
 * The pupil disc only, inside the eye cluster above (transforms compose, so a
 * blink during a glance still closes correctly). 1.1 user units is the width
 * of the iris minus the pupil: the eye looks forward and comes back, it does
 * not roll. Base 23 s, so a figure glances about every 20-30 s. */
.gk-icon[data-gk-anim] .gk-a-pupil {
  transform-origin: 50% 50%;
  animation-name: gk-glance;
  animation-duration: calc(23s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: cubic-bezier(.4, 0, .3, 1);
}
@keyframes gk-glance {
  0%, 61%    { transform: translateX(0); }
  63.5%      { transform: translateX(1.1px); }
  73%        { transform: translateX(1.1px); }
  75.5%      { transform: translateX(0); }
  100%       { transform: translateX(0); }
}

/* --- IDLE: the drapery stirring --------------------------------------------
 * Odysseus. Wraps HR_DRAPE — the fold sweep across the bust and its four hem
 * ticks. 🔴 This is the FOLDS moving, not the cloak: icons.js draws the bust
 * and its drapery as one clay path, so the silhouette cannot stir. See the
 * "missing hooks" note in icons-anim.js. Amplitude 0.35 units / 0.35 deg —
 * under half a device pixel at 96 px, which is the point. */
.gk-icon[data-gk-anim] .gk-a-drape {
  transform-origin: 50% 0%;
  animation-name: gk-drape;
  animation-duration: calc(9s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-drape {
  0%, 100% { transform: translateX(0) rotate(0deg); }
  50%      { transform: translateX(0.35px) rotate(0.35deg); }
}

/* --- IDLE: the helmet crest ------------------------------------------------
 * Agamemnon and Athena. Wraps HG_CREST (clay). The pivot is the crown, where
 * the horsehair is seated, so the free tail at the nape is what moves.
 * 🔴 amplitude is held to 0.55 deg on purpose: HG_HELM_CUT (the filled glaze
 * band that separates plume from bowl) is a SEPARATE path in a different paint
 * bucket and does not move with it. At 0.55 deg the crest displaces ~0.47
 * units at the tail and ~0 at the seated end, so the cut never opens. */
.gk-icon[data-gk-anim] .gk-a-crest {
  transform-origin: 85% 89%;
  animation-name: gk-crest;
  animation-duration: calc(7.5s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-crest {
  0%, 100% { transform: rotate(-0.55deg); }
  50%      { transform: rotate(0.55deg); }
}

/* --- IDLE: the petasos wings -----------------------------------------------
 * Hermes. Wraps HG_WINGS (clay, both wings in one path) AND the two wing
 * relief lines from HGR_PETASOS, which live in a different bucket.
 * 🔴 TRANSLATE ONLY. A scaleY flutter would be the natural choice, but the two
 * groups have different bounding boxes, so a fill-box scale would move them by
 * different amounts and the relief would slip off the clay by ~1 px at 128.
 * A pure translate is the same displacement for both bounding boxes.
 *
 * 🔴 SAME DISPLACEMENT IS NOT THE SAME AS IN REGISTER, AND THIS FILE USED TO
 * CLAIM OTHERWISE. Two hooks sharing `.gk-a-wing` share the keyframes, but the
 * DURATION and the DELAY come from the `.gk-ph*`/`.gk-sk*` pair, and
 * icons-anim.js hands that pair out PER HOOK from one counter — deliberately,
 * because de-synchronisation is the point everywhere else. Measured on #/cdn:
 * the clay wing drew `gk-ph1 gk-sk9` (3907.663 ms, delay -18800 ms) and the two
 * relief lines drew `gk-ph2 gk-sk9` (2553.827 ms, delay -22200 ms), and the
 * peak |ΔtranslateY| between them was 0.5466 units at t=2289 ms — 99.4 % of the
 * 0.55 amplitude. The wing split in two.
 *
 * The fix is in icons-anim.js, not here: hooks that must move as one body
 * declare `reg: '<group>'` in the recipe and are issued ONE phase pair, so they
 * share period AND offset and Δ is 0 by construction. Anything that has to stay
 * in register with something else needs `reg`; `cls` alone will not do it. */
.gk-icon[data-gk-anim] .gk-a-wing {
  transform-origin: 50% 100%;
  animation-name: gk-wing;
  animation-duration: calc(2.9s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: cubic-bezier(.35, 0, .5, 1);
}
@keyframes gk-wing {
  0%, 100% { transform: translateY(0); }
  45%      { transform: translateY(-0.55px); }
}

/* --- TELL: the hammer striking ---------------------------------------------
 * Hephaestus. Wraps the hammer head and its haft from HA.forge; the pivot is
 * the far end of the haft, i.e. the hand. Slow lift, fast fall, one bounce.
 * The strike occupies 13 % of a 5.2 s base — about one blow every 5 s, which
 * is a smith working, not a machine. */
.gk-icon[data-gk-anim] .gk-a-hammer {
  transform-origin: 0% 100%;
  animation-name: gk-hammer;
  animation-duration: calc(5.2s * var(--gk-k, 1) * var(--gk-ks, 1));
}
@keyframes gk-hammer {
  0%, 62%   { transform: rotate(0deg); animation-timing-function: ease-in-out; }
  80%       { transform: rotate(-17deg); animation-timing-function: cubic-bezier(.7, 0, 1, .5); }
  86%       { transform: rotate(1.5deg); animation-timing-function: ease-out; }
  89.5%     { transform: rotate(-2deg); animation-timing-function: ease-in-out; }
  93%, 100% { transform: rotate(0deg); }
}

/* --- TELL: the ear twitch --------------------------------------------------
 * Argos the hound. Wraps the two ear crease lines (relief).
 * 🔴 the ear SILHOUETTE cannot move: icons.js draws ears, skull and body as one
 * clay path. This twitches the interior line only. See icons-anim.js. */
.gk-icon[data-gk-anim] .gk-a-ear {
  transform-origin: 100% 100%;
  animation-name: gk-ear;
  animation-duration: calc(8.5s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-out;
}
@keyframes gk-ear {
  0%, 87.5%  { transform: rotate(0deg); }
  89%        { transform: rotate(-2.6deg); }
  90.6%      { transform: rotate(1.4deg); }
  92.2%,100% { transform: rotate(0deg); }
}

/* --- TELL: the owl turning its head ----------------------------------------
 * Athena. Wraps the owl's facial disc, ear tufts, eye rings, pupils and beak
 * from HA.owl; the pivot is the centre of the disc. It looks away, holds, and
 * looks back — an owl's head does not sweep, it snaps and stops. */
.gk-icon[data-gk-anim] .gk-a-owlhead {
  transform-origin: 50% 60%;
  animation-name: gk-owlhead;
  animation-duration: calc(15s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: cubic-bezier(.2, .9, .3, 1);
}
@keyframes gk-owlhead {
  0%, 29%   { transform: rotate(0deg); }
  32.5%     { transform: rotate(-5deg); }
  55%       { transform: rotate(-5deg); }
  58.5%     { transform: rotate(0deg); }
  100%      { transform: rotate(0deg); }
}

/* The owl's two eyes: rings + pupils, wrapped together inside the head group
 * above, so a blink during a head-turn does both. */
.gk-icon[data-gk-anim] .gk-a-owleyes {
  transform-origin: 50% 50%;
  animation-name: gk-blink;
  animation-duration: calc(11s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}

/* --- IDLE: the loom -------------------------------------------------------
 * Penelope. Three hooks on HA.loom.
 *   warp    the three warp threads, shifting on the beam;
 *   weight  each loom weight, swinging about where its thread leaves the shed
 *           (that is why the origin is ABOVE the box: -27 %);
 *   thread  the one unpicked thread, swaying free.
 * 🔴 THERE IS NO SHUTTLE IN THE DRAWING. See icons-anim.js. */
.gk-icon[data-gk-anim] .gk-a-warp {
  transform-origin: 50% 0%;
  animation-name: gk-warp;
  animation-duration: calc(11s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-warp {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(0.3px); }
}

.gk-icon[data-gk-anim] .gk-a-weight {
  transform-origin: 50% -27%;
  animation-name: gk-weight;
  animation-duration: calc(6.8s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-weight {
  0%, 100% { transform: rotate(-0.9deg); }
  50%      { transform: rotate(0.9deg); }
}

.gk-icon[data-gk-anim] .gk-a-thread {
  transform-origin: 50% 0%;
  animation-name: gk-thread;
  animation-duration: calc(8.2s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-thread {
  0%, 100% { transform: rotate(-1.4deg); }
  50%      { transform: rotate(1.4deg); }
}

/* --- IDLE: the punt-pole dipping -------------------------------------------
 * Charon. Wraps the pole and its shoe from HA.pole; the pivot is the top of
 * the pole, where his hands are, so the shod end swings through the water.
 * 🔴 THERE IS NO FERRY IN THE DRAWING. See icons-anim.js. */
.gk-icon[data-gk-anim] .gk-a-pole {
  transform-origin: 100% 0%;
  animation-name: gk-pole;
  animation-duration: calc(6.4s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-pole {
  0%, 100% { transform: rotate(-1.7deg); }
  50%      { transform: rotate(1.7deg); }
}

/* --- IDLE: the gnomon shadow creeping --------------------------------------
 * Chronos. The three hour lines of HA.gnomon, each wrapped on its own and each
 * given a different phase lane, so the emphasis walks from one hour to the
 * next around a ~21 s base. Opacity only — no transform, because a sundial's
 * shadow does not wobble, it arrives.
 * 🔴 the shadow itself is not a separate path in the drawing; this animates
 * the hour lines it falls on. See icons-anim.js. */
.gk-icon[data-gk-anim] .gk-a-hour {
  animation-name: gk-hour;
  animation-duration: calc(21s * var(--gk-k, 1) * var(--gk-ks, 1));
  animation-timing-function: ease-in-out;
}
@keyframes gk-hour {
  0%, 8%   { opacity: 1; }
  22%      { opacity: 0.4; }
  92%      { opacity: 0.4; }
  100%     { opacity: 1; }
}

/* ==========================================================================
 * 4. THE OFF-SWITCHES
 * ========================================================================*/

/* 🔴 REDUCED MOTION. Everything stops, and the drawing is the drawing.
 *
 * 🔴 NOTE WHAT IS **NOT** HERE. There is no `transform: none` and no `opacity`.
 * This rule used to carry both, against `.gk-icon *`, and the two of them were
 * the single worst defect in the theme: `transform: none` beat the `transform=`
 * presentation attributes that POSITION the drawing, so switching motion off
 * grew the 48 px header portrait by 13 % and shifted it (-4.1,-3.6) px and
 * unmirrored 25 of the 53 sidebar marks; `opacity: inherit` made nested group
 * opacity compound to 0.7351 and overwrote explicit per-group values. See the
 * header. Cancelling the ANIMATION is sufficient and is the only thing that is
 * safe: a hook is a bare <g>, so with no animation it contributes nothing.
 *
 * The `[data-gk-anim]` qualifier is deliberately dropped here — a hook must
 * stop even if icons-anim.js left the attribute behind. */
@media (prefers-reduced-motion: reduce) {
  .gk-icon [class^="gk-a-"],
  .gk-icon [class*=" gk-a-"] { animation: none !important; }
}

/* 🔴 THE KILL-SWITCH — ALL THREE SPELLINGS, AND WHY THIS RULE USED TO BE DEAD.
 *
 * This section carried `:root[data-fm-hellenic="none"]` ALONE, and the header
 * above listed it as one of the layer's off-switches. That attribute value is
 * never written by any code path in this console. theme/hellenic.js
 * applyRootAttrs() does the opposite of what the selector expects — when the
 * ornament level is 'none' it REMOVES the attribute:
 *
 *     if (p.ornament === 'none') { root.removeAttribute('data-fm-hellenic'); … }
 *
 * so the selector matched nothing, ever. (The same dead spelling appears in
 * hellenic.css and odyssey-login.css; those rules only restyle the plain
 * console and boot.js disables both of those sheets under the kill-switch
 * anyway, so nothing there is load-bearing. Here it WAS load-bearing.)
 *
 * What FMTheme.setOrnament('none') actually writes is in boot.js step 1:
 *     <html data-ornament="off" class="hel-no-ornament">
 * Both are matched below. `data-fm-hellenic="none"` is kept as a third spelling
 * because odyssey-login.css:43 documents it as a hook an operator may set by
 * hand, and a selector that costs nothing is worth keeping for that.
 *
 * 🔴 hellenic.css hides the eyebrow WRAPPER, not the icon, so an icon mounted
 * anywhere else stays visible under the kill-switch — which is exactly how the
 * old `transform: none !important` here went unnoticed on the page header while
 * still wrecking every other surface. Animation only. */
:root[data-ornament="off"] .gk-icon [class^="gk-a-"],
:root[data-ornament="off"] .gk-icon [class*=" gk-a-"],
:root.hel-no-ornament .gk-icon [class^="gk-a-"],
:root.hel-no-ornament .gk-icon [class*=" gk-a-"],
:root[data-fm-hellenic="none"] .gk-icon [class^="gk-a-"],
:root[data-fm-hellenic="none"] .gk-icon [class*=" gk-a-"] { animation: none !important; }

/* 🔴 MOBILE. Below 720 px the 48 px header portrait — the surface this whole
 * layer exists for — is not rendered: hellenic.js's own injected sheet carries
 *
 *     @media (max-width: 720px) { .fm-hell-eyebrow .fm-hell-icon { display:none } }
 *
 * and the breakpoint here is copied from there so the two cannot drift apart.
 * The login seal is NOT hidden on a handset (odyssey-login.css:726 and :753
 * only shrink it, to 68 px then 56 px), so without this rule a phone would sit
 * on the login screen animating a seal while the surface the motion was
 * designed for is absent — spending battery on a device that has least of it.
 *
 * icons-anim.js refuses to bind at all under the same media query, so in
 * practice no hook exists here to stop; this rule is what makes that true when
 * the JS is absent, and it is the reason the JS gate can be trusted. */
@media (max-width: 720px) {
  .gk-icon [class^="gk-a-"],
  .gk-icon [class*=" gk-a-"] { animation: none !important; }
}

/* Windows High Contrast: the operator's palette wins, and so does their
 * expectation that nothing in it moves. Their palette does NOT mean we are
 * entitled to move their figures — animation only, same as every other gate. */
@media (forced-colors: active) {
  .gk-icon [class^="gk-a-"],
  .gk-icon [class*=" gk-a-"] { animation: none !important; }
}

/* ==========================================================================
 * 5. THE HARD BLOCK — independent of JS.
 *
 * 🔴 This section is the reason a bug in icons-anim.js cannot put a moving
 * figure next to a failing run. It does not read `data-gk-anim`, it does not
 * care whether the JS loaded, and `!important` means a hook rule cannot win
 * against it.
 *
 * 🔴 IT IS `animation: none` AND NOTHING ELSE, AND THAT IS THE WHOLE POINT OF
 * THE SECTION. It used to carry `transform: none !important` as well, which
 * meant the safety net was itself the largest source of visual corruption in
 * the theme: a portrait dropped into a `<td>`, a `<pre>`, `.fm-queue`,
 * `.fm-timeline`, `.fm-verifier`, `.rp-card`, `.fm-log` or `[data-fm-noanim]`
 * had its fit transform `translate(12.2 15.2)scale(.82)` flattened to `none`
 * and rendered up to 17.26 px out of position and 19.34 px out of size inside a
 * 96 px box — measured, all eight containers. Stopping a figure and MOVING a
 * figure are opposite jobs. This section only ever does the first.
 *
 * 🔴 IT TARGETS HOOKS, NOT DESCENDANTS. `[data-fm-noanim] *` and
 * `.fm-hell-navmark *` used to match every element under those containers, so
 * they also silenced `.fm-spinner` (a rotate), `.fm-dot.pulse` and the terminal
 * caret — console UI this theme has no business touching. A hook only ever
 * exists inside a figure, so the hook selector is both narrower and complete.
 *
 * TIERS. The 18 px rail marks and the 24 px mono glyphs are never animated at
 * all — not paused, not throttled, never declared. icons-anim.js cannot even
 * see them (SEL is portrait + seal), so in practice a hook never exists inside
 * one; this rule is what makes that true even if it did.
 * ========================================================================*/

.gk-icon--mark [class^="gk-a-"], .gk-icon--mark [class*=" gk-a-"],
.gk-icon--mono [class^="gk-a-"], .gk-icon--mono [class*=" gk-a-"] {
  animation: none !important;
}

/* CONTAINERS. Data surfaces, in the operator's words: tables, logs, job
 * timelines, verifier reports, code, and the queue. Plus the sidebar rail,
 * because peripheral motion is a permanent cost with no benefit.
 *
 * One line per container, both hook forms on each. The list is the same one
 * DENY carries in icons-anim.js and the two must be changed together. */
table [class^="gk-a-"], table [class*=" gk-a-"],
thead [class^="gk-a-"], thead [class*=" gk-a-"],
tbody [class^="gk-a-"], tbody [class*=" gk-a-"],
tr [class^="gk-a-"], tr [class*=" gk-a-"],
td [class^="gk-a-"], td [class*=" gk-a-"],
th [class^="gk-a-"], th [class*=" gk-a-"],
pre [class^="gk-a-"], pre [class*=" gk-a-"],
code [class^="gk-a-"], code [class*=" gk-a-"],
samp [class^="gk-a-"], samp [class*=" gk-a-"],
kbd [class^="gk-a-"], kbd [class*=" gk-a-"],
.fm-table [class^="gk-a-"], .fm-table [class*=" gk-a-"],
.fm-table-wrap [class^="gk-a-"], .fm-table-wrap [class*=" gk-a-"],
.fm-terminal [class^="gk-a-"], .fm-terminal [class*=" gk-a-"],
.fm-sidebar [class^="gk-a-"], .fm-sidebar [class*=" gk-a-"],
.fm-nav [class^="gk-a-"], .fm-nav [class*=" gk-a-"],
.fm-nav-icon [class^="gk-a-"], .fm-nav-icon [class*=" gk-a-"],
.fm-hell-navmark [class^="gk-a-"], .fm-hell-navmark [class*=" gk-a-"],
[data-fm-hell="navmark"] [class^="gk-a-"], [data-fm-hell="navmark"] [class*=" gk-a-"],
.q-table [class^="gk-a-"], .q-table [class*=" gk-a-"],
.q-backlog [class^="gk-a-"], .q-backlog [class*=" gk-a-"],
.q-accts [class^="gk-a-"], .q-accts [class*=" gk-a-"],
.q-bk-row [class^="gk-a-"], .q-bk-row [class*=" gk-a-"],
.q-row-active [class^="gk-a-"], .q-row-active [class*=" gk-a-"],
.log-out [class^="gk-a-"], .log-out [class*=" gk-a-"],
.log-cmd [class^="gk-a-"], .log-cmd [class*=" gk-a-"],
.fm-log [class^="gk-a-"], .fm-log [class*=" gk-a-"],
.fm-logs [class^="gk-a-"], .fm-logs [class*=" gk-a-"],
.fm-timeline [class^="gk-a-"], .fm-timeline [class*=" gk-a-"],
.fm-queue [class^="gk-a-"], .fm-queue [class*=" gk-a-"],
.fm-jobs [class^="gk-a-"], .fm-jobs [class*=" gk-a-"],
.fm-job [class^="gk-a-"], .fm-job [class*=" gk-a-"],
.fm-run [class^="gk-a-"], .fm-run [class*=" gk-a-"],
.fm-runs [class^="gk-a-"], .fm-runs [class*=" gk-a-"],
.fm-verify [class^="gk-a-"], .fm-verify [class*=" gk-a-"],
.fm-verifier [class^="gk-a-"], .fm-verifier [class*=" gk-a-"],
.fm-report [class^="gk-a-"], .fm-report [class*=" gk-a-"],
.rp-card [class^="gk-a-"], .rp-card [class*=" gk-a-"],
.rp-table [class^="gk-a-"], .rp-table [class*=" gk-a-"],
[data-fm-noanim] [class^="gk-a-"], [data-fm-noanim] [class*=" gk-a-"] {
  animation: none !important;
}
