/*
 * TradeDojo - local screen.
 *
 * Both themes are defined here from the first unit, because retrofitting a
 * light theme onto hard-coded colours is how a codebase ends up with two
 * palettes that disagree. Every colour a component uses is a token below;
 * nothing downstream writes a hex value.
 *
 * Dark is the default (a trading desk at night), light is a deliberate switch.
 * Red and green are reserved for P&L, and they mean "this number is negative"
 * or "this number is positive" - never "do something".
 *
 * Amended when the side control became a pair of buttons: red and green also
 * name the DIRECTION of an order, long or short. That is still a statement of
 * fact - which way this position points - and not an incitement, which is why
 * the two buttons are the same size, the same weight, and equally easy to
 * press. The rule the amendment does not touch: neither colour ever means
 * "act", so nothing on this screen is green because it wants to be clicked.
 * The tokens are --side-long and --side-short, distinct from --up and --down
 * precisely so that a change to the P&L colours cannot silently restyle a
 * control, or the reverse.
 *
 * Every pair here is measured, not eyeballed: tests/test_front_end.py computes
 * the WCAG ratio of each one on every run. Text clears 4.5:1 and the outline of
 * a form control clears 3:1 - which is why --text-faint and --border-strong are
 * less subtle than they would be if the choice were left to taste.
 *
 * Several tokens are translucent, because the panels are glass. That is not a
 * hole in the measurement: the test composites each one onto the surface it is
 * painted on before measuring, so the ratio describes what the eye receives.
 * Two consequences worth knowing before editing this block:
 *
 *   - --bg is the page. Nothing is painted under it, so it stays opaque.
 *     Since E02 something is painted OVER it: --scene, and two haloes above
 *     that. The colour still has to be here - it is the fallback, and it is
 *     the surface every ratio below is composited on - but on the parts of
 *     the page no panel covers, what the eye receives is the decor and not
 *     this colour. The inks that end up there are measured separately,
 *     against the decor itself, and one of them does not clear 4.5:1 under a
 *     halo. See tests/test_front_end.py, HALO_SHORTFALL.
 *   - --bg-raised is the opaque surface beneath the glass, and the fallback
 *     when backdrop-filter is unavailable or switched off. A panel that drops
 *     its blur must land on --bg-raised, never on a translucent token alone,
 *     or its text loses the background its contrast was measured against.
 */

/* Revue calibrée P-04 (MAJEUR, passe libre) : une barre fixe en HAUT
   occulte la cible de toute ancre et de tout scrollIntoView — ce qu'une
   barre au pied ne faisait jamais. Le lien d'accessibilité « Skip to
   content » amenait le titre TRANCHÉ EN DEUX derrière la barre (mesuré,
   A/B contre la base). Une règle ferme la classe entière. */
html { scroll-padding-top: var(--pad-nav-top); }

:root {
  color-scheme: dark;

  /* RESTYLE: how far down the five restyled pages start, i.e. the height of
     the deck's own two rows plus the gap under them. A LENGTH token, not a
     colour - the --pad prefix is the one the theme-parity guard already
     excludes, and there is nothing theme-dependent about a distance. The
     value written here is the pre-script frame only: deckMeasure() (app.js)
     replaces it with a real getBoundingClientRect the moment the deck is
     shown, and again on every resize. */
  --pad-deck-top: 5.75rem;

  /* P-04 : LA hauteur de la barre de navigation haute, la seule constante
     de géométrie qu'elle grave. Tout ce qui doit commencer sous elle
     (le shell, la sidebar sticky, les coques Console/deck, la feuille
     Others) lit CETTE variable — jamais une copie. Le safe-area-inset-top
     est dans la barre elle-même (son padding), donc inclus ici. */
  --pad-nav-top: calc(2.9rem + env(safe-area-inset-top));

  /* RESTYLE (chantier pages v1 -> design v2, decision d'Erwann du
     2026-08-21/22, verbatim: "surtout des neons dores derriere les touches
     en hover ou au click"). The gold behind a key, and the ONE colour this
     chantier adds. What it is allowed to say, in full: it says ACTIVE or
     HOVERED KEY - never a sign, never a market state. Red and green remain
     the sign of a number or the direction of an order, and nothing else in
     this product may borrow their job.

     Four bornes, from RESTYLE_TRACKER.md sec.3.1, none of them negotiable
     without a second word from Erwann: never at rest (opacity 0 by
     default), never on the Trade screen, never on a control that commits
     an order, never a sign. And it is a SHADOW, never ink: --neon-* is
     forbidden in `color` and in a border a reader has to read, because an
     ink has to pass the contrast audit and a glow does not.

     THE HALO IS TWO LAYERS (R8, decision d'Erwann du 2026-08-22, verbatim :
     « j'aime bien or lumineux moi, un peu des deux c'est bien »). An inner
     ring of LUMINOUS gold - what the eye reads as gold - and an outer
     edging, --neon-rim, which is what makes the ring have a body against
     the page. In DARK the two are the same gold: on a #0b0a18 page the
     luminous gold already carries the ring at 12:1, so the rim is simply
     that gold's own soft outer feather, and these four values are UNCHANGED
     from what the browser pass validated. In LIGHT the luminous gold cannot
     carry it - see the light block, where the rim becomes a deeper gold.

     Floors, per THEME and per LAYER, in
     test_the_gold_neon_is_perceptible_in_both_themes (tests/test_front_end.py). */
  --neon-gold: #f5c451;
  --neon-soft: rgba(245, 196, 81, 0.35);
  --neon-mid: rgba(245, 196, 81, 0.55);
  --neon-hot: rgba(245, 196, 81, 0.85);
  --neon-rim: rgba(245, 196, 81, 0.35);

  /* Surfaces. --bg-sunken is translucent: it darkens whatever carries it. */
  --bg: #0b0a18;
  --bg-raised: #171430;
  --bg-sunken: rgba(11, 10, 24, 0.45);
  --border: rgba(199, 205, 235, 0.16);
  --border-strong: rgba(199, 205, 235, 0.5);

  /* Text, four tones from loudest to quietest. */
  --text: #ffffff;
  --text-soft: #c3c9da;
  --text-muted: #9aa1b8;
  --text-faint: #999eb2;

  /* Meaning. Factual only: the sign of a number, the state of a feed. */
  --up: #3fd699;
  --down: #f17a89;
  --flat: #9aa1b8;
  --accent: #ac8cf9;
  --warn: #d6a860;

  /* The same two hues, tuned to be read as text rather than seen as a mark. */
  --accent-text: #c4b5fd;
  --warn-text: #f0c37e;
  --em: #ffffff;

  /* Chart tokens, handed to the charting library so it follows the theme. */
  --chart-grid: rgba(199, 205, 235, 0.07);
  --chart-text: #999eb2;
  --chart-watermark: rgba(199, 205, 235, 0.07);
  --chart-up: #3fd699;
  --chart-down: #f17a89;

  /* Indicator overlays (chart(F01)/(F02), WORKFLOWS_MULTI_AGENTS.md sec.5,
     avenant CH1). None of these are --up/--down/--warn/--accent: those four
     are reserved (the header comment above this block, and the P&L
     doctrine) for a sign or a state, never for "one more line among six on
     a chart". --indicator-volume-* are the one deliberate exception - a
     volume bar's own colour IS the sign of that candle, the same fact
     --chart-up/--chart-down already carry, so it borrows their hue (muted)
     rather than inventing an eighth one. chart(F03) adds the MACD tokens
     beside these if it ships - a token declared and read nowhere fails
     test_no_token_is_declared_and_never_read on purpose. chart(corr-L2)
     removed --indicator-donchian for the same reason: the toggle it
     painted is gone (Lea's review, no fiche backs it yet), and a declared,
     unread token fails that same test on purpose. */
  --indicator-ema-fast: #5ec8f2;
  --indicator-ema-slow: #3d6fd6;
  --indicator-bollinger: #38b2ac;
  --indicator-rsi: #e0c341;
  --indicator-volume-up: rgba(63, 214, 153, 0.5);
  --indicator-volume-down: rgba(241, 122, 137, 0.5);

  /* Glass. Every one of these is translucent by design; --bg-raised is the
     opaque surface underneath, and the fallback when blur is unavailable. */
  --glass-panel: rgba(255, 255, 255, 0.05);
  --glass-border: rgba(199, 205, 235, 0.16);
  --glass-row: rgba(255, 255, 255, 0.04);
  --glass-row-hover: rgba(255, 255, 255, 0.08);
  --glass-track: rgba(255, 255, 255, 0.08);
  /* The outline had to be firmed up when it was finally painted: a control
     edge needs 3:1, and on the stack it really sits on - a recessed well over
     glass over the decor - it was measuring 2.95. Two points of alpha. */
  --input-bg: rgba(11, 10, 24, 0.55);
  --input-border: rgba(199, 205, 235, 0.45);

  /* Identity gradient. It is a surface: it carries --on-accent, so it is
     darker than --accent, which is an ink and has to be lighter.

     JEU Z0 / correction C10 of SPEC_JEU.md 6.3 - re-checked here rather than
     retuned. The prototype's own --grad-a/--grad-b (#6d6af8/#8b5cf6) measure
     4.14:1 and 4.23:1 for white text, an AA failure the audit found on 15+
     buttons in both themes. This sheet's values are already darker than the
     prototype's - retuned before this chantier existed, for a reason this
     comment did not used to say - and the two endpoints alone were already in
     the suite's contrast list below. What was missing is the MIDDLE of the
     gradient: two passing endpoints do not prove a passing path between them.
     Computed by hand, both themes (grad-a/b do not vary by theme), WCAG
     relative luminance at each stop: the two endpoints alone give 4.601:1 at
     grad-a and 4.561:1 at grad-b. On the CONTINUOUS interpolation (no
     rounding) that path is monotonic - sRGB gamma decoding is convex - so
     its true minimum is the grad-b endpoint. A real renderer does not paint
     the continuous path: it rounds each channel to an 8-bit integer at every
     pixel, and that rounding is not monotonic (R rises, G falls with the
     heaviest WCAG weight, B barely rises). JEU-FIX2-F20: the exhaustive
     8-bit-rounded worst point is 4.5442:1 at t=0.9516 (#8459ec), strictly
     between the two endpoints - not grad-b - which still clears 4.5:1, but
     with a 0.98% margin, not 1.4%. Voie (a) of the correction - darken the
     gradient - remains satisfied; nothing here moved. The test below sweeps
     at 1/255 (8-bit resolution) rather than the coarser 2% this comment used
     to describe, so it can actually see the rounding ripple that carries the
     real worst point.
     The margin is thin, which is a decision for whoever retunes this next:
     do not lighten either stop without re-running the sweep, not just the
     endpoints. And the corollary this chantier's primitives hold to: no text
     painted on this gradient ever drops to less than full --on-accent via
     `opacity` - that is what actually broke the prototype's banner subtitle
     (12px at opacity:.85, 3.46:1) and its "PROPOSED TODAY" pill (9px on a
     translucent white fill, 3.21:1). Hierarchy on this surface is carried by
     weight and size, never by dimming the ink toward the background. */
  --grad-a: #6664e9;
  --grad-b: #8558ec;
  --on-accent: #ffffff;

  /* The page behind the panels. */
  --scene: radial-gradient(1200px 680px at 4% -12%, #33236d 0%, rgba(51, 35, 109, 0) 55%),
           radial-gradient(1000px 620px at 106% 116%, #421f88 0%, rgba(66, 31, 136, 0) 55%),
           linear-gradient(160deg, #141031 0%, #0b0a18 55%, #110d2a 100%);
  --orb-opacity: 1;

  /* The two haloes that drift over the scene. Each is a whole gradient rather
     than a colour, exactly like --scene above, for a reason worth keeping: the
     fade has to end on the same hue at zero alpha, and spelling that end
     `transparent` fades through transparent BLACK in the engines that follow
     the older rule - a grey bruise around a violet halo. The hue is identical
     in both themes; only --orb-opacity tells them apart. */
  --orb-a: radial-gradient(circle, rgba(109, 106, 248, 0.18), rgba(109, 106, 248, 0) 70%);
  --orb-b: radial-gradient(circle, rgba(139, 92, 246, 0.14), rgba(139, 92, 246, 0) 70%);

  /* Direction of a trade, and the ink that sits on it. */
  --side-long: #22c07e;
  --side-long-ink: #04150d;
  --side-short: #e0596e;
  --side-short-ink: #1c0509;

  /* Verdicts. Deliberately the same hues as --up/--warn/--down: a verdict is
     a statement of fact, not a fourth vocabulary. */
  --verdict-supported: #3fd699;
  --verdict-mixed: #d6a860;
  --verdict-refuted: #f17a89;
  --verdict-none: #c2c9d8;

  /* L2: the Laboratory's own seven, lessons.VERDICTS - the SAME hues as
     --up/--warn/--accent/--down/--text-faint, byte for byte (never a new
     colour, and never a var() reference either - the existing four verdict
     tokens just above already set that precedent). Test_the_ink_on_a_
     glass_panel_clears_its_floor's own comment says why no new INK_ON_GLASS
     entry is needed: a token worth the SAME value as one already on that
     list is covered the moment that one is, checked rather than assumed. */
  --verdict-validated: #3fd699;
  --verdict-not-yet: #d6a860;
  --verdict-ambiguous: #d6a860;
  --verdict-other-lesson: #ac8cf9;
  --verdict-no-line: #f17a89;
  --verdict-curriculum-complete: #3fd699;
  --verdict-no-curriculum: #999eb2;

  /* Emphasis washes and rings. Surfaces and outlines of state -
     --ring-accent-strong doubles as the global :focus-visible outline
     (style.css, "The focus ring is the one outline that has to beat every
     other state"), which is why F8 (jeu-fix, BLOQUANT, jeu-socle-lea.md)
     holds it to WCAG 1.4.11's 3:1 floor for a non-text UI indicator, not
     just the 4.5:1 text floor the rest of this block answers to.
     0.78 measured 3.04:1 at its worst surface (--bg-raised) - technically
     over the floor, close enough to fail on any future rounding of the
     surfaces it sits on. Raised to 0.9: worst point 3.65:1
     (test_every_colour_pair_meets_its_contrast_floor, test_front_end.py),
     same hue, more of it - exactly what ":focus-visible"'s own comment
     already says the strongest ring in the set is for. */
  --wash-accent: rgba(139, 92, 246, 0.14);
  --ring-accent: rgba(139, 92, 246, 0.42);
  --ring-accent-strong: rgba(139, 92, 246, 0.9);
  --shadow-accent: rgba(109, 106, 248, 0.38);
  --wash-up: rgba(63, 214, 153, 0.12);
  --ring-up: rgba(63, 214, 153, 0.4);
  --wash-warn: rgba(214, 168, 96, 0.12);
  --ring-warn: rgba(214, 168, 96, 0.42);
  /* --scrim was removed at E15 and returns here (JEU Z0, jeu-Z0-4), in the
     same commit as the rule that spends it - the day the E15 comment named
     as the only honest way back. What changed: this chantier's node sheet
     and full-screen practice session (.jeu-sheet / .jeu-session below) ARE
     the modal dialog E15 said this product did not have. The one
     confirmation the account ticket asks for stays an inline block - that
     doctrine does not move - but a sheet that covers the path behind it, or
     a session that replaces the screen for the length of twelve flashcards,
     needs the backdrop to read as inert while it is up, and a scrim is what
     says so without a word.
     The values are this chantier's own, not the prototype's - the audit
     (docs/reviews/jeu-audit-gardes.md, piege 5) measured the prototype's
     tokens against this sheet's own contrast harness and found 20 of them
     under floor, so nothing here is aligned to that source. Dark: derived
     from --bg itself, so the veil reads as the room going quiet rather than
     an alien black flash. Light: derived from --em, this sheet's own near-
     black ink, at an alpha gentle enough not to read as an error state. No
     ink is ever painted ON a scrim - it carries no text - so no contrast
     floor applies to it the way one applies to every token above. */
  --scrim: rgba(11, 10, 24, 0.6);

  /* The lab keeper. Illustration fills: no text sits on any of them. */
  --keeper-body: #f0aab0;
  --keeper-body-2: #d4868f;
  --keeper-gill: #f2778f;
  --keeper-coat: #f4f6fd;
  --keeper-coat-line: #a8adc6;
  --keeper-ink: #241c46;
  --keeper-blush: #e59aa6;
  --keeper-glass: rgba(226, 232, 255, 0.34);

  /* Type and rhythm. Rewritten against the maquette rather than nudged: the
     three numbers below were the ones this sheet shipped with before the first
     refonte and they never moved, so counting was the only honest way to set
     them.

     --gap is the layout gutter. The proto writes it 27 times as 13px and the
     second export 11 times as 14px, always on the container that lays panels
     out; 14px is the later of the two sources and the one whose file is a
     stylesheet rather than inline attributes. Was 16px.

     --pad is what a panel holds its content in. The proto's glass blocks run
     13 to 17px with a mode of 14x15, so 15px. Was 20px.

     --radius goes the OTHER WAY, and that is worth saying because the unit is
     called "tighten". On radius the proto is looser than us, not tighter: 204
     border-radius declarations with a mode of 9px for controls, against our
     6px. Following the maquette here means growing. The panel radius is
     separate and already right - E03 set it to 16px, which is the proto's
     mode for a panel.

     The body stays at 15px/1.55. It is NOT an écart despite what the tracker
     row said: the second export declares exactly `font:15px/1.55 var(--font-ui)`
     on body, and this sheet has carried that byte for byte since before the
     boucle. The row has been corrected.

     One mechanical fact that bounds all of this: no rule sets a font-size on
     `html`, so the rem is pinned at 16px and nothing here rescales with the
     body. Every rem below is an absolute measurement in disguise. */
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  /* JEU Z3-magazine (M2a, decision de cadrage 2026-08-02): the fiche's own
     reading face. Georgia, never an embedded binary and never a CDN link -
     REVIEW-DELTA sec.1 proposed the prototype's "Source Serif 4" -> Georgia
     -> "Iowan Old Style" chain; the first link is a Google Fonts request
     this product refuses (rule 1 of this file's own header: nothing here
     depends on a network fetch to render). The gap in rendering against the
     prototype is assumed, not hidden. Declared once, in :root only, same
     convention as --font-ui/--font-mono directly above: a font stack does
     not vary by theme, so it is exempt from
     test_both_themes_define_exactly_the_same_tokens (the "--font" prefix)
     rather than duplicated into the light block for no reason. */
  --font-read: Georgia, "Iowan Old Style", serif;
  --radius: 9px;
  --radius-panel: 16px;
  --gap: .875rem;
  --pad: .9375rem;

  /* JEU Z3-magazine (M2a): the fiche's own reading ink, distinct from
     --text/--text-soft - REVIEW-DELTA sec.1's second new token. This one DOES
     vary by theme (a colour, not a font stack), so it is declared in both
     blocks and carries its own contrast measurement rather than reusing an
     existing pair by assumption. Measured against --bg-raised (the panel's
     opaque fallback) at 11.70:1, and against #382b6a (WORST_GLASS_SURFACE,
     test_front_end.py - the darkest real point a glass panel actually
     rests on) at 8.01:1: comfortably over the 4.5:1 floor either way the
     fiche's own .panel ends up painted. Same value the prototype shipped
     (#ccd1e3) - re-measured here rather than trusted, and it holds. */
  --text-read: #ccd1e3;

  /* L1 (fidelite chantier), the Laboratory's bench. Named per the PHOTO they
     point to rather than per which theme is active - both tokens exist in
     BOTH blocks with the SAME url() in each (the prototype's own
     IMAGE_TOKENS shape, IMPLEMENTATION_PREP sec.1) - the theme decides which
     one a rule actually PAINTS (`#labo-bench`/`:root[data-theme="light"]
     #labo-bench`, further down this sheet), the same division of labour
     --bg/--scene already keep between "what exists" and "what is active".
     No CDN, no embedded binary: served from this app's own /img/, vendored
     and budgeted by tests/test_static_assets_budget.py (A0). */
  --bench-dark: url("/img/labo-bench-dark.webp");
  --bench-light: url("/img/labo-bench-light.webp");
  /* The scrim the bench header's own text sits on, over a real photograph
     rather than a flat token - --bg-sunken (already used for text-over-decor
     elsewhere in this sheet) was measured and rejected for THIS surface: its
     existing contrast pairs are proven against --bg, a flat colour, never
     against an arbitrary photograph, and the worst theoretical pixel a photo
     can offer (pure white under a dark scrim, pure black under a light one)
     dropped --text-faint under 4.5:1 (3.90:1 light, computed by hand before
     writing this token) even where --text/--text-muted held. Derived from
     --bg's own RGB per theme (the same convention --scrim documents for
     itself, two entries up), at an alpha chosen so --text-muted clears
     4.5:1 against the WORST possible photograph, not merely the real one -
     5.34:1 dark / 5.16:1 light at pure white/black, computed by hand;
     re-measured against the real photos in a browser before commit. The
     header therefore only ever sets --text/--text-muted on this scrim, never
     --text-faint (residual: a future header line that needs --text-faint
     here would need either a stronger scrim or its own measurement, not an
     assumption that this one covers it). */
  --bench-scrim: rgba(11, 10, 24, 0.86);

  /* The letter's own paper - cream in BOTH app themes (a page does not
     change colour when the room's lights do), so --paper/--paper-ink/
     --paper-muted read identically here and in the light block below;
     declared in both anyway, never exempted, because
     test_both_themes_define_exactly_the_same_tokens compares token NAMES
     and would not know "this one never varies" from a coincidence of value.
     K-L1p3-7 (Karim revue L1 passe 3, 2026-08-22): it did not know it
     either - `--paper` was #fffaf0 in the light block while all three
     comments claimed one colour. The claim is now held by name, by
     test_the_letters_paper_is_the_same_cream_in_both_themes
     (tests/test_front_end.py).
     Measured: 13.27:1 (--paper-ink on --paper, this block's own values,
     opaque colours composited directly - no page beneath them to blend
     with, unlike a translucent surface). Same pair the prototype shipped
     (#f4e9d8/#2a2018), re-measured here rather than trusted. */
  --paper: #f4e9d8;
  --paper-ink: #2a2018;
  /* The paper's own quiet tone (a caption, an eyebrow) - never --text-faint/
     --text-muted on this surface: both are tuned for the app's OWN dark/
     light backgrounds, and --text-muted's dark-theme value (#9aa1b8, a pale
     blue-grey meant for a DARK surface) reads at a crushing ~2:1 on cream
     paper. Measured before picking this value: #7a6a53 (the prototype's own
     --paper-muted) gave 4.36:1 here, under the 4.5:1 floor by enough that
     rounding could not be trusted; darkened to #6a5c46, which clears
     5.41:1. */
  --paper-muted: #6a5c46;

  /* fidelite.C3 - the touch of an instrument (CONSOLE_TRACKER sec.2/3).
     Every colour the dials, the rail and the key paint, named here so the
     component rules below read tokens only. Knobs are CSS (K-A0-7): a
     brushed cap on a bevelled body, the prototype's own values. */
  --knob-face:
    radial-gradient(circle at 50% 35%, rgba(255, 255, 255, .14), transparent 60%),
    conic-gradient(from 0deg, #3a3d4c, #24262f 25%, #3a3d4c 50%, #24262f 75%, #3a3d4c);
  --knob-shadow:
    0 3px 7px rgba(0, 0, 0, .55),
    0 1px 0 rgba(255, 255, 255, .12),
    inset 0 1px 1px rgba(255, 255, 255, .18),
    inset 0 -2px 3px rgba(0, 0, 0, .5);
  --knob-cap: radial-gradient(circle at 50% 40%, rgba(255, 255, 255, .08), rgba(0, 0, 0, .25));
  --knob-puck: #cfd2df;
  --knob-puck-hl: #ffffff;
  --knob-puck-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, .5));
  --rail-shadow: inset 0 1px 3px rgba(0, 0, 0, .45), inset 0 -1px 0 rgba(255, 255, 255, .06);
  --rail-key-shadow:
    0 1px 0 rgba(255, 255, 255, .35) inset,
    0 -2px 3px rgba(0, 0, 0, .25) inset,
    0 2px 4px rgba(0, 0, 0, .35),
    0 0 0 1px rgba(0, 0, 0, .15);
  --lamp-off: rgba(0, 0, 0, .3);
  --lamp-off-shadow: inset 0 1px 1px rgba(0, 0, 0, .4);
  --lamp-on: #ffffff;
  --lamp-on-glow: 0 0 4px rgba(255, 255, 255, .85);
  --plate-shadow: inset 0 1px 2px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .05);
  --touch-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .35),
    0 2px 0 rgba(0, 0, 0, .45),
    0 4px 8px rgba(0, 0, 0, .35);
  --touch-shadow-hover:
    inset 0 1px 0 rgba(255, 255, 255, .35),
    0 2px 0 rgba(0, 0, 0, .45),
    0 7px 14px rgba(0, 0, 0, .4);
  --touch-shadow-down:
    inset 0 1px 0 rgba(255, 255, 255, .2),
    0 0 0 rgba(0, 0, 0, .45),
    0 1px 2px rgba(0, 0, 0, .35);

  /* fidelite.C4 - the shell's MATTER (prototype tokens, A0's vendored
     textures; the gradients alone paint if a file is missing - the same
     pure-CSS fallback the bench already practices). */
  --tex-shell:
    linear-gradient(180deg, rgba(10, 9, 20, .40), rgba(8, 7, 16, .62)),
    url("/img/console-tex-graphite.webp");
  --tex-seam:
    linear-gradient(180deg, rgba(30, 27, 50, .30), rgba(10, 9, 20, .58)),
    url("/img/console-tex-microbead.webp");
  --tex-face:
    linear-gradient(180deg, rgba(11, 10, 24, .84), rgba(11, 10, 24, .90)),
    url("/img/console-tex-graphite.webp");
  --face-vignette: radial-gradient(130% 100% at 50% 0%, rgba(0, 0, 0, 0) 62%, rgba(0, 0, 0, .16) 100%);
  --orb-metal: conic-gradient(from 210deg, #b9bcc6, #6f727c 12%, #e2e4ea 26%, #7f828b 41%, #c7cad3 55%, #686b74 70%, #dfe1e8 86%, #b9bcc6);
  --orb-metal-shadow:
    0 4px 9px rgba(0, 0, 0, .5),
    0 1px 0 rgba(255, 255, 255, .14),
    inset 0 1px 1px rgba(255, 255, 255, .55),
    inset 0 -2px 3px rgba(0, 0, 0, .4);
  --orb-cap: radial-gradient(circle at 34% 28%, #3b3750 0%, #221f33 46%, #14121f 100%);
  --orb-cap-shadow:
    inset 0 3px 5px rgba(0, 0, 0, .65),
    inset 0 -1px 1px rgba(255, 255, 255, .08),
    0 1px 1px rgba(255, 255, 255, .16);
  --face-seat-shadow: inset 0 2px 6px rgba(0, 0, 0, .28), inset 0 -1px 0 rgba(255, 255, 255, .05);
}

:root[data-theme="light"] {
  color-scheme: light;

  /* RESTYLE: see the dark block's comment for the doctrine this token
     carries, and for the TWO LAYERS this block is the whole reason for.

     Trois etalonnages, et le troisieme est une DECISION, pas une mesure.

     1. Or pale (#a9741a, alphas .40/.62/.90), pose a l'aveugle en U0.
        Mesure au navigateur par la passe claire (U7), contre --bg #eef0f7 :
        3.55 (gold), 3.08 (hot), 2.10 (mid), 1.58 (soft), la ou le sombre
        rend 12.04 / 8.82 / 4.20 / 2.34 contre le sien. Le `0 0 0 1px` du
        survol - le trait qui FAIT le halo - a 1.58:1, ne se voyait pas.
     2. Or profond (#8a5a06, alphas .55/.78/.95), pose par U7 : 5.20 / 4.72
        / 3.42 / 2.27. Le halo existait enfin sur le papier, au prix de
        l'eclat : ce n'etait plus de l'or lumineux, c'etait du brun dore.
     3. CELUI-CI. Decision d'Erwann du 2026-08-22, verbatim : « j'aime bien
        or lumineux moi, un peu des deux c'est bien ». Alors les deux, mais
        pas melanges - EMPILES, chacun a son travail :

        --neon-gold/hot/mid/soft = le COEUR LUMINEUX, ce que l'oeil lit
          comme de l'or. La teinte est #d29810, et elle n'est pas choisie a
          l'oeil : c'est le point median en LUMINANCE RELATIVE entre l'or
          lumineux du theme sombre (#f5c451, Y = 0.5949) et l'or profond de
          U7 (#8a5a06, Y = 0.1273) - Y = 0.3620 pour une cible de 0.3611.
          « Un peu des deux », au sens exact. Teinte 42 deg et saturation
          0.86 : la meme famille que #f5c451 (42 deg, 0.89), pas un brun.
          Les alphas montent (.95 / .85 / .70 contre .85 / .55 / .35 en
          sombre) parce que sur du papier une lueur diluee n'est plus une
          lueur.
        --neon-rim = le LISERE EXTERIEUR, et lui seul porte la
          perceptibilite. Il garde l'or profond de U7 (#8a5a06) et monte a
          .85 : 3.92:1 sur --bg, 4.29 sur --bg-raised, soit BIEN AU-DESSUS
          de ce que U7 avait obtenu de son meilleur ton (2.27 / 2.39). Le
          halo clair est donc plus present qu'apres U7, et il est dore
          lumineux. C'est la seule facon d'avoir les deux.

     Mesures de ce bloc, contre --bg / --bg-raised :
       --neon-gold 2.24 / 2.55   (U7 : 5.20 / 5.92 ; U0 : 3.55 / 4.04)
       --neon-hot  2.16 / 2.43   (U7 : 4.72 / 5.32 ; U0 : 3.08 / 3.43)
       --neon-mid  1.99 / 2.20   (U7 : 3.42 / 3.72 ; U0 : 2.10 / 2.23)
       --neon-soft 1.76 / 1.89   (U7 : 2.27 / 2.39 ; U0 : 1.58 / 1.64)
       --neon-rim  3.92 / 4.29   (le trait qui dessine : +73 % sur U7)

     ET LE SENS DES GARDES CHANGE AVEC, ce qui est le point a ne pas
     manquer : un PLANCHER de contraste ne peut pas tenir « reste
     lumineux » - foncer un ton fait MONTER son contraste, donc un plancher
     seul laisse repasser l'or profond en silence. Alors les gardes sont de
     deux sortes, et test_front_end.py les porte toutes les deux :
       - le LISERE a des planchers (3.5 / 3.8), plus une garde de
         non-regression qui epingle les chiffres de U7 ;
       - le COEUR a des planchers BAS (il n'a plus a porter la forme) et,
         surtout, un plancher de LUMINANCE et un test de famille de teinte :
         c'est la decision d'Erwann rendue falsifiable. */
  --neon-gold: #d29810;
  --neon-soft: rgba(210, 152, 16, 0.70);
  --neon-mid: rgba(210, 152, 16, 0.85);
  --neon-hot: rgba(210, 152, 16, 0.95);
  --neon-rim: rgba(138, 90, 6, 0.85);

  --bg: #eef0f7;
  --bg-raised: #ffffff;
  --bg-sunken: rgba(233, 231, 246, 0.75);
  --border: rgba(70, 60, 140, 0.18);
  --border-strong: rgba(70, 60, 140, 0.62);

  --text: #191c2b;
  --text-soft: #3d445e;
  --text-muted: #4e5570;
  --text-faint: #5d6480;

  --up: #0d825a;
  --down: #c43f55;
  --flat: #4e5570;
  --accent: #6d3df1;
  --warn: #966a25;

  --accent-text: #5b3fc4;
  --warn-text: #8a6220;
  --em: #10131f;

  --chart-grid: rgba(70, 60, 140, 0.12);
  --chart-text: #5d6480;
  --chart-watermark: rgba(35, 30, 80, 0.07);
  --chart-up: #0d825a;
  --chart-down: #c43f55;

  /* Same keys as the dark theme, darkened for a light background the way
     --up/--down/--verdict-* already are above. */
  --indicator-ema-fast: #0e7bab;
  --indicator-ema-slow: #1d4f9e;
  --indicator-bollinger: #157b73;
  --indicator-rsi: #8a6d10;
  --indicator-volume-up: rgba(13, 130, 90, 0.35);
  --indicator-volume-down: rgba(196, 63, 85, 0.35);

  --glass-panel: rgba(255, 255, 255, 0.8);
  --glass-border: rgba(70, 60, 140, 0.18);
  --glass-row: rgba(70, 60, 140, 0.05);
  --glass-row-hover: rgba(70, 60, 140, 0.1);
  --glass-track: rgba(70, 60, 140, 0.14);
  --input-bg: rgba(255, 255, 255, 0.92);
  --input-border: rgba(70, 60, 140, 0.61);

  --grad-a: #6664e9;
  --grad-b: #8558ec;
  --on-accent: #ffffff;

  --scene: radial-gradient(1200px 680px at 4% -12%, #ded8f6 0%, rgba(222, 216, 246, 0) 55%),
           radial-gradient(1000px 620px at 106% 116%, #e7dcfa 0%, rgba(231, 220, 250, 0) 55%),
           linear-gradient(160deg, #f7f6fd 0%, #eef0f7 55%, #f3effc 100%);
  --orb-opacity: 0.4;

  /* Same two hues as the dark theme, by design: --orb-opacity is what makes
     them whisper here instead of glow. */
  --orb-a: radial-gradient(circle, rgba(109, 106, 248, 0.18), rgba(109, 106, 248, 0) 70%);
  --orb-b: radial-gradient(circle, rgba(139, 92, 246, 0.14), rgba(139, 92, 246, 0) 70%);

  --side-long: #0d865d;
  --side-long-ink: #ffffff;
  --side-short: #c43f55;
  --side-short-ink: #ffffff;

  --verdict-supported: #0d825a;
  --verdict-mixed: #966a25;
  --verdict-refuted: #c43f55;
  --verdict-none: #707584;

  /* L2: the Laboratory's own seven - same hues as the dark theme's own
     comment explains, this theme's own --up/--warn/--accent/--down/
     --text-faint values. */
  --verdict-validated: #0d825a;
  --verdict-not-yet: #966a25;
  --verdict-ambiguous: #966a25;
  --verdict-other-lesson: #6d3df1;
  --verdict-no-line: #c43f55;
  --verdict-curriculum-complete: #0d825a;
  --verdict-no-curriculum: #5d6480;

  --wash-accent: rgba(109, 106, 248, 0.1);
  --ring-accent: rgba(109, 106, 248, 0.34);
  /* F8 (jeu-fix, BLOQUANT, jeu-socle-lea.md): 0.62 on the light theme's own
     rgb(109,106,248) measured 2.05-2.28:1 against this theme's three
     surfaces - well under WCAG 1.4.11's 3:1 floor for the non-text focus
     ring this token doubles as (see the dark block's own comment on the
     same token). Alpha alone could not fix it even at 1.0 (3.37:1, no
     margin): this base rgb reads too pale against a near-white theme
     regardless of opacity. Darkened toward the hue --border-strong
     already uses on this theme (rgba(70,60,140,0.62), already proven at
     this exact 3:1 floor) rather than invented cold: worst point now
     3.86:1 (test_every_colour_pair_meets_its_contrast_floor). */
  --ring-accent-strong: rgba(70, 66, 220, 0.8);
  --shadow-accent: rgba(109, 106, 248, 0.26);
  --wash-up: rgba(14, 143, 99, 0.1);
  --ring-up: rgba(14, 143, 99, 0.32);
  --wash-warn: rgba(185, 131, 46, 0.1);
  --ring-warn: rgba(185, 131, 46, 0.34);
  /* See the dark block for the arbitrage this token reopens (JEU Z0). This
     value is derived from --em, the light theme's own near-black ink. */
  --scrim: rgba(16, 19, 31, 0.35);

  --keeper-body: #e2919c;
  --keeper-body-2: #bf6a78;
  --keeper-gill: #d94e6e;
  --keeper-coat: #ffffff;
  --keeper-coat-line: #8f93ab;
  --keeper-ink: #1d1638;
  --keeper-blush: #cf7b8b;
  --keeper-glass: rgba(120, 130, 170, 0.16);

  /* JEU Z3-magazine (M2a): see the dark block's own comment for the token's
     purpose and measurement method. Light-theme value, also the
     prototype's own (#2d3252) - re-measured, not trusted: 12.43:1 against
     --bg-raised, 11.76:1 against #f9f8fd (WORST_GLASS_SURFACE, light). */
  --text-read: #2d3252;

  /* See the dark block for what these mean and how they were measured.
     --bench-dark/--bench-light: identical url()s in both blocks on purpose
     (IMAGE_TOKENS shape) - the theme decides which one paints, not which one
     exists. --bench-scrim: derived from THIS theme's own --bg (238, 240,
     247), alpha 0.90 rather than the dark block's 0.86 - --text-muted's
     margin against a pure-black photo pixel was thinner here (4.91:1 at
     0.86) than the dark block's own worst case, computed by hand before
     picking the higher alpha. --paper/--paper-ink/--paper-muted: same cream,
     same ink as the dark block - the letter does not change colour with the
     room.

     K-L1p3-7 (Karim revue L1 passe 3, 2026-08-22, mineur): that sentence
     was FALSE for one of the three. `--paper` shipped as #fffaf0 here
     against #f4e9d8 in the dark block - two creams, not one - while three
     separate comments (this one, the dark block's, and .labo-paper's in
     index.html) all claimed a single colour, and
     test_both_themes_define_exactly_the_same_tokens compares token NAMES,
     never values. Corrected TOWARDS the prose and towards D4's own "papier
     creme": #f4e9d8, the value the prototype shipped and the one the dark
     block has measured (13.27:1 for --paper-ink, 5.41:1 for --paper-muted -
     the same numbers now hold in both themes, since all three values are
     identical). Held by
     test_the_letters_paper_is_the_same_cream_in_both_themes
     (tests/test_front_end.py); the per-theme contrast floors keep their own
     guard beside it. Decision taken alone and journalled "a relire": it is
     a VISIBLE colour, so Lea/Erwann may still want a different cream under
     the light theme - in which case it is the three comments that change,
     and this guard that goes away. */
  --bench-dark: url("/img/labo-bench-dark.webp");
  --bench-light: url("/img/labo-bench-light.webp");
  --bench-scrim: rgba(238, 240, 247, 0.90);
  --paper: #f4e9d8;
  --paper-ink: #2a2018;
  --paper-muted: #6a5c46;

  /* fidelite.C3: the same instruments under light - a pale cap, the
     prototype's own light values; the shadows soften, the rail and the
     key keep their geometry. */
  --knob-face:
    radial-gradient(circle at 50% 35%, rgba(255, 255, 255, .55), transparent 60%),
    conic-gradient(from 0deg, #e9eaf1, #c9ccd8 25%, #e9eaf1 50%, #c9ccd8 75%, #e9eaf1);
  --knob-shadow:
    0 3px 7px rgba(35, 30, 80, .30),
    0 1px 0 rgba(255, 255, 255, .8),
    inset 0 1px 1px rgba(255, 255, 255, .7),
    inset 0 -2px 3px rgba(0, 0, 0, .28);
  --knob-cap: radial-gradient(circle at 50% 40%, rgba(255, 255, 255, .35), rgba(0, 0, 0, .12));
  --knob-puck: #ffffff;
  --knob-puck-hl: #ffffff;
  --knob-puck-shadow: drop-shadow(0 1px 2px rgba(35, 30, 80, .35));
  --rail-shadow: inset 0 1px 3px rgba(35, 30, 80, .25), inset 0 -1px 0 rgba(255, 255, 255, .5);
  --rail-key-shadow:
    0 1px 0 rgba(255, 255, 255, .5) inset,
    0 -2px 3px rgba(0, 0, 0, .18) inset,
    0 2px 4px rgba(35, 30, 80, .25),
    0 0 0 1px rgba(35, 30, 80, .12);
  --lamp-off: rgba(35, 30, 80, .18);
  --lamp-off-shadow: inset 0 1px 1px rgba(35, 30, 80, .25);
  --lamp-on: #ffffff;
  --lamp-on-glow: 0 0 4px rgba(255, 255, 255, .9);
  --plate-shadow: inset 0 1px 2px rgba(35, 30, 80, .15), 0 1px 0 rgba(255, 255, 255, .6);
  --touch-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .45),
    0 2px 0 rgba(35, 30, 80, .35),
    0 4px 8px rgba(35, 30, 80, .22);
  --touch-shadow-hover:
    inset 0 1px 0 rgba(255, 255, 255, .45),
    0 2px 0 rgba(35, 30, 80, .35),
    0 7px 14px rgba(35, 30, 80, .26);
  --touch-shadow-down:
    inset 0 1px 0 rgba(255, 255, 255, .3),
    0 0 0 rgba(35, 30, 80, .35),
    0 1px 2px rgba(35, 30, 80, .22);

  /* fidelite.C4: the same instruments under light - aluminium and the
     pale bead, the prototype's own light layers. */
  --tex-shell:
    linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(186, 189, 203, .22)),
    url("/img/console-tex-aluminium.webp");
  --tex-seam:
    linear-gradient(180deg, rgba(255, 255, 255, .30), rgba(200, 203, 218, .34)),
    url("/img/console-tex-light.webp");
  --tex-face:
    linear-gradient(180deg, rgba(255, 255, 255, .66), rgba(250, 250, 253, .58)),
    url("/img/console-tex-aluminium.webp");
  --face-vignette: radial-gradient(130% 100% at 50% 0%, rgba(0, 0, 0, 0) 66%, rgba(35, 30, 80, .07) 100%);
  --orb-metal: conic-gradient(from 210deg, #e9eaf1, #c2c5d1 12%, #ffffff 26%, #cdd0da 41%, #f2f3f7 55%, #babdc9 70%, #ffffff 86%, #e9eaf1);
  --orb-metal-shadow:
    0 3px 7px rgba(35, 30, 80, .30),
    0 1px 0 rgba(255, 255, 255, .8),
    inset 0 1px 1px rgba(255, 255, 255, .7),
    inset 0 -2px 3px rgba(0, 0, 0, .28);
  --orb-cap: radial-gradient(circle at 34% 28%, #ffffff 0%, #e6e8f0 46%, #cfd2de 100%);
  --orb-cap-shadow:
    inset 0 3px 5px rgba(35, 30, 80, .25),
    inset 0 -1px 1px rgba(255, 255, 255, .6),
    0 1px 1px rgba(255, 255, 255, .8);
  --face-seat-shadow: inset 0 2px 6px rgba(35, 30, 80, .14), inset 0 -1px 0 rgba(255, 255, 255, .5);
}

* { box-sizing: border-box; }

/* The browser hides [hidden] with a rule of its own, and any class here that
   sets `display` outranks it. A panel would then stay on screen with its
   hidden attribute set: the markup says gone, the eye says there, and no test
   that reads files can tell. This line settles it once for the whole sheet -
   the attribute is a statement about existence, not a style suggestion. */
[hidden] { display: none !important; }

body {
  margin: 0;
  padding: 0;
  /* A colour with an image over it, in that order, and the order is the whole
     point. --bg stays painted and opaque underneath --scene, so the page falls
     back to it if the three gradients never render. Writing `background:
     var(--scene)` alone would have dropped the colour and left --bg declared
     and read nowhere in the sheet.

     What the colour does NOT do any more is describe what the eye receives
     everywhere: the suite still composites its twenty-five pairs onto --bg,
     which is right under a panel and wrong in the gutters, where the decor is
     what is actually there. Two guards cover that gap for the inks that are
     on the decor today; the day the panels turn to glass, most of the screen
     joins them and the whole measurement has to be re-opened.

     Fixed, so the decor stays anchored to the window while content scrolls. */
  background-color: var(--bg);
  background-image: var(--scene);
  background-attachment: fixed;
  color: var(--text);
  font: 15px/1.55 var(--font-ui);
  -webkit-font-smoothing: antialiased;
}

/* The two haloes of the decor, as pseudo-elements rather than markup. Two
   reasons, both structural: an element in the page needs an id for nothing,
   and the id contract runs both ways here - anything the page declares, the
   script must address; and a decoration in the DOM is one more thing a screen
   reader has to be told to ignore, where a pseudo-element is already silent.
   Fixed and inert: they never scroll, never lengthen the page, never take a
   click. At z-index -1 they sit above the page colour and below every panel,
   so the decor shows in the gutters and behind the sidebar - which, until the
   panels turn to glass, is the only place it shows at all. */
body::before,
body::after {
  content: "";
  position: fixed;
  z-index: -1;
  border-radius: 50%;
  pointer-events: none;
  opacity: var(--orb-opacity);
}

body::before {
  top: 100px;
  left: 44%;
  width: 360px;
  height: 360px;
  background: var(--orb-a);
  filter: blur(12px);
  animation: orb-drift 10s ease-in-out infinite;
}

body::after {
  bottom: -80px;
  left: 12%;
  width: 320px;
  height: 320px;
  background: var(--orb-b);
  filter: blur(14px);
  animation: orb-drift 14s ease-in-out infinite;
}

/* Eight pixels, over ten seconds. The proto calls this tdFloat; the front has
   never carried the maquette's `td` prefix (--td-bg-grad became --scene the
   day the palette landed), so the name follows the sheet's own convention
   rather than the export's namespace. Like the unread pulse, it stops
   entirely for anyone who has asked their system for less motion. */
@keyframes orb-drift {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-8px); }
}

@media (prefers-reduced-motion: reduce) {
  body::before,
  body::after { animation: none; }
}

/* The grade card at the foot of the sidebar. It is the one component of the
   gamified set the maquette places OUTSIDE the four views that do not exist
   here yet - a sidebar foot, visible from every screen - which is why it is
   the only one E11 lands rather than reports.

   Branched to GET /api/ranks at W2/R06. Rank 1 (Observer) is granted on
   arrival (ECHELLE_RANGS.md), so there is no truly empty state left: a fresh
   account reads "Observer" here, for real, rather than a placeholder saying
   nothing has happened yet. The bar's width is the share of the NEXT grade's
   own public criteria already met - a count of booleans the route served,
   never a percentage this sheet invented; app.js sets it from that count and
   nothing else. */
.grade-card {
  margin-top: auto;
  padding: .625rem .6875rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
}

.grade-card .eyebrow {
  margin: 0 0 .25rem;
  color: var(--text-faint);
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.grade-card p {
  margin: 0;
  font-size: .75rem;
  line-height: 1.45;
  color: var(--text-muted);
}

.grade-card .grade-name {
  font-size: .9375rem;
  font-weight: 700;
  color: var(--text);
  line-height: 1.3;
}

.grade-card .hint {
  margin: .25rem 0 0;
  font-size: .75rem;
}

.grade-card .bar {
  height: 4px;
  margin: .4rem 0 .35rem;
  border-radius: 2px;
  background: var(--glass-track);
  overflow: hidden;
}

.grade-card .bar > i {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

/* The next grade's public criteria (Home view). One line per criterion,
   verbatim text served by the route with its own number spliced in - never
   retyped here - and a plain met/not-yet marker that is a direction
   (achieved or not), not a verdict on a person. */
.grade-criteria {
  margin: .5rem 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .5rem;
}

.grade-criteria li {
  display: flex;
  align-items: flex-start;
  gap: .55rem;
  font-size: .875rem;
  color: var(--text-muted);
}

/* A plain dot, not a glyph: no checkmark character survives the CI's ban on
   decorative characters above U+2190, and building an SVG at runtime would
   need the namespace URI as a JS string, which the same CI forbids inside
   static/. The dot is decoration only - the fact itself is the "progress"
   text beside it ("3 of 5"), which says the same thing in words. */
/* CAMPUS-V2 U5 (revue wf_5d9a1bd9, BLOQUANT L7549) : le carnet de la
   scene partage le BUILDER des lignes de critere — il partage donc les
   REGLES qui peignent leurs etats, dans la MEME declaration (une virgule,
   jamais une jumelle qui derive). */
.grade-criteria .criterion-mark,
.campus-notebook-criteria .criterion-mark {
  flex: none;
  width: .5rem;
  height: .5rem;
  margin-top: .3rem;
  border-radius: 50%;
  background: var(--text-faint);
}

.grade-criteria li[data-met="true"] .criterion-mark,
.campus-notebook-criteria li[data-met="true"] .criterion-mark { background: var(--up); }

.grade-criteria .criterion-text,
.campus-notebook-criteria .criterion-text { flex: 1; }

.grade-criteria .criterion-progress {
  display: block;
  margin-top: .1rem;
  font-size: .75rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* A criterion nobody can satisfy on this deployment, said once and factually
   - never a second colour, which would read as a warning or a fault of the
   trader's rather than a fact about this product's current reach. Nothing
   wears it since R02 (2026-08-10); the style stays for the next one. */
.grade-criteria .criterion-note {
  display: block;
  margin-top: .1rem;
  font-size: .75rem;
  color: var(--text-faint);
  font-style: italic;
}

/* Regularity (Home view). A count read from the engine, and a calendar of
   small blocks whose only job is to show density - none of them carries text
   of its own, which is why the grid is aria-hidden and the real numbers live
   in .regularity-count instead. No colour here is directional (up/down):
   the wash is the same hue at every level, only its strength changes, so
   nothing on this grid can be misread as a gain or a loss. */
.regularity-count {
  margin: 0;
  font-size: .875rem;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

.regularity-cal {
  margin-top: .6rem;
  display: grid;
  grid-template-columns: repeat(14, 1fr);
  gap: .2rem;
}

.regularity-cal .reg-day {
  aspect-ratio: 1;
  border-radius: 3px;
  background: var(--glass-track);
}

.regularity-cal .reg-day[data-level="1"] { background: var(--wash-accent); }
.regularity-cal .reg-day[data-level="2"] { background: var(--ring-accent); }
.regularity-cal .reg-day[data-level="3"] { background: var(--accent); }

/* The path list. Its own marker is dropped because each item already carries
   its lesson number, and a browser counter beside "1.1" reads as a second,
   contradictory index. */
.path-list {
  margin: .5rem 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .35rem;
}

.path-list li {
  color: var(--text-muted);
  font-size: .875rem;
}

.path-list .node-idx {
  margin-right: .4rem;
  color: var(--text-faint);
}

/* The experiment's milestones (W3). Same shape as the path list because they
   are read the same way — a clock, then a fact — with a class of their own so
   the two lists stay tellable apart: one is a curriculum, the other is a day.
   The clock is `num` so it lines up as the session advances. */
.milestone-list {
  margin: .5rem 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .35rem;
}

.milestone-list li {
  display: flex;
  align-items: baseline;
  gap: .4rem;
  color: var(--text-muted);
  font-size: .875rem;
}

.milestone-list .node-idx {
  color: var(--text-faint);
  flex: none;
}

/* The path, as a list of lists.

   HISTOIRE, AU PASSE (SENTIER->CAMPUS S-1, 2026-09-01) : ce paragraphe a
   longtemps decrit le portage du chemin sinueux - l'arithmetique des
   centres, la Bezier, le rail SVG - depuis la maquette de SPEC_JEU sec.3.
   Ce dessin est MORT avec S-1 (decision d'Erwann : le campus remplace le
   sentier), et `jeuPathGeometry` avec lui. La prose qui decrivait un
   dessin mort a ete corrigee dans le meme mouvement : un commentaire faux
   coute autant qu'un chiffre faux (regle 4).

   Ce que ce bloc possede AUJOURD'HUI est le regroupement que cette liste a
   toujours dessine - la grille des unites, et la banniere au-dessus de
   chacune : le blason, le titre, la jauge segmentee et le compte servi.
   Sous cette banniere vit desormais une `.path-list`, la meme graphie que
   #home-path, et rien d'autre. */
.unit-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--gap);
}

/* A grid item's automatic min-width is its own max-content size by
   default. Trouve au navigateur a 375px, le 2026-08 : le scroller du
   sentier aurait du pouvoir retrecir et ne le pouvait pas, parce que
   .unit (l'item de grille un cran plus haut) ne descendait pas sous les
   339px que son contenu voulait. Le sentier est mort a S-1 et cette cause
   avec lui ; la release, elle, reste juste par elle-meme : rien dans une
   unite n'a jamais besoin d'etre plus large que la colonne qu'on lui
   donne. */
.unit { min-width: 0; }

.unit-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .5rem .6rem;
  margin: 0 0 .4rem;
  padding: .5rem .625rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
}

.unit-head .u-n {
  color: var(--text-faint);
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.unit-head .u-title {
  color: var(--text-soft);
  font-size: .875rem;
  font-weight: 600;
}

/* The crest (J1-4): the unit's own number, quiet until the unit is whole -
   then it wears the same identity gradient a validated segment and a
   validated node both already wear (never --up: a finished unit is not a
   profit either). No per-unit pictogram: the maquette indexes one by an
   `icon` field /api/lessons does not serve, and seven invented icons with
   no source would be exactly the kind of figure C1 already struck once
   from this screen (the belt). The number the route already serves is the
   crest, full stop. */
.unit-crest {
  flex: none;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  background: var(--wash-accent);
  border: 1px solid var(--ring-accent);
  color: var(--accent-text);
  font-size: .8125rem;
  font-weight: 800;
}

.unit-crest[data-done="true"] {
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  border-color: transparent;
  color: var(--on-accent);
}

.unit-head .unit-count {
  margin-left: auto;
  color: var(--text-faint);
  font-size: .75rem;
  white-space: nowrap;
}

/* UNITE CAMPUS-FRONT: the wing's own fictional name (CAMPUS_WING_KEYS,
   static/app.js), decorative chrome ahead of the real .unit-head banner -
   never a replacement for .u-title, which still carries the served
   unit.title unchanged just below it. */
.wing-eyebrow {
  margin: 0 0 .3rem;
  color: var(--accent-text);
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .08em;
  text-transform: uppercase;
}

/* UNITE CAMPUS-FRONT: the portal plaque - two paragraphs the page already
   ships elsewhere (learn.units.doctrine at the foot of .unit-list,
   learn.path.footnote atop the path panel), presented here a second time
   as the campus's own entrance signage. Never re-typed: both keep the
   SAME data-i18n key as their other instance, so a translation only ever
   has one place to change. */
.campus-portal .plaque {
  color: var(--text);
  font-size: .9375rem;
  font-weight: 700;
  line-height: 1.45;
}

.campus-portal .plaque-sub {
  margin-top: .5rem;
  color: var(--text-soft);
  font-size: .8125rem;
  line-height: 1.55;
}

/* The orientation map: a plain disclosure panel (WAI-ARIA APG
   "disclosure"), toggled by #campus-map-btn (static/app.js,
   initCampusMap) - ships `hidden`, the honest closed state, never a
   dialog with a focus trap since nothing behind it stops being
   interactive. */
.campus-map-list {
  display: grid;
  gap: .5rem;
}

.campus-map-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: .15rem;
  width: 100%;
  min-height: 44px;
  padding: .5rem .75rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.campus-map-item:hover { background: var(--glass-row-hover); }

.campus-map-item-wing {
  color: var(--accent-text);
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.campus-map-item-unit {
  color: var(--text-soft);
  font-size: .8125rem;
}

/* --- CAMPUS-MONDE U1: the pannable full-width map ---------------------- */
/* Same tokens as every other panel on this page - a distinct wrapper
   because it is not itself a .panel (it takes the column's full width and
   clips its own scroll area, which .panel's grid neighbours never do). */
.campus-map-world {
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--glass-panel);
  overflow: hidden;
}

.campus-topbar {
  display: flex;
  align-items: center;
  gap: .625rem;
  flex-wrap: wrap;
  padding: .75rem var(--pad);
  border-bottom: 1px solid var(--glass-border);
}

.campus-topbar-title { flex: 1 1 auto; min-width: 0; }

.campus-topbar-h {
  margin: 0;
  color: var(--text);
  font-size: .9375rem;
  font-weight: 800;
}

.campus-topbar-sub {
  margin: .15rem 0 0;
  color: var(--text-muted);
  font-size: .75rem;
  overflow-wrap: break-word;
}

/* S-4 (retouche Lea, revue calibree) : meme habillage que .campus-topbar-
   sub, une classe A ELLE plutot qu'un partage - .campus-topbar-sub
   disparait sous 760px (media query plus bas), exactement la largeur ou
   le repli est la SEULE voie reelle (iOS Safari, brief S-4) : partager la
   classe aurait tu le message la ou il compte le plus. */
.campus-fullscreen-hint {
  margin: .15rem 0 0;
  color: var(--text-muted);
  font-size: .75rem;
  overflow-wrap: break-word;
}

.campus-topbar-controls {
  display: flex;
  align-items: center;
  gap: .4rem;
  flex-wrap: wrap;
}

.campus-ctl {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0 .625rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-soft);
  font: inherit;
  font-weight: 700;
  font-size: .8125rem;
  cursor: pointer;
  white-space: nowrap;
}

.campus-ctl:hover { background: var(--glass-row-hover); color: var(--text); }

/* S-4 : meme regle minimale que .labo-tool-icon (l'icone jumelle du banc
   plein ecran) - empeche le SVG de s'etirer dans le bouton flex. */
.campus-fullscreen-icon { flex: none; }

/* CAMPUS-MONDE U2: the illustrations toggle's pressed state - "off" is the
   pressed one (aria-pressed="true" means the ambient pulse is silenced),
   same accent-ring idiom the theme button already wears elsewhere in this
   sheet, never a second colour vocabulary invented for one control. */
.campus-ctl[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--text);
}

/* Suite E1 of the prototype prompt's corrective pass: one compact row,
   96px at most, below 760px - the subtitle drops, the controls scroll
   sideways instead of wrapping onto a second and third line, and every
   target keeps its 44px floor. */
@media (max-width: 759px) {
  .campus-topbar {
    max-height: 96px;
    flex-wrap: nowrap;
    overflow-x: auto;
  }
  .campus-topbar-sub { display: none; }
  .campus-topbar-controls { flex-wrap: nowrap; flex: none; }
  /* The title itself must not wrap onto a second and third line either -
     measured: with only .campus-topbar-controls held to one line, "Learn
     — the campus" alone was still enough to blow the 96px ceiling wide
     open (four stacked lines of title, then the controls, then a runt
     line under them). One `nowrap` on the title joins it to the SAME
     horizontally-scrollable row the controls already have - reachable in
     full by scrolling, never clipped, and it is what keeps the whole bar
     to one line. */
  .campus-topbar-title { flex: none; min-width: 0; }
  .campus-topbar-h { white-space: nowrap; }
}

/* CAMPUS-PROTO U3: the hero and its three cartridges (campus.html
   .learn-hero) - static markup, native fragment anchors, no script. The
   cartridges are real <a>: color and decoration are set explicitly so
   none of them ever falls back to the browser's link blue (the removed-
   class trap, CHECKLIST_CHANTIER.md sec.4). */
.campus-hero {
  padding: 1.25rem 1.25rem .875rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--bg-raised);
}

.campus-hero-kicker {
  margin: 0;
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.campus-hero-h {
  margin: .35rem 0 .4rem;
  font-size: 1.55rem;
  line-height: 1.15;
}

.campus-hero-lede {
  margin: 0 0 .5rem;
  max-width: 46rem;
  color: var(--text-soft);
  line-height: 1.5;
}

.campus-hero-slot {
  display: block;
  width: 100%;
  max-width: 32rem;
  height: auto;
  margin: .25rem auto;
}

.campus-carts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: .75rem;
  margin-top: .5rem;
}

.campus-cart {
  display: flex;
  flex-direction: column;
  gap: .3rem;
  padding: .75rem;
  min-height: 44px;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--bg-sunken);
  color: var(--text);
  text-decoration: none;
}

.campus-cart:hover { background: var(--glass-row-hover); }

.campus-cart-art { display: block; width: 4.5rem; height: auto; }

.campus-cart-name { font-weight: 700; }

.campus-cart-line { font-size: .82rem; color: var(--text-soft); line-height: 1.4; }

.campus-cart-note { font-size: .75rem; color: var(--text-faint); }

/* Announced, not built - the dashed border is the whole difference, the
   words do the honest work. */
.campus-cart--announced { border-style: dashed; }

.campus-story-name { margin: 0 0 .4rem; font-weight: 700; }

.campus-viewport {
  position: relative;
  overflow: hidden;
  height: 22rem;
  touch-action: none;
  cursor: grab;
  background: var(--bg-sunken);
}

.campus-viewport:active { cursor: grabbing; }

/* S-4 (plein ecran) : la racine (#campus-fullscreen-root, index.html)
   n'a AUCUNE regle tant qu'elle n'est pas active - un div nu autour de
   #campus-map-section ne doit rien changer a la mise en page d'aujourd'hui.
   `.campus-fullscreen-active` est la SEULE classe que campusApplyFullscreen
   State() (app.js) ecrit dessus, dans les DEUX chemins (vraie Fullscreen
   API et repli maison) - une classe, un lecteur CSS, jamais deux jeux de
   regles a garder en phase.

   z-index 68 : au-dessus de TOUT ce que le reste de la feuille empile
   (barre 62, feuille 63, moment gagne 65, tour d'accueil 66, toast XP 67)
   - "z-index au-dessus du reste" (brief S-4) - jamais SOUS la barre comme
   la Console/le Labo/le deck (60/61) : ceux-la restent volontairement
   PARTIELS (decision (a), "la Console garde son ecran moins une barre
   fine"), le repli maison campus vise au contraire a APPROCHER une vraie
   fullscreen, la seule maniere de le faire sur iOS Safari qui n'a pas
   l'API sur un element. En-dessous du jeu (.jeu-overlay, 70) : une fiche
   de lecon doit pouvoir s'ouvrir PAR-DESSUS le campus plein ecran - c'est
   pour cela que campusBorrowNeighbours() (app.js) deplace #nodesheet-
   overlay ET #mentor-dock DANS la racine plein ecran plutot que de
   compter sur la seule cascade : en VRAIE Fullscreen API (top layer), un
   z-index ne peut rien pour un element reste HORS du sous-arbre plein
   ecran, quelle que soit sa valeur - seul le deplacer dans l'arbre le
   rend a nouveau atteignable/visible. test_the_bar_stacks_above_the_
   shells_and_below_the_immersive_surfaces (tests/test_front_end_
   tabbar.py) tient ce rang. */
.campus-fullscreen-root.campus-fullscreen-active {
  position: fixed;
  inset: 0;
  z-index: 68;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  background: var(--bg);
  padding: max(.625rem, env(safe-area-inset-top))
           max(.625rem, env(safe-area-inset-right))
           max(.625rem, env(safe-area-inset-bottom))
           max(.625rem, env(safe-area-inset-left));
}

/* La section garde son propre cadre (bordure/fond) mais remplit la racine
   plutot que la hauteur fixe (22rem) que .campus-viewport porte hors
   plein ecran - `flex: 1` + `min-height: 0` (CHECKLIST_CHANTIER sec.4 :
   un enfant flex sans plancher se fait ecraser a zero par ses propres
   freres) laisse #campus-map-panel, deplace dans la meme racine (index.
   html), garder sa place normale EN DESSOUS plutot que d'etre pousse hors
   cadre. */
.campus-fullscreen-active > #campus-map-section {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.campus-fullscreen-active > #campus-map-section .campus-viewport {
  flex: 1 1 auto;
  min-height: 0;
  height: auto;
}

.campus-world {
  position: absolute;
  inset: 0;
  transform: translate(var(--campus-pan-x, 0px), var(--campus-pan-y, 0px))
    scale(var(--campus-zoom, 1));
  transform-origin: center center;
  transition: transform .2s ease;
}

/* No transition while a real drag is in progress (app.js toggles this
   class on pointerdown/up) - see initCampusWorld's own comment for why. */
.campus-world.is-panning { transition: none; }

@media (prefers-reduced-motion: reduce) {
  .campus-world { transition: none; }
}

.campus-diorama { display: block; width: 100%; height: auto; }

.campus-diorama-accent {
  stroke: var(--accent);
  stroke-width: 2.4;
  stroke-linecap: round;
  fill: none;
}

/* The lantern - CATALOGUE_OBJETS_CAMPUS.md's own cross-wing object: one per
   building always lit in dark theme. This is the unit's ONLY ambient-life
   declaration; it moves by opacity alone never a transform and both
   endpoints of its cycle stay well above zero (never 0) so the element it
   lights is never mistaken for hidden between beats - the keyframe-that-
   ends-hidden trap this repo's own cliquet already names (CHECKLIST_
   CHANTIER.md L6082) does not apply here by construction. Comma-free on
   purpose - a comment full of commas directly above a rule gets read as
   fake selector text by this sheet's own global reduced-motion guard
   (test_front_end.py::animated_selectors) exactly the "comma trap" this
   repo's own cliquet already names (L2298/L3088). */
.campus-diorama-glow { fill: var(--accent); opacity: .78; animation: campusLanternPulse 4.2s ease-in-out infinite; }

@keyframes campusLanternPulse {
  0%, 100% { opacity: .55; }
  50% { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .campus-diorama-glow { animation: none; }
}

/* CAMPUS-PROTO U1: the named image mount. The box commits to the
   prototype's own facade proportions (300x150 - campus.html facadeSvg) so
   a painting dropped in later reshapes nothing; the skeleton SVG centers
   inside it (preserveAspectRatio letterboxes, never stretches), and the
   raster, once loaded, covers the same box. */
.campus-slot {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 2 / 1;
  overflow: hidden;
  border-radius: 8px;
  background: var(--bg-sunken);
}

.campus-slot .campus-diorama {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.campus-slot-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* La porte de revelation (2026-08-31, premiere reception reelle) : une
     img `hidden` n'a pas de boite et un lazy sans boite ne charge JAMAIS.
     Opacite 0 au repos - la boite existe pour le chargeur, le squelette
     reste seul visible, l'icone cassee d'un fichier absent aussi - et
     SEULE la regle data-art ci-dessous revele. */
  opacity: 0;
}

/* Suite E3, extended to the raster: illustrations off never empties the
   box - the skeleton stays drawn, the painting stands down. Own guard,
   separate from the lantern's (one selector each, neither entails the
   other). */
[data-decor="off"] .campus-slot-img { display: none; }

/* Le raster ne se revele que par l'evenement load reel : campusSlotNode
   pose data-art="loaded" sur le slot, et cette regle est l'UNIQUE
   ecrivain d'opacite non nulle de l'img. */
.campus-slot[data-art="loaded"] .campus-slot-img { opacity: 1; }

/* ===== CAMPUS-V2 U2 : la scene-carte ============================== */
/* La couche entiere est une porte d'OPACITE (jamais hidden/display sur
   le monde) : elle ne monte que si la scene ACTIVE a reellement charge
   (data-campus-scene, pose par le seul campusSceneRefresh sur load reel)
   ET si les illustrations sont on - sinon c'est la LISTE SIMPLE de
   #learn-units qui sert (D5 reecrite, 2026-09-01 : l'epine est morte). */
.campus-scene {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
}
.campus-world[data-campus-scene="on"] .campus-scene {
  opacity: 1;
}
/* Illustrations off : la scene se retire, meme si son attribut est encore
   pose - a specificite EGALE la derniere regle gagne, et celle-ci vit
   APRES la revelation ci-dessus, a dessein (garde d'ordre + unicite). */
.campus-world[data-decor="off"] .campus-scene {
  opacity: 0;
}
/* Les etats des quatre montures, poses par campusSceneRefresh :
   off  = l'autre variante (portrait/paysage) - display:none, donc son
          lazy ne tire JAMAIS tant qu'on ne bascule pas de variante ;
   idle = l'autre theme de la variante courante - boite a opacite 0,
          c'est le prechargement discret de l'autre scene ;
   active = la scene du theme courant. */
.campus-scene .campus-scene-slot { display: none; }
.campus-scene .campus-scene-slot[data-scene-state="idle"] {
  display: block;
  position: absolute;
  inset: 0;
  opacity: 0;
}
.campus-scene .campus-scene-slot[data-scene-state="active"] {
  display: block;
  position: absolute;
  inset: 0;
  opacity: 1;
}
/* Le squelette de la scene ne peint jamais par-dessus la scene elle-meme :
   il vit sous la porte d'opacite de la couche, comme l'img. */
.campus-scene .campus-scene-slot svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  stroke: var(--border);
}
/* ===== fin CAMPUS-V2 U2 =========================================== */

/* ===== CAMPUS-V2 U3 : les 95 noeuds sur la scene ================== */
/* La couche ne prend la carte que quand la scene ET les noeuds sont
   vraiment la (deux attributs, chacun pose par son seul ecrivain) ; sous
   illustrations off, tout se retire et c'est la LISTE SIMPLE de
   #learn-units qui ressert (la porte du repli, plus bas dans cette
   feuille) - a specificite egale, la derniere regle gagne : les regles
   decor-off vivent APRES. */
.campus-node-layer { position: absolute; inset: 0; display: none; }
.campus-world[data-campus-scene="on"][data-campus-nodes="on"] .campus-node-layer { display: block; }
/* L'attribut decor est DOUBLE a dessein : la regle de revelation porte
   DEUX attributs (0,4,0) et l'egalite de specificite est ce qui permet a
   l'ordre de source de trancher - un [data-decor="off"] simple (0,3,0)
   perdrait TOUJOURS. */
.campus-world[data-decor="off"][data-decor="off"] .campus-node-layer { display: none; }

.campus-node-paths { position: absolute; inset: 0; width: 100%; height: 100%; opacity: .55; }
.campus-paths-mobile { display: none; }
@media (max-width: 620px) {
  .campus-paths-desktop { display: none; }
  .campus-paths-mobile { display: block; }
}

.campus-scene-labels { position: absolute; inset: 0; pointer-events: none; }
.campus-scene-label {
  position: absolute;
  left: var(--x);
  top: var(--y);
  transform: translate(-50%, -120%);
  pointer-events: auto;
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: .15rem .6rem;
  font-size: .75rem;
  background: var(--glass-panel);
  color: var(--text);
  box-shadow: 0 0 0 1px var(--wing-color, var(--accent));
  cursor: pointer;
  white-space: nowrap;
}

/* D-4 (decision d'Erwann du 2026-09-01) : LA STRUCTURE ACCESSIBLE DE LA
   COUCHE. Quand la photo tient la carte, #learn-units passe en
   display:none - donc hors de l'arbre d'accessibilite - et la couche
   n'offrait alors qu'UNE liste plate de 95 boutons sans nom accessible.
   Elle porte desormais une <ol> NOMMEE par aile, precedee d'une en-tete
   visuellement cachee (.sr-only) qui redit le nom de l'aile, le libelle
   d'unite servi et le compte « N of M validated ».
   Aucune de ces trois boites n'est positionnee : le bloc de reference des
   95 jalons absolus reste #campus-nodes, exactement comme avant. */
.campus-node-groups,
.campus-node-group,
.campus-node-list { list-style: none; margin: 0; padding: 0; }
.campus-node {
  position: absolute;
  left: var(--x);
  top: var(--y);
  transform: translate(-50%, -50%);
}
@media (max-width: 620px) {
  .campus-node { left: var(--mx); top: var(--my); }
}
/* Le bouton reste lessonNode tel quel (meme aria, meme fiche) - seule sa
   PEAU change sous cette couche : un jalon rond, le titre en infobulle
   au survol comme au focus (jamais retire du nom accessible). */
.campus-node .node {
  display: block;
  width: 16px;
  height: 16px;
  min-width: 16px;
  padding: 0;
  border-radius: 50%;
  border: 2px solid var(--wing-color, var(--accent));
  background: var(--glass-row);
  overflow: visible;
  position: relative;
}
.campus-node-major .node { width: 22px; height: 22px; min-width: 22px; }
.campus-node .node[data-state="validated"] {
  background: var(--wing-color, var(--accent));
}
.campus-node .node[data-state="proposed"] {
  box-shadow: 0 0 0 4px var(--glass-track);
}
/* S-1 CLOTURE (J7) : LA CIBLE D'UN JALON EST SON DISQUE, ET RIEN D'AUTRE.
   Mesure au vrai navigateur (2026-09-01) : `.campus-node .node` est
   `overflow: visible`, et ces trois spans - invisibles a l'oeil par
   `font-size: 0` - restent DANS LE FLUX et restent CLIQUABLES. Le verdict
   mesurait 29x13 px, pendu 32 px SOUS le haut d'un disque de 16 px : la
   cible reelle d'un jalon depassait donc largement sa pastille, et c'est
   par cette queue que « The Registrar's Hall » et « The Specimen Gallery »
   recevaient le clic d'un noeud voisin a leur centre EXACT, au cadre
   etroit. `font-size: 0` repond a « est-ce lisible » ; il ne repond pas a
   « est-ce cliquable » - deux questions differentes (cliquet sec.4,
   L3533). Le titre en infobulle porte deja la meme declaration, pour la
   meme raison. */
.campus-node .node-idx,
.campus-node .node-state,
.campus-node .verdict {
  font-size: 0;
  /* HORS FLUX ET SANS SURFACE. `pointer-events: none` seul ne suffit
     PAS - mesure au navigateur : le disque restait cliquable 19 a 31 px
     SOUS son centre, la ou ces spans se posaient. Un <button> natif
     (`appearance: auto`) repond au clic sur la zone de debordement de son
     contenu, pas seulement sur sa boite de bordure ; la seule fermeture
     est donc de ne rien laisser deborder. Le texte reste dans le DOM (des
     gardes le lisent, et le nom accessible vient de l'aria-label du
     bouton, jamais de ces spans). */
  position: absolute;
  width: 0;
  height: 0;
  padding: 0;
  border: 0;
  overflow: hidden;
  pointer-events: none;
}
.campus-node .node-title {
  position: absolute;
  left: 50%;
  bottom: 130%;
  transform: translateX(-50%);
  font-size: .75rem;
  white-space: nowrap;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: .1rem .45rem;
  opacity: 0;
  pointer-events: none;
}
.campus-node .node:hover .node-title,
.campus-node .node:focus-visible .node-title { opacity: 1; }
/* Le jalon de repli (slug servi sans position) reste VISIBLE et dit sa
   nature par sa forme - jamais un trou silencieux. */
.campus-node-unplaced .node { border-style: dashed; }

/* D-11 : LE VERDICT SUR LA CARTE, SANS UNE SEULE TEINTE DE PLUS.

   Le probleme, deja paye une fois (cliquet L11389) : le span de verdict
   est reduit ici a une boite sans surface (largeur et hauteur nulles,
   debordement coupe), parce que la cible d'un jalon est son disque et
   RIEN d'autre (J7, cloture S-1) - tout ce qu'on peint dedans est efface,
   quelles que soient ses dimensions. Le porteur ne peut donc pas vivre
   dans la pastille : il nait sur le DISQUE lui-meme, dont le niveau
   arrive par un attribut que lessonNode recopie depuis la pastille (une
   seule ecriture, jamais deux valeurs qui peuvent diverger).

   POURQUOI LA SILHOUETTE ET RIEN D'AUTRE. Un disque de 16 px porte deja
   trois etats : la couleur d'aile a sa bordure, le remplissage quand la
   lecon est validee, un halo quand elle est proposee, et le tirete quand
   la table ne la place pas. Le rayon des coins est la seule dimension
   libre - et c'est aussi la seule qui ne touche PAS la cible de clic :
   elementFromPoint ignore le rayon des coins (mesure du 2026-09-01,
   cliquet L11001), donc la boite qui recoit le clic reste EXACTEMENT
   celle d'avant, au pixel pres. Un decoupage (clip-path) l'aurait
   retrecie, une rotation l'aurait deplacee : ni l'un ni l'autre ici.

   Les quatre valeurs sont celles de la pastille, mot pour mot - un
   verdict se dessine pareil sur les deux surfaces ou il apparait. Un
   jalon SANS verdict ne porte pas l'attribut et garde son disque plein :
   c'est ce qui empeche « soutenu par la recherche » et « aucun verdict »
   de se peindre pareil. */
.campus-node .node[data-verdict-level="green"] { border-radius: 50% 50% 50% 0; }
.campus-node .node[data-verdict-level="yellow"] { border-radius: 50% 0 50% 0; }
.campus-node .node[data-verdict-level="red"] { border-radius: 0; }
.campus-node .node[data-verdict-level="white"] { border-radius: 50% 50% 0 0; }
/* ===== fin CAMPUS-V2 U3 =========================================== */

/* ===== CAMPUS-V2 U4 : Vera et les residents ======================= */
.campus-character {
  position: absolute;
  left: var(--x);
  top: var(--y);
  transform: translate(-50%, -92%);
  width: 56px;
  height: 84px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  /* La porte du personnage est la VISIBILITE (jamais hidden : une img
     lazy sans boite ne charge JAMAIS ; jamais disabled : cache, jamais
     desactive) - relevee par le seul load reel (data-art). */
  visibility: hidden;
}
.campus-character[data-art="loaded"] { visibility: visible; }
.campus-character-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  pointer-events: none;
}
.campus-character:focus-visible { outline: 2px solid var(--accent); }
/* Revue Lea U4 : le clic repond meme quand le personnage n'a rien a dire
   - un etat de PRESSION statique (jamais une animation). */
.campus-character:active .campus-character-img { opacity: .65; }
/* S-1 CLOTURE (J7) : LA NOTE DE GEL NE GONFLE PLUS LA PORTE QUI LA PORTE.
   Mesure au vrai navigateur (2026-09-01) : en flux, ce chip faisait passer
   le label de l'aile 8 de 25 a 44 px de haut et de 132 a 443 px de large.
   Or un label est remonte de 120 % de SA PROPRE hauteur : la porte de
   l'aile 8 avait donc DEUX centres, 13 px l'un de l'autre, selon que
   /api/meta avait repondu ou non - dans la region la plus dense de la
   carte (27 jalons). Aucune coordonnee ne pouvait rendre les deux libres :
   c'etait la BOITE qu'il fallait rendre stable, pas la position.
   En absolu, la note reste exactement la ou elle etait a l'oeil, elle
   reste ENFANT du label (donc `textContent` la porte toujours, et la
   garde qui prouve que l'annonce lit `dataset.wingName` garde son seul cas
   ou les deux graphies divergent), et elle cesse d'etre dans le chemin des
   clics. */
.campus-scene-frost {
  display: block;
  position: absolute;
  /* Centree sur la MEME ancre que le label, comme elle l'etait quand elle
     etait dans son flux : la sortir du flux ne doit pas la DEPLACER. */
  left: 50%;
  transform: translateX(-50%);
  top: 100%;
  font-size: .75rem;
  color: var(--text-muted);
  white-space: nowrap;
  pointer-events: none;
}
/* La MOITIE MIROIR du carnet : la bulle des residents est elle aussi une
   boite en absolu, plus haut encore dans la pile (z-index 3), et elle aussi
   sans le moindre controle. Elle etait absente de la mesure d'Iris pour la
   seule raison qu'elle etait CACHEE au moment ou celle-ci a ete prise -
   fermer la moitie qu'une revue a nommee sans sa jumelle, c'est laisser le
   defaut revenir des qu'un resident parle (cliquet L6290). */
.campus-character-line {
  position: absolute;
  left: 50%;
  bottom: .6rem;
  transform: translateX(-50%);
  z-index: 3;
  pointer-events: none;
  max-width: min(92%, 34rem);
  margin: 0;
  padding: .4rem .8rem;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg);
  color: var(--text);
  font-size: .8rem;
}
/* Sous illustrations off ou sans scene, les personnages se retirent avec
   la couche (ils vivent DANS #campus-nodes, deja gouverne par la bascule
   de U3) - rien a redoubler ici. */
/* ===== fin CAMPUS-V2 U4 =========================================== */

/* ===== CAMPUS-V2 U5 : le carnet de terrain ======================== */
/* Meme famille de porte que la couche : le carnet n'apparait que quand
   la scene a pris la carte, et se retire sous illustrations off - le
   combinateur ~ lit les attributs du monde (le carnet est un FRERE
   APRES lui, prouve au markup parse), attribut decor double a dessein
   pour l'egalite de specificite. */
/* I4 (Iris, mesure au vrai navigateur, corrigee le 2026-09-01) : LE CARNET
   NE MANGE PLUS LES CLICS. Il est un FRERE du monde, pose en absolu sur le
   coin bas-gauche du viewport avec z-index: 2 - donc au-dessus de TOUTE la
   carte, panoramique et zoom compris. Mesure : 8 jalons de lecon avaient
   leur centre sous sa boite, plus 4 sous ses lignes de critere, et l'une
   des deux portes de salle speciales etait recouverte elle aussi.
   Il ne porte AUCUN controle - un titre, trois paragraphes, une liste de
   criteres, tout en lecture seule (prouve a l'execution par
   test_the_notebook_and_the_speech_bubble_carry_no_control_at_all) - donc
   `pointer-events: none` ne retire rien a personne et rend a la carte les
   clics qui lui appartiennent. Herite par ses enfants, la ligne de critere
   comprise : c'est ce qui ferme les DEUX moities d'un coup.
   Le prix, dit plutot que tu : son texte ne se selectionne plus a la
   souris. */
.campus-notebook { display: none; pointer-events: none; }
.campus-world[data-campus-scene="on"][data-campus-nodes="on"] ~ .campus-notebook {
  display: block;
  position: absolute;
  left: .75rem;
  bottom: .75rem;
  z-index: 2;
  max-width: 15rem;
  padding: .55rem .75rem;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg);
}
.campus-world[data-decor="off"][data-decor="off"] ~ .campus-notebook { display: none; }
.campus-notebook-title {
  margin: 0 0 .2rem;
  font-size: .75rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.campus-notebook-rank { margin: 0; font-size: .9rem; }
.campus-notebook-progress { margin: .15rem 0 0; font-size: .75rem; color: var(--text-muted); }
.campus-notebook-next { margin: .3rem 0 0; font-size: .75rem; color: var(--text-muted); }
.campus-notebook-criteria {
  list-style: none;
  margin: .25rem 0 0;
  padding: 0;
  font-size: .75rem;
}
.campus-notebook-criteria li {
  margin-top: .15rem;
  display: flex;
  gap: .4rem;
}
/* ===== fin CAMPUS-V2 U5 =========================================== */


/* CAMPUS-PROTO U2: the LQIP layer - the image's own blurred micro-version
   (D2 bis), painted by campusApplyLqipTo from the fenced manifest, over
   the skeleton and under the raster's final paint. It stands down with
   the raster under illustrations-off, and once the real image has loaded
   (data-art) it stops painting for good. */
.campus-slot-lqip {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
}

[data-decor="off"] .campus-slot-lqip { display: none; }

.campus-slot[data-art="loaded"] .campus-slot-lqip { display: none; }

/* CAMPUS-PROTO U6: the three static room mounts. Empty until
   initCampusInteriors fills them (script dead = nothing reserved, no
   empty box), then the shared slot box takes over. */
.campus-slot-host { display: block; }

.campus-slot-host .campus-slot { margin-top: .5rem; max-width: 30rem; }

/* Suite E3: illustrations disabled never empties a single box - the same
   diorama stays drawn, only its one animation stops. Its own cascade
   guard, separate from the reduced-motion pair above (CHECKLIST_CHANTIER.md
   L2972/L4531: one animation declaration, two independent overrides, two
   independent pins - neither entails the other). */
[data-decor="off"] .campus-diorama-glow { animation: none; }

/* The desktop composition (flex-direction: row) lives in the SHARED
   @media (min-width: 1024px) block further down this sheet, beside the
   rest of Learn's own desktop rules - see that block's own comment for
   why a second, earlier block of the same query would have been wrong. */

/* The Specimen Gallery wing's own honest line (renderCampusGalleryNote) -
   ships `hidden` until /api/meta answers with both counts, same discipline
   as #atrium-stats just above it in app.js. Plain .hint styling already
   used throughout this view; no new rule needed for the text itself. */

/* A verdict badge. The four hues are the ones the palette already reserved for
   this and nothing else: they are deliberately the same as up/warn/down,
   because a verdict is a statement of fact and not a fourth vocabulary. */
.verdict {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  padding: .1rem .5rem .1rem .35rem;
  border: 1px solid var(--glass-border);
  border-radius: 999px;
  background: var(--glass-row);
  color: var(--text-muted);
  font-size: .75rem;
  white-space: nowrap;
}


.verdict-legend {
  margin: .5rem 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
}

/* --- Page frame ------------------------------------------------------- */

.masthead {
  display: flex;
  /* Centre, not baseline: a 40 px mark has no baseline to share with a line
     of text, and aligning it on one hung the keeper below its own wordmark. */
  align-items: center;
  /* Tighter than the page gap. The keeper is 40 px wide and the row was
     already full: at var(--gap) the theme toggle fell to a second line. */
  gap: .75rem;
  flex-wrap: wrap;
  /* A panel now, like the sidebar beside it: the second export gives this bar
     the panel class outright, and the proto gives it the same material at a
     slightly tighter radius. It stopped being a rule across the top of the
     page the moment it moved into the column, so the border went with it. */
  margin: var(--gap) var(--gap) 0 0;
  padding: .625rem var(--pad);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--bg-raised);
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .masthead {
    background: var(--glass-panel);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
}

/* The brand block at the head of the sidebar. Neither export puts a mark in
   the banner; both put it here, and moving it is also what buys the banner the
   width it needs to stay on one line. */
.brandmark {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: 0 .25rem .75rem;
}

.brandmark h1,
.masthead h1 {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: .02em;
}

/* The sentence that says what this is. It is allowed to wrap inside its own
   box rather than push the rest of the row onto a second line - it is never
   shortened, because it is the one claim the page cannot afford to trim. */
.notice {
  margin: 0;
  min-width: 0;
  flex-shrink: 1;
  color: var(--text-muted);
  /* 12px, the floor, and it took a measurement to justify: once E05 moved the
     banner into the right-hand column the row was 12 px too wide and wrapped
     onto a second line, which is the D17 regression the GO criteria name. The
     proto writes this sentence at 11.5px; we stop at the floor and it is
     enough. The sentence itself is never shortened. */
  font-size: .75rem;
}

.masthead .spacer { flex: 1; }

/* Equity and free margin follow the reader from section to section: they are
   the two figures that decide whether the next click is even possible. */
.topline {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: .4rem;
}

.topline-label {
  color: var(--text-faint);
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
}

/* The shell: a column of sections beside the page. The sidebar is a real
   <nav> of real buttons - the proto used focusable div elements, which
   announce nothing to a screen reader and cannot be reached by the keyboard
   in the order a user expects. */
/* Two columns, and the banner lives in the right one. Both exports of the
   maquette agree on this and the annexe draws it the same way; it is what
   gives the screen the silhouette of an application rather than of a site.

   `auto` and not a fixed track, which is the whole reason this is a grid and
   not a simpler thing. showSignIn() puts [hidden] on the sidebar, and a grid
   item in display:none leaves its FIXED track standing: `main` then auto-places
   into column one and the sign-in panel renders inside 192px with a thousand
   pixels of nothing beside it. Measured in a browser, not deduced, and no test
   in this suite would have caught it.

   `align-items: start` is carried over from the flex layout and is load-
   bearing: a grid stretches by default, which pulls the sidebar to the height
   of the whole document and quietly stops its `position: sticky` from doing
   anything. Also measured. */
.shell {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--gap);
  align-items: start;
  /* P-04 : la nav vit en haut, fixe — le flux commence sous elle. */
  padding-top: var(--pad-nav-top);
}

/* The right-hand column: banner, then content. Nothing else - the dock stays a
   sibling of the shell, fixed to the window, and the sign-in panel stays inside
   `main` so that hiding the app body cannot take the theme toggle and the
   connection state with it. */
.column {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.sidebar {
  flex: none;
  width: 12rem;
  position: sticky;
  top: var(--pad-nav-top);
  /* Full height, or the rule that separates the sections from the content
     stops halfway down the page and reads as an unfinished box. */
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  gap: .125rem;
  padding: var(--pad) .75rem;
  border-right: 1px solid var(--glass-border);
  /* A surface of its own, which this column has never had. E02 measured what
     its absence costs: the five labels were the only ink on the whole screen
     with nothing between them and the page, and --text-muted fell to 4.15:1
     under a halo. The maquette does not have that problem because its sidebar
     is a glass panel; this gives ours the same material, and no geometry -
     sticky, min-height and the narrow-width row are untouched, because moving
     this column is E05's work and not the material's. */
  background: var(--bg-raised);
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .sidebar {
    background: var(--glass-panel);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
}

/* No width ceiling. There was one at 1180px, and on a 1920px screen it cost
   267px of bare decor down each side while the banner above it - which was
   never capped - ran the full width of the column. The panels and the bar that
   heads them did not line up, and the maquette has no ceiling anywhere: its
   grid spreads to whatever the window gives it. Measured before and after; the
   cap only ever bit above 1386px of viewport, so nothing changes at the
   composition width. */
main {
  flex: 1;
  min-width: 0;
  padding: var(--pad);
  display: grid;
  gap: var(--gap);
}

/* The rhythm between panels used to come from main's own grid. The panels now
   sit one level deeper, inside a section, so the grid moves down with them. */
.view {
  display: grid;
  gap: var(--gap);
}

/* The Atrium's own five legacy panels (U-atrium.front, index.html) - same
   base rhythm as .view above, so the single-column stack below 1024px reads
   identically to every other view's own gap; the desktop 2-column
   placement is added below, inside the same media query .view[data-view=
   "trade"]/.view[data-view="mentor"]/.view[data-view="home"] already use. */
.home-legacy-grid {
  display: grid;
  gap: var(--gap);
}

/* Learn's own two panel groups (gains-rapides U2, index.html) - same base
   rhythm as .home-legacy-grid above, for the same reason: below 1024px this
   class draws nothing of its own (no grid-template-columns applies outside
   the media query below), so the group's panels stack with the identical
   gap every other view uses, and `.path-side` (hidden below 1024px, see
   the ".path-side { display: none; }" rule further down) contributes
   nothing either. There are two elements sharing this one class - the
   campus portal group before `.path-side` in the DOM, the rest of the path
   after it - not one: see the desktop rule below for why splitting the
   panels into two groups, rather than one, is what makes the sticky
   sidebar's own row-span stop drifting every time a panel is added. */
.learn-panels-col {
  display: grid;
  gap: var(--gap);
}

/* The desk, in two columns. The annexe draws the market on the left, the
   ticket down the right, and the positions running the full width beneath
   them; the proto does the same with a third column for an order book, which
   borne 3 excludes - so two, and the width it frees goes to the chart.

   Placed from the sheet and not from the markup, deliberately: a wrapper
   around each column would need a class the script never reads, and the id
   contract in this repo runs both ways. Three panels, three grid areas, no
   new element. Below 1024px the whole thing folds back to one column, where
   `grid-template-columns` simply stops applying. */
@media (min-width: 1024px) {
  .view[data-view="trade"] {
    grid-template-columns: minmax(0, 1fr) 22.5rem;
    align-items: start;
  }

  /* The market, then the ticket beside it, then the desk under both. */
  .view[data-view="trade"] > .panel:nth-child(3) { grid-column: 1 / -1; }

  /* The bridge on the left, the inbox down the right - the order the markup
     already has, so nothing needs placing. The rail is fixed at the same width
     as the ticket rather than following the proto's 1:1.15 ratio, and that is a
     correction rather than a liberty: the proto gives its wide column to the
     inbox, but in this front it is the BRIDGE that is the tall panel, so
     copying the ratio would have widened the wrong side. Measured, the fixed
     rail saves 219.5px against 177.4px for the proto's proportions. */
  .view[data-view="mentor"] { grid-template-columns: minmax(0, 1fr) 22.5rem; }

  /* Home: the day's work down the left, where you stand down the right.
     Five panels since W2 - Today, Where you are, The path, Next grade,
     Regularity - and every one is placed by hand, because the stats panel
     already paid for the lesson: grid's cursor never moves backwards, and
     two panels left to auto-placement land in whichever cell the cursor
     reached, stretched to the tall path beside them. The rail stacks the
     three standing cards; the path takes the left column's remaining rows.
     `align-items: start` so a short rail card hugs its content instead of
     stretching to the row the path defines.

     Scoped to .home-legacy-grid (U-atrium.front), not .view[data-view=
     "home"] any more: the Atrium's own four sections (index.html) are now
     the view's first children, and nth-child placement meant for these
     five panels would have grabbed the Atrium's sections instead - measured
     at the real navigator, the tabs and closing sections landing side by
     side in these same two columns rather than stacked. The five panels
     below are wrapped in their own grid container so this placement still
     addresses THEM, nth-child(1) through nth-child(5) exactly as before. */
  .home-legacy-grid {
    grid-template-columns: minmax(0, 1fr) 22.5rem;
    align-items: start;
  }
  .home-legacy-grid > .panel:nth-child(1) { grid-area: 1 / 1 / 2 / 2; }
  .home-legacy-grid > .panel:nth-child(2) { grid-area: 1 / 2 / 2 / 3; }
  .home-legacy-grid > .panel:nth-child(4) { grid-area: 2 / 2 / 3 / 3; }
  .home-legacy-grid > .panel:nth-child(5) { grid-area: 3 / 2 / 4 / 3; }
  .home-legacy-grid > .panel:nth-child(3) { grid-area: 2 / 1 / 4 / 2; }

  /* Account's own two columns used to live here. RESTYLE U5 moved them to
     the cabinet section at the end of this sheet, and the reason is the trap
     these very rules were standing in: they addressed three of the five
     children by `nth-child`, so the two last drawers fell to the grid cursor,
     and inserting anything at the top of that view would have renumbered all
     three in silence - the defect measured at U-atrium.front with the whole
     suite green. The placement E09 measured and E10 earned is NOT redecided:
     the offers drawer is still the tall one and still takes a column of its
     own, with the short ones stacked beside it. What changed is that all
     five are placed by their own class, and none by a position. */

  /* Learn (N4, JEU_TRACKER.md constat N4): a fifth columned view, closed
     over test_the_two_column_views_are_composed_from_the_sheet's own
     COLUMNED_VIEWS list in the same commit.

     gains-rapides U2 (debt accepted at the Atrium/Campus merge,
     GAINS_RAPIDES_TRACKER.md unit 2): the panels used to sit directly
     under this view, one plain rule reaching every `.panel` on column 1,
     and `.path-side` spanned column 2 for as many rows as that count -
     "seven", raised from six by chart(F06). UNITE CAMPUS-FRONT then
     prepended two more panels (the campus portal, its map) ahead of
     `.path-side` in the DOM without anyone touching that seven - a literal
     ceiling sized against CONTENT, and content is exactly what a Learn
     unit adds. Above 1024px the rail stopped covering the column the
     moment the ninth panel's row came due (measured: the campus map panel
     opening pushes column 1 to nine rows while the rail still only
     reached seven).

     Fixed the way `.home-legacy-grid` (above) already fixed the sibling
     regression on Home: the panels that share column 1 are wrapped in
     their own grid container (`.learn-panels-col`, index.html) rather than
     placed directly under this view. `.path-side` sits in the DOM between
     the two groups the Campus front now makes (the portal+map pair before
     it, the other seven panels after it) and stays there - moving it
     would reorder keyboard focus for no CSS reason, and grid placement
     does not care where in the DOM an item sits. What `.path-side` now has
     to span is the COUNT OF GROUPS flanking it, not the count of panels
     inside them: two, not nine, and two is a number a panel added to
     either group can never change - only a THIRD group splitting around
     this aside would, which is a deliberate restructure a reviewer would
     see, not a silent drift. `-1` still cannot stand in for it: this view
     sets no `grid-template-rows`, so `-1` counts the explicit grid's own
     last line, which is line 1 with nothing declared, and collapses the
     span to nothing - unchanged by this fix, still measured true.
     test_the_span_two_covers_a_panel_added_to_either_learn_group
     (tests/test_front_end_jeu_parcours.py) proves the count stays two
     after a synthetic panel is inserted into each group in turn. */
  .view[data-view="learn"] {
    grid-template-columns: minmax(0, 1fr) 22.5rem;
    align-items: start;
  }
  .view[data-view="learn"] > .learn-panels-col { grid-column: 1; }
  .view[data-view="learn"] > .path-side {
    display: flex;
    flex-direction: column;
    gap: var(--gap);
    grid-column: 2;
    grid-row: 1 / span 2;
    /* Sticky, at the prototype's own offset (14px,
       TrainingLab Prototype.dc.html:221) - not the app shell's own
       `.sidebar` (top: 0), because that one sits flush with the viewport
       it belongs to and this one sits inside scrollable page content one
       level in. `align-items: start` above is load-bearing for this to do
       anything at all - the exact lesson `.sidebar` already paid for
       (style.css, ".shell"/".sidebar" comment): a grid stretches its items
       by default, which would pull this column to the height of both
       groups' own combined span and leave no room to stick within. */
    position: sticky;
    top: .875rem;
  }

}

/* --- The brief banner ------------------------------------------------- */

/* Deliberately the loudest thing on the page. Everything else here is a
   display of numbers the user could get elsewhere; this is the one element
   that points at what the product actually does. */
.brief {
  display: flex;
  align-items: center;
  gap: var(--gap);
  flex-wrap: wrap;
  padding: var(--pad);
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--flat);
  border-radius: var(--radius);
  background: var(--bg-raised);
}

.brief-main { flex: 1; min-width: 18rem; }

.brief-title {
  margin: 0 0 .2rem;
  font-size: 1rem;
  font-weight: 600;
}

/* One step louder than muted: the proto paints the dock's explanation line
   --td-soft, beside a close button in --td-faint. The stale state below still
   overrides it to --warn, which is a statement about the data and outranks a
   statement about hierarchy. */
.brief-detail {
  margin: 0;
  color: var(--text-soft);
  font-size: .875rem;
}

.brief-detail code {
  font-family: var(--font-mono);
  color: var(--text);
}

.brief-actions {
  display: flex;
  align-items: center;
  gap: .6rem;
}

/* Ready to be read by the user's AI. */
.brief[data-state="ready"] { border-left-color: var(--accent); }

/* Written before the last event: regeneration did not keep up, and saying so
   matters more than looking tidy. */
.brief[data-state="stale"] { border-left-color: var(--warn); }
.brief[data-state="stale"] .brief-detail { color: var(--warn); }

/* --- Panels ----------------------------------------------------------- */

/* Glass, at the proto's own values: a 5% white veil in the dark theme, an 80%
   white one in the light, over a 14px blur. Thirty of the maquette's forty-two
   backdrop-filters are this exact panel, so it is the material rather than an
   effect - which is why it lands here, on the one class eleven sections share,
   and not on each of them.

   The opaque line comes FIRST and the glass overrides it, because the order is
   the fallback: a browser without backdrop-filter, a browser with it switched
   off, or a printout, all land on --bg-raised - the surface every ratio in the
   suite was measured against before this unit. The @supports form is copied
   from the second export rather than invented, down to the -webkit- half.

   What the glass costs is measured, not assumed: on a panel the ink no longer
   sits on --bg-raised but on this veil over the page decor, and four inks lost
   their floor in the dark theme when it went on. They were retuned in the same
   commit - see the token block, and test_the_ink_on_a_glass_panel_clears_its
   _floor. Retuning the ink rather than the material is the project's own
   order of precedence: the palette is derived from the measurement. */
.panel {
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  overflow: hidden;
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .panel {
    background: var(--glass-panel);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
}

.panel-head {
  display: flex;
  align-items: baseline;
  gap: var(--gap);
  flex-wrap: wrap;
  padding: .75rem var(--pad);
  border-bottom: 1px solid var(--border);
}

.panel-head h2 {
  margin: 0;
  font-size: .8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--text-muted);
}

.panel-head .spacer { flex: 1; }

/* JEU-FIX2-F7/F9 (jeu-socle-noor.md F2): #recall-close carried no rule of
   its own and fell back to the generic `button` - measured at 36.45px,
   under the 44px floor C13/CLASSES.md name, on the one control that closes
   every practice session this product runs. Same fix as #theme-toggle
   above: min-height/min-width alone, nothing else about its resting look
   moves. */
#recall-close {
  min-height: 2.75rem;
  min-width: 2.75rem;
}

.panel-body { padding: var(--pad); }
.panel-body.flush { padding: 0; }

/* --- The second sign-in door (G05) ------------------------------------ */

/* Separated from the form by a rule rather than by whitespace: the two are
   alternatives, and a gap alone reads as "and then", which is what an
   accidental second step looks like. */
.signin-alt {
  margin-top: var(--pad);
  padding-top: var(--pad);
  border-top: 1px solid var(--border);
}

/* An anchor that looks like the button beside it, because it does the same
   kind of thing. `inline-flex` so the padding applies and the box stays the
   width of its words rather than the width of the card. */
.alt-door {
  display: inline-flex;
  align-items: center;
  margin: .5rem 0;
  padding: .35rem .7rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-sunken);
  color: var(--text);
  text-decoration: none;
}

.alt-door:hover { border-color: var(--text-faint); }

/* The screen a refused sign-in lands on (G08, `server.AUTH_SCREEN`).

   That page is served by the route itself, so it has no `.shell` and no
   `.column` around it — and `main`'s `flex: 1` is inert without a flex parent.
   Left alone it stretched one sentence across a desktop monitor, which was
   visible the moment somebody opened it in a browser and invisible to every
   test. A reading measure and a centre, in the same `ch` unit the other prose
   columns of this sheet use. */
.auth-screen {
  width: 100%;
  max-width: 62ch;
  margin: 0 auto;
}

/* The identity this browser already carries, above the one the link offers
   (G22, Léa on G10). Deliberately NOT muted like `.hint` below it: it is the
   reference the reader compares the next sentence against, so it has to be the
   thing read first. The rule under it separates two statements about two
   different accounts — without it the two lines read as one paragraph, which is
   the confusion this line exists to remove. */
.signed-in-as {
  margin: 0 0 .6rem;
  padding: 0 0 .5rem;
  border-bottom: 1px solid var(--border);
  color: var(--text);
  font-size: .875rem;
}

/* --- The inbox -------------------------------------------------------- */

.badge {
  padding: .1rem .45rem;
  border: 1px solid var(--accent);
  border-radius: 999px;
  color: var(--accent);
  font-size: .6875rem;
  letter-spacing: .04em;
}

/* A badge that is its own block-level element rather than a span inside a line
   of text — the experiment's "Reconstructed data" label (W3). Found in the
   browser and nowhere else: `.badge` has no `display`, so on a `<p>` the pill
   stretched the whole width of the panel and read as a banner. The label has to
   stay a paragraph of its own (it is served on every state, including the one
   with no day to describe), so it hugs its text instead. */
.badge-standalone {
  display: inline-block;
  margin-bottom: .35rem;
}

.reply {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  margin-bottom: var(--gap);
  overflow: hidden;
}

.reply:last-child { margin-bottom: 0; }

.reply[data-unread="true"] { border-left: 3px solid var(--accent); }

.reply-head {
  display: flex;
  align-items: baseline;
  gap: .75rem;
  flex-wrap: wrap;
  padding: .6rem var(--pad);
  background: var(--bg-sunken);
  border-bottom: 1px solid var(--border);
}

.reply-title { font-weight: 600; font-size: .9375rem; }

.reply-meta {
  font-family: var(--font-mono);
  font-size: .75rem;
  color: var(--text-faint);
}

.reply-head .spacer { flex: 1; }

/* Rendered by the same function the terminal prints, so it is shown the way a
   terminal shows it: monospace, pre-wrapped, nothing re-interpreted here. */
.reply-text {
  margin: 0;
  padding: var(--pad);
  font-family: var(--font-mono);
  font-size: .8125rem;
  /* The body of a reply, soft: the proto writes this same block in --td-soft,
     under a monospace header the front already matches. It inherited full ink
     only because nothing here declared a colour. */
  color: var(--text-soft);
  line-height: 1.5;
  white-space: pre-wrap;
  overflow-x: auto;
}

/* --- Statistics ------------------------------------------------------- */

/* 240px, and it never was: this rule has the same specificity as `.chart` and
   is written 227 lines above it, so `.chart { height: 380px }` has been
   winning all along and the equity curve renders at 380. Kept, corrected, and
   moved after its rival rather than deleted, because a shorter curve is what
   it was asking for. */
.chart.chart-equity { height: 240px; }

.histogram {
  display: grid;
  grid-template-columns: 8.5rem 1fr 3rem;
  gap: .25rem .75rem;
  align-items: center;
  margin-top: .5rem;
}

.histogram .bucket {
  font-family: var(--font-mono);
  font-size: .8125rem;
  color: var(--text-muted);
  white-space: nowrap;
}

/* The one track in this front. --glass-track exists for exactly this: the
   groove a bar is drawn in, which has to read as an empty channel rather than
   as a value of zero. No blur - it is inside a panel that already carries one,
   and a backdrop-filter inside a backdrop-filter only blurs its parent's own
   paint, never the page behind it. */
.histogram .bar-track {
  height: .75rem;
  background: var(--glass-track);
  border-radius: 2px;
  overflow: hidden;
}

/* Losses left of zero, gains right: the colour states the sign of the bucket
   and nothing else. An empty bucket keeps its row - "never happened" is a
   result too. */
.histogram .bar {
  height: 100%;
  background: var(--flat);
  border-radius: 2px;
}

.histogram .bar[data-side="loss"] { background: var(--down); }
.histogram .bar[data-side="gain"] { background: var(--up); }

.histogram .count {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .8125rem;
  text-align: right;
  color: var(--text-muted);
}

/* The cautions, in the maquette's caution surface. They were a bare list in a
   muted grey; the second export gives this exact block a warm wash, a warm
   ring and the ink that was tuned to be read on it. Nothing about the wording
   changes - the engine still decides what is listed here. */
.cautions {
  margin: var(--gap) 0 0;
  padding: .7rem .8rem .7rem 1.9rem;
  border: 1px solid var(--ring-warn);
  border-radius: var(--radius);
  background: var(--wash-warn);
  color: var(--warn-text);
  font-size: .875rem;
}

.cautions li { margin-bottom: .3rem; }

/* --- The demo, and the journal ---------------------------------------- */

/* The strategy's rules, shown before it is started - the same lines the
   terminal prints, so the screen cannot advertise a softer version. */
.rules {
  margin: var(--gap) 0 0;
  padding-left: 1.1rem;
  color: var(--text-muted);
  font-size: .875rem;
}

.rules li { margin-bottom: .2rem; }

.event {
  display: flex;
  gap: .9rem;
  align-items: baseline;
  padding: .6rem 0;
  border-bottom: 1px solid var(--border);
}

.event:last-child { border-bottom: 0; }

.event-when {
  font-family: var(--font-mono);
  font-size: .75rem;
  color: var(--text-faint);
  white-space: nowrap;
}

.event-kind {
  font-size: .6875rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
  min-width: 5.5rem;
}

/* The plain-English note is the point of the journal: it is what the user's AI
   reads, so it is what the screen shows - not a re-description of it.

   Soft rather than full white, which is a correction and not a demotion. The
   proto paints this note --td-soft at both places it appears (the Trade
   journal and the Account one), against a monospace timestamp in --td-faint -
   and the front already had that half right. Nothing in the proto's journal
   line is at full ink; being the point of the journal is what the LAYOUT says,
   by giving the note the whole remaining width. */
.event-note {
  flex: 1;
  font-size: .875rem;
  color: var(--text-soft);
}

/* --- Numbers ---------------------------------------------------------- */

.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

.price {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 1.75rem;
  line-height: 1.1;
}

.up { color: var(--up); }
.down { color: var(--down); }
.flat { color: var(--flat); }

.label {
  display: block;
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
  margin-bottom: .15rem;
}

.readout {
  display: flex;
  gap: 2rem;
  flex-wrap: wrap;
  align-items: flex-end;
}

.readout .figure { min-width: 7rem; }

.figure .value {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 1rem;
}

/* --- Tables ----------------------------------------------------------- */

.table-scroll { overflow-x: auto; }

table.grid {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .875rem;
}

table.grid th {
  text-align: left;
  font-family: var(--font-ui);
  font-size: .6875rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
  padding: .6rem .6rem;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

table.grid td {
  padding: .55rem .6rem;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

/* An empty state is a sentence, not a figure: under nowrap it slid behind
   the horizontal scroll and read as "Nobody is listed. A name here woul" on
   a phone - a doctrine sentence cut mid-word. Found in the browser. */
table.grid td.empty { white-space: normal; }

/* Same lesson, same table, a different cell: a REFUSED figure carries the
   engine's whole sentence, and under nowrap a 156-character reason claims 1334
   pixels of column. MEASURED in the browser at 1280 (K-V3-P2-2, 2026-08-22):
   with the eleven weekly lines' five refused cells, `table.grid` demanded
   6994px inside a 1042px frame - five columns of nine off screen, absorbed in
   silence by the container's own `overflow-x` exactly as the C-1 blocker of
   2026-08-19 was. Wrapping the five brings it back to 1042px in a 1042px
   frame; wrapping only the three this unit touched leaves 3342px, because the
   two that already carried a reason since U4.c hold the table open on their
   own - so the class goes on every refused cell of this table, not only the
   new ones.

   Scoped to `#watchlist-body` and never to the shared class: `table.grid` runs
   several other screens, and a rewrite there would be the "classe partagee
   reecrite globalement" this repo has already paid for.

   The floor is MEASURED on the real population - the eleven weekly rows
   together, never one row alone, because the first column widens with the
   longest id and takes the rest with it. At 1280: 7rem gives 1042px in a
   1042px frame and ZERO cells out of frame; 8rem gives 1046px and clips the
   badge column on all eleven. At 1440: 1202px in 1202px, zero out of frame.
   At 375 the table still scrolls (982px in 349px) - so does an ordinary daily
   row (692px in 349px, measured on the same page), so that is the pre-existing
   shape of a nine-column table on a phone and not something this rule
   introduces.

   There is no layout engine in this suite, so the textual pin on this rule is
   the SOCLE and not a complement - the same assumed exception
   `.console-orb-label` documents. What this does NOT fix, and what is named at
   the tracker instead: the row is 366px tall at 1280, the same sentence five
   times over. That heaviness is Lea's open reserve (V2-corr2-c), and a short
   form with a footnote is her call - never a return to the bare dash. */
/* A panel's gutter belongs at the panel's edge, not between every pair of
   columns. Twelve columns at --pad each side spent four hundred pixels on
   whitespace, which is how a P&L ends up off screen with the row it belongs
   to still visible. The container still scrolls; it just rarely has to. */
table.grid th:first-child,
table.grid td:first-child { padding-left: var(--pad); }

table.grid th:last-child,
table.grid td:last-child { padding-right: var(--pad); }

table.grid tr:last-child td { border-bottom: 0; }

table.grid .right { text-align: right; }

/* The asset class beside a symbol: present, but quieter than the numbers. */
table.grid .muted { color: var(--text-muted); }

/* U4.c: a market's distinct label, under its id in the watchlist's first
   cell - EUR (the frozen Hyperliquid perp) and EURUSD (the ECB fix) share a
   currency and nothing else. `white-space: normal` overrides the table's own
   nowrap so the label wraps within the column instead of forcing the whole
   row wider - the id above it still reads at a glance, and the fuller name
   is there in full underneath it, never truncated. */
table.grid .market-name-sub {
  display: block;
  white-space: normal;
  color: var(--text-muted);
}

table.grid .side {
  font-family: var(--font-ui);
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-muted);
}

/* --- Freshness and status -------------------------------------------- */

.age {
  font-size: .8125rem;
  color: var(--text-muted);
}

.age[data-state="stale"] { color: var(--warn); }
.age[data-state="offline"] { color: var(--down); }

/* A cached mark is not an error and not a live price: it is the third thing
   the engine actually reports, and it gets its own quiet shade rather than
   being rounded up to "live" or down to "stale". */
.age[data-state="cache"] { color: var(--text-faint); }

/* U-console.2f: `sources.FRESHNESS_UNREAD` - nothing has been read from the
   market's own source at all, which is neither a verified-fresh number nor
   a verified-too-old one. Not `--warn` (that colour is the engine's own
   verdict "this number IS too old", a claim `unread` never makes) - the
   same quiet shade as a cached mark, the repo's own precedent for "a third
   thing the engine reports" rather than a rounding of the other two. */
.age[data-state="unread"] { color: var(--text-faint); }

/* Fetched this instant, and behind the market anyway - a delayed provider.
   It is not `ok`, which has no colour of its own and would read as fine, and
   it is not `stale`, a word the engine owns for an outage fallback. Same
   warning shade as stale because the consequence is the same: the number
   under the button is not the market's current price. */
.age[data-state="delayed"] { color: var(--warn); }

/* JEU-FIX2-F7/F9 (jeu-socle-noor.md F2): #theme-toggle carried no rule of
   its own and fell back to the generic `button` (padding: .35rem .7rem on
   15px/1.55 text) - measured at the box model, that is 36.45px, under the
   44px floor C13/CLASSES.md name for every tap target, on a control present
   on all eleven screens. min-height/min-width alone, so its own padding and
   text keep deciding its resting look. */
#theme-toggle {
  min-height: 2.75rem;
  min-width: 2.75rem;
}

/* I2 (chantier i18n): the language toggle, styled to match #theme-toggle
   right beside it - same surface and border tokens `button` reads, same
   44px floor (C13/CLASSES.md), because it is another instance of the same
   kind of control rather than a new one. A native <select> already ships
   full keyboard operation; :focus-visible (below) is the one rule it still
   needs and already gets, being unscoped. */
.lang-toggle-wrap {
  display: inline-flex;
  align-items: center;
}

/* U-console corrections (Léa NO_GO, phase A review, B1): #console-lang-toggle
   is the console's own reachable copy of this same control (index.html) -
   same rule, same tokens, extended rather than duplicated. */
#lang-toggle,
#console-lang-toggle {
  font: inherit;
  color: var(--text);
  background: var(--bg-sunken);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: .35rem .55rem;
  min-height: 2.75rem;
  cursor: pointer;
}

#lang-toggle:hover,
#console-lang-toggle:hover { border-color: var(--text-faint); }

/* --- Chart ------------------------------------------------------------ */

/* No background. The chart used to carry an opaque slab of --bg-raised, 1042
   by 380 px, in the middle of a glass panel - it was what kept the lower halo
   80.9% hidden, and the proto has none of it: its own chart is declared
   `background: { color: 'transparent' }`.

   Two gestures were needed, not one, and removing only the CSS proved nothing:
   the library repaints the canvas from app.js on every theme switch, so the
   slab came straight back. It is safe because lightweight-charts paints its
   pane background with globalCompositeOperation "copy", which replaces the
   destination pixels alpha included - the requested alpha survives instead of
   being blended. Verified by reading the canvas back: rgba(0,0,0,0). */
.chart {
  height: 380px;
  width: 100%;
}

/* --- States ----------------------------------------------------------- */

.empty,
.error {
  padding: var(--pad);
  color: var(--text-muted);
  font-size: .9375rem;
}

.error {
  color: var(--down);
  border-top: 1px solid var(--border);
}

/* --- The order ticket ------------------------------------------------- */

.fields {
  display: flex;
  gap: var(--gap);
  flex-wrap: wrap;
  align-items: flex-end;
}

.field { display: flex; flex-direction: column; }

/* The two buttons and, when the engine refuses, a whole sentence saying why -
   all three in one flex row. `flex-shrink` defaults to 1, so the sentence
   squeezed the button: measured at 79 px wide and 105 px TALL, its label
   broken across two lines inside a box more square than a control.

   The same defect as the keeper in the dock pill, in a second place. That one
   was found first and fixed on its own, which fixed a symptom; this rule is
   the pattern. A control never shrinks, and a refusal is a sentence rather
   than a column - it takes the next line instead of a sliver of this one. */
.field.actions {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: .5rem;
}

.field.actions > button { flex: none; }

.field.actions > .note { flex: 1 0 100%; }

/* U-cB.1 (CONSOLE_TRACKER sec.1, measured): "#ticket-send has no rule of
   its own - the button generique of style.css, identical to Close position
   beside it" was the one concrete gap that tracker's audit named against a
   real location, and root-cause of most of the dissatisfaction it read
   (sec.1: "80% of it is kit debt, not drift"). Scoped to #console-app-order
   rather than rewritten globally, because the ticket's own generic
   `button` styling is still what every OTHER form on this page (sign-in,
   reset, the demo order form...) falls back to - one control earns a
   dedicated rule here for being the one every trade goes through, not
   every button everywhere. Full width, coloured by the side it would open:
   the SAME data-* idiom #ticket-confirm-yes already uses two screens down
   (askConfirmation, dataset.confirmSide) - one convention, not two -
   written by markTicketSide (app.js) on load and on every side toggle. */
#console-app-order .field.actions {
  flex-direction: column;
  align-items: stretch;
}

#console-app-order #ticket-send {
  flex: none;
  width: 100%;
  padding: .6rem .8rem;
  font-weight: 600;
  border-radius: var(--radius);
  border: 1px solid transparent;
}

#console-app-order #ticket-send[data-side="long"] {
  background: var(--side-long);
  color: var(--side-long-ink);
}

#console-app-order #ticket-send[data-side="short"] {
  background: var(--side-short);
  color: var(--side-short-ink);
}

#console-app-order #ticket-close { width: 100%; }

/* ==========================================================================
   fidelite.C3 - the touch of an instrument (CONSOLE_TRACKER sec.2/3, D3).
   Everything below is scoped to the Console's Order app (`#console-app-order`),
   the one place `form#ticket` lives today - scoped by the habit of this
   sheet, not because a second ticket exists (Lea F3, revue C3).
   ========================================================================== */

/* --- 1. The direction rail (sec.3 point 1 - "retenue telle quelle") ---
   The two buttons stay the control (markTicketSide + aria-pressed); the rail
   is redrawn as a hollow with ONE key that slides. The key is the rail's own
   ::before - placed by :has() on the pressed button; no script decides where
   it sits. The slide takes 160 ms but colour and lamp switch in step-end:
   the key travels first; the new pole's lamp lights at the end of its run -
   a contact closes at the end of the course. (No comma in this comment: the
   transform-transition guard reads the text before a brace as a selector.) */
#console-app-order .direction-rail {
  position: relative;
  isolation: isolate;
  gap: 0;
  padding: 3px;
  border-radius: calc(var(--radius) + 3px);
  background: var(--bg-sunken);
  box-shadow: var(--rail-shadow);
}

#console-app-order .direction-rail::before {
  content: "";
  position: absolute;
  z-index: 0;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  border-radius: var(--radius);
  background: var(--side-long);
  box-shadow: var(--rail-key-shadow);
  transform: translateX(0);
  transition: transform .16s cubic-bezier(.4, 0, .2, 1), background-color 0s step-end;
}

#console-app-order .direction-rail:has(.segment-short[aria-pressed="true"])::before {
  transform: translateX(100%);
  background: var(--side-short);
}

#console-app-order .direction-rail .segment {
  position: relative;
  z-index: 1;
  background: transparent;
  border-color: transparent;
  color: var(--text-muted);
  transition: color 0s step-end;
}

#console-app-order .direction-rail .segment[aria-pressed="true"] {
  background: transparent;
  border-color: transparent;
}

#console-app-order .direction-rail .segment-long[aria-pressed="true"] { color: var(--side-long-ink); }
#console-app-order .direction-rail .segment-short[aria-pressed="true"] { color: var(--side-short-ink); }

/* The lamp: a small dot before each label, lit only on the pressed pole. */
#console-app-order .direction-rail .segment::before {
  content: "";
  display: inline-block;
  width: .45rem;
  height: .45rem;
  margin-right: .3rem;
  border-radius: 50%;
  vertical-align: middle;
  background: var(--lamp-off);
  box-shadow: var(--lamp-off-shadow);
  transition: background-color 0s step-end, box-shadow 0s step-end;
}

#console-app-order .direction-rail .segment[aria-pressed="true"]::before {
  background: var(--lamp-on);
  box-shadow: var(--lamp-on-glow);
}

/* --- 2. The dials grid (D3): four places, two live ---
   `grid-template-areas` reserves a cell for limit and take profit; while
   those two are `hidden` the grid simply has two rows of one live dial each
   at 45rem and under, two columns above it. */
#console-app-order .dials {
  flex-basis: 100%;
  display: grid;
  /* The stop column a little wider than the amount's: its label ("Prix du
     stop (optionnel)") is the longest of the two and measured on two lines
     in FR at equal columns. */
  grid-template-columns: minmax(0, .88fr) minmax(0, 1.12fr);
  grid-template-areas:
    "amount stop"
    "margin margin"
    "plate plate"
    "limit tp";
  gap: .4rem .8rem;
}

#console-app-order .dial-margin { grid-area: margin; margin: 0; }

#console-app-order .dial-label-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .3rem;
}

#console-app-order .dial-label-row .label { margin: 0; min-width: 0; white-space: nowrap; }
#console-app-order .dial-label-row .dial-key { padding: .2rem .35rem; flex: none; }

/* The state badge of the Order panel head ("paper only - EURUSD - EUR/USD -
   ECB reference fix (currency)") took two lines in FR and the market label
   it carries is the <select>'s own text one row below. One line, clipped
   with an ellipsis: "paper only" - the part rule 1 needs - always shows,
   the label is read in full by assistive tech and shown in full in the
   picker beneath. Measured: 20 px of the FR budget (Iris F1).
   Cliquet: a clipped badge is a visual economy, never a hidden mention. */
#console-app-order #ticket-state {
  min-width: 0;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Hypothesis: label and field side by side in the Console (Iris F1). */
#console-app-order #ticket .field-hypothesis {
  flex-direction: row;
  align-items: center;
  gap: .6rem;
}
#console-app-order #ticket .field-hypothesis .label { margin: 0; flex: none; }
#console-app-order #ticket .field-hypothesis input { flex: 1; min-width: 0; width: auto; }

/* The ticket has to fit the column (sec.3 of FIDELITE_TRACKER: "le ticket
   tient en un ecran" - measured 1 358 px of form in a 594 px column before
   this unit). Tighter rows in the Console only; the generic ticket keeps
   the kit's own gap. */
#console-app-order #ticket .fields { gap: .4rem .75rem; }
/* Found at 375 while measuring this unit: a native <select> takes the
   width of its LONGEST option - "EURUSD - EUR/USD - ECB reference fix
   (currency)" made the market picker 662 px wide in a 243 px form and the
   panel scrolled sideways. The field is the form's width; the browser
   truncates the option text on its own. */
#console-app-order #ticket-market { width: 100%; max-width: 100%; min-width: 0; }

#console-app-order .dial-amount { grid-area: amount; }
#console-app-order .dial-stop { grid-area: stop; }
#console-app-order .dial-plate { grid-area: plate; }
#console-app-order .dial-limit { grid-area: limit; }
#console-app-order .dial-tp { grid-area: tp; }

/* Iris F1 (revue C3, BLOQUANT, 2026-08-23): "the ticket fits one screen"
   was measured in EN only - in FR the Direction rail ("Long (achat)" /
   "Short (vente)") grew to 257 px, pushed "Order type" onto its own row and
   the send key 162 px under the column. The rail and the order type share
   ONE row in the three languages: the rail's field takes what is left and
   shrinks, its two keys share it; the order-type field keeps its own size. */
#console-app-order #ticket .field:has(.direction-rail) { flex: 1 1 0; min-width: 0; }
#console-app-order #ticket .field:has([data-order-type]) { flex: 0 0 auto; }
#console-app-order .direction-rail .segment {
  flex: 1 1 0;
  min-width: 0;
  font-size: .8125rem;
  padding: .45rem .35rem;
}

#console-app-order .dial {
  display: flex;
  flex-direction: column;
  gap: .3rem;
  min-width: 0;
}

#console-app-order .knob-arc-row {
  display: flex;
  align-items: center;
  gap: .55rem;
}

#console-app-order .knob-arc {
  position: relative;
  flex: none;
  width: 70px;
  height: 57px;
}

#console-app-order .karc {
  position: absolute;
  left: 0;
  top: 0;
  width: 70px;
  height: 35px;
  overflow: visible;
  touch-action: none;
  cursor: pointer;
}

#console-app-order .arc-track { fill: none; stroke: var(--border-strong); stroke-width: 3; stroke-linecap: round; }
#console-app-order .arc-hit { fill: none; stroke: transparent; stroke-width: 18; pointer-events: stroke; }
#console-app-order .arc-progress { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; filter: drop-shadow(0 0 4px var(--ring-accent)); }
/* CONSOLE_TRACKER sec.2: "at rest the Stop is the most LUMINOUS object of
   the screen" - its arc is a stroke wider and its halo half as wide again
   as the amount dial's (Lea F1, revue C3). Static: nothing moves. */
#console-app-order .dial-stop .arc-progress { stroke: var(--warn); stroke-width: 3.5; filter: drop-shadow(0 0 6px var(--warn)); }
#console-app-order .arc-puck-base { fill: var(--knob-puck); filter: var(--knob-puck-shadow); }
#console-app-order .arc-puck-hl { fill: var(--knob-puck-hl); opacity: .9; }
#console-app-order .karc.off .arc-progress { opacity: 0; }
#console-app-order .karc.off .arc-puck-base,
#console-app-order .karc.off .arc-puck-hl { opacity: .5; }

/* The knob: top view; a brushed cap on a bevelled body. Pure CSS - the
   photo in docs/design/assets-labo-console/ref-knob.webp is the reference;
   never a raster under the pointer (K-A0-7). The cap's angle is the one
   custom property the script writes (--ang; -90deg to 90deg). */
#console-app-order .knob {
  position: absolute;
  left: 50%;
  top: 35px;
  transform: translate(-50%, -50%);
  width: 44px;
  height: 44px;
  border-radius: 50%;
  cursor: ns-resize;
  touch-action: none;
  background: var(--knob-face);
  box-shadow: var(--knob-shadow);
}

#console-app-order .knob .kcap {
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  transform: rotate(var(--ang, -90deg));
  background: var(--knob-cap);
  transition: transform .1s linear;
}

#console-app-order .knob .kcap::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 3px;
  width: 2.5px;
  height: 36%;
  margin-left: -1.25px;
  border-radius: 2px;
  background: var(--accent);
  box-shadow: 0 0 4px var(--ring-accent);
}

#console-app-order .dial-stop .knob .kcap::after {
  background: var(--warn);
  box-shadow: 0 0 6px var(--warn);
}

#console-app-order .knob[data-off="true"] .kcap::after {
  background: var(--text-faint);
  box-shadow: none;
  opacity: .5;
}

#console-app-order .knob[aria-disabled="true"] { cursor: default; opacity: .55; }
#console-app-order .knob:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* The readout beside the knob IS the real field, plus its key. */
#console-app-order .kread {
  display: flex;
  flex-direction: column;
  gap: .3rem;
  min-width: 0;
  flex: 1;
}

#console-app-order .kread .num { width: 100%; min-width: 0; }
#console-app-order .kread .hint { margin: 0; }

#console-app-order .dial-key {
  align-self: flex-start;
  font: inherit;
  font-size: .75rem;
  font-weight: 600;
  padding: .25rem .6rem;
  color: var(--text-muted);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  cursor: pointer;
}

#console-app-order .dial-key:hover:not(:disabled) { border-color: var(--text-faint); }
#console-app-order .dial-key:disabled { opacity: .5; cursor: default; }

/* --- 3. The plate under the stop dial (sec.3 point 5) --- */
#console-app-order .dial-plate {
  margin-top: 0;
  padding: .4rem .6rem;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--bg-sunken);
  box-shadow: var(--plate-shadow);
  font-size: .8125rem;
  color: var(--text-muted);
}

#console-app-order .dial-plate summary {
  cursor: pointer;
  font-weight: 600;
  letter-spacing: .02em;
  color: var(--text-soft);
}

#console-app-order .dial-plate p { margin: .4rem 0; line-height: 1.45; }

#console-app-order .dial-plate-lesson {
  font: inherit;
  font-size: .75rem;
  font-weight: 600;
  padding: .25rem .6rem;
  color: var(--accent-text);
  background: transparent;
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  cursor: pointer;
}

/* --- 4. The touch (sec.3 point 3): relief; no neon ---
   A light edge on top; a 2px wall; air below. Hover lengthens the SHADOW
   only - the key acknowledges the cursor; it does not call it. Press: the
   full course against the stop; 90 ms; no bounce. Nothing here exists
   while no finger is on it: zero @keyframes at rest (sec.2 and the guard
   in tests/test_paper_only_guard.py). */
#console-app-order #ticket-send {
  position: relative;
  transform: translateY(0);
  box-shadow: var(--touch-shadow);
  transition: transform .09s ease-out, box-shadow .09s ease-out;
}

#console-app-order #ticket-send:hover:not(:disabled) {
  box-shadow: var(--touch-shadow-hover);
}

#console-app-order #ticket-send:active:not(:disabled) {
  transform: translateY(2px);
  box-shadow: var(--touch-shadow-down);
}

#console-app-order #ticket-send:disabled { box-shadow: none; transform: none; }

/* --- 5. F4's two answers (sec.3 point 4): the key LIFTS, the ring OPENS.
   Both once, both only when motion is allowed, both taken back by the
   script on animationend (ticketAnswerF4). The ring grows - the field does
   not: an input that changes size pushes the layout and moves what the eye
   came for. */
/* The ring exists ONLY while the dial carries `.ring` and motion is allowed
   - no resting rule at all (a resting `opacity: 0` on the pseudo-element
   read, to the visibility guard, as hiding the `.knob-arc` it hangs on;
   K-C3 revue, Karim F1's table extension surfaced it). Under reduced motion
   there is no ring: the question is still asked, nothing moves. */
@media (prefers-reduced-motion: no-preference) {
  #console-app-order #ticket-send.lifted { animation: ticket-lift .24s ease-out 1; }
  #console-app-order .dial-stop.ring .knob-arc::after {
    content: "";
    position: absolute;
    left: 50%;
    top: 35px;
    width: 44px;
    height: 44px;
    margin: -22px 0 0 -22px;
    border-radius: 50%;
    border: 2px solid var(--warn);
    pointer-events: none;
    animation: ticket-ring .6s ease-out 1;
  }
}

@keyframes ticket-lift {
  0% { transform: translateY(0); }
  40% { transform: translateY(-3px); }
  100% { transform: translateY(0); }
}

@keyframes ticket-ring {
  0% { opacity: .9; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.7); }
}
/* (The fade is the keyframes' own; at 100 % the ring is gone and the script
   takes the class back on animationend - there is no resting state.) */

@media (prefers-reduced-motion: reduce) {
  #console-app-order .direction-rail::before,
  #console-app-order .direction-rail .segment,
  #console-app-order .direction-rail .segment::before,
  #console-app-order .knob .kcap,
  #console-app-order #ticket-send { transition: none; }
  #console-app-order #ticket-send.lifted,
  #console-app-order .dial-stop.ring .knob-arc::after { animation: none; }
}

/* --- 6. Under 45rem: one dial per row, the knob a size smaller --- */
@media (max-width: 45rem) {
  #console-app-order .dials {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "amount" "stop" "margin" "plate" "limit" "tp";
  }
  #console-app-order .knob-arc { width: 54px; height: 44px; }
  #console-app-order .karc { width: 54px; height: 27px; }
  #console-app-order .knob { width: 34px; height: 34px; top: 27px; }
  #console-app-order .knob .kcap { inset: 3px; }
  #console-app-order .dial-stop.ring .knob-arc::after { width: 34px; height: 34px; margin: -17px 0 0 -17px; top: 27px; }
}

/* The export control (G05, RGPD chantier) is an <a download>, not a
   <button>: a download keeps the browser's own save dialog and progress
   indicator, which a click handler would only have to reimplement. It has
   to read exactly like the buttons beside it even so, so it repeats
   `button`'s own declaration below rather than the two ever drifting apart
   silently. */
.field.actions > a.button {
  font: inherit;
  color: var(--text);
  background: var(--bg-sunken);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: .35rem .7rem;
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  flex: none;
}

.field.actions > a.button:hover { border-color: var(--text-faint); }

/* The form surfaces read --input-bg and --input-border rather than the sunken
   page tokens they borrowed. The pair was declared with the palette and read
   nowhere; it exists because a control needs a surface of its own - one that
   can be tuned without moving every recessed block on the screen with it. Its
   values are the ones the maquette gives a field, and the outline clears the
   3:1 floor a control needs to be identifiable at all. */
.field input,
.field select {
  font: inherit;
  color: var(--text);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  padding: .35rem .55rem;
  min-width: 9rem;
}

/* The bridge (H19.b.a). A textarea is the one control this product needs that
   v1 never had: the answer comes back as text, and it is shown as text. */
.field textarea {
  font: inherit;
  font-family: var(--font-mono);
  color: var(--text);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  padding: .45rem .55rem;
  width: 100%;
  resize: vertical;
}

.hint {
  color: var(--text-muted);
  font-size: .85rem;
  margin: .35rem 0 .6rem;
}

/* ATTR.8 (Léa's review, correction 1): `.hint` moved OFF the TradingView
   attribution anchor and onto the <p> wrapping it (index.html, block margins
   instead of an inline element's ignored ones) - a bare <a> inherits no
   colour from any rule in this file, so without this it would fall back to
   the browser's own unstyled link blue. The one token already used for
   interactive/linked text elsewhere in this sheet (~18 call sites), never
   `.hint`'s own muted grey again - that grey crushing the link's identity is
   exactly what Léa's review named as the second half of the same bug.
   :focus-visible needs nothing here - it is already global (below).

   ATTR.9 (Karim's ATTR.8 review, WCAG 1.4.1 note, decision alone - to
   revisit): Karim read this rule as "colour only at rest" and flagged it.
   Measured in a real browser before touching anything (both themes, the
   real Trade view): the anchor WAS already underlined at rest - no rule in
   this file resets `text-decoration` on a bare `<a>` (checked: no `a {}`,
   no `*{}` reset), so the browser's own default underline was carrying the
   non-colour cue the whole time, same as what Léa's ATTR.7 review saw.
   Karim's finding did not hold up against a render. Made EXPLICIT anyway,
   deliberately, rather than left resting on an unstated browser default no
   test here checks for: the standard 1.4.1 remedy, and the same marker
   Léa's review approved. `:hover` no longer needs its own rule - the
   state it used to add is now the resting state. */
.hint a { color: var(--accent-text); text-decoration: underline; }

.bridge form { margin: .6rem 0 .2rem; }

/* The offers, and the F4 question (H19.b.b). Neither is an error state: they
   are steps the product takes on purpose, so they read like the rest of the
   page rather than like something that went wrong. */
.offers {
  list-style: none;
  margin: .6rem 0;
  padding: 0;
  display: grid;
  gap: .8rem;
}

.offers li {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .7rem .8rem;
  background: var(--bg-sunken);
}

.offers li .hint { margin: .3rem 0 .5rem; }

/* D1 (Erwann, relecture systematique, fidelite.C1 2026-08-21): a NEUTRAL
   surface, on purpose - unlike .confirm just below, this sheet is not yet a
   settled question, it is the ordinary read-back every order passes
   through. --border-strong/--bg-sunken are the same pair the offer cards
   and the export button already read for "a box on this page", never the
   confirm block's affirmative wash-up/ring-up (that green means "F4 says
   yes", a meaning this sheet has not earned). */
.review-sheet {
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: .7rem .8rem;
  margin: .6rem 0 .2rem;
  background: var(--bg-sunken);
}

.review-sheet p { margin: .6rem 0; }

.review-optout {
  display: flex;
  align-items: center;
  gap: .4rem;
  margin: .6rem 0 0;
  font-size: .8125rem;
  color: var(--text-faint);
}

/* The confirmation block, in the affirmative surface of the set. --wash-up and
   --ring-up are the maquette's "this is settled" pair, and a confirmation is
   the one place on this screen that means it. The green here is not a P&L
   statement and not an invitation: it marks a question that has been answered,
   which is why the block keeps its neutral ink. */
.confirm {
  border: 1px solid var(--ring-up);
  border-left: 3px solid var(--up);
  border-radius: var(--radius);
  padding: .7rem .8rem;
  margin: .6rem 0 .2rem;
  background: var(--wash-up);
}

.confirm p { margin: 0 0 .6rem; }

/* The figure the question is about. Large, and deliberately NOT coloured: F4
   fires because a number got big, and painting it red would answer the
   question the screen is supposed to be asking. --text is measured against
   this recessed surface; --warn and --down are not. */
.confirm-lead {
  font-size: 1.375rem;
  font-weight: 600;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

/* Confirm wears the side it would open. Adjust stays a plain button: two
   answers, equally easy to give, and only one of them needs to say which
   direction it means. */
.confirm-yes[data-confirm-side="long"] {
  color: var(--side-long-ink);
  background: var(--side-long);
  border-color: var(--side-long);
}

.confirm-yes[data-confirm-side="short"] {
  color: var(--side-short-ink);
  background: var(--side-short);
  border-color: var(--side-short);
}

.field input.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* The preview: what this order costs and risks, before it is sent. */
.preview {
  display: flex;
  gap: 2rem;
  flex-wrap: wrap;
  margin: var(--gap) 0 0;
  padding: .75rem var(--pad);
  /* Raised, not sunken, and for a measured reason: --warn and --down clear
     4.5:1 on this surface and fall to 3.8 and 4.2 on the recessed one in the
     light theme. These figures are read to decide an order; the block keeps
     its border to stay a block, and gives up a shade to stay legible. */
  background: var(--bg-raised);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
}

.preview div { display: flex; flex-direction: column; gap: .15rem; }

.preview dt {
  font-size: .6875rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
}

.preview dd {
  margin: 0;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .9375rem;
}

/* A stop is what makes a result measurable; its absence is stated, not styled
   away. Warn, never red: trading without a stop is a choice, not an error. */
.preview[data-risk="none"] dd#preview-risk,
.preview[data-risk="none"] dd#preview-risk-pct {
  color: var(--warn);
}

/* The gradation. The bands come from the design kit's proposal, not the spec,
   and they are applied to the percentage the ENGINE served. Colour is all they
   change: no adjective is added to the screen, because "heavy" is a judgement
   and the number is a fact. Someone who cannot separate these three hues still
   reads the same figure they always did. */
.preview[data-risk="light"] dd#preview-risk-pct { color: var(--text); }
.preview[data-risk="measured"] dd#preview-risk-pct { color: var(--warn); }
.preview[data-risk="heavy"] dd#preview-risk-pct { color: var(--down); }

button[disabled] {
  opacity: .5;
  cursor: not-allowed;
}

/* Why the ticket cannot send right now. Not an error: nothing failed, the
   order simply has not been placed. Red is kept for what actually went wrong. */
.note {
  margin: .75rem 0 0;
  color: var(--warn);
  font-size: .9375rem;
}

/* What the last order did. Stated once, factually, and it stays put. */
.result {
  margin: 0;
  padding: .75rem var(--pad);
  border-top: 1px solid var(--border);
  color: var(--text);
  font-size: .9375rem;
}

/* --- Controls --------------------------------------------------------- */

button {
  font: inherit;
  color: var(--text);
  background: var(--bg-sunken);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: .35rem .7rem;
  cursor: pointer;
}

button:hover { border-color: var(--text-faint); }

/* The focus ring is the one outline that has to beat every other state on the
   screen, so it takes the strongest ring of the set rather than the accent it
   shares with three pressed states. Same hue, more of it. */
:focus-visible {
  outline: 2px solid var(--ring-accent-strong);
  outline-offset: 2px;
}

/* --- Timeframe scale selector ---------------------------------------- */

/* fidelite.C3 (2026-08-23): the share-of-free-margin RANGE and its five
   named stops left this sheet with the ticket's dials (#console-app-order
   .dials, further down) - the amount dial is the control now, its keyboard
   the slider's own (arrows, Page, Home/End), its value spoken by
   aria-valuetext. The hypothesis field keeps the line below. */

/* R02/H6: its own line - a sentence sharing a row with the stop price read
   as cramped as soon as it held more than a few words, its placeholder
   clipped mid-word. Full width does not make the field feel required; it
   stays exactly as optional, just legible. */
.field-hypothesis { flex-basis: 100%; }
.field-hypothesis input { width: 100%; }

/* An unread reply, marked on the card itself. The pulse is slow and small: it
   says "this one is still waiting", not "deal with me now". It stops entirely
   for anyone who has asked their system for less motion. */
.reply[data-unread="true"] {
  border-left: 3px solid var(--accent);
}

.reply[data-unread="true"] .reply-title::before {
  content: "";
  display: inline-block;
  width: .5rem;
  height: .5rem;
  margin-right: .45rem;
  border-radius: 50%;
  background: var(--accent);
  animation: unread-pulse 2.4s ease-in-out infinite;
}

@keyframes unread-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: .35; }
}

@media (prefers-reduced-motion: reduce) {
  .reply[data-unread="true"] .reply-title::before { animation: none; }
}

/* The three gestures. Numbered by the list itself rather than by hand: the
   order is the product, and a step that loses its number stops being a step. */
.scene {
  margin: 0;
  padding: 0 0 0 1.6rem;
  display: grid;
  gap: var(--gap);
}

.step { padding-left: .2rem; }

.step::marker {
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.step-title {
  margin: 0 0 .5rem;
  font-weight: 600;
}

/* The prompt, foldable and verbatim. Closed by default because it is long;
   available always because it is what the reader is handing over. */
.prompt-peek { margin-top: .6rem; }

.prompt-peek summary {
  color: var(--text-muted);
  font-size: .875rem;
  cursor: pointer;
}

.prompt-peek pre {
  margin: .5rem 0 0;
  padding: .6rem .75rem;
  max-height: 18rem;
  overflow: auto;
  background: var(--bg-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: .8125rem;
  white-space: pre-wrap;
}

/* The keeper. Forty pixels, in both places it appears, because forty is the
   smallest size the design plate certifies for this drawing - below it the
   glasses and the gills stop being either. It is the identity mark, so it is
   never given a hover state and never asked to do a control's job. */
.keeper {
  display: inline-flex;
  flex: none;
}

/* Two sizes, and they are not a matter of taste. The design plate certifies
   this artwork down to 40 px and no further: the dock pill sits exactly on
   that floor, and the Mentor screen gets the 64 px the proto gives it, which
   is where the drawing can actually be read. Anything smaller uses the
   pictogram instead - that is what the plate drew it for. */
.keeper svg {
  width: 64px;
  height: 64px;
}

.dock-pill svg {
  width: 40px;
  height: 40px;
  /* `flex: none`, and it is not decoration. The pill is a flex row capped at
     30rem, and an SVG is a flex item like any other: with the default
     `flex-shrink: 1` a long state sentence squeezed the box to 17 px wide.
     The drawing is not distorted by that - `preserveAspectRatio` defaults to
     `meet`, so it scales to the NARROWER side and painted at 15 px, which is
     2.6x below the floor the design plate certifies. The height read 40 and
     the paint was 15; only the paint is what anyone sees. */
  flex: none;
}

/* The announcer. Present to assistive technology, absent from the layout.

   Not `display: none` and not `visibility: hidden`: both of those remove the
   node from the accessibility tree, which is the one thing this element must
   never be. The clipped-rectangle recipe is the only one that leaves it
   readable to a screen reader while taking no space and painting nothing.
   `white-space: nowrap` matters too - without it a clipped 1px box wraps its
   text and some readers announce it a word per line. */
.announcer {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The pictogram at the head of the sidebar. Monochrome and inheriting its
   colour, so it follows the brand row rather than carrying a palette of its
   own. */
.keeper-mark {
  display: inline-flex;
  flex: none;
  color: var(--accent-text);
}

.dock-pill.has-keeper {
  padding-left: .35rem;
  padding-top: .25rem;
  padding-bottom: .25rem;
}

/* The Mentor dock. Anchored to the corner rather than to a section, because
   the brief is ready whatever you are looking at. It never spans the screen
   and never dims what is behind it: nothing here is a modal, and nothing here
   has to be dealt with before the rest of the page can be used. */
.dock {
  position: fixed;
  right: var(--gap);
  bottom: var(--gap);
  z-index: 20;
  display: flex;
  /* Reversed: the pill is first in the markup - it is what you tab to - but a
     dock anchored to the bottom has to grow upward. Opened downward it pushed
     its own panel over whatever sat beneath it. */
  flex-direction: column-reverse;
  align-items: flex-end;
  gap: .5rem;
  max-width: min(30rem, calc(100vw - 2 * var(--gap)));
}

.dock-pill {
  display: flex;
  align-items: center;
  gap: .5rem;
  font: inherit;
  font-size: .875rem;
  font-weight: 600;
  color: var(--text);
  background: var(--bg-raised);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  padding: .5rem .9rem;
  cursor: grab;
  /* A-1 (2026-08-19, decision Erwann - the dock becomes a draggable
     bubble): the browser's own pan/zoom gestures would otherwise fight a
     pointermove-driven drag the instant a touch starts on this button. No
     transition anywhere near this rule, on purpose - a dragged position is
     applied on every pointermove, and animating that would make the bubble
     feel like it is chasing the finger instead of following it; there is
     therefore nothing here for prefers-reduced-motion to neutralise. */
  touch-action: none;
}

.dock-pill:hover { border-color: var(--accent); }
.dock-pill:active { cursor: grabbing; }

/* The count of replies waiting. A number, not an alarm: it says how many, and
   it says nothing about whether they are urgent. */
.dock-unread {
  min-width: 1.4rem;
  padding: 0 .35rem;
  border-radius: 999px;
  color: var(--on-accent);
  background: var(--grad-b);
  font-size: .75rem;
  text-align: center;
}

.dock .brief { margin: 0; }

/* Tabs at the foot of the desk. Real buttons with aria-pressed, like the
   sections in the sidebar: the same question deserves the same control. */
.tabs {
  display: flex;
  gap: .35rem;
}

.tab {
  font: inherit;
  font-size: .8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
  background: none;
  border: 0;
  border-bottom: 2px solid transparent;
  padding: .2rem .1rem;
  margin-right: .8rem;
  cursor: pointer;
}

.tab:hover { color: var(--text-muted); }

.tab[aria-pressed="true"] {
  color: var(--text);
  border-bottom-color: var(--accent);
}

.filter-row {
  display: flex;
  align-items: baseline;
  gap: .6rem;
  margin-bottom: .6rem;
}

.field-filter { flex-direction: row; align-items: baseline; gap: .5rem; }

/* Close, in the row it closes. Small and neutral: ending a position is not a
   verdict on it, and a coloured button here would make it feel like one. */
.row-close {
  font: inherit;
  font-size: .8125rem;
  padding: .15rem .5rem;
  color: var(--text-muted);
  background: none;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  cursor: pointer;
}

.row-close:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); }

/* The market's name, as the way into it. It reads as the name it is - the
   underline on hover is the only thing that says it can be pressed. */
.row-open {
  font: inherit;
  font-family: var(--font-mono);
  color: var(--accent-text);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

.row-open:hover { text-decoration: underline; }

/* Segmented controls: side, and order type. Both answer a closed question with
   every answer visible at once, which is what a dropdown hides. */
.segments {
  display: flex;
  gap: .35rem;
}

.segment {
  flex: 1;
  font: inherit;
  font-size: .875rem;
  font-weight: 600;
  /* A side that reads "Sell /" on one line and "Short" on the next is a side
     read twice. The group asks for the width it needs, and the row of fields
     wraps around it rather than through it. */
  white-space: nowrap;
  color: var(--text-muted);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  padding: .45rem .7rem;
  cursor: pointer;
}

.segment:hover:not(:disabled) { border-color: var(--text-faint); }

.segment:disabled {
  opacity: .5;
  cursor: default;
}

.segment[aria-pressed="true"] {
  color: var(--em);
  background: var(--glass-row-hover);
  border-color: var(--accent);
}

/* Direction, said in the colour the whole screen uses for it. The ink tokens
   exist so the label on a filled button is measured against that button, not
   against the panel it sits on. */
.segment-long[aria-pressed="true"] {
  color: var(--side-long-ink);
  background: var(--side-long);
  border-color: var(--side-long);
}

.segment-short[aria-pressed="true"] {
  color: var(--side-short-ink);
  background: var(--side-short);
  border-color: var(--side-short);
}

/* The market bar. Same shape as the scales below it on purpose: both answer
   "which one am I looking at", and a reader should not have to learn two
   vocabularies for one question. */
.markets {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem;
  margin-bottom: .6rem;
}

.market-chip {
  font: inherit;
  font-size: .875rem;
  font-weight: 600;
  letter-spacing: .02em;
  color: var(--text-muted);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  padding: .3rem .7rem;
  cursor: pointer;
}

.market-chip:hover { border-color: var(--text-faint); }

/* Chosen is said twice, by weight and by colour, never by colour alone. */
/* The pressed states, in the maquette's own vocabulary. A wash and a ring say
   "this is the current one" everywhere in the second export - on a nav entry,
   on a chip, on a lesson node - and the front had been saying it with a hover
   colour and a full-strength accent border, which is the same claim made twice
   in two different dialects. --wash-accent and --ring-accent were declared for
   exactly this and read nowhere. */
.market-chip[aria-pressed="true"] {
  color: var(--em);
  background: var(--wash-accent);
  border-color: var(--ring-accent);
}

/* Where the figures beside the chart are anchored, said next to the figures.
   It was written at the foot of the panel, which is where a reader arrives
   after having already believed something else. */
.anchor-note {
  margin: 0 0 .5rem;
  color: var(--text-faint);
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
}

.scales {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem;
}

.scale {
  font: inherit;
  font-size: .875rem;
  color: var(--text-muted);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  padding: .25rem .6rem;
  cursor: pointer;
}

.scale:hover { border-color: var(--text-faint); }

/* The chosen scale is stated by colour and by aria-pressed, not colour alone. */
.scale[aria-pressed="true"] {
  color: var(--accent);
  background: var(--wash-accent);
  border-color: var(--ring-accent);
  font-weight: 600;
}

/* chart(F01): the indicator toggle group. Same pill recipe as .scale/.scale
   above (same tokens, same aria-pressed convention) but its own selector
   rather than a shared one - two independent buttons groups, built by two
   independent functions (buildScales/buildIndicatorToggles), and a rename of
   one was never meant to touch the other. */
.indicator-toggles {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem;
  margin-top: .5rem;
}

.indicator-toggle {
  font: inherit;
  font-size: .8125rem;
  color: var(--text-muted);
  background: var(--input-bg);
  border: 1px solid var(--input-border);
  border-radius: var(--radius);
  padding: .25rem .6rem;
  cursor: pointer;
}

.indicator-toggle:hover { border-color: var(--text-faint); }

.indicator-toggle[aria-pressed="true"] {
  color: var(--accent);
  background: var(--wash-accent);
  border-color: var(--ring-accent);
  font-weight: 600;
}

/* The status line under the selector: why a scale is short, or that the
   market is trading thin right now. It is factual, never a warning to act. */
/* What is true about this market right now, shaped so it reads as a state and
   not as a sentence that happened to be coloured. The outline borrows the
   text's own colour and no fill is added: the panel stays the background these
   three colours were measured against, so the floor they clear is the floor
   they actually sit on. */
.note[data-kind] {
  display: inline-block;
  margin-top: .6rem;
  padding: .2rem .6rem;
  border: 1px solid currentColor;
  border-radius: var(--radius);
  font-size: .8125rem;
}

.note[data-kind="stale"] { color: var(--down); }
.note[data-kind="quiet"] { color: var(--warn); }
.note[data-kind="thin-history"] { color: var(--text-muted); }
/* D2-bis (U3.b): a frozen data source, never the offer's own lock two lines
   up — `--warn` rather than `thin-history`'s `--text-muted`, an amber
   "awaiting" rather than a neutral "restricted by your plan". Both are
   already measured at the 4.5:1 floor (test_every_colour_pair_meets_its_
   contrast_floor), so this spends a pair the suite already holds rather
   than opening a new one. */
.note[data-kind="source-frozen"] { color: var(--warn); }

@media (max-width: 1023px) {
  /* Below the supported width the layout still reads, it is simply narrower.
     This line existed to TIGHTEN the page padding when --pad was 20px. Now
     that --pad is 15px, leaving it at 1rem would make the narrow layout looser
     than the wide one - the override would invert its own purpose. 12px keeps
     it doing what it was written to do.

     The bottom clearance is new with the bar: the page scrolls under fixed
     glass now, and the last rows of the last panel have to be able to climb
     out from behind it - the bar's height, the dock's pill above it, and the
     inset a phone without a home button reserves at the foot of the screen. */
  main {
    padding: .75rem;
    /* P-04 : la barre est partie en haut — il ne reste au pied que la
       pastille du dock et l'inset d'un téléphone sans bouton. */
    padding-bottom: calc(5rem + env(safe-area-inset-bottom));
  }
  .chart { height: 300px; }

  .shell { display: block; }

  /* The column that was a sidebar becomes a brand strip: the keeper, the
     wordmark, and the grade in one line. The nine sections used to fold
     into a scrolling row here - still real buttons, but a row a thumb had
     to chase. They live in the top bar now (P-04, the one navigation),
     which is what that row was reaching for.

     The grade card stays, compacted to its one load-bearing sentence and
     the empty track. The first cut hid it whole, and the review was right
     to refuse that: "earned by proofs of discipline, never by P&L" is the
     product's most explicit refusal of grading by money, it exists nowhere
     else on the page, and the phone is precisely where it would have
     vanished. What goes is the furniture - the eyebrow and the second
     sentence - not the claim. */
  .sidebar {
    width: auto;
    position: static;
    min-height: 0;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    padding: .5rem 1rem;
    border-right: 0;
    border-bottom: 1px solid var(--border);
  }


  .brandmark { padding: 0; }

  .grade-card {
    flex-basis: 100%;
    margin-top: .4rem;
    padding: .4rem .625rem;
  }

  /* The card is live since W2, so the strip keeps what a glance needs: the
     rank's name, the bar WITH its legend, and the ladder's first principle.
     Only the eyebrow and the plaque fold away - the plaque's story lives
     two taps down, in Home's Next grade panel. The legend stays because the
     counter-pass found the first cut of this rule hiding it: a bar between
     a name and a sentence measured nothing anyone could name, on screen or
     to a screen reader. And the rule names what it hides - never
     p:last-child - because the principle rides the last paragraph, and a
     positional selector would fold it the day the markup moves. Both facts
     are pinned by test now; the sentence already vanished silently once. */
  .grade-card .eyebrow,
  .grade-card #grade-plaque { display: none; }

  /* The masthead loses its one-line ambitions before it loses its figures:
     the notice takes the first row whole - it is the one sentence this page
     cannot afford to shrink further - and the figures share the second. The
     margin gains a left side because the sidebar it used to lean on is gone. */
  .masthead { margin: .75rem .75rem 0; }
  .notice { flex-basis: 100%; }
  .topline { font-size: .875rem; }

  /* The two figures stay; only their voice drops. At the desk size the pair
     filled a third of the first screen and pushed the theme toggle onto a
     row of its own - measured on a 375px viewport. */
  .topline .price { font-size: 1.05rem; }

  /* The dock crosses the floor: up onto the bar, and over to the LEFT. The
     first cut kept its desktop corner, which is the exact point the More
     sheet rises from - the one floating control of the screen sat precisely
     under the one popover, and both reviews read the stack as a bug. Two
     floating mechanisms, two corners. */
  .dock {
    left: .75rem;
    right: auto;
    /* P-04 : le pied de l'écran est libre — la barre est en haut. */
    bottom: calc(.75rem + env(safe-area-inset-bottom));
    align-items: flex-start;
  }

  /* A-1 (2026-08-19, decision Erwann): the collapsed pill becomes a round
     bubble at this width, rather than a shorter version of the same bar.
     Measured in the browser at 375x667, all three languages: the sentence
     this pill carries ("Waiting for the first event" and friends) is 241 to
     388px wide including its icon, and the Atrium hero promise leaves it no
     clearance to sit in - the CTA's own left edge lands as close as 81px
     from the screen edge (fr, the widest of the three), while the sentence
     alone needs more room than that just for its icon and padding. There is
     no position for a pill this wide that clears both the CTA above it and
     the tabbar below it on a short screen; there is room for its icon alone.
     The sentence is not removed - #dock-state keeps every word, unchanged,
     for whoever is reading with a screen reader (the clip-rect recipe is
     the same one .announcer already uses elsewhere on this page) - it opens
     back into full view the moment this same button opens its panel. */
  .dock-pill {
    width: 3rem;
    height: 3rem;
    padding: 0;
    justify-content: center;
    position: relative;
  }

  /* .dock-pill.has-keeper (base rule, above) sets padding-left/top/bottom
     individually rather than through the shorthand, and its TWO classes
     outrank this rule's one regardless of source order - measured in the
     browser: the plain `padding: 0` above left 5.6px on the icon's left
     and 4px top/bottom, 0 on the right, so the keeper painted 5.6px off
     centre inside the circle. has-keeper is always present the instant the
     dock has a drawing to clone, so this is not a rare case to leave open. */
  .dock-pill.has-keeper {
    padding-left: 0;
    padding-top: 0;
    padding-bottom: 0;
  }

  #dock-state {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* The unread count moves from the sentence's side to the bubble's own
     shoulder - the sentence beside it just went silent on screen, not to a
     screen reader, which still meets both spans in document order. */
  .dock-unread {
    position: absolute;
    top: -.25rem;
    right: -.25rem;
  }

  /* Thumbs are wider than pointers. Every repeated small control on the desk
     grows to the 44px a thumb lands on without aiming; the figures they sit
     beside are unchanged. */
  .segment,
  .scale,
  .dial-key,
  .tab,
  .market-chip { min-height: 2.75rem; }

  .field input,
  .field select { min-height: 2.75rem; }

  /* Two columns of figures rather than a wrapped run: on a narrow screen the
     flex wrap puts three figures on one row and one on the next, and the eye
     loses the pairs. A grid says where every figure is. */
  .readout {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .path-list .node { padding: .3rem 0; }
}

/* Tablet: the width where two short panels fit side by side and a thumb still
   drives. Only Home composes here. Five panels since W2, and the flow is
   DECIDED rather than inherited: Today and Where you are pair on the first
   row, the path spans the second full-width, and Next grade and Regularity
   pair beneath it - auto-placement lands exactly there because the one
   full-width panel resets the cursor to a fresh row, which is why no
   per-panel placement is written. The four views the desktop composes in
   columns keep their single column until 1024px, and that is the guard's
   line as much as this sheet's. */
@media (min-width: 768px) and (max-width: 1023px) {
  .view[data-view="home"] {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .view[data-view="home"] > .panel:nth-child(3) { grid-column: 1 / -1; }
}

/* --- W1 PARCOURS: the path, once it is served ------------------------- */

/* A node is a button because it opens. Learn locks nothing, so every lesson
   is reachable in any order, with the keyboard alone - and there is no
   disabled state in this block, on purpose. */
.path-list .node {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: .4rem;
  width: 100%;
  padding: .1rem 0;
  font: inherit;
  color: inherit;
  text-align: left;
  background: none;
  border: 0;
  cursor: pointer;
}

.path-list .node:hover .node-title { text-decoration: underline; }

/* I5 (Iris, mesure au vrai navigateur, corrigee le 2026-09-01) : LA
   PASTILLE DE VERDICT NE DECIDE PLUS DE LA LARGEUR DE LA COLONNE.

   `.verdict` est `white-space: nowrap` depuis toujours - une pastille est
   un jeton, et un jeton coupe en deux ne se lit pas. Sur la carte Home,
   qui n'a jamais montre que l'unite 1, cela n'a jamais coute un pixel :
   l'unite 1 ne porte aucun verdict. S-1 a mis les verdicts A L'ECRAN sur
   la liste des unites (le sentier les mettait a `font-size: 0`), et
   l'unite 6 en porte vingt-quatre, dont des phrases a deux membres.
   La contribution min-content d'un flex item `nowrap` est son texte
   ENTIER : la piste devenait donc au moins aussi large que la plus longue
   d'entre elles. Mesure : FR 678 px contre 638 disponibles (+40), ES 676
   (+38), EN 0 - une unite sur huit, et seulement hors anglais.

   `white-space: normal` rend le retour a la ligne ; `overflow-wrap:
   anywhere` est ce qui ferme VRAIMENT la propriete, parce que lui seul
   fait descendre la taille min-content (a la difference de `break-word`,
   qui autorise la coupure sans changer le calcul intrinseque). Il ne
   coupe un mot que quand il n'y a plus d'autre issue.

   Portee a `.path-list` et non a `.unit-list` a dessein : la propriete
   « un verdict n'elargit jamais sa colonne au-dela du panneau » est vraie
   des DEUX surfaces, et Home ne bouge pas d'un pixel aujourd'hui puisque
   l'unite 1 n'a aucun verdict.

   D-7 (decision d'Erwann, 2026-09-01, DECISIONS_ERWANN.md) : « Pastille
   sur la liste, phrase a la fiche ». La liste redevient donc MUETTE sur
   le texte du verdict - `white-space`/`overflow-wrap` ci-dessus restent
   (aucun cout a les garder, et un jour ou le `font-size: 0` reviendrait
   en arriere ne doit pas rouvrir I5), mais elles ne protegent plus qu'un
   risque devenu structurellement impossible : un texte a `font-size: 0`
   n'a AUCUNE contribution min-content, quel que soit son wrap.

   MEME TECHNIQUE que la couche campus (`.campus-node .verdict`, plus
   haut) : le TEXTE part a `font-size: 0`, jamais le noeud - il reste
   dans le DOM (js_harness le lit encore, `describe().verdict`), et le
   nom accessible vient de l'aria-label du bouton (`labelParts`,
   `lessonNode`), jamais de ce span. Contrairement au disque compact du
   campus (J7, S-1), la ligne de la liste a de la place et TOUT le `<li>`
   est le meme bouton : aucun risque de recouvrir un noeud voisin, donc
   aucun besoin de sortir le badge du flux ni de lui couper sa boite
   (`width`/`height`/`overflow`) - le faire aurait aussi efface le POINT
   de couleur (`.verdict[data-level]::before`), que D-7 veut au contraire
   garder visible : seul le texte se tait, la boite (padding, bordure,
   fond) reste et lit desormais comme une pastille.

   TENSION CONNUE, ecrite plutot que tue : la pastille redevient le SEUL
   porteur du niveau sur cette liste, et WCAG 1.4.1 (jamais la couleur
   seule) n'est plus tenu par CETTE surface prise seule - la doctrine que
   test_a_verdict_on_the_list_is_told_by_its_own_words_not_by_a_hue (C14)
   avait ecrite le jour meme pour la justifier disparait avec le texte
   qu'elle protegeait. C'est la fiche (un clic, #nodesheet-verdict et
   #fiche-verdict, deja gardees a l'execution ailleurs) qui porte
   desormais la phrase entiere ; Erwann tranche ainsi les yeux ouverts
   (24 verdicts, unite 6, illisible en toutes lettres). Voir
   RAPPORT_D7_D9.md pour le constat complet. */
.path-list .verdict {
  white-space: normal;
  overflow-wrap: anywhere;
  font-size: 0;
}

/* The lesson the brief is proposing reads as the current one, and nothing
   else on the path is dimmed for it: an order is not a gate. */
.path-list .node[data-state="proposed"] .node-title {
  color: var(--text-soft);
  font-weight: 600;
}

/* The state, in the engine's own word. Never a colour of its own: green for
   "validated" would borrow the vocabulary of a profit, which is exactly what
   this curriculum refuses to grade on. */
.node-state {
  color: var(--text-faint);
  font-size: .75rem;
  letter-spacing: .04em;
  text-transform: uppercase;
}

/* La pastille de verdict : un jeton peint depuis le niveau que le moteur a
   servi. Le dessin est TOUJOURS le meme peintre - la legende, la liste des
   unites, la fiche, le tiroir de leçon et les cartes de la salle de memo
   lisent cette seule regle, jamais une seconde graphie de la meme verite.

   D-11 (decision d'Erwann du 2026-09-01, DECISIONS_ERWANN.md) : « Ajouter
   un porteur NON CHROMATIQUE a la pastille ». D-7 avait rendu la liste
   muette sur le texte du verdict (vingt-quatre phrases d'affilee sur
   l'unite 6) ; le niveau ne se lisait donc plus qu'a la TEINTE, ce qui
   rouvrait WCAG 1.4.1 et contredisait la doctrine C14 du depot (« couleur
   et position ne portent jamais seules une information »). Le porteur
   choisi est une FORME, jamais une lettre :

     - une lettre est du contenu genere, et le contenu genere par CSS
       ENTRE dans le nom accessible (cliquet L6166) - elle s'ajouterait
       donc a l'aria-label que le bouton porte deja ; et une lettre est
       liee a la LANGUE, donc aux trois catalogues (regle 2) ;
     - une forme n'a ni l'un ni l'autre de ces couts, et le depot a deja
       le precedent : la zone de trace dit le juste et le faux « par la
       largeur de bordure et la forme, jamais par une teinte ».

   LE VOCABULAIRE, quatre silhouettes, lues au NOMBRE et a la DISPOSITION
   des coins vifs - une famille ordinale, pas quatre dessins arbitraires :

     supported (green)   un seul coin vif   la plus ronde des quatre
     mixed (yellow)      deux coins vifs opposes   litteralement mi-figue
     refuted (red)       quatre coins vifs   la plus dure
     none (white)        deux coins vifs adjacents, base plate   hors echelle

   Le DISQUE PLEIN (50% partout) reste reserve : c'est ce que porte un
   jalon de campus qui n'a AUCUN verdict, et aucun des quatre niveaux ne
   le reprend - sans quoi « soutenu par la recherche » et « pas de verdict
   du tout » se peindraient pareil sur la carte.

   Le disque passe de .5rem a .7rem : a huit pixels, un coin vif de quatre
   pixels ne se lit pas. C'est la taille qui rend la FORME lisible, et
   elle est mesuree au navigateur plutot que devinee. */
.verdict[data-level]::before {
  content: "";
  flex: none;
  width: .7rem;
  height: .7rem;
  border-radius: 50%;
  background: var(--verdict-none);
}

.verdict[data-level="green"]::before { background: var(--verdict-supported); }
.verdict[data-level="yellow"]::before { background: var(--verdict-mixed); }
.verdict[data-level="red"]::before { background: var(--verdict-refuted); }

/* --- The memory room --------------------------------------------------- */

/* The deck's own verdict levels. The Learn nodes speak the engine's colour
   words (green/yellow/red); the cards speak the scale's names. Same dot,
   same reserved hues, painted from the level exactly as the badge above. */
.verdict[data-level="supported"]::before { background: var(--verdict-supported); }
.verdict[data-level="mixed"]::before { background: var(--verdict-mixed); }
.verdict[data-level="refuted"]::before { background: var(--verdict-refuted); }

/* D-11 : LES QUATRE SILHOUETTES. Une declaration par niveau, sa VALEUR
   seule - aucune teinte ici, et c'est la propriete : effondrer toutes les
   couleurs de la feuille sur une seule ne change RIEN a ces quatre lignes,
   donc rien a la distinguabilite. Les trois alias de la salle de memo
   recoivent la MEME silhouette que le niveau qu'ils nomment : un verdict
   doit se dessiner pareil partout ou il apparait, ou l'un des deux endroits
   ment. Decision prise seule, journalisee « a relire » : la question « la
   salle de memo suit-elle ? » etait ouverte au rapport de cloture S-2. */
.verdict[data-level="green"]::before,
.verdict[data-level="supported"]::before { border-radius: 50% 50% 50% 0; }
.verdict[data-level="yellow"]::before,
.verdict[data-level="mixed"]::before { border-radius: 50% 0 50% 0; }
.verdict[data-level="red"]::before,
.verdict[data-level="refuted"]::before { border-radius: 0; }
.verdict[data-level="white"]::before { border-radius: 50% 50% 0 0; }

/* --- CAMPUS-MONDE U3: the Reading Room / Still Room door, and the Practice
   Benches door - decorative dressing ahead of the unchanged memory-room and
   trace mechanics (JEU Z5 / JEU Z12), all token colours, no motion (a
   static lamp dot, not a pulse - nothing here needs a reduced-motion
   guard because nothing here moves). */
.campus-reading-door {
  display: flex;
  align-items: center;
  gap: .75rem;
  padding: .625rem .75rem;
  margin: 0 0 .75rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
}

.campus-reading-door-art {
  flex: none;
  width: 2.75rem;
  height: 1.75rem;
  color: var(--text-muted);
}

.campus-reading-door-glow { fill: var(--accent); }

.campus-reading-kicker {
  margin: 0;
  color: var(--text);
  font-weight: 800;
  font-size: .875rem;
}

.campus-reading-sub, .campus-reading-vera {
  margin: .125rem 0 0;
  color: var(--text-muted);
  font-size: .75rem;
}

.campus-still-note {
  margin: .75rem 0 0;
  padding: .5rem .75rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
  color: var(--text-muted);
  font-size: .75rem;
}

.campus-still-kicker {
  display: block;
  margin: 0 0 .125rem;
  color: var(--text);
  font-weight: 800;
  font-size: .8125rem;
}

.campus-bench-door {
  padding: .625rem .75rem;
  margin: 0 0 .75rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
}

.campus-bench-kicker {
  margin: 0;
  color: var(--text);
  font-weight: 800;
  font-size: .875rem;
}

.campus-bench-sub {
  margin: .125rem 0 0;
  color: var(--text-muted);
  font-size: .75rem;
}

.campus-bench-note { color: var(--text-muted); }

/* The four package cards live here (J5-1, JEU Z5 below styles their
   content); this container only lays them out in a grid two wide, same
   shape the prototype's own dk-for grid used. */
.recall-decks {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .5rem;
  margin: .75rem 0 0;
}

/* Visually hidden, spoken: the naming game's stage carries one line that
   exists only for a screen reader, because the drawing's accessible name
   would be the answer. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* The stage. One thing at a time, read at arm's length: a card is closer to
   a flash of paper than to a table row, so the type is larger than the
   panels around it and the stage keeps its height between steps rather than
   jumping with every card. */
.recall-stage {
  min-height: 9rem;
  display: flex;
  flex-direction: column;
  gap: .6rem;
  align-items: flex-start;
}

.recall-term {
  margin: 0;
  color: var(--text);
  font-size: 1.25rem;
  font-weight: 650;
}

.recall-meaning {
  margin: 0;
  max-width: 62ch;
  color: var(--text-muted);
  font-size: .9375rem;
  line-height: 1.6;
}

.recall-verdict { margin: 0; }

.recall-evidence { max-width: 62ch; }

/* The anchor line of the palace, set apart from prose: it is an image to
   stand in, not an explanation to read. */
.recall-anchor {
  margin: 0;
  max-width: 56ch;
  padding-left: .75rem;
  border-left: 3px solid var(--accent);
  color: var(--text-soft);
  font-size: 1rem;
  line-height: 1.55;
  font-style: italic;
}

.recall-feedback {
  margin: 0;
  color: var(--text);
  font-size: .9375rem;
}

/* A drawn card: candles or a price line, painted from the same reserved
   hues as the chart. The frame is quiet on purpose - the shape is the
   question, so nothing else on the stage may compete with it. */
.recall-drawing {
  width: min(16rem, 100%);
  height: auto;
  padding: .6rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--bg-sunken);
}

.candle-wick { stroke: var(--text-faint); stroke-width: 1.6; }
.candle-up .candle-body { fill: var(--up); }
.candle-down .candle-body { fill: var(--down); }
/* The address bars of the same-shape twins: present, and clearly not the
   question. */
.candle-context { opacity: .38; }

.figure-line {
  stroke: var(--accent);
  stroke-width: 2.4;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.recall-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin-top: .75rem;
}

/* An answer among four. Wider than an ordinary control because its label is
   a sentence as often as a word, and the state colours only exist AFTER the
   answer: a button that hinted before the press would be doing the drill's
   job for it. */
.recall-option {
  text-align: left;
  max-width: 100%;
}

/* J6-4 / J7-2 (SPEC_JEU.md 6.5, S4): a game answer no longer wears --up or
   --down here - both equal --verdict-supported/--verdict-refuted byte for
   byte (this file's own :root and :root[data-theme="light"] blocks), the
   hue doctrine reserves for a research verdict, never a right or wrong
   game answer. The grade buttons (Forgot/Hard/Good/Easy) take the plain
   .jeu-btn-outline treatment instead (JS, JEU Z6); the quiz/naming right
   and wrong mark is answered below, in the JEU Z7 anchor, with shape and
   motion rather than a colour. */

/* The matching board: names on one side, meanings or shapes on the other.
   A matched pair stays visible and goes quiet - removing it would reflow
   the board under the reader's finger. */
.recall-board {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
  gap: .5rem;
  width: 100%;
}

.recall-column {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  min-width: 0;
}

.recall-pair { font-size: .875rem; }

.recall-pair[data-state="matched"] {
  opacity: .45;
  border-color: var(--glass-border);
}

.recall-pair[aria-pressed="true"] { border-color: var(--accent); }

.recall-pair .recall-drawing {
  width: min(9rem, 100%);
  padding: .35rem;
}

/* --- MOBILE BAR & TRAIL (refonte-mobile) ------------------------------- */

/* The same nine sections, one navigation. Nothing below invents a
   mechanism: the bar's buttons carry data-view-target, the sheet is a
   popover and not a modal, and every state painted here is a state the
   engine served. P-04: fixed to the TOP of the screen, every width - the
   sidebar's own nav, the Console's tabs and the deck's navbar died with
   the unification, and this bar is what replaced all three. */
/* P-04 (décisions Erwann 2026-08-28) : LA navigation, une seule, EN HAUT,
   toutes largeurs. La barre 4+Others qui vivait au pouce sous 1024 px est
   promue navigation unique — la nav 9 items de la sidebar, les navtabs de
   la Console et la navbar du deck disparaissent avec ce chantier. Fixe en
   haut, au-dessus des coques plein écran (Console/deck z-index 60, labo 61
   — « la Console garde son écran moins une barre fine », décision (a)) et
   SOUS les surfaces immersives (earned-moment 65, jeu-overlay 70) : une
   expérience qui couvre l'écran garde le droit de le couvrir en entier.
   La hauteur vit dans UNE variable, --pad-nav-top, que consomment le shell,
   la sidebar sticky, les deux coques et la feuille — jamais une constante
   recopiée (leçon des 4.75rem/8rem du bas, payée en P-04). */
.tabbar {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 9rem));
  justify-content: center;
  gap: .25rem;
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  z-index: 62;
  padding: calc(.3rem + env(safe-area-inset-top)) .625rem .3rem;
  border-bottom: 1px solid var(--glass-border);
  background: var(--bg-raised);
}

/* The sheet hangs from the bar, under the More button that opens it. A
   popover, never a curtain: no scrim, nothing dimmed, and the page stays
   readable and pressable around it - the same reasoning that made the one
   confirmation this product asks for an inline block. */
.sheet {
  display: grid;
  gap: .25rem;
  position: fixed;
  right: .75rem;
  top: calc(var(--pad-nav-top) + .4rem);
  z-index: 63;
  min-width: 13rem;
  padding: .5rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--bg-raised);
  box-shadow: 0 12px 32px var(--bg-sunken);
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .tabbar {
    background: var(--glass-panel);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .sheet {
    background: var(--glass-panel);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
}

@keyframes sheet-rise {
  from { opacity: 0; transform: translateY(-8px); }
  to { opacity: 1; transform: none; }
}

.tabbar-item {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .2rem;
  min-height: 3rem;
  padding: .25rem .25rem .35rem;
  font: inherit;
  font-size: .75rem;
  font-weight: 600;
  color: var(--text-muted);
  background: none;
  border: 0;
  border-radius: var(--radius);
  cursor: pointer;
  touch-action: manipulation;
}

/* The count of replies waiting, on the tab's shoulder. The dock's own badge
   sets the vocabulary - a number and nothing else - and this one repeats it
   at the size the bar allows. */
.tabbar-unread {
  position: absolute;
  top: 0;
  right: 50%;
  /* -1.3rem and not more: at 320px a tab is 64px wide and the badge's right
     edge lands at half of that plus this offset - 1.55rem was already on
     the neighbouring tab. */
  margin-right: -1.3rem;
  min-width: 1.05rem;
  padding: 0 .25rem;
  border-radius: 999px;
  background: var(--grad-b);
  color: var(--on-accent);
  font-size: .75rem;
  font-weight: 700;
  line-height: 1.05rem;
  text-align: center;
}

/* The icon sits in a pill so the pressed state has a surface to land on. The
   pill is transparent until it means something: the identity sweep marks the
   open section, exactly as it does on the sidebar entry - colour and weight
   both, because colour alone is not a signal everyone gets. */
.tabbar-item svg {
  box-sizing: content-box;
  width: 20px;
  height: 20px;
  padding: .2rem .8rem;
  border-radius: 999px;
}

.tabbar-item:active svg { transform: scale(.88); }

.tabbar-item[aria-pressed="true"],
.tabbar-item[data-holds="true"] {
  color: var(--accent-text);
  font-weight: 700;
}

.tabbar-item[aria-pressed="true"] svg,
.tabbar-item[data-holds="true"] svg {
  color: var(--on-accent);
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  box-shadow: 0 4px 12px var(--shadow-accent);
}

.sheet-item {
  display: flex;
  align-items: center;
  gap: .625rem;
  min-height: 2.75rem;
  padding: .5rem .75rem;
  font: inherit;
  font-weight: 600;
  text-align: left;
  color: var(--text-muted);
  background: none;
  border: 0;
  border-radius: var(--radius);
  cursor: pointer;
  touch-action: manipulation;
}

.sheet-item:active { background: var(--glass-row); }

.sheet-item[aria-pressed="true"] {
  color: var(--on-accent);
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  box-shadow: 0 6px 18px var(--shadow-accent);
}

/* A section arrives rather than pops: six pixels of rise over a fifth of a
   second. The animation replays every time `hidden` comes off, because
   display going from none to grid restarts it - the mechanism is free and
   touches no script. It stops, like everything that moves here, for anyone
   who asked their system for less motion.

   The three declarations that start something moving sit at the end of this
   section, on bare rules apart from the blocks they animate. That is for the
   motion guard: it reads whatever stands before a brace as the selector, so
   a comment with a comma in it would split into fragments it cannot strip. */
@keyframes view-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

/* --- The path as a trail (Home's own device now, not the whole screen) - */

/* This used to be the whole answer for both Home and Learn: a disc per
   lesson, a straight rail joining them, read top to bottom. SPEC_JEU.md
   sec.3 adopts the maquette's serpentine for the drawn path itself (JEU Z1,
   below) - so from here down this block is what Home's own preview card
   still uses, unchanged, because Home shows one unit's worth of lessons in
   a column and never needed the offset geometry the full path now draws.
   Every rule below still applies to #home-path's nodes exactly as it did;
   nothing here is dead, and the three states it paints are the same three
   the drawn path paints too: validated wears the identity sweep, proposed
   wears a ring, available stays quiet. Never green - a validated lesson is
   not a profit, and this curriculum does not grade on one. */
.path-list .node { align-items: center; }

.path-list li { position: relative; }

.path-list li::before {
  content: "";
  position: absolute;
  left: 1.05rem;
  top: -.45rem;
  bottom: 0;
  width: 2px;
  transform: translateX(-50%);
  background: var(--glass-track);
}

.path-list li:first-child::before { top: .7rem; }
.path-list li:last-child::before { bottom: calc(100% - .7rem); }

.path-list .node-idx {
  position: relative;
  z-index: 1;
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.1rem;
  min-height: 1.4rem;
  padding: 0 .35rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: var(--bg-raised);
  color: var(--text-faint);
  font-size: .75rem;
}

.path-list .node[data-state="validated"] .node-idx {
  color: var(--on-accent);
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  border-color: transparent;
  box-shadow: 0 4px 10px var(--shadow-accent);
}

/* A tick beside the number, decorative and silent: the state a screen reader
   hears is the node-state span, in the engine's own word. The empty alt is
   what keeps this drawing from being read as content. */
.path-list .node[data-state="validated"] .node-idx::after {
  content: "\2713" / "";
  margin-left: .25rem;
}

/* The rail remembers the work: the segment arriving at a validated lesson
   takes the identity hue, so the done stretch of the path reads as a done
   stretch at arm's length. :has() renders the state the engine served - no
   script involved and nothing decided here. */
.path-list li:has(> .node[data-state="validated"])::before {
  background: var(--ring-accent-strong);
}

/* The proposal breathes, gently, and never counts anything: it is the same
   invitation the brief already wrote, made findable in a list of sixty-six.
   The ring widens by three pixels and comes back; nothing else moves. */
.path-list .node[data-state="proposed"] .node-idx {
  color: var(--accent-text);
  border-color: var(--ring-accent-strong);
  box-shadow: 0 0 0 3px var(--ring-accent);
}

@keyframes proposed-breathe {
  0%, 100% { box-shadow: 0 0 0 3px var(--ring-accent); }
  50% { box-shadow: 0 0 0 6px var(--wash-accent); }
}

/* What a thumb presses answers back. A transition and not an animation - it
   follows the finger and has no life of its own. Never on a disabled
   control. The marker scales when its whole row is pressed - the one place
   the feedback lands on a proxy - because the row is the button. The guard
   on transform transitions reads the text before a brace as the selector -
   hence no comma anywhere in this comment. */
.tabbar-item svg,
.segment,
.dial-key,
.scale,
.market-chip,
.tab,
.sheet-item,
.path-list .node-idx { transition: transform .12s ease; }

.segment:active:not(:disabled),
.dial-key:active:not(:disabled),
.scale:active,
.market-chip:active,
.tab:active,
.sheet-item:active,
.path-list .node:active .node-idx { transform: scale(.94); }

/* The motion itself - bare on purpose. See the note above view-in. */
.view { animation: view-in .22s ease-out; }

/* fidelite.C4 - the allocation ring: the served allocation as an arc per
   class, the centre figure the readout's own. Rotated -90deg so 0 % sits at
   noon; the track is the sheet's own border ink. */
.allocation-ring {
  position: relative;
  width: 9.5rem;
  margin: .25rem 0 .5rem;
}

.allocation-ring svg {
  display: block;
  width: 100%;
  height: auto;
  transform: rotate(-90deg);
}

.allocation-ring .ring-track {
  fill: none;
  stroke: var(--border);
  stroke-width: 12;
}

.allocation-ring .ring-slice {
  fill: none;
  stroke-width: 12;
}

.allocation-ring-center {
  position: absolute;
  inset: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .1rem;
}

.allocation-ring-pct {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--em);
}

.allocation-ring-label {
  font-size: .75rem;
  color: var(--text-faint);
  text-transform: uppercase;
  letter-spacing: .06em;
}

/* fidelite.C4 - the view-in trap closed at its cause (CHECKLIST sec.1;
   measured at C1 corr4 and again at C3: an ancestor animating TRANSFORM
   traps the fixed #console-shell for the 220 ms of the entry - 374x28 px
   mid-animation; real cost on every entry to Trade). Trade's own entry
   fades WITHOUT transform: a fixed child needs an untransformed ancestor
   and the eye keeps the fade. (No comma here - the motion guard reads the
   text before a brace as a selector.) */
.view[data-view="trade"] { animation-name: view-in-still; }

@keyframes view-in-still {
  from { opacity: 0; }
  to { opacity: 1; }
}

.sheet { animation: sheet-rise .18s ease-out; }

#lesson-panel { animation: view-in .22s ease-out; }

.path-list .node[data-state="proposed"] .node-idx { animation: proposed-breathe 2.4s ease-in-out infinite; }

/* --- Progress, as long as the count it restates ------------------------ */

/* The track under the progress sentence. The two counts arrive as custom
   properties, verbatim - renderLearn is forbidden an operator on a served
   number, and the guard on its sentences enforces that - so the division
   happens here, in calc(), where a length is allowed to be the ratio of two
   figures the engine owns. A harness test pins the handover: a bar that
   flattered would be an encouragement this product refuses to manufacture.
   Empty, it is an empty track under an honest sentence - not a sliver of
   something. */
#home-progress,
#learn-progress {
  --path-done: 0;
  --path-of: 1;
}

#home-progress::after,
#learn-progress::after {
  content: "";
  display: block;
  height: .5rem;
  margin-top: .55rem;
  border-radius: 999px;
  background:
    linear-gradient(90deg, var(--grad-a), var(--grad-b)) no-repeat left center / calc(100% * var(--path-done) / var(--path-of)) 100%,
    var(--glass-track);
}

@media (prefers-reduced-motion: reduce) {
  .view,
  .view[data-view="trade"],
  .sheet,
  #lesson-panel,
  .path-list .node[data-state="proposed"] .node-idx {
    animation: none;
  }

  .tabbar-item svg,
  .segment,
  .dial-key,
  .scale,
  .market-chip,
  .tab,
  .sheet-item,
  .path-list .node-idx { transition: none; }
}

/* ===============================================================   LE JEU D'APPRENTISSAGE - onze ecrans, dix zones (SPEC_JEU.md sec. 4)
   ====================================================================== */

/* ===== JEU Z0 : fondations : tokens et primitives partagees ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* --- The game card: glass, a lift on hover, an opaque fallback ---------
   Same recipe as .panel (style.css:1015-1028), copied rather than reinvented:
   the opaque background comes first so a browser without backdrop-filter -
   or one where it is switched off, or a printout - still has something
   behind the text. Registered in GLASS_SURFACES (test_front_end.py) like
   every other glass surface this sheet declares; the ordering and the
   @supports count are both asserted there. */
.jeu-card {
  position: relative;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--bg-raised);
  overflow: hidden;
}

@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .jeu-card {
    background: var(--glass-panel);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
}

.jeu-card:hover { transform: translateY(-2px); }

/* --- The hero card: the identity gradient as a surface (screen 03) -----
   Text on it stays at full --on-accent, always - see the calculation above
   --grad-a. Hierarchy here is weight and size, never opacity: dimming the
   ink toward this background is exactly what failed AA in the prototype's
   own banner subtitle and its "PROPOSED TODAY" pill (SPEC_JEU.md 6.3, C10).
   The eyebrow badge below is deliberately an OPAQUE chip rather than a
   translucent one sitting on the gradient, for the same reason: a light
   fill over the gradient lightens the effective background under its own
   text and is how the prototype's badge fell to 3.21:1. */
.jeu-hero {
  border-radius: var(--radius-panel);
  padding: 1.25rem;
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  color: var(--on-accent);
  box-shadow: 0 12px 30px var(--shadow-accent);
}

.jeu-hero-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  padding: .15rem .55rem;
  border-radius: 999px;
  background: var(--bg-raised);
  color: var(--accent-text);
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
}

.jeu-hero-meta {
  display: block;
  margin-top: .35rem;
  font-size: .75rem;
  font-weight: 600;
  font-family: var(--font-mono);
}

.jeu-hero-title {
  margin: .6rem 0 0;
  font-size: 1.375rem;
  font-weight: 800;
  letter-spacing: -.01em;
}

.jeu-hero-sub {
  margin: .4rem 0 0;
  font-size: .8125rem;
  line-height: 1.5;
}

/* --- The progress ring: one SVG component, one caller (Z5) -------------
   Built by jeuProgressRing(numerator, denominator) below: it draws a ratio
   the caller already has and computes nothing else - no "mastery", no
   percentage the engine did not serve. The two custom properties are set
   from JS exactly like #home-progress/#learn-progress already do
   (style.css, "Progress, as long as the count it restates"): the division
   happens here, in calc(), never in the script. Decorative only - the
   number it draws is read from a text label the caller renders beside it,
   never from the ring itself, which is why the ring carries aria-hidden. */
.jeu-ring {
  display: block;
  width: 100%;
  height: 100%;
}

.jeu-ring-track,
.jeu-ring-fill {
  fill: none;
  stroke-width: 8;
}

.jeu-ring-track { stroke: var(--glass-track); }

.jeu-ring-fill {
  --jeu-ring-done: 0;
  --jeu-ring-of: 1;
  stroke: var(--accent);
  stroke-linecap: round;
  stroke-dasharray: 251.33;
  stroke-dashoffset: calc(251.33 - (251.33 * var(--jeu-ring-done) / var(--jeu-ring-of)));
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
}

/* --- The segmented bar: one capsule per lesson of a unit (screen 01) ---
   Never green: a validated lesson is not a profit (SPEC_JEU.md doctrine,
   point 5 of the walkthrough). The identity gradient marks "validated", the
   same tone the drawn path already uses for its own filled rail. */
.jeu-segments {
  display: flex;
  gap: .2rem;
}

.jeu-segment {
  flex: 1;
  height: .3rem;
  min-width: .15rem;
  border-radius: 999px;
  background: var(--glass-track);
}

.jeu-segment[data-state="validated"] {
  background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

.jeu-segment[data-state="proposed"] {
  background: var(--ring-accent-strong);
}

/* --- The pill badge: a status chip that is not a verdict ---------------
   For a VERDICT, reuse .verdict as it already stands (style.css, "A verdict
   badge") - same class, same four levels, same palette, already carrying
   its own text label beside the dot. This is for everything else that wants
   a small rounded tag: "PROPOSED", "Today", a room name. Always with text:
   nothing here is a colour standing alone for meaning. */
.jeu-pill {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  padding: .15rem .55rem;
  border: 1px solid var(--glass-border);
  border-radius: 999px;
  background: var(--glass-row);
  color: var(--text-muted);
  font-size: .75rem;
  font-weight: 700;
  white-space: nowrap;
}

/* --- Room identity: a discreet cue for which palace room this is -------
   C18 (SPEC_JEU.md 6.5). One shared, quiet surface rather than four
   different hues: repurposing a reserved token (--wash-up, --wash-warn) to
   mean "place" would fight the meaning this sheet's own header comment
   already reserves for them. The sense of place is carried by the
   pictogram (jeuRoomIcon below) and by the room's own served name, both
   indexed by decks.json's `room`/deck id - never by a fifth colour invented
   for decoration. Discreet, as asked: a room cue, not a fifth theme. */
.jeu-room-head {
  display: flex;
  align-items: center;
  gap: .55rem;
  padding: .55rem .7rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
}

.jeu-room-icon {
  flex: none;
  width: 1.375rem;
  height: 1.375rem;
  color: var(--text-muted);
}

.jeu-room-name {
  font-size: .8125rem;
  font-weight: 700;
  color: var(--text);
}

/* --- Control primitives: buttons, focus, and the 44px tap floor --------
   C12 / C13 (SPEC_JEU.md 6.3). :focus-visible on --ring-accent-strong
   mirrors the sheet's own global rule (style.css - "The focus ring is the
   one outline that has to beat every other state") rather than replacing
   it: restated here so the six zones that build on these classes are never
   the ones inventing a focus style. Every class below holds the 44x44 floor
   the audit measured against the prototype's 30x30 theme toggle and its six
   32x32 close buttons - the glyph inside can stay small; the pressable area
   cannot. */
.jeu-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .4rem;
  min-height: 2.75rem;
  min-width: 2.75rem;
  padding: .5rem 1.1rem;
  border-radius: var(--radius);
  font: inherit;
  font-size: .875rem;
  font-weight: 700;
  cursor: pointer;
  touch-action: manipulation;
}

.jeu-btn-primary {
  border: 1px solid transparent;
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  color: var(--on-accent);
  box-shadow: 0 8px 20px var(--shadow-accent);
}

.jeu-btn-primary:hover { box-shadow: 0 10px 26px var(--shadow-accent); }

.jeu-btn-primary:disabled {
  opacity: .5;
  cursor: default;
  box-shadow: none;
}

.jeu-btn-outline {
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text);
}

.jeu-btn-outline:hover {
  border-color: var(--text-faint);
  background: var(--glass-row-hover);
}

.jeu-btn-outline:disabled {
  opacity: .5;
  cursor: default;
  background: transparent;
}

/* The full-width answer button (quiz, "name the shape"): text-aligned left
   because an answer is read, not centred like a call to action. */
.jeu-btn-answer {
  display: flex;
  width: 100%;
  min-height: 2.75rem;
  padding: .65rem .9rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
  color: var(--text);
  text-align: left;
  font: inherit;
  font-size: .9375rem;
  cursor: pointer;
  touch-action: manipulation;
}

.jeu-btn-answer:hover { background: var(--glass-row-hover); }

.jeu-btn-answer:disabled {
  opacity: .6;
  cursor: default;
  background: var(--glass-row);
}

.jeu-btn:focus-visible,
.jeu-btn-answer:focus-visible {
  outline: 2px solid var(--ring-accent-strong);
  outline-offset: 2px;
}

/* The press - bare on purpose. Same reasoning as the lift below: no comma
   stands in a comment directly above a rule the motion guard has to read
   as a selector. */
.jeu-btn,
.jeu-btn-answer { transition: transform .12s ease; }

.jeu-btn:active:not(:disabled),
.jeu-btn-answer:active:not(:disabled) { transform: scale(.97); }

/* --- The overlay: bottom sheet and full-screen session, one mechanism --
   J0-5, the most shared primitive of this chantier: Z2, Z6, Z7, Z8 and Z9
   all open one. .jeu-overlay is the fixed wrapper holding a scrim and one
   piece of content - either .jeu-sheet (a node's detail docked to the foot
   of the screen) or .jeu-session (a full-screen flashcard/quiz/palace run).
   Both share the same JS below: openJeuOverlay(node, trigger) and
   closeJeuOverlay(node) own the focus trap, Escape, the scrim's click-to-
   close, aria-modal, and returning focus to whatever opened it. A consuming
   zone toggles nothing by hand - it calls those two functions and nothing
   else.
   Opaque rather than glass, on purpose: a full-bleed overlay does not sit
   over other visible content the way a floating card does, so it earns no
   place in GLASS_SURFACES - one fewer surface for that guard to hold. */
.jeu-overlay {
  position: fixed;
  inset: 0;
  z-index: 70;
}

.jeu-scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim);
}

.jeu-sheet {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  max-width: 30rem;
  max-height: 82vh;
  margin: 0 auto;
  overflow-y: auto;
  padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel) var(--radius-panel) 0 0;
  background: var(--bg-raised);
  box-shadow: 0 -12px 32px var(--bg-sunken);
}

.jeu-session {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow-y: auto;
  padding: calc(.75rem + env(safe-area-inset-top)) 1rem calc(1rem + env(safe-area-inset-bottom));
  background: var(--bg-raised);
}

@keyframes jeu-sheet-rise {
  from { opacity: 0; transform: translateY(16px); }
  to { opacity: 1; transform: none; }
}

@keyframes jeu-session-rise {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Both entrances - bare on purpose. Same trap as the lift and the press
   above: no comma in a comment directly over a rule the motion guard has
   to read as a selector. */
.jeu-sheet { animation: jeu-sheet-rise .18s ease-out; }

.jeu-session { animation: jeu-session-rise .2s ease-out; }

/* The lift - bare on purpose. See the note above .view's own entry: the
   motion guard reads whatever stands before a brace as the selector text
   including the comment - and a comma in that comment splits it into
   fragments the guard cannot clean. No comma above this line. */
.jeu-card { transition: transform .15s ease; }

@media (prefers-reduced-motion: reduce) {
  .jeu-card { transition: none; }

  .jeu-btn,
  .jeu-btn-answer { transition: none; }

  .jeu-sheet { animation: none; }

  .jeu-session { animation: none; }
}

/* ===== fin JEU Z0 ===== */

/* ===== JEU Z1 : ecran 01 - la liste des unites ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* SENTIER->CAMPUS S-1 : le dessin du sentier est mort (D5 reecrite,
   decision d'Erwann du 2026-09-01). L'espace de coordonnees fixe a 339px,
   le rail SVG, les trois proprietes custom par noeud, la legende
   « Proposed, not imposed » et toute la famille `.path-trail ...` sont
   parties avec lui : #learn-units est une LISTE SIMPLE, peinte par la MEME
   `.path-list` que #home-path emploie depuis W1 (le trait vertical, la
   pastille numerotee, le titre, l'etat, le verdict - tout est deja
   au-dessus dans cette feuille, rien n'est duplique ici).

   Une seule chose que Home n'a pas : le halo one-shot du moment de
   validation (J4-2). jeuPathUnit pose `data-just-earned` sur le seul noeud
   qui vient de passer valide, et il n'y a que la liste des unites pour le
   porter - Home ne le peint jamais. Le selecteur est donc scope a
   .unit-list, la vue de Learn, plutot qu'a la classe partagee : sans ce
   scope la regle pourrait un jour peindre la carte Home pour un attribut
   qu'elle ne recevra jamais, ce qui est une regle sans sujet.

   Ce paragraphe est place AVANT le @keyframes et jamais juste avant la
   regle qui anime : le garde de mouvement de cette feuille
   (test_every_animation_stops_for_anyone_who_asked_for_less_motion) lit
   tout le texte qui precede une accolade comme un selecteur et decoupe sur
   la virgule AVANT de retirer les commentaires - c'est le comma trap
   (docs/design/prototype-jeu/CLASSES.md, cliquet L2352/L6570), et il a
   mordu ici a la premiere mesure. */
@keyframes jeu-node-earned {
  0%, 100% { box-shadow: 0 0 0 4px var(--wash-accent), 0 6px 16px var(--shadow-accent); }
  55% { box-shadow: 0 0 0 18px var(--wash-accent), 0 6px 16px var(--shadow-accent); }
}

/* Le halo one-shot du moment de validation - aucune virgule dans ce
   paragraphe (voir juste au-dessus). */
.unit-list .node[data-just-earned="true"] { animation: jeu-node-earned 1.1s ease-out; }

/* SENTIER->CAMPUS S-1 : LA PORTE DU REPLI.
   D5 reecrite : la photo EST la carte ; la liste semantique ne s'affiche
   que quand le campus NE revendique PAS la carte. L'etat est porte par
   <html data-campus-claims="on"> - l'ancetre commun de #campus-world et de
   #learn-units - pose par UN SEUL ecrivain (campusClaimRefresh, app.js) et
   DERIVE des trois attributs du monde. Une regle descendante depuis
   #campus-world ne pourrait pas atteindre cet element : il vit dans un
   autre panneau du meme ecran.
   Specificite (1,2,0) : un id plus deux composes de gauche - rien dans
   cette feuille ne vise #learn-units pour display aujourd'hui et la garde
   de cascade dediee le tient (tests/test_front_end_campus_nodes.py). */
:root[data-campus-claims="on"] #learn-units { display: none; }

/* SENTIER->CAMPUS S-2 (LE REPLI HONNETE) : LA MEME PORTE, POUR LA PHRASE.
   #learn-campus-fallback (static/index.html) est un SIBLING statique de
   #learn-units, jamais un enfant construit dedans (voir le commentaire de
   campusFallbackNoticeSync, static/app.js, pour le piege que ca evite :
   report()/unitList() lisent tout enfant de #learn-units comme un bloc
   d'unite, sans filtre de classe). Meme ancetre commun, meme specificite
   (1,2,0), meme raison : quand le campus revendique la carte, la phrase
   qui explique pourquoi une liste s'affiche n'a plus rien a expliquer -
   elle disparait AVEC #learn-units, jamais un instant apres. `hidden`
   (JS) fait le reste : un corps SANS unite fait tomber la revendication
   pour une raison qui n'a rien a voir avec cette phrase (learnEmptyPathLine
   parle a sa place), donc cette regle seule ne suffirait pas a la taire -
   elle ferme seulement le cas ou le campus revendique VRAIMENT. */
:root[data-campus-claims="on"] #learn-campus-fallback { display: none; }

@media (prefers-reduced-motion: reduce) {
  .unit-list .node[data-just-earned="true"] { animation: none; }
}

/* --- N4 (JEU_TRACKER.md constat N4): the desktop sidebar ---------------
   Mechanism for "gone below 1024px" is this rule, and only this rule -
   `[hidden]` stays for the reader's own hand (a lesson or a practice
   session opened or closed), and the two mechanics do not mix on the same
   element here. The 1024px override that turns this back into a column is
   with the rest of the columned-view contract, in the shared
   `@media (min-width: 1024px)` block near the top of this sheet (E08's
   block, the one `test_the_two_column_views_are_composed_from_the_sheet`
   reads), not repeated here. */
.path-side { display: none; }

/* Neither card sets its own padding by default (CLASSES.md, ".jeu-card":
   "Does not: set its own padding") - this is that padding, plus the
   vertical rhythm between a card's own lines, the same split `.room-deck`
   (Z5) already draws between the glass surface and what sits on it. */
.path-side-card {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  padding: var(--pad);
}

.path-side-card-title {
  margin: 0;
  font-size: .8125rem;
  font-weight: 800;
  color: var(--text);
}

/* ===== fin JEU Z1 ===== */

/* ===== JEU Z2 : ecran 02 - la feuille de noeud ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

#nodesheet-num {
  margin: 0;
  font-family: var(--font-mono);
  font-size: .8125rem;
  color: var(--text-faint);
}

#nodesheet-title {
  margin: .2rem 0 0;
  font-size: 1.0625rem;
  color: var(--text);
}

#nodesheet-verdict {
  margin: .6rem 0 0;
  font-size: .8125rem;
  color: var(--text-muted);
}

.jeu-sheet .row {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin-top: 1rem;
}

.jeu-sheet .row .jeu-btn { flex: 1; }

/* ===== fin JEU Z2 ===== */

/* ===== JEU Z3 : ecran 03 - la fiche gamifiee ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* --- The top bar: a compact back control, the fiche's own number and
   title (truncated), the same dots the bar below repeats. No position:
   sticky here - #lesson-panel is an in-flow <section class="panel"> among
   five others in the learn view, not a full-screen sheet, and pinning this
   bar risks fighting the masthead's own fixed chrome on a screen this
   chantier does not own. */
.fiche-topbar {
  display: flex;
  align-items: center;
  gap: .6rem;
  padding: 0 0 .6rem;
}

.fiche-topbar-back { padding: .5rem; }

p.fiche-topbar-label {
  flex: 1;
  min-width: 0;
  margin: 0;
  font-size: .8125rem;
  font-weight: 700;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.fiche-progress-dots {
  display: flex;
  gap: .3rem;
  flex: none;
}

.fiche-progress-dot {
  width: .3125rem;
  height: .3125rem;
  border-radius: 999px;
  background: var(--glass-track);
  flex: none;
}

.fiche-progress-dot[data-read="true"] { background: var(--accent); }

/* --- The reading-progress bar (J3-2): one dot per SECTION SERVED, never
   the prototype's fixed eight - openLesson sets --fiche-of to
   sections.length, and the ratio itself stays in calc(), the same rule
   #home-progress/#learn-progress and .jeu-ring already hold to. It reports
   where the reader is; it does not count down and does not congratulate. */
.fiche-progress-track {
  height: .1875rem;
  border-radius: 999px;
  background: var(--glass-track);
  overflow: hidden;
  margin: 0 0 .9rem;
}

.fiche-progress-fill {
  display: block;
  height: 100%;
  width: calc(100% * var(--fiche-read, 0) / var(--fiche-of, 1));
  background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

.fiche-hero { margin: 0 0 .75rem; }

#fiche-verdict { margin: 0 0 .9rem; }

/* --- JEU Z3-magazine (M2b): the reading column. The topbar and the hero
   stay full width (unchanged above); the table of contents, the flow of
   sections and the foot are capped and centred, the same 672px column
   REVIEW-DELTA sec.1 measured off the prototype. */
.fiche-toc,
.fiche-sections,
.fiche-footer {
  max-width: 42rem;
  margin-left: auto;
  margin-right: auto;
}

.fiche-toc {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: .5rem;
  padding: .25rem 0 0;
}

.fiche-toc-label {
  flex: none;
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.fiche-toc-list {
  font-family: var(--font-mono);
  font-size: .75rem;
  color: var(--text-faint);
}

.fiche-sections {
  display: flex;
  flex-direction: column;
  gap: 1.75rem;
}

/* --- The numbered chapter: a mono ordinal, the fiche's own heading in the
   reading face, a filet that fills the rest of the row - then the body,
   split into one <p> per paragraph the fiche itself already breaks with a
   blank line (M2b replaces the single-blob prose the generic card used to
   set, so a multi-paragraph section reads as paragraphs rather than one
   run-on block). Every heading that is not one of the four titled sections
   below becomes one of these (SPEC_JEU.md sec.2-03 amended: "fini les
   sections en cartes"). */
.fiche-chapter-head {
  display: flex;
  align-items: center;
  gap: .625rem;
}

.fiche-chapter-num {
  flex: none;
  font-family: var(--font-mono);
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .06em;
  color: var(--accent-text);
}

.fiche-chapter-head h3 {
  margin: 0;
  font-family: var(--font-read);
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--em);
}

.fiche-chapter-rule {
  flex: 1;
  height: 1px;
  background: var(--glass-border);
}

.fiche-chapter-body {
  margin-top: .875rem;
  display: flex;
  flex-direction: column;
  gap: .75rem;
}

.fiche-chapter-body p {
  margin: 0;
  font-family: var(--font-read);
  font-size: 1rem;
  line-height: 1.75;
  color: var(--text-read);
}

/* The lettrine (Contraintes d'ouverture, JEU_TRACKER.md: CSS only, never the
   served prose cut to make room for it). Scoped to the first paragraph of
   the chapter openLesson itself marks .fiche-chapter-first - never a bare
   :first-of-type, which would also light up on a fiche whose first served
   section happens to be one of the four titled ones below. */
.fiche-chapter-first .fiche-chapter-body p:first-of-type::first-letter {
  float: left;
  font-family: var(--font-read);
  font-size: 3rem;
  line-height: .8;
  font-weight: 700;
  color: var(--accent-text);
  padding: .3rem .5rem .1rem 0;
}

/* "The evidence" (J3-4, REVIEW-DELTA sec.1): a square list rather than the
   checklist's round mark or the chapter's own prose - reuses
   ficheParagraphs/ficheChecklistItems (Mentor check's own pure functions,
   app.js) rather than a second regrouping rule, so a fiche whose evidence
   opens with a citation paragraph before the list (unit 6) still renders
   both, in order, nothing dropped. */
.fiche-evidence-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .75rem;
}

.fiche-evidence-list li {
  display: flex;
  align-items: flex-start;
  gap: .625rem;
}

.fiche-evidence-mark {
  flex: none;
  width: .4375rem;
  height: .4375rem;
  margin-top: .55rem;
  border-radius: 2px;
  background: var(--accent);
}

.fiche-evidence-label {
  font-family: var(--font-read);
  font-size: 1rem;
  line-height: 1.7;
  color: var(--text-read);
}

/* --- The inline figures M1 serves (GET /api/lessons/{slug}/figures,
   WORKFLOWS_MULTI_AGENTS.md sec.5 avenant of 2026-08-02). Plain glass
   frame - the SVG itself already carries role="img"/aria-label/<title>,
   so nothing here adds a caption beside it. */
.fiche-figure {
  margin: 0;
  padding: .875rem .875rem .625rem;
  border-radius: var(--radius);
  background: var(--glass-row);
  border: 1px solid var(--glass-border);
}

.fiche-figure svg {
  display: block;
  width: 100%;
  height: auto;
}

/* --- Section cards: the shape the four titled sections below still get
   (J3-4 - unchanged by M2b, which only replaces the GENERIC card), .jeu-card
   itself carrying the glass surface (already registered). The fiche's own
   reading size (1rem, J3-3's doctrine: the corpus never shrinks toward
   decoration) anchors the cards' own GRADED prose
   (p.fiche-reflection-body, .fiche-evidence-label, below) - a decor that
   shrinks THAT is a decor that beats the lesson, Léa's own review names
   the risk. Two cards also carry smaller SUPPORTING text on purpose (the
   quest/mentor paragraph's own .9375rem, the reflection eyebrow's own
   .75rem) - named and reconciled with the absolute 12px floor at each of
   their own rules, below (contre-passe M3, finding 3, Karim). */
.fiche-card {
  position: relative;
  padding: 1rem 1.0625rem;
}

.fiche-card-icon {
  position: absolute;
  top: 1rem;
  right: 1.0625rem;
  width: 1.375rem;
  height: 1.375rem;
  color: var(--accent-text);
}

/* "The experiment" and "Mentor check" (J3-4, matched by openLesson on the
   SERVED TITLE, never on position) share one quiet accent - not a fifth
   colour, the same --ring-accent a proposed lesson's own node already
   wears in the units list (S-1 : sur la LISTE, le sentier est mort). */
.fiche-quest,
.fiche-mentor { border-color: var(--ring-accent); }

/* h3 (jeu-z3mag-karim.md, contre-passe M3, finding 2 - C2's own residue,
   Karim's word for it): C2 above only accounted for the <p> inside "The
   experiment"/"Mentor check" - `.lesson-body h3` ALSO painted
   `questHeading` and `mentorHeading` (app.js), and its retirement (C2/C3)
   left the two of them at the browser default sheet: roughly 18.7px bold,
   1em margin. That default outsizes the numbered chapter's own heading
   (.fiche-chapter-head h3, 1.25rem/20px, above - the hierarchy inverts:
   an undecorated card heading reads LARGER than a real chapter title) and
   its default margin sits awkwardly against `.fiche-card-icon`'s own
   absolute positioning (below - `top: 1rem`, no flow-based clearance from
   a sibling the default box model does not know is there).

   Decision: restore the exact rendering `.lesson-body h3` shipped before
   this chantier (proven, previously live at this size) rather than invent
   a new one - .9375rem/15px, var(--text-soft), the same near-floor size
   and muted-heading ink C2's own paragraph fix already carries into these
   two cards. `:first-child` kept from the original rule even though
   `questHeading`/`mentorHeading` are always the first node appended to
   their card today (app.js) - a defensive margin-collapse, not a claim
   that the DOM order could vary; if a future unit reorders the icon
   before the heading this rule keeps holding without a second look. */
.fiche-quest > h3,
.fiche-mentor > h3 {
  margin: 1rem 0 .35rem;
  font-size: .9375rem;
  color: var(--text-soft);
}

.fiche-quest > h3:first-child,
.fiche-mentor > h3:first-child { margin-top: 0; }

/* C2 (jeu-z3mag-karim.md, M3): "The experiment"'s own body paragraph and
   "Mentor check"'s intro paragraph(s) (before the checklist, unclassed)
   painted no rule at all once `.lesson-body` stopped being assigned to
   `#fiche-sections` (M2b) - UA margin, inherited size, by accident rather
   than by decision.

   CORRECTION (contre-passe M3, finding 3, Karim): this comment used to
   say .9375rem was "the same muted ink and near-floor size every other
   card paragraph on this screen already carries" - false, and Karim
   proved it: no other card PARAGRAPH in this zone was ever at .9375rem
   before this rule. There are two different floors on this sheet, never
   the same number, and this sentence conflated them:
     - the fiche's OWN reading size, 1rem - a doctrine choice (J3-3: the
       corpus never shrinks toward decoration), the size
       .fiche-chapter-body p, p.fiche-reflection-body and
       .fiche-evidence-label all carry, below and above;
     - the site-wide ABSOLUTE floor, 12px - a CI guard every font-size on
       this sheet is held to regardless of doctrine
       (test_nothing_typed_below_the_readability_floor_in_z3s_own_css),
       the size p.fiche-reflection-eyebrow sits at exactly, further down.
   .9375rem/15px is neither: a size introduced HERE, on purpose, for
   supporting card prose that is not the fiche's own graded reading text -
   below the reading size, well clear of the absolute one. No number
   changed by this correction; only the sentence that misdescribed it.

   No `white-space: pre-wrap` here either - decision journalisee at
   JEU_TRACKER.md's Z3-magazine M3 corrections: the magazine flow re-wraps
   every paragraph it prints (.fiche-chapter-body p's own precedent,
   below), and a quest/mentor paragraph is exactly the same served prose.
   `.fiche-mentor-note` (further down) still wins its own fainter colour
   for the notes that come AFTER the checklist: same specificity as this
   rule, later in the sheet. */
.fiche-quest > p,
.fiche-mentor > p {
  margin: .6rem 0 0;
  font-size: .9375rem;
  line-height: 1.6;
  color: var(--text-muted);
}

.fiche-checklist {
  list-style: none;
  margin: .6rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .5rem;
}

.fiche-checklist li {
  display: flex;
  align-items: flex-start;
  gap: .5rem;
  color: var(--text-muted);
  font-size: .875rem;
  line-height: 1.55;
}

/* F2 (jeu-fix, BLOQUANT, docs/reviews/jeu-parcours-lea.md): Mentor check
   criteria are what the AI mentor looks for in a conversation this screen
   does not run - the same curriculum text on every fiche that carries
   this section (every unit under content/lessons), never a per-student
   result. No
   route serves a per-criterion state here, so this reuses
   .grade-criteria's own motif verbatim rather than inventing one
   (style.css, ".criterion-mark" above "Progress, as long as the count it
   restates"): a neutral dot, affirmative only behind a data-met="true"
   openLesson never writes on this list's own <li>. A checkmark used to
   sit here unconditionally - it read "done" beside sentences that say
   "before proposing the next lesson", which was the bug. */
.fiche-checklist-mark {
  flex: none;
  width: .5rem;
  height: .5rem;
  margin-top: .35rem;
  border-radius: 50%;
  background: var(--text-faint);
}

.fiche-checklist li[data-met="true"] .fiche-checklist-mark { background: var(--up); }

.fiche-checklist-label { flex: 1; }

p.fiche-mentor-note { color: var(--text-faint); }

/* "The question" (J3-4): the fiche's own heading as a small kicker
   (p.fiche-reflection-eyebrow, .75rem/12px) - RECONCILED (contre-passe M3,
   finding 3, Karim) with the correction above: "the floor" here is the
   ABSOLUTE one, 12px, the site-wide CI minimum
   (test_nothing_typed_below_the_readability_floor_in_z3s_own_css) - the
   eyebrow sits AT it, never below, which is what "never smaller than the
   floor" always meant for this one line, distinct from the OTHER floor
   (the fiche's own 1rem reading size, J3-3's doctrine) that
   p.fiche-reflection-body below is held to instead - the body reads as
   the reflection it is, at the fiche's own full reading size, never the
   eyebrow's smaller one. */
p.fiche-reflection-eyebrow {
  margin: 0;
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--text-faint);
}

p.fiche-reflection-body {
  margin: .5rem 0 0;
  font-size: 1rem;
  line-height: 1.6;
  font-style: italic;
  color: var(--text);
}

.fiche-footer {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .75rem;
  text-align: center;
  padding: .9rem 0 .2rem;
}

/* ===== fin JEU Z3 ===== */

/* ===== JEU Z4 : ecran 04 - le moment de validation ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* Fixed, not in the normal flow - found in the browser: this markup sits
   after every view in #app-body (the comment at its HTML anchor said
   "first"; the anchor it was actually given is last, and a first version
   of this rule trusted that comment instead of checking). In flow, on a
   view as long as Learn's own 48 lessons, that put the card thousands of
   pixels below the fold - reachable only by scrolling past everything,
   which is no way to show something that is meant to be noticed once. The
   trigger can also fire from Mentor while the reader is looking at Learn
   (a reply validates a lesson, loadLessons() runs in the background) - a
   fixed position is what makes it findable regardless of which view is on
   screen or how far the reader has scrolled into it. Léa (revue P-04,
   BLOQUANT n°2) : « top: 1rem » datait du temps où la barre vivait au
   pied — depuis P-04 c'est le HAUT qui appartient à la navigation, et la
   carte couvrait Learn/Trade/Mentor de la seule barre du produit (z 65 >
   62, l'ordre tenait, la géométrie mentait). Elle s'accroche désormais
   SOUS la barre, à la variable mesurée — le seul bord fixe que rien
   d'autre ne réclame à cet endroit. */
.earned-moment {
  position: fixed;
  top: calc(var(--pad-nav-top) + .5rem);
  left: 50%;
  transform: translateX(-50%);
  z-index: 65;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .5rem 1rem;
  width: calc(100% - 1.5rem);
  max-width: 34rem;
  padding: .85rem 1rem;
  box-shadow: 0 12px 30px var(--bg-sunken);
}

.earned-title {
  flex: 1 1 12rem;
  min-width: 0;
  margin: 0;
  font-size: .9375rem;
  font-weight: 700;
  color: var(--text);
}

.earned-moment .hint {
  flex: 1 1 16rem;
  min-width: 0;
  margin: 0;
}

/* ===== fin JEU Z4 ===== */

/* ===== JEU Z5 : ecran 05 - le hub de la salle de memoire ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* One package card (J5-1): .jeu-card supplies the glass surface and the
   hover lift, this class only supplies the button reset and the internal
   layout .jeu-card itself deliberately does not own (CLASSES.md: "Does
   not: set its own padding"). */
.room-deck {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  width: 100%;
  padding: .8rem .85rem;
  text-align: left;
  font: inherit;
  color: inherit;
}

.room-deck[aria-pressed="true"] { border-color: var(--accent); }

.room-deck-title {
  font-size: .875rem;
  font-weight: 800;
  color: var(--text);
}

.room-deck-readout {
  display: flex;
  align-items: center;
  gap: .6rem;
}

/* Sizes the ring: .jeu-ring itself is width:100%/height:100% of whatever
   box it is given (CLASSES.md, ".jeu-ring / jeuProgressRing"). */
.room-deck-ring {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
}

.room-deck-count {
  color: var(--text-muted);
  font-size: .75rem;
}

/* "All rooms" is a filter toggle, not a fifth package card - the four
   cards above are the four real decks, each with a palace room of its
   own; "all" has none (SPEC_JEU.md J5-1 says "the four", not "the
   five"). Built on .jeu-btn-outline rather than a new class of its own. */
.room-all[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--text);
}

.room-modes {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .5rem;
  margin: .85rem 0 0;
}

/* CAMPUS-PROTO U4: the arcade dressing - one cover above one REAL button,
   art beside the control and never inside it. The button keeps the full
   cell width it had as a direct grid child. */
.campus-game {
  display: flex;
  flex-direction: column;
  gap: .375rem;
  min-width: 0;
}

.campus-game > .jeu-btn { width: 100%; }

.campus-game-cover {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--glass-border);
  border-radius: 8px;
  background: var(--bg-sunken);
}

.campus-game--bench { max-width: 22rem; }

.room-cta { width: 100%; margin: .75rem 0 0; }

.room-reset-row {
  display: flex;
  align-items: center;
  gap: .6rem;
  flex-wrap: wrap;
}

/* The reset link (J5-4, C13): kept visually quiet on purpose - a bordered
   .jeu-btn here would outweigh the sentence beside it that says exactly
   what clearing costs - but still a 44px tap target, never the ~32px the
   audit measured against this exact control. */
.room-reset {
  min-height: 2.75rem;
  min-width: 2.75rem;
  padding: 0 .75rem;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: .8125rem;
  font-weight: 700;
  cursor: pointer;
}

.room-reset:hover { background: var(--glass-row); color: var(--text); }

/* ===== fin JEU Z5 ===== */

/* ===== JEU Z6 : ecran 06 - la session de flashcards ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* J6-2: the flip card. Both faces sit absolute inside .recall-flip-card, so
   the wrapper needs a FIXED height, not a min-height - .recall-flip-card's
   own height:100% only resolves against a definite parent height (CSS
   percentage-height rule), and with both faces taken out of flow by
   position:absolute, nothing is left to grow that height from content. A
   min-height here collapsed the whole card to 0px in a real browser while
   every text-only harness test stayed green, having no layout engine to
   catch it - found only by opening the page. A long back face scrolls
   inside itself (.recall-flip-face's own overflow-y:auto) instead of
   stretching the box past this fixed height. */
.recall-flip {
  width: 100%;
  height: 17.5rem;
  perspective: 1400px;
}

.recall-flip-card {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
}

.recall-flip[data-flipped="true"] .recall-flip-card {
  transform: rotateY(180deg);
}

.recall-flip-face {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .6rem;
  padding: 1rem;
  overflow-y: auto;
  text-align: center;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--bg-raised);
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.recall-flip-face-back {
  transform: rotateY(180deg);
}

.recall-flip-card { transition: transform .45s cubic-bezier(.4, .2, .2, 1); }

@media (prefers-reduced-motion: reduce) {
  .recall-flip-card { transition: none; }
}

/* ===== fin JEU Z6 ===== */

/* ===== JEU Z7 : ecrans 07-08 - quiz et nommer la figure ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* J7-2: right and wrong, told apart by weight, shape and motion rather
   than by a colour that would double as a research verdict.
   JEU-FIX2-F3 (jeu-memoire-lea.md, jeu-memoire-noor.md 5): this used to be
   ONE rule shared by both states, byte-identical border-width and
   border-color on each - the docstring of the test below claimed the
   border told them apart and never checked it, and it did not: the border
   alone only ever said "answered", never "right" or "wrong". It now
   carries the actual difference (border-width, never a hue - the verdict
   tokens stay reserved for a research verdict, never a game answer), so under
   `prefers-reduced-motion` - where the pulse/shake below switches off -
   the border and the mark are BOTH still telling the two states apart,
   not just the mark alone. */
.recall-option[data-state="right"] {
  border-width: 2px;
  border-color: var(--border-strong);
}

.recall-option[data-state="wrong"] {
  border-width: 3px;
  border-color: var(--border-strong);
}

.recall-option-mark {
  flex: none;
  width: 1rem;
  height: 1rem;
  margin-right: .5rem;
}

.recall-option[data-state="right"] { animation: recall-answer-right .6s ease-out; }

.recall-option[data-state="wrong"] { animation: recall-answer-wrong .4s ease; }

@keyframes recall-answer-right {
  0% { box-shadow: 0 0 0 0 var(--ring-accent-strong); }
  100% { box-shadow: 0 0 0 10px transparent; }
}

@keyframes recall-answer-wrong {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-5px); }
  40% { transform: translateX(5px); }
  60% { transform: translateX(-3px); }
  80% { transform: translateX(3px); }
}

@media (prefers-reduced-motion: reduce) {
  .recall-option[data-state="right"] { animation: none; }

  .recall-option[data-state="wrong"] { animation: none; }
}

/* ===== fin JEU Z7 ===== */

/* ===== JEU Z8 : ecran 09 - le palais de memoire ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort.

   Vide par construction : la porte, le locus et l'epreuve reutilisent en
   entier le vocabulaire deja pose - .jeu-room-head/.jeu-room-icon/
   .jeu-room-name (Z0), .jeu-btn/.jeu-btn-primary/.jeu-btn-outline (Z0),
   .recall-anchor/.recall-term/.recall-meaning/.hint (le bloc "The memory
   room" pre-existant). Rien de neuf a peindre ici sans dupliquer une regle
   qui existe deja - voir docs/design/prototype-jeu/CLASSES.md. */

/* ===== fin JEU Z8 ===== */

/* ===== JEU Z9 : ecran 10 - la fin de session ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* The end-of-session card (J9-1..J9-5): built by jeuDoneCard(), documented
   in CLASSES.md section 2 as a primitive Z10/S6 reuses for the day
   experiment's own debrief. .jeu-card supplies the glass surface; this
   class only centres the content the way a closing card reads best. A
   card that is not itself clickable should not lift on hover like one
   that is - the base rule's hover is switched off here on purpose. */
.done-card {
  padding: 1.1rem 1rem;
  max-width: 24rem;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .4rem;
  text-align: center;
}

.done-card:hover { transform: none; }

.done-eyebrow {
  margin: 0;
  color: var(--text-faint);
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .06em;
  text-transform: uppercase;
}

.done-line {
  margin: 0;
  color: var(--text);
  font-size: 1.5rem;
  font-weight: 800;
  font-family: var(--font-mono);
}

.done-detail {
  margin: 0;
  max-width: 34ch;
  color: var(--text-muted);
  font-size: .875rem;
  line-height: 1.6;
}

/* Vera, if and only if the mode is palace (C17: the line only makes sense
   next to a walk that just happened) - never a colour of her own, just
   the room's own muted ink plus a bold name to say who is speaking. */
.done-vera {
  margin: .3rem 0 0;
  color: var(--text-muted);
  font-size: .8125rem;
}

.done-vera-name {
  color: var(--accent-text);
  font-weight: 700;
}

/* The two actions sit in #recall-actions (not owned by this zone) but are
   this zone's own two buttons - equal weight is a deliberate choice
   (J9-3: Lea's note that a primary/outline pair nudges toward "continue"),
   so both stretch evenly rather than one out-sizing the other. */
#done-again,
#done-close {
  flex: 1;
}

/* ===== fin JEU Z9 ===== */

/* ===== JEU Z10 : ecran 11 - l'experience du jour ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* The "Reconstructed data" label. An OPAQUE chip on --bg-raised rather than
   a translucent wash on --wash-warn - the exact fix C10 taught .jeu-hero-
   eyebrow, applied here so the ink pair stays --warn-text on --bg-raised,
   already measured at the contrast floor rather than a new pair this sheet
   would have to measure from nothing. The ring is a border, not an ink, so
   it can read --ring-warn without entering the contrast registry at all. */
#experiment-label {
  display: inline-flex;
  align-items: center;
  padding: .2rem .55rem;
  border: 1px solid var(--ring-warn);
  border-radius: var(--radius);
  background: var(--bg-raised);
  color: var(--warn-text);
  /* .jeu-pill's own floor (C9, SPEC_JEU.md 6.3): 12px exactly, no exemption
     needed, rather than a fifth name in SMALL_LABELS_BELOW_THE_FLOOR. */
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
}

/* "Bar N of M": the replay's own progress, never computed here (J10-2). The
   two served counts land on the track as custom properties from
   renderExperiment and the division happens in calc() - see
   #home-progress/#learn-progress for the rule this repeats. */
.xp-bar-label {
  display: flex;
  justify-content: space-between;
  margin: .8rem 0 0;
  font-size: .8125rem;
  color: var(--text-muted);
}

.xp-bar-track {
  --xp-bar-done: 0;
  --xp-bar-of: 1;
  height: .375rem;
  margin-top: .4rem;
  border-radius: 999px;
  background:
    linear-gradient(90deg, var(--grad-a), var(--grad-b)) no-repeat left center
      / calc(100% * var(--xp-bar-done) / var(--xp-bar-of)) 100%,
    var(--glass-track);
}

/* The chart (J10-4, wired now that the §5 avenant of 2026-07-31 serves
   `run.candles`). `.chart` is the Trade view's own base rule (`height: 380px;
   width: 100%;`, `static/style.css` "Two gestures were needed") - reused
   here with a shorter modifier the same way `.chart.chart-equity` already
   overrides it for the equity mini-chart, rather than a second base rule. */
.chart.xp-chart { height: 200px; }

/* The fallback text (`typeof LightweightCharts === "undefined"`, the same
   guard `createChart` already holds to for the Trade view) - hidden whenever
   the library actually loaded, which is every real visit. */
.xp-chart-note { margin: .8rem 0 0; }

/* The debrief (J10-5): an empty seat for `jeuDoneCard()` (S3, Z9,
   `docs/design/prototype-jeu/CLASSES.md` "the end-of-session primitive") -
   `.done-card`/`.jeu-card` supply all of their own styling, so this id only
   places the seat inside the card it sits in. */
#xp-debrief { margin-top: .8rem; }

/* Long is the order form's own submit and carries neither id nor class
   (J10-3: nothing reads one, and the dead-markup guard is right to insist) -
   styled from the form it lives in instead. Same box as .jeu-btn, same ink
   pair as .segment-long[aria-pressed="true"] and .confirm-yes[data-confirm-
   side="long"] elsewhere on this page: a side button is a coloured surface
   carrying its own ink, already measured at the contrast floor. */
#experiment-order button[type="submit"] {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 2.75rem;
  min-width: 2.75rem;
  padding: .5rem 1.1rem;
  border: 1px solid var(--side-long);
  border-radius: var(--radius);
  background: var(--side-long);
  color: var(--side-long-ink);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}

/* Short carries .jeu-btn-outline in the markup for its 44px floor and its
   focus ring; these three properties override its transparent fill with the
   same measured pair the confirm sheet's short button already wears. */
#experiment-short {
  border-color: var(--side-short);
  background: var(--side-short);
  color: var(--side-short-ink);
}

/* ===== fin JEU Z10 ===== */

/* ===== JEU Z11 : ecran 03b, le guided drill ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort. */

/* Screen 03b. Everything below is built on Z0's own vocabulary - .jeu-btn,
   .jeu-btn-primary, .jeu-btn-outline, .jeu-btn-answer, .jeu-overlay,
   .jeu-scrim, .jeu-session - and adds only what a drill needs that no other
   screen has: a budget of R, a bank of chips, a gauge that fills while a
   button is held, and a feedback box.

   Right and wrong are told apart by BORDER WIDTH, shape and motion, never by
   a hue. That is not this zone's invention: it is exactly what Z7 already
   does for the quiz (.recall-option[data-state], style.css above), for
   exactly the reason SPEC_JEU.md gives - --up/--down/--warn/--verdict-* are
   the research verdict's tokens and a game answer never borrows them. The
   recipe is copied, not the class: a Z11 rule that reached into .recall-*
   would be this zone writing in Z7's. */

.drill-topbar {
  display: flex;
  align-items: center;
  gap: .6rem;
  max-width: 35rem;
  margin: 0 auto;
}

.drill-topbar-back {
  flex: none;
  padding: .5rem;
}

.drill-topbar-label {
  flex: 1;
  min-width: 0;
  margin: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: .8125rem;
  font-weight: 800;
  color: var(--text);
}

/* "x3 in a row", never the banned word - SPEC_JEU.md sec. 4 ter decision 6.
   Hidden below two, because one right answer is not a run. */
.drill-run {
  flex: none;
  padding: .2rem .55rem;
  border: 1px solid var(--ring-accent);
  border-radius: 999px;
  background: var(--wash-accent);
  font-family: var(--font-mono);
  font-size: .75rem;
  font-weight: 700;
  color: var(--accent-text);
}

.drill-budget {
  display: flex;
  flex: none;
  gap: .25rem;
}

/* One chip per R the drill's own file budgets, spent left to right. Spent is
   an outline gone empty, not a colour swapped in. */
.drill-budget-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;
  height: 1.25rem;
  border: 1px solid var(--ring-accent);
  border-radius: 999px;
  background: var(--wash-accent);
  font-family: var(--font-mono);
  font-size: .75rem;
  font-weight: 700;
  color: var(--accent-text);
}

.drill-budget-chip[data-spent="true"] {
  border-color: var(--border);
  background: transparent;
  color: var(--text-faint);
  text-decoration: line-through;
}

.drill-track {
  max-width: 35rem;
  height: 3px;
  margin: .6rem auto 0;
  overflow: hidden;
  border-radius: 999px;
  background: var(--glass-track);
}

/* The ratio stays in calc, the way .fiche-progress-fill already keeps it:
   the script hands over two counts it already has and divides nothing. */
.drill-track-fill {
  display: block;
  width: calc(100% * var(--drill-done, 0) / var(--drill-of, 1));
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

.drill-stage,
.drill-end {
  max-width: 35rem;
  margin: 0 auto;
  padding-top: 1.1rem;
}

/* .jeu-card carries the material and no padding at all - every zone that
   consumes it adds its own (found in a browser: the intro's own text ran
   edge to edge without this). */
.drill-intro {
  padding: 1.1rem 1rem;
}

.drill-kicker {
  margin: 0;
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--accent-text);
}

.drill-title {
  margin: .3rem 0 0;
  font-size: 1.25rem;
  font-weight: 800;
  line-height: 1.25;
  color: var(--text);
}

.drill-meta {
  margin: .4rem 0 0;
  font-family: var(--font-mono);
  font-size: .75rem;
  color: var(--text-faint);
}

.drill-points {
  margin: .9rem 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: .55rem;
}

.drill-point {
  position: relative;
  padding-left: 1.1rem;
  font-size: .8125rem;
  line-height: 1.55;
  color: var(--text-muted);
}

/* The bullet is drawn, not typed: no glyph on this page may sit above
   U+2190, and a hyphen would read as a dash inside the sentence. */
.drill-point::before {
  content: "";
  position: absolute;
  top: .5rem;
  left: .15rem;
  width: .4rem;
  height: .4rem;
  border-radius: 999px;
  background: var(--accent);
}

.drill-note {
  margin: .9rem 0 0;
  text-align: center;
  font-size: .75rem;
  font-style: italic;
  line-height: 1.6;
  color: var(--text-faint);
}

.drill-step-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: .6rem;
}

.drill-counter {
  flex: none;
  font-family: var(--font-mono);
  font-size: .75rem;
  color: var(--text-faint);
}

.drill-prompt {
  margin: .6rem 0 0;
  font-size: 1.0625rem;
  font-weight: 700;
  line-height: 1.5;
  color: var(--text);
}

.drill-options {
  display: flex;
  flex-direction: column;
  gap: .55rem;
  margin-top: .9rem;
}

/* Z7's recipe, copied: the two states differ by border WIDTH, so they stay
   distinguishable when the motion below is switched off. */
.drill-option[data-state="right"] {
  border-width: 2px;
  border-color: var(--border-strong);
}

.drill-option[data-state="wrong"] {
  border-width: 3px;
  border-color: var(--border-strong);
}

/* .jeu-btn-answer:disabled fades every answered option to .6; the two that
   carry the verdict have to stay readable, and the untouched ones stay faded
   because "these are out of it now" is exactly what they mean. */
.drill-option[data-state]:disabled {
  opacity: 1;
}

.drill-drop {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .45rem;
  min-height: 3.25rem;
  margin-top: .9rem;
  padding: .6rem;
  border: 1px dashed var(--input-border);
  border-radius: var(--radius-panel);
}

.drill-drop-hint {
  margin: 0;
  font-size: .75rem;
  color: var(--text-faint);
}

.drill-placed {
  padding: .45rem .7rem;
  border: 1px solid var(--ring-accent);
  border-radius: 999px;
  background: var(--wash-accent);
  font-size: .8125rem;
  font-weight: 700;
  color: var(--accent-text);
}

.drill-bank {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .45rem;
  margin-top: .75rem;
}

.drill-chip {
  min-height: 2.75rem;
  padding: .5rem .85rem;
  border: 1px solid var(--glass-border);
  border-radius: 999px;
  background: var(--glass-row);
  font: inherit;
  font-size: .8125rem;
  font-weight: 700;
  color: var(--text);
  cursor: pointer;
  touch-action: manipulation;
}

.drill-chip:hover:not(:disabled) {
  background: var(--glass-row-hover);
}

.drill-chip:disabled {
  opacity: .35;
  cursor: default;
}

/* A chip out of order costs nothing - it is refused, not punished. The
   refusal shows as a thicker edge as well as the shake, so it still reads
   when motion is off. */
.drill-chip[data-state="wrong"] {
  border-width: 3px;
  border-color: var(--border-strong);
}

/* The hold: a gauge that FILLS, never a number that falls. SPEC_JEU.md
   sec. 4 ter decision 3 - the time plays for patience, and nothing on this
   screen counts down. */
.drill-hold {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 3.5rem;
  margin-top: .9rem;
  padding: .75rem 1rem;
  overflow: hidden;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: transparent;
  font: inherit;
  font-size: .875rem;
  font-weight: 700;
  color: var(--text);
  cursor: pointer;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

/* Spelled out rather than left to the browser: a disabled <button> picks up
   a user-agent grey otherwise, which is the one ink on this screen no theme
   controls. Same pair .jeu-btn-answer:disabled already uses. */
.drill-hold:disabled {
  opacity: .6;
  cursor: default;
}

/* The same gradient the progress bar uses, damped: measured in a browser,
   --wash-accent alone is so close to --bg-raised that the gauge did not read
   as filling at all. The opacity is on a decorative overlay, never on text -
   the label is a sibling above it, at full ink. */
.drill-hold-gauge {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: calc(100% * var(--drill-hold, 0));
  background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
  opacity: .3;
}

.drill-hold-label {
  position: relative;
}

.drill-range {
  width: 100%;
  margin: 1rem 0 0;
  accent-color: var(--accent);
  cursor: pointer;
}

.drill-gesture-hint {
  margin: .5rem 0 0;
  font-size: .75rem;
  color: var(--text-faint);
}

/* The feedback box (SPEC_JEU.md sec. 1.5's own form): full width, a title
   and the exercise's own note. Not the pastille+label motif - that one
   belongs to a research verdict. */
.drill-feedback {
  margin-top: .9rem;
  padding: .85rem .9rem;
  border: 2px solid var(--border-strong);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
}

.drill-feedback[data-state="wrong"] {
  border-width: 3px;
}

.drill-feedback-title {
  margin: 0;
  font-size: .9375rem;
  font-weight: 800;
  color: var(--text);
}

.drill-feedback-note {
  margin: .4rem 0 0;
  font-size: .8125rem;
  line-height: 1.6;
  color: var(--text-muted);
}

.drill-feedback-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: .75rem;
}

.drill-end {
  text-align: center;
}

/* The tick, drawn from two borders: no glyph, no emoji, no SVG namespace to
   smuggle a URL into app.js. */
.drill-check {
  position: relative;
  display: block;
  width: 4.5rem;
  height: 4.5rem;
  margin: 0 auto;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  box-shadow: 0 10px 26px var(--shadow-accent);
}

.drill-check::after {
  content: "";
  position: absolute;
  top: 1.5rem;
  left: 1.75rem;
  width: .9rem;
  height: 1.5rem;
  border-right: 3px solid var(--on-accent);
  border-bottom: 3px solid var(--on-accent);
  transform: rotate(45deg);
}

.drill-done-label {
  margin: 1rem 0 0;
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.drill-done-title {
  margin: .25rem 0 0;
  font-size: 1.25rem;
  font-weight: 800;
  color: var(--text);
}

.drill-chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .4rem;
  margin-top: .9rem;
}

.drill-chip-stat {
  padding: .3rem .7rem;
  border: 1px solid var(--glass-border);
  border-radius: 999px;
  background: var(--glass-row);
  font-family: var(--font-mono);
  font-size: .75rem;
  font-weight: 700;
  color: var(--text-muted);
}

.drill-guard {
  margin: 1rem 0 0;
  padding: .85rem .9rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
  text-align: left;
  font-size: .8125rem;
  line-height: 1.6;
  color: var(--text-muted);
}

.drill-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin-top: 1rem;
}

.drill-actions .jeu-btn {
  flex: 1;
}

/* Its own line under the two exits: re-running a drill is a smaller act
   than leaving it, and a third column would have squeezed all three. */
.drill-again {
  width: 100%;
  margin-top: .5rem;
}

/* The node sheet's entry button (SPEC_JEU.md sec. 4 ter decision 8). Every
   .jeu-btn in that row is flex: 1; this one takes its own line instead,
   because three columns leave "Guided drill - practice this step" too narrow
   to read. It is written LAST in the row for the same reason - a full-width
   item in the middle pushed Z2's own pair onto three lines. The fiche's
   footer needs no rule at all: it is already a centred flex column. */
.jeu-sheet .row .drill-entry {
  flex-basis: 100%;
}

@keyframes drill-answer-right {
  0% { box-shadow: 0 0 0 0 var(--ring-accent-strong); }
  100% { box-shadow: 0 0 0 10px transparent; }
}

@keyframes drill-answer-wrong {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-5px); }
  40% { transform: translateX(5px); }
  60% { transform: translateX(-3px); }
  80% { transform: translateX(3px); }
}

/* The three moving rules stand bare on purpose. No comma may appear in a
   comment sitting directly above a rule the motion guard has to read as a
   selector - the trap this repo has walked into three times. */

.drill-option[data-state="right"] { animation: drill-answer-right .6s ease-out; }

.drill-option[data-state="wrong"] { animation: drill-answer-wrong .4s ease; }

.drill-chip[data-state="wrong"] { animation: drill-answer-wrong .4s ease; }

@media (prefers-reduced-motion: reduce) {
  .drill-option[data-state="right"] { animation: none; }

  .drill-option[data-state="wrong"] { animation: none; }

  .drill-chip[data-state="wrong"] { animation: none; }
}

/* ===== fin JEU Z11 ===== */

/* ===== JEU Z12 : le tracage des figures ===== */
/* Cette zone appartient a une seule ingenieure. Rien d'une autre zone
   n'entre ici, et rien d'ici ne sort.

   Screen built on Z0's own vocabulary - .jeu-btn, .jeu-btn-primary,
   .jeu-btn-outline, .jeu-overlay, .jeu-scrim, .jeu-session - and its own
   classes for what no other screen has: a chart the reader clicks on, a
   phase prompt, and a criterion-by-criterion verdict. The recipe for a
   met/not-met mark is Z7's (border-width and shape, never a hue - a traced
   figure is judged, not scored, and the doctrine SPEC_JEU.md gives for a
   game answer applies here too: --up/--down/--verdict-* stay out), the
   class is this zone's own. */

.trace-topbar {
  display: flex;
  align-items: center;
  gap: .6rem;
  max-width: 40rem;
  margin: 0 auto;
}

.trace-topbar-back {
  flex: none;
  padding: .5rem;
}

.trace-topbar-label {
  flex: 1;
  min-width: 0;
  margin: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: .8125rem;
  font-weight: 800;
  color: var(--text);
}

.trace-stage {
  max-width: 40rem;
  margin: 1rem auto 0;
}

.trace-picker {
  display: flex;
  flex-direction: column;
  gap: .6rem;
}

.trace-picker-item {
  display: block;
  width: 100%;
  text-align: left;
  padding: .85rem .9rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
  color: inherit;
  font: inherit;
  cursor: pointer;
}

.trace-picker-item:hover { border-color: var(--text-faint); }

.trace-picker-item-title {
  margin: 0 0 .25rem;
  font-weight: 700;
}

.trace-picker-item-meta {
  margin: 0 0 .4rem;
  font-size: .8125rem;
  color: var(--text-muted);
}

.trace-picker-item-instruction {
  margin: 0;
  font-size: .875rem;
  color: var(--text-soft);
}

.trace-back-to-picker {
  margin-bottom: .6rem;
}

.trace-instruction {
  margin: 0 0 .6rem;
  color: var(--text-soft);
}

.trace-chart {
  height: 420px;
  width: 100%;
  /* Same reasoning as .chart (Trade view): transparent, so the session's own
     --bg-raised shows through instead of a slab the library would paint. */
}

.trace-phase {
  margin: .6rem 0 0;
  font-size: .875rem;
  color: var(--text-muted);
}

.trace-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin-top: .6rem;
}

.trace-points {
  margin: .6rem 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
}

.trace-point-chip {
  padding: .2rem .55rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  background: var(--glass-row);
  font-size: .8125rem;
  color: var(--text-muted);
}

.trace-banner {
  margin: .9rem 0;
  padding: .7rem .85rem;
  border: 2px solid var(--border-strong);
  border-radius: var(--radius-panel);
  font-weight: 700;
}

/* Accepted widens the border further still (Z7's own recipe: width and
   shape, never a hue) - the mark beside the text carries the rest. */
.trace-banner[data-accepted="true"] { border-width: 3px; }

.trace-criteria {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: .5rem;
}

.trace-criterion {
  display: flex;
  align-items: flex-start;
  gap: .5rem;
  padding: .6rem .7rem;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
}

.trace-criterion[data-met="true"] { border-width: 2px; border-color: var(--border-strong); }

.trace-criterion[data-met="false"] { border-width: 3px; border-color: var(--border-strong); }

.trace-criterion-mark {
  flex: none;
  width: 1rem;
  height: 1rem;
  margin-top: .15rem;
  color: var(--text-muted);
}

.trace-criterion-body { min-width: 0; }

.trace-criterion-label {
  margin: 0;
  font-weight: 700;
}

/* chart(corr-L4), Lea's review: this note is engine English in every
   language build (avenant CH2 clause 4, debt D8) - italic reads as a
   deliberate product choice rather than a rendering bug, the same width-
   and-shape-not-a-hue discipline .trace-banner already applies to a
   verdict. Paired with `lang="en"` set in app.js on the node itself. */
.trace-criterion-note {
  margin: .15rem 0 0;
  font-size: .8125rem;
  font-style: italic;
  color: var(--text-muted);
}

@media (max-width: 1023px) {
  .trace-chart { height: 320px; }
}

/* ===== fin JEU Z12 ===== */

/* ===== JEU Z15 : les expeditions, le mode histoire ===== */
/* ===== fin JEU Z15 ===== */

/* --- The keeper's tour (W4, V02) --------------------------------------- */
/* Never a true modal trap (rule 5, anti-urgence): the overlay sits above
   everything the dock and the mobile sheet can reach (z-index 20 and 30
   respectively), but every exit — Back, Skip, Escape, a nav item pressed
   through it — is one press away, wired in app.js rather than enforced by
   this file being unable to be dismissed. */
/* Found at the keyboard, in V06's control pass, not by any test: z-index
   alone hides the dock from SIGHT while the tour is open, but leaves its
   button reachable by Tab and pressable by Enter — a keyboard user could
   open the brief banner UNDER the tour and never see it happen. `display:
   none` removes it from the tab order too, which visibility alone never
   would. */
.onboarding:not([hidden]) ~ .dock {
  display: none;
}

.onboarding {
  position: fixed;
  inset: 0;
  /* Revue calibrée P-04 (BLOQUANT, passe libre + lentille absorption) : à
     50, le tour vivait AU-DESSUS d'un dock à 20 et d'une feuille à 30 —
     P-04 a monté la barre à 62/63 et le premier écran de tout nouveau
     compte peignait cinq touches nettes, pressées, INERTES par-dessus le
     voile opaque (« une navigation qui a l'air vivante et qui est morte »,
     mesuré au vrai Chromium, elementFromPoint rendait onboarding, cinq fois sur cinq). Le tour
     est une surface immersive : il couvre TOUT — barre (62), feuille (63)
     et moment gagné (65) compris — et ne laisse au-dessus de lui que le
     jeu (70), qui ne s'ouvre jamais pendant un tour. */
  z-index: 66;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--gap);
  /* Solid, not translucent: an `opacity` on this element would dim the card
     inside it by the same amount (opacity composites down the whole
     subtree, and a compensating >1 value on the child is clamped to 1 by
     the spec) — measured wrong once, fixed by not fighting the cascade. */
  background: var(--bg);
}

.onboarding-card {
  width: min(30rem, 100%);
  max-height: calc(100vh - 2 * var(--gap));
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: .6rem;
}

.onboarding-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .6rem;
  padding: var(--pad) var(--pad) 0;
}

.onboarding-dots {
  display: flex;
  gap: .35rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.onboarding-dots li {
  width: .5rem;
  height: .5rem;
  border-radius: 999px;
  background: var(--glass-track);
}

.onboarding-dots li[data-state="current"] { background: var(--accent); }
.onboarding-dots li[data-state="done"] { background: var(--text-faint); }

.onboarding-body {
  padding: 0 var(--pad);
  display: flex;
  flex-direction: column;
  gap: .5rem;
}

.onboarding-body h2 {
  margin: .2rem 0 0;
  font-size: 1.15rem;
}

.onboarding-body .notice { margin: 0; }

/* Cloned at boot from the same drawing `.keeper svg` already sizes at 64px
   (initKeeperClone mirrors initDock's own clone-not-redraw approach) - sized
   here directly rather than by sharing the `.keeper` class, because that
   class is guarded to appear exactly once in the shipped markup
   (test_the_keeper_is_drawn_once_and_shown_where_it_is_legible). */
#onboarding-keeper svg {
  width: 64px;
  height: 64px;
}

.onboarding-gestures {
  margin: 0;
  padding: 0 0 0 1.6rem;
  display: grid;
  gap: .3rem;
}

.onb-step { padding-left: .2rem; }

.onboarding-mentors {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: .6rem;
  margin: .3rem 0;
}

.mentor-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: .2rem;
  text-align: left;
  padding: .6rem .7rem;
}

.mentor-card .mc-n { font-weight: 600; }
.mentor-card .mc-d { color: var(--text-muted); font-size: .8125rem; }

.mentor-card[aria-pressed="true"] {
  border-color: var(--ring-accent);
  background: var(--wash-accent);
}

.onboarding-paths {
  display: grid;
  gap: .5rem;
  margin: .3rem 0;
}

.onboarding-actions {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: 0 var(--pad) var(--pad);
  flex-wrap: wrap;
}

#onboarding-foot {
  margin: 0 var(--pad) var(--pad);
}

.btn-quiet {
  background: none;
  border-color: transparent;
  color: var(--text-muted);
}

.btn-quiet:hover {
  background: var(--glass-row);
  border-color: transparent;
  color: var(--text);
}

@media (max-width: 1023px) {
  .onboarding-mentors { grid-template-columns: 1fr; }
}

@media (prefers-reduced-motion: reduce) {
  /* Nothing here animates today; the rule exists so a future transition on
     the overlay is born inside this query instead of outside it. */
  .onboarding-card { transition: none; }
}

/* ==========================================================================
   Console (U-console.1, DESK_TRACKER sec.2bis) - the Trade view's shell.

   Every colour below is one of the EXISTING tokens declared at the top of
   this file, checked one by one against the proto
   (docs/design/prototypes/console/console.html) rather than copied: zero
   new custom property, so nothing here needs a twin in the light block and
   the "both themes define exactly the same tokens" guard has nothing new to
   hold. The proto's own instrument skin - brushed-metal orbs, screen
   vignettes, glass insets keyed to uploaded texture photos - is not
   reproduced: this port reuses the glass/panel language style.css already
   has (.panel's own tokens, restated below rather than duplicated onto
   .console-face by relying on the class), a scoping decision journalled
   "a relire" in DESK_TRACKER's Journal rather than spending a new token
   family on a skin no other screen shares yet.

   Fixed rather than laid out in normal flow: `.view` is a scrolling page
   today (`body` carries no height/overflow rule, `main` adds `padding`,
   `.shell` adds `gap`), so a height:100dvh child placed in normal flow
   would measure taller than the viewport by exactly that padding and the
   page would grow a scrollbar - the one thing "non-scroll strict" rules
   out. Fixed sidesteps the whole ancestor chain: this is always exactly
   the viewport, regardless of what wraps it. z-index below .jeu-overlay's
   70 (a full-screen learning session always wins if one is somehow still
   open - in practice showView() already closes it first) and above
   everything else ordinary. ========================================== */
#console-shell {
  position: fixed;
  /* P-04 : la Console garde son écran MOINS la barre de navigation haute
     (décision (a) d'Erwann) — l'inset du haut est la hauteur de la barre,
     qui porte déjà le safe-area-inset-top. */
  inset: var(--pad-nav-top) 0 0 0;
  z-index: 60;
  display: grid;
  /* P-09 (POLISH_TRACKER, 2026-08-29) : le bandeau de compte au PIED de
     l'écran mobile — le graphe et l'Order se touchent, les chiffres que la
     console re-sert déjà (Portfolio) ne s'intercalent plus. Le DOM suit le
     même ordre (index.html) : lecture et tabulation = ce que l'œil voit. */
  grid-template-areas: "top" "os" "seam";
  grid-template-rows: minmax(0, 45fr) minmax(0, 55fr) auto;
  grid-template-columns: minmax(0, 1fr);
  gap: .625rem;
  padding: .625rem
           max(.625rem, env(safe-area-inset-right))
           max(.625rem, env(safe-area-inset-bottom))
           max(.625rem, env(safe-area-inset-left));
  background-color: var(--bg);
  /* fidelite.C4: the shell is MATTER now - graphite (aluminium under
     light) beneath the scene's own wash. The texture is a real file (A0);
     if it is missing the gradient layer still paints (L1's own fallback
     doctrine). */
  background-image: var(--tex-shell);
  background-size: cover;
}

#console-shell[data-layout="chart"] {
  grid-template-rows: minmax(0, 70fr) minmax(210px, 30fr) auto;
}

#console-shell[data-layout="console"] {
  grid-template-rows: minmax(120px, 22fr) minmax(0, 78fr) auto;
}

@media (min-width: 900px) {
  #console-shell {
    grid-template-areas: "top os" "seam seam";
    grid-template-columns: minmax(0, 62fr) minmax(0, 38fr);
    grid-template-rows: minmax(0, 1fr) auto;
    gap: .75rem;
    padding: .875rem;
  }

  #console-shell[data-layout="chart"] {
    grid-template-rows: minmax(0, 1fr) auto;
    grid-template-columns: minmax(0, 80fr) minmax(300px, 20fr);
  }

  #console-shell[data-layout="console"] {
    grid-template-rows: minmax(0, 1fr) auto;
    grid-template-columns: minmax(220px, 30fr) minmax(0, 70fr);
  }
}

/* The two screens share one face: same panel language as .panel itself
   (background/border/radius restated rather than shared by class, because
   .panel also carries `margin-bottom` this layout does not want). */
.console-top, .console-os {
  min-height: 0;
  min-width: 0;
  display: flex;
}

.console-top { grid-area: top; }
.console-os { grid-area: os; }

.console-face {
  position: relative;
  flex: 1;
  min-height: 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  /* fidelite.C4: the face is a screen IN the matter - its own texture
     under the raised glass, an inset seat (the "enfoncement"), and the
     vignette overlay drawn by ::after below. */
  background-color: var(--bg-raised);
  background-image: var(--tex-face);
  background-size: cover;
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  box-shadow: var(--face-seat-shadow);
  overflow: hidden;
}

/* The screen vignette (prototype .c-face::before): a breath of shadow at
   the face's foot, over everything, touching nothing - pointer-events
   none, no hue, and it never hides: a transparent centre by construction. */
.console-face::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--face-vignette);
  pointer-events: none;
  z-index: 5;
}

/* U-console.2, found in the browser at 375px, "balanced" layout: the
   toolbar + the four meta lines (age/credit/notice/cost, one of them a
   full licence sentence that wraps to five lines at this width) can ask
   for MORE than `.console-top`'s own share of the grid - measured, 282px
   of face against ~230px the meta block alone wants once the toolbar's
   own ~40px is subtracted. `.console-face`'s shared `overflow: hidden`
   just above (also relied on by `.console-os`'s OWN face, untouched here)
   would silently CUT the TradingView attribution paragraph out of the
   accessibility tree entirely - the exact failure ATTR's own guards exist
   to catch, just not from CSS reachable by this text scan. Scoped to the
   top screen alone (never the OS one, which already has its own internal
   scroll story per U-console.1) rather than widening the shared rule. */
.console-top .console-face {
  overflow-y: auto;
  overflow-x: hidden;
}

/* U-console.2: the real vendored chart mounts here (createConsoleChart,
   app.js) - `autoSize: true` (chartTheme's sibling option, set at the call
   site like the other four LightweightCharts.createChart() sites) reads
   this box's own dimensions, so it needs nothing beyond a concrete size:
   `flex: 1` inside `.console-face`'s column. No `position: relative` -
   the library repaints with globalCompositeOperation "copy" onto its own
   canvas regardless of how the host is positioned, the same reasoning
   `.chart`'s own comment (the legacy desk's chart, above) already
   established for a plain block host.

   `min-height: 140px`, not `0` - found in the browser at 375px,
   "balanced" layout: `flex: 1` alone let the toolbar + the four meta
   lines below it (one of them a full licence sentence, five lines at this
   width) squeeze this box to a MEASURED 0px, an invisible chart under a
   visible "Reset view" button that had nothing left to reset. A flex
   item's own explicit min-height is a floor `flex-shrink` cannot cross
   (CSS Flexbox, unlike the automatic `min-height: auto` a bare `flex: 1`
   leaves in place) - 140px is enough for a legible line/candle series on
   the narrowest layout this repo tests (SPEC_JEU.md's own 375px). What no
   longer fits below the chart at that width now scrolls inside
   `.console-top .console-face` (just above) instead of being clipped. */
.console-chartwrap {
  flex: 1;
  /* P-05a (POLISH_TRACKER, 2026-08-28): the floor and the preset's share,
     in ONE declaration. --chartwrap-share is set by the layout presets
     below (0px when unset - the plain 140px floor this rule always had),
     and max() makes the 140px legibility floor unbeatable by construction:
     no preset value, however wrong, can push this box below it. Chosen
     over per-preset min-height rules on the cascade guard's own finding
     (test_no_rule_defeats_the_chartwraps_min_height_floor, Karim M2): a
     higher-specificity rule that WINS min-height is exactly the sabotage
     shape that guard exists to refuse, whatever value it carries. */
  min-height: max(140px, var(--chartwrap-share, 0px));
  /* fidelite.C2: the instrument header is an overlay of this box. */
  position: relative;
}

/* P-05a (POLISH_TRACKER, 2026-08-28), measured at 375x812: the chart sat at
   exactly 140px in ALL THREE layout presets - the presets only move the
   shell's grid shares, and inside the scrolling face (`.console-top
   .console-face`, above) the meta block always asks for more than the row
   grants, so flex-shrink pins this box to its own floor whatever the
   preset says. Under 900px the preset now hands the chart its share
   through --chartwrap-share (consumed by the single min-height rule
   above - the presets never set min-height themselves, so the cascade
   guard on the floor keeps its full force): "chart" makes the chart the
   principal surface, the default grants a real share, "console" keeps the
   bare floor and lets the ticket have the rest. The attribution/licence
   meta below the chart keeps scrolling in the face, never collapsed (ATTR
   obligations are not a display preference). Desktop (>=900px) splits by
   COLUMNS and is untouched (the variable stays unset there). */
@media (max-width: 899px) {
  #console-shell[data-layout="chart"] {
    --chartwrap-share: 58vh;
  }
  #console-shell:not([data-layout]),
  #console-shell[data-layout=""] {
    --chartwrap-share: 30vh;
  }
}

/* fidelite.C2 - the instrument header ON the chart (REVUE_VISUELLE C-2):
   label - last price - timeframe, as renderConsoleChartHead writes them. An
   overlay at the top-left corner of the chart wrap, `pointer-events: none`
   so every drag and wheel still reaches the canvas beneath; `z-index: 1`
   over the canvas the library positions; never a background, the chart
   shows through. */
.console-chart-head {
  position: absolute;
  left: .75rem;
  top: .5rem;
  z-index: 1;
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: .45rem;
  pointer-events: none;
  font-size: .8125rem;
  line-height: 1.2;
  color: var(--text-muted);
  max-width: calc(100% - 1.5rem);
  /* Iris C2-1 (revue C2 - the reported overlap at 375 was NOT reproduced
     by the sceptic, but the head IS an overlay on a canvas that draws its
     own labels wherever the prices fall): a glass backdrop keeps it
     readable whatever the library paints beneath. Tokens only. */
  padding: .15rem .45rem;
  border-radius: var(--radius);
  background: var(--glass-panel);
}

.console-chart-head-qual {
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
}

.console-chart-head-symbol {
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.console-chart-head-price {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--em);
}

.console-chart-head-interval {
  text-transform: uppercase;
  letter-spacing: .06em;
  font-size: .75rem;
  color: var(--text-faint);
}

/* P-07c: the loading veil - the click's own confirmation, centred over the
   chart. An overlay like .console-chart-head above (same glass recipe,
   tokens only, both themes for free), inert to the pointer so a reader can
   keep panning while deeper history loads. Visibility is the [hidden]
   attribute's business alone: showConsoleChartLoading()/renderConsoleChart
   are its only writers. */
.console-chart-loading {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  margin: 0;
  pointer-events: none;
  font-size: .8125rem;
  line-height: 1.2;
  color: var(--text-muted);
  padding: .3rem .7rem;
  border-radius: var(--radius);
  background: var(--glass-panel);
  border: 1px solid var(--border);
  /* Léa (revue P-07, à corriger n°1) : le voile porte le MÊME label long
     que .console-chart-head-symbol (jusqu'à 63 caractères au registre) et
     doit hériter la même protection — une bulle qui se replie sur trois
     lignes au plancher mobile de 140px mange l'écran qu'elle annonce. */
  max-width: min(85%, 30rem);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* fidelite.C2 - the meta under the chart, condensed without losing a word
   (Lea, REVUE_VISUELLE C-4): the same ids, the same sentences, laid as
   inline runs of ONE paragraph instead of six stacked ones. The separator
   is drawn by the sheet (a middle dot before every run but the first
   visible) - never a character typed in the page. `hidden` keeps its
   meaning: a hidden line is display:none and draws no dot either. */
.console-chart-meta {
  padding: .25rem .625rem 0;
  line-height: 1.45;
}

.console-top .console-chart-meta > .age,
.console-top .console-chart-meta > .hint {
  display: inline;
  padding: 0;
  margin: 0;
}

.console-top .console-chart-meta > .age[hidden],
.console-top .console-chart-meta > .hint[hidden] { display: none; }

/* The age line is always first and always written; every hint that follows
   and is shown gets the dot. Decorative: `/ ""` keeps it out of the
   accessible name. */
.console-top .console-chart-meta > .hint:not([hidden])::before {
  content: "\00b7" / "";
  margin: 0 .4em;
  color: var(--text-faint);
}

/* U-console.2: the timeframe dropdown + reset button, ABOVE the chart
   (DESK_TRACKER sec.0 point 4). A thin row, never competing with the
   chart for height - both children keep their own intrinsic size. */
.console-chart-tools {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .5rem .625rem .5rem;
  /* P-10 (POLISH_TRACKER, 2026-08-29, constat d'Erwann au téléphone) : la
     barre vivait en premier enfant de la face qui DÉFILE (`.console-top
     .console-face`, overflow-y:auto — posé pour que la licence ne soit
     jamais coupée) ; une fois la face défilée, la barre sortait par le
     haut, et le canvas du graphe mange les gestes (pan/zoom de la lib) —
     plus aucun moyen tactile de remonter : « Reset view » disparu au
     moment exact où on en a besoin. Collante en tête de SA zone de
     défilement, sur le fond de la face (les deux couches, sinon le graphe
     transparaît), au-dessus du chartwrap (et de la vignette décorative de
     la face, z-index 5, pointer-events none — la barre reste cliquable). */
  position: sticky;
  top: 0;
  z-index: 7;
  background-color: var(--bg-raised);
  background-image: var(--tex-face);
  background-size: cover;
}

.console-chart-tools select {
  font: inherit;
  font-size: .8125rem;
  color: var(--text);
  background: var(--bg-sunken);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: .3rem .5rem;
}

.console-chart-tools select:hover { border-color: var(--text-faint); }

#console-chart-reset {
  margin-left: auto;
  font-size: .8125rem;
  padding: .3rem .6rem;
}

/* SESSIONS v2, U2: the mode switch sits in the tools row, styled like its
   reset neighbour - the auto margin stays on reset, which keeps the pair
   pushed to the right together. No new ink pair: the button inherits the
   generic button recipe like every control of this row. */
#console-mode-switch {
  font-size: .8125rem;
  padding: .3rem .6rem;
}

/* SESSIONS v2, U2: the projection banner. A bordered strip, no background
   of its own (the face's own surface stays the ground, so no new
   contrast pair enters the audit - the accent word reuses --accent-text,
   already inked on this surface by `.hint a`). No animation, no
   transition: nothing here moves, so the reduced-motion ledger owes
   nothing. Wraps at every width; nothing in it may force the face wider
   (min-width: 0 on the flexible children - the U-cB.4 lesson). */
.console-projection-bar {
  display: flex;
  flex-direction: column;
  gap: .35rem;
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  margin: .5rem .625rem 0;
  padding: .45rem .6rem;
}

.console-projection-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .25rem .75rem;
  margin: 0;
  min-width: 0;
}

.console-projection-word {
  color: var(--accent-text);
  font-weight: 700;
  letter-spacing: .04em;
  overflow-wrap: anywhere;
}

.console-projection-label {
  color: var(--text-muted);
  font-size: .8125rem;
}

/* V2 U3: the bar is a flex column - without a start-edge pin the debrief
   button would stretch into a full-width bar. */
.console-projection-debrief { align-self: flex-start; }

.console-projection-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .4rem;
  min-width: 0;
}

.console-projection-controls button,
.console-projection-controls select {
  font-size: .8125rem;
  padding: .3rem .6rem;
}

.console-projection-clock {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}

.console-projection-attempt {
  color: var(--text-muted);
  font-size: .8125rem;
}

/* The banner's own hint lines keep the face's `.console-top .hint`
   register; inside the bordered strip the horizontal padding is the
   strip's own, so the rule below zeroes the doubled indent. */
.console-projection-bar .hint { padding: 0; margin: 0; }

.console-top .hint { padding: 0 .625rem; }

/* U-console.2: the freshness/attribution/cost lines between the chart and
   the TradingView library credit, all `.hint`-sized - the same muted,
   small-print register as that anchor's own paragraph, never competing
   with the chart for visual weight. `.age` keeps its own existing
   state-driven colour (style.css, "Freshness and status") rather than a
   new rule: `#console-chart-age` is one more caller of a class this file
   already had. */
#console-chart-age { padding: 0 .625rem; margin: .35rem 0 0; }

/* --- seam: status bar --------------------------------------------------- */
.console-seam {
  grid-area: seam;
  display: flex;
  align-items: center;
  gap: .375rem;
  flex-wrap: wrap;
  padding: .4rem .625rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
}

.console-seam-cell {
  display: flex;
  flex-direction: column;
  gap: .05rem;
  padding: 0 .625rem;
  min-width: 0;
}

.console-seam-cell + .console-seam-cell { border-left: 1px solid var(--border); }
.console-seam-cell .label { margin-bottom: 0; }

.console-seam-val {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .8125rem;
  white-space: nowrap;
}

/* K3-6 (Karim, revue-sabotage fidelite.C1, PASSE 3, mineur): the Data cell
   is a read-back of #console-chart-age (renderConsoleSeamData, app.js) and
   used to copy only the WORDS. `.age` carries the engine's own verdict as
   `data-state` and paints it ("Freshness and status", further up this
   file); this cell painted nothing, so the sentence of a stale price wore
   the look of a normal one - on the strip a reader checks precisely to know
   whether the figures under the button are good.

   The two states `consoleFreshnessState` can ever produce, and only those:
   `stale` (the engine says this number IS too old) and `unread` (nothing
   has been read from the source at all). `fresh` gets no colour, the same
   "ok has no colour of its own" this file already applies to `.age`. Same
   tokens as `.age`'s own rules, never a second palette - a test derives
   them from those rules rather than retyping them. */
.console-seam-val[data-state="stale"] { color: var(--warn); }
.console-seam-val[data-state="unread"] { color: var(--text-faint); }

.console-seam-spacer { flex: 1; }

.console-layout-switch { display: flex; gap: .125rem; }

/* P-08 (POLISH_TRACKER, 2026-08-28): one drawing per width. Under 900px the
   shell splits by ROWS and the row icons tell the truth; at 900px and up it
   splits by COLUMNS (the same breakpoint as #console-shell's own grid) and
   the column icons take over - an icon promising a vertical gesture for a
   horizontal preset was the measured defect. Never both at once. */
.console-layout-notch .notch-cols { display: none; }
@media (min-width: 900px) {
  .console-layout-notch .notch-rows { display: none; }
  .console-layout-notch .notch-cols { display: block; }
}

.console-layout-notch {
  width: 1.75rem;
  height: 1.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  color: var(--text-faint);
}

.console-layout-notch[aria-pressed="true"] {
  color: var(--accent-text);
  border-color: var(--accent);
}

/* --- the OS: rail + stage ------------------------------------------------ */
/* B2 (Léa's NO_GO, phase B Console review, bloquant): measured at U-cB.5 and
   left dispatched rather than fixed - 375px, Spanish, the badge (143px) and
   the "Copiar el prompt" button (139px) left the title `flex:1; min-width:
   0` exactly 26.5px, "Orden" rendering "O…". `min-width: 0` is the CSS
   default that let it, and a value alone can never fix that: without
   `flex-wrap`, the browser fits every item onto ONE line by shrinking the
   flexible one toward its floor BEFORE ever considering a second line, so a
   min-width floor with no wrap just moves the same collapse to a slightly
   less catastrophic width (measured: `min-width: 6rem` alone still let
   "Ordres en attente", French, ellipsis at 99px). `flex-wrap` fixes the
   direction (a title that cannot fit its floor drops to its own line
   instead of shrinking past it), and the floor fixes the amount (8.5rem -
   136px - measured in-browser against the widest of the twenty-one
   app-name/language pairs, "Órdenes en espera" at 121.3px content width,
   with headroom rather than a value fitted to today's longest string
   exactly). Neither alone closes the defect; both are the SOCLE (no
   headless test can read a min-content word-wrap decision), verified
   in-browser at 375px in all three languages, on all seven apps: zero
   truncation (`scrollWidth === clientWidth` on every title), and no
   regression at 1280px/866px-wide "maximise the console", where the row
   never wraps because everything already fits on one line. */
.console-os-bar {
  flex: none;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .625rem;
  padding: .5rem .75rem;
  border-bottom: 1px solid var(--glass-border);
}

.console-os-title {
  font-size: .8125rem;
  font-weight: 600;
  letter-spacing: .01em;
  flex: 1;
  min-width: 8.5rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.console-os-bar .label { margin-bottom: 0; }

.console-os-body {
  flex: 1;
  min-height: 0;
  display: flex;
}

.console-rail {
  flex: none;
  width: 4.5rem;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .5rem;
  padding: .625rem .25rem;
  border-right: 1px solid var(--glass-border);
  /* fidelite.C4: the rail is the machined SEAM of the shell. */
  background-color: var(--bg-sunken);
  background-image: var(--tex-seam);
  background-size: cover;
}

.console-orb {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .2rem;
  flex: none;
  width: 100%;
  background: none;
  border: 0;
  padding: .3rem 0;
  color: var(--text-faint);
}

/* Gains-rapides U1.4: round, the same recipe `.tabbar-item svg` already
   uses (style.css above) - equal padding on all four sides (never the
   tabbar's own .2rem/.8rem split, tuned for a HORIZONTAL icon+label row;
   this rail stacks icon above label, so the pill needs to be a circle,
   not an ellipse) turns the 20px icon into a 32px round target with
   border-radius: 999px rather than the button's own rectangular footprint
   (still var(--radius) by the sheet's generic `button` rule - unchanged,
   untouched, and now purely the outer hit target's own soft corner,
   never the visible shape a reader's eye lands on). */
.console-orb svg {
  color: inherit;
  padding: .35rem;
  border-radius: 999px;
  box-sizing: content-box;
  /* fidelite.C4: the orb is a machined button - the prototype's metal ring
     (conic gradient) and its recessed cap, both tokens, both themes. The
     icon keeps its own ink; the pressed state below keeps its identity
     gradient exactly as gains-rapides U1.4 shipped it. */
  background: var(--orb-cap);
  box-shadow: var(--orb-cap-shadow);
}

.console-orb::before {
  content: "";
  position: absolute;
  top: .3rem;
  left: 50%;
  width: 2.45rem;
  height: 2.45rem;
  margin-left: -1.225rem;
  border-radius: 50%;
  background: var(--orb-metal);
  box-shadow: var(--orb-metal-shadow);
  z-index: -1;
}
.console-orb:hover { color: var(--text-muted); }

.console-orb[aria-pressed="true"] { color: var(--accent-text); }

.console-orb[aria-pressed="true"] svg {
  color: var(--on-accent);
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  box-shadow: 0 4px 12px var(--shadow-accent);
}

/* The count of open positions, on the Positions orb's own shoulder - same
   vocabulary as .dock-unread/.tabbar-unread (a number, never an alarm).
   Served by GET /api/account's own open_positions array (renderAccount,
   app.js) - .length, never a second endpoint or a front-computed guess.
   Hidden at zero, the same "hidden rather than a printed 0" the unread
   badges already hold to. */
.console-orb-badge {
  position: absolute;
  top: -.05rem;
  right: .35rem;
  min-width: 1.05rem;
  padding: 0 .25rem;
  border-radius: 999px;
  background: var(--grad-b);
  color: var(--on-accent);
  /* .75rem (12px): the readability floor - same size as .dock-unread/
     .tabbar-unread, never the smaller size this badge started at
     (test_nothing_is_typed_below_the_readability_floor caught it). */
  font-size: .75rem;
  font-weight: 700;
  line-height: 1.05rem;
  text-align: center;
}

.console-orb-label {
  font-size: .75rem;
  letter-spacing: .01em;
  text-align: center;
  line-height: 1.15;
  /* U-cB.5, browser-measured: a single unbreakable FR/ES word longer than
     the 49px orb ("Portefeuille", "Posiciones") has no space to wrap on,
     so its min-content width IS its full length and it bled past the rail
     on both sides at 375px (measured getBoundingClientRect overflow: up to
     ~17px total for "Portefeuille" (FR), ~13px for "Posiciones" (ES) - m2,
     passe 3 note N2, corrected here to match CHECKLIST_CHANTIER.md sec.4
     rather than under-quoting the worse of the two cases). `anywhere` lets
     a long word break mid-word so the used width clamps to the orb, never
     past it - the readability floor above (font-size, pinned by
     test_nothing_is_typed_below_the_readability_floor) is untouched. */
  overflow-wrap: anywhere;
}

.console-stage {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* Each app fills the stage and scrolls INSIDE its own window - the
   non-scroll requirement is about the page, never about a long list of
   positions or resting orders. */
.console-app {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.console-app-scroll {
  flex: 1;
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
  padding: .625rem .6875rem .4375rem;
}

.console-app-scroll h2 {
  font-size: .75rem;
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

@media (max-width: 419px) {
  /* No further shrink on .console-orb-label here: 12px is this sheet's own
     floor (test_nothing_is_typed_below_the_readability_floor) and the base
     rule above already sits on it. */
  .console-rail { width: 3.5rem; gap: .4375rem; padding: .5rem .1875rem; }
}

/* U-cB.3: the Markets app's own source selector - the first real
   `<input type="checkbox">` this product ships, so there is nothing to
   reuse for the fieldset/label layer below. Everything ELSE in this app
   (the table, the row-open button, the frozen badge) is table.grid/
   .table-scroll/.row-open/.note[data-kind="source-frozen"], already
   tokened and already measured for contrast by the watchlist's own table -
   zero new rule for any of it. No new custom property either: every colour
   below is one of the existing tokens declared at the top of this file. */
.console-markets-sources {
  padding: .5rem .625rem;
  margin: 0 0 .75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.console-markets-sources legend {
  font-size: .75rem;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-faint);
  padding: 0 .25rem;
}

/* The checkboxes live in their own child of the fieldset, never as direct
   flex children beside <legend> - a <legend> is laid out as the
   fieldset's caption, not as a normal box, in ways that vary enough by
   engine that this file does not lean on it. Also the container
   renderConsoleMarkets clears with `.textContent = ""` before rebuilding
   (app.js) - the same idiom every other dynamic list on this page already
   uses, never `.remove()`/`removeChild()` (both guarded by a nominal
   call-site list, checklist sec.4). */
#console-markets-source-list {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem .875rem;
}

.console-markets-source {
  display: flex;
  align-items: center;
  gap: .4rem;
  font-size: .8125rem;
  cursor: pointer;
}

/* D2-bis: a greyed source's own checkbox is `disabled` (the checklist's own
   exception to "hidden rather than disabled" for exactly this doctrinal
   state) - `:has()` reads that native state rather than a class this file
   would have to keep in sync with it by hand, the same idiom the path list
   already uses (`.path-list li:has(> .node[data-state="validated"])`). */
.console-markets-source:has(input:disabled) {
  color: var(--text-faint);
  cursor: not-allowed;
}

/* The badge reused from watchlistRow (app.js) reads correctly inside a
   table cell, where `.note[data-kind]`'s own top margin separates it from
   the price above it - inside this flex row there is no price above it to
   separate from, so the margin only pushed the badge out of vertical
   centre with the checkbox and its label. Nothing else about the badge
   changes: same border, same colour, same text. */
.console-markets-source .note[data-kind] { margin-top: 0; }

/* C-1 (REVUE_VISUELLE_2026-08-19.md, bloquant de doctrine): `table.grid td`
   is `white-space: nowrap`, and the freeze mention inherits it - so a badge
   sitting in a cell asks for the WHOLE sentence as that column's minimum
   width, which is how a three-column grid grew to 471px inside a 375px
   frame and pushed all 25 mentions out of sight. Moving the badge into the
   market's own (leftmost) cell is only half the fix: without this line the
   sentence would simply widen the FIRST column instead, and be cut on the
   right rather than pushed off it. With it, the mention contributes its
   longest WORD ("licence"/"lecture"/"licencia") and nothing more, so it can
   never widen this grid by a sentence again, in any of the three languages.

   This is a CSS pin held as a SOCLE, not a complement - the same standing
   `.console-orb-label`'s `overflow-wrap` has (U-cB.5): no execution in this
   suite can measure a min-content width, that needs a real layout engine.
   Scoped to the Console's own app tables so the order ticket, which
   carries the same badge in a different geometry (inline in the ticket,
   never a table cell), keeps its own unchanged (the discipline
   `.console-top .console-face` established at U-console.2: a shared class
   is never rewritten globally). Measured in a real browser before and
   after: 471px -> 375px table width at 1280x860, 25 mentions out of
   frame -> 0.

   RESIDUAL, named rather than left to a wrong label (m1, Karim
   revue-sabotage passe 3, 2026-08-20): this comment used to call the
   scope's OTHER excluded surface "the Learn watchlist", as if it were a
   small side table with no stake in the doctrine - it is the Markets
   SCREEN's own watchlist (`data-view="markets"`, `#watchlist-body`, nine
   columns, reachable from both the sidebar and the "More" sheet, 25 of 34
   rows frozen today), carrying the SAME badge in the SAME `table.grid`,
   without this pin. Not fixed here - GAINS_RAPIDES_TRACKER.md F2/M2,
   dispatched to C-1-bis, needs a real-browser measurement before any
   geometry correctif (U-console.2's own doctrine). The wrong label is
   very likely why the gap survived three reviews: read as written, the
   exclusion sounded like it cost nothing. */
.console-app table.grid .note[data-kind="source-frozen"] { white-space: normal; }

/* The family header row (renderConsoleMarkets, app.js): a plain <th
   colspan> inside the body, so it inherits table.grid th's own uppercase/
   faint-colour/bottom-border styling for free - the same look a real
   header already has, without a second rule set to keep in step with it. */
#console-markets-body th { padding-top: 1rem; }
#console-markets-body tr:first-child th { padding-top: .6rem; }

/* U-cB.4, found at the real browser (375px, never by a headless test - a
   flex budget needs a real layout engine): the legacy .event row's own
   nowrap timestamp (.event-when) plus its min-width kind badge
   (.event-kind) already fill most of the Console app's own, narrower
   width, before the note - or a close event's own P&L/R figures - gets any
   room at all. Scoped to `#console-hist-list` only: the bare `.event` class
   and its still-parked sibling panel (a wide desktop context where this
   never mattered) are untouched. */
#console-hist-list .event { flex-wrap: wrap; }
#console-hist-list .event-note { flex-basis: 100%; min-width: 0; }

/* Same measured budget problem, one row up: the market select, the three
   result segments, the spacer and the count do not fit `.filter-row`'s own
   nowrap default at the Console's width either. Scoped to this screen's own
   filter row - `.filter-row` has two other, unrelated callers (a wider
   desktop context neither of them touches). */
#console-app-history .filter-row { flex-wrap: wrap; }

/* U-cB.4: History's own past-accounts list (renderConsoleHistoryGenerations,
   app.js) - the same --border token .event already uses between rows, no
   new colour, no transition (nothing here moves). */
#console-hist-generations-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .75rem;
}

#console-hist-generations-list li {
  padding-bottom: .75rem;
  border-bottom: 1px solid var(--border);
}

#console-hist-generations-list li:last-child { border-bottom: 0; }

/* --- The Laboratory's recipes (U-labo.2) -------------------------------

   docs/design/prototypes/labo/IMPLEMENTATION_PREP.md sec.2: the prototype's
   142 inline `style="..."` attributes are gone (index.html carries none,
   ever) - everything below is a class, every colour a token already
   defined in :root/[data-theme="light"] above. No new custom property is
   introduced: the seven verdict states borrow the same generic --up/--warn/
   --text-muted this sheet already uses for "went well" / "not yet, and
   that is fine" / "neither" elsewhere, rather than a second colour system
   naming the same three ideas again. */

/* --- L1 (fidelite chantier): the bench and the rail -------------------

   docs/design/prototypes/labo/Laboratory-Mentor-Page.dc.html,
   CONCEPT_LABO_MENTOR.md sec.3 ("le panneau des recettes - a DROITE sur PC,
   en BAS sur telephone"). .labo-main is the two-zone layout; below 900px it
   is one column (the rail falls under the bench, the fold every narrow
   viewport in this sheet already uses); at 900px and up it is a row. 900px
   rather than this sheet's usual 1024px: the OUTER `.view[data-view=
   "mentor"]` grid (above, this file) only itself becomes two columns at
   1024px, so between 900 and 1024px `.labo-recipes` already has the full
   viewport width to itself - comfortable room for an inner split the outer
   grid has not yet taken. */
.labo-main {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

#labo-bench {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
  /* K-L1p4-3 (Karim revue L1 passe 4, 2026-08-22, MINEUR): `min-height:
     28rem` used to sit here, and CSS gives `min-height` the last word over
     `height` - so the bound below stopped being a bound the moment
     `calc(100vh - 9rem)` fell under 448px, i.e. under a window 592px TALL.
     A phone in landscape is 390px (iPhone 12) or 320px (SE): the bench
     stood 448px inside a 390px window, `#labo-bench-body`'s own
     `overflow-y: auto` never engaged, and the seal bar sat below the fold
     again - bug 3 of this unit's own journal, restored at every width by
     the property next door. Removed rather than rewritten: measured
     INERT wherever it did no harm (`height` is already `min(42rem,
     calc(100vh - 9rem))`, so a floor of 28rem can only ever equal or
     exceed it), and harmful everywhere else. Held now by
     test_no_min_height_can_outgrow_the_benchs_viewport_bound
     (tests/test_paper_only_guard.py), which reads min-height as well as
     height - the sister property the two older guards never looked at.
     Decision taken alone, journalled "a relire". */
  /* K-L1-8 (Karim revue L1 passe 1, 2026-08-21/22, MINEUR), second pass:
     this bound used to live ONLY inside the >=900px media query further
     down this sheet - the comment on `.labo-bench-footer` above already
     claimed it had been "moved out... applied at every width instead",
     but the declaration itself never moved, and the test written to prove
     it (`test_the_benchs_bounded_height_applies_at_every_width_not_only_
     desktop`) matched the WRONG `@media (min-width: 900px)` block (this
     sheet has two, and a plain `re.search` always finds the first) -
     passing green on a fix that had never actually shipped, prose and
     guard both wrong the same way. `min()` here, unconditional: without a
     bound, `#labo-bench-body`'s own `overflow-y: auto` never activates
     below 900px either, and a long letter pushes `.labo-bench-footer`
     (the only path from the letter to the clipboard) off the bottom of
     the page - measured at the real browser, 375x812. */
  height: min(42rem, calc(100vh - 9rem));
  border-radius: var(--radius-panel);
  border: 1px solid var(--glass-border);
  overflow: hidden;
  /* The photo, and the plain CSS fallback underneath it (D7/A0: the
     dark/light bench photos are vendored and budgeted,
     tests/test_static_assets_budget.py) - if the image never loads, this
     gradient is what a reader sees instead, never a blank rectangle.
     `cover`/`no-repeat`, never a tiled `repeat`: the two photos are 25-38
     levels apart at their own top/bottom edges (Karim K-A0-9, measured) and
     do not seam.

     K-L1p3-8 (Karim revue L1 passe 3, 2026-08-22, note): the promise above
     was held by nothing - dropping the `linear-gradient` layer measured
     green across all 29 test files that read this sheet. K-A0-15 closes
     the neighbouring question (any `url()` this sheet paints names a
     tracked, budgeted file); the RUNTIME fallback - a deploy without
     static/img, a CSP, a 404 - is held by
     test_the_bench_photo_always_carries_its_plain_css_fallback_underneath
     (which also pins the ORDER: CSS paints the first layer on top, so a
     gradient written first would hide the photograph) and by
     test_no_rule_defeats_the_benchs_photograph_and_its_fallback for the
     cascade, both in tests/test_paper_only_guard.py. */
  background-image: var(--bench-dark), linear-gradient(160deg, var(--bg-raised), var(--bg));
  background-size: cover, cover;
  background-position: center, center;
  background-repeat: no-repeat, no-repeat;
}

:root[data-theme="light"] #labo-bench {
  background-image: var(--bench-light), linear-gradient(160deg, var(--bg-raised), var(--bg));
}

/* Concentrated reading (the prototype's toggleFullscreen): the bench alone
   covers the viewport, and the rest of the shell (sidebar/tabbar/masthead/
   mentor-dock) is HIDDEN underneath it (laboUpdateNavAbsorption, app.js) -
   the same four ids, the same `.hidden` mechanism, and the same reason
   updateConsoleNavAbsorption already carries for Trade: a panel that only
   COVERS a control visually still leaves it in the tab order. z-index 61:
   one above #console-shell (60, mutually exclusive screens), one below
   .jeu-overlay's own 70 - a full-screen practice session still wins if one
   is somehow still open.

   Measured at the real browser (1280x800): `position: fixed; inset: 0;`
   alone did NOT cover the viewport - it filled `.panel.labo-recipes`'s own
   box instead (860x656 at 16,16, not 1280x800 at 0,0). `.panel` carries
   `backdrop-filter: blur(14px)` (this sheet's own glass, above) - the
   Filter Effects spec makes a `backdrop-filter` (not just `filter`) create
   a new containing block for a `position: fixed` DESCENDANT, exactly the
   ancestor five levels up here. `:has()` (already this sheet's own idiom,
   `.path-list li:has(...)`/`.console-markets-source:has(...)`) strips the
   filter from the one ancestor that matters, for exactly as long as the
   bench is actually fullscreen - never a blanket rule that would cost
   every OTHER `.panel` its glass. */
.panel.labo-recipes:has(#labo-bench.labo-bench--fullscreen) {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

#labo-bench.labo-bench--fullscreen {
  position: fixed;
  /* Revue calibrée P-04 (BLOQUANT géométrie) : inset:0 laissait tout
     l'en-tête du banc — Vera, le titre, et LE BOUTON DE SORTIE du plein
     écran — sous la bande de la barre (61 < 62, elementFromPoint rendait la barre),
     invisible et non cliquable, Escape pour seul chemin. La même clause que
     les deux coques : le banc commence sous la barre mesurée. */
  inset: var(--pad-nav-top) 0 0 0;
  z-index: 61;
  border-radius: 0;
  min-height: 0;
  /* `height: auto`, not left unset: the @media(min-width:900px) rule two
     screens up pins `height: min(42rem, calc(100vh - 9rem))` on the SAME
     id, and the cascade applies it PROPERTY BY PROPERTY - a more specific
     selector that never mentions `height` does not un-set a less specific
     one that does. Measured at the real browser: without this line,
     fullscreen filled the correct WIDTH (the backdrop-filter fix above)
     but stayed pinned to the desktop card's own height (655px of a
     799px-tall viewport) - `inset: 0` only computes a fill height for an
     `auto` one. */
  height: auto;
}

.labo-bench-header {
  position: relative;
  flex: none;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .75rem;
  row-gap: .5rem;
  padding: .875rem 1rem;
  /* Over a real photograph rather than a flat colour - measured against the
     worst theoretical pixel a photo can offer, style.css's own :root
     comment for --bench-scrim. Only --text/--text-muted are painted here,
     never --text-faint (the one ink that measurement rules out). */
  background: var(--bench-scrim);
}

.labo-bench-heading { flex: 1; min-width: 8rem; }

.labo-bench-eyebrow {
  margin: 0;
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* K-L1-11b (Karim revue L1 passe 1, 2026-08-21/22, note - "a confirmer par
   Iris", faute de navigateur dans son worktree; confirmee ici a l'ecran
   reel): this is the ONLY place that names the active recipe (D4), and an
   ellipsis with no width floor truncated it EVERY TIME at 375px, in all
   six recipes, English included - measured, scrollWidth/clientWidth on the
   real DOM: "Analyze one specific trade" 219px wanted against 129px
   available, "Analyze my trading style" 205/129, all six over. `nowrap` +
   `text-overflow: ellipsis` assumed a single line was always wide enough;
   `.labo-bench-heading`'s own `min-width: 8rem` (above) is the width that
   actually wins the flex layout at this size, not the title's content.
   Fixed by letting the title WRAP instead of truncating - the same family
   as `.console-orb-label`'s own `overflow-wrap: anywhere`
   (CHECKLIST_CHANTIER sec.4, U-cB.5), here `normal` wrapping is enough
   (every recipe name breaks cleanly on a word boundary in all three
   languages) with `anywhere` only as the same safety net for a future name
   with no break point. At >=900px the bench has ample width and every
   title still renders on one line - remeasured after this change. */
.labo-bench-title {
  margin: .1rem 0 0;
  font-size: 1.0625rem;
  font-weight: 800;
  color: var(--text);
  overflow-wrap: anywhere;
}

.labo-bench-tools {
  display: flex;
  align-items: center;
  gap: .5rem;
  flex: none;
}

/* K-L1-2: the header's own simulation-notice copy - `flex-basis: 100%` on
   a `flex-wrap: wrap` parent (.labo-bench-header, above) drops it to its
   own row below the eyebrow/title/tools, the same trick the header's own
   wrap already relies on at narrow widths. `.hint`'s own --text-muted is
   already the one ink this header's own comment measures as safe over the
   real photograph (--bench-scrim) - never --text-faint, ruled out there. */
.labo-bench-notice {
  flex-basis: 100%;
  margin: 0;
}

/* Anti-urgence, canon (CONCEPT_LABO_MENTOR.md sec.4): "the lab clock has no
   second hand" - hour:minute only (laboClockText, app.js), never a
   ticking/animated face - the digits themselves are the only thing that
   ever changes, once a minute, with no transition to neutralise. */
.labo-clock {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  font-size: .8125rem;
  font-weight: 600;
}

.labo-clock-face { flex: none; }

.labo-tool-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.75rem;
  min-height: 2.75rem;
  padding: 0;
  color: var(--text-muted);
  background: transparent;
  border-color: var(--glass-border);
}

.labo-tool-icon { flex: none; }

#labo-bench-body {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 1rem 1.125rem 1.25rem;
  /* The same scrim as the header, over the same photograph - the rest of
     the bench's own text (.hint's --text-muted, the recipe grid before D3
     moved it to the rail) needs the identical floor the header measured. */
  background: var(--bench-scrim);
}

/* The footer, at the FOOT of the bench - reachable while a long letter
   scrolls under it, never inside the scrolling body itself. hidden by
   construction (index.html), cleared alongside #labo-brief by
   laboRenderBrief. It never needs `position: sticky` to stay in place: a
   `flex: none` sibling AFTER the scrolling `#labo-bench-body` (below)
   simply never moves while the BODY scrolls under it - the same shape
   this sheet already trusts for `.labo-bench-header` above it.

   K-L1p3-2 (Karim revue L1 passe 3, 2026-08-22, MAJEUR): that sentence
   described a STRUCTURE nothing held. Moving the whole `#labo-bench-
   footer` INSIDE `#labo-bench-body` left the front suite green while the
   seal dropped below the letter, reachable only after scrolling the whole
   brief. The tests that hold it now, by name: test_the_seal_bar_is_a_band_
   of_the_bench_never_a_child_of_the_scrolling_body (the ancestor chain)
   and test_one_box_of_the_bench_scrolls_and_the_two_bands_around_it_never_
   move (this `flex: none`, the body's `overflow-y: auto`), plus
   test_no_rule_defeats_the_benchs_own_geometry for the cascade - all three
   in tests/test_paper_only_guard.py.

   K-L1-8 (Karim revue L1 passe 1, 2026-08-21/22, MINEUR): the commit that
   shipped this bar called it "sticky" while `#labo-bench` had no bounded
   height below 900px - the whole card (footer included) scrolled away
   with the rest of the page there, exactly the "the only path from the
   letter to the clipboard disappears" the word was supposed to rule out.
   `position: sticky; bottom: 0` was tried first and MEASURED broken: this
   card's own `overflow: hidden` (needed to clip the header/footer's own
   square corners under the card's rounded ones) makes `#labo-bench`
   itself the sticky ancestor's scrollport rather than the page, so the
   footer never moved off its flow position at all (getBoundingClientRect()
   identical before and after `window.scrollTo(0, 100000)` at the real
   browser). Fixed instead by giving `#labo-bench` the SAME bounded height
   at every width (`height: min(...)`, moved out of the >=900px media
   query below) - the mechanism this rule's own body already needed,
   applied where the bug actually lived. */
.labo-bench-footer {
  position: relative;
  flex: none;
  display: flex;
  align-items: center;
  gap: .6rem;
  flex-wrap: wrap;
  padding: .75rem 1rem;
  background: var(--bench-scrim);
  border-top: 1px solid var(--glass-border);
}

/* K-L1p4-1 (Karim revue L1 passe 4, 2026-08-22, MAJEUR - found by the guard
   his finding asked for, on the UNTOUCHED tree rather than by a sabotage):
   `background: var(--accent)` put --on-accent (plain white in both themes) on a
   light lavender under the DARK theme - measured 2.67:1, well under the
   4.5:1 floor, on the ONE control that carries the letter to the clipboard.
   The product's own answer for a primary action is the identity gradient
   (`.jeu-btn-primary`, above in this sheet), and BOTH its ends already carry
   a MEASURED pair with --on-accent in tests/test_front_end.py's own
   parametrised list (`("--on-accent", "--grad-a", 4.5)` 4.60:1,
   `("--on-accent", "--grad-b", 4.5)` 4.56:1 - identical in both themes,
   since neither token varies). Held now by
   test_every_ink_painted_under_a_bench_surface_clears_its_floor_on_that_surface
   (tests/test_paper_only_guard.py) rather than by this comment. Decision
   taken alone and journalled "a relire": it is a VISIBLE colour. */
.labo-seal-btn {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  min-height: 2.75rem;
  padding: .5rem .9rem;
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  color: var(--on-accent);
  border: none;
  font-weight: 700;
}

.labo-seal-icon { flex: none; color: var(--on-accent); }
/* The hole reads as a hole because the button's own surface shows through
   the seal - so it follows the background above: the gradient's FIRST stop,
   which is the end the icon really sits over (135deg starts at the top-left
   corner, and this icon is the button's leftmost child). */
.labo-seal-icon-hole { fill: var(--grad-a); }

.labo-rail {
  border-radius: var(--radius-panel);
  border: 1px solid var(--glass-border);
  background: var(--glass-panel);
  padding: var(--pad);
}

.labo-rail h2 {
  margin: 0 0 .5rem;
  font-size: .8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--text-muted);
}

/* The height bound itself now lives on the unconditional `#labo-bench`
   rule above (K-L1-8, second pass) - only `flex: 1` (meaningful once
   `.labo-main` is a row, below) stays scoped to this media query. */
@media (min-width: 900px) {
  .labo-main {
    flex-direction: row;
    align-items: stretch;
  }
  #labo-bench { flex: 1; }
  .labo-rail { flex: 0 0 20rem; max-width: 20rem; overflow-y: auto; }
}

.labo-recipe-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
  gap: .5rem;
  margin-top: .2rem;
}

.labo-recipe-btn {
  display: flex;
  flex-direction: column;
  gap: .15rem;
  align-items: flex-start;
  text-align: left;
  padding: .6rem .75rem;
  background: var(--bg-sunken);
  border: 1px solid var(--border);
}

.labo-recipe-btn[aria-pressed="true"] {
  border-color: var(--accent);
  background: var(--wash-accent);
}

.labo-recipe-label { font-weight: 600; }

.labo-recipe-hint {
  font-size: .8125rem;
  color: var(--text-faint);
}

.labo-choice { margin-top: .875rem; }

.labo-market-symbol-field { max-width: 16rem; margin-bottom: .5rem; }

.labo-choice-options {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-top: .3rem;
}

.labo-choice-btn {
  padding: .4rem .7rem;
  background: var(--bg-sunken);
  border: 1px solid var(--border);
}

.labo-choice-btn[aria-pressed="true"] {
  border-color: var(--accent);
  background: var(--wash-accent);
}

.labo-empty { padding: .5rem 0; }

.labo-empty-title { font-weight: 600; }

.labo-brief { margin-top: .875rem; }

/* L1: over the bench's own photo+scrim now, rather than a flat panel
   background - --text-faint measured under 4.5:1 against the WORST
   theoretical photo pixel (style.css's own :root comment for
   --bench-scrim), so this eyebrow reads --text-muted instead, the ink the
   scrim was actually tuned for. */
/* L2: now a <button> (it opens the "ingredients" margin note - see the
   comment above .labo-notes in index.html) rather than a <div>, so the
   browser's own button chrome is reset back to exactly what this caption
   looked like before this unit - the interactivity is new, the reading is
   not. */
.labo-ingredients-label {
  display: block;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  text-align: left;
  /* K-L2p3-4: explicit, and identical to `.labo-section-heading`'s own
     reset just above - a caption that is not wired to a note reads as a
     caption, and only the gated rule below makes it read as a control. */
  cursor: default;
  position: relative;
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: .35rem;
}

/* K-L2p3-4 (Karim revue L2 passe 3, 2026-08-23, MAJEUR): the pointer
   cursor - like the "?" badge below - follows the WIRING, not the tag.
   `.labo-section-heading`'s own two rules were gated on
   `[aria-describedby]` at corr2d and this one, the fifth anchor, was left
   unconditional with the argument "it is the STATIC caption, its
   aria-describedby is written in index.html". True, and gated by nothing:
   deleting that one attribute measured 2514 green, the badge kept painting
   and the click kept opening, and only the screen-reader link was gone -
   a silent defect where the other four are loud ones. Same gesture on both
   halves of the same decision, or none (L5048). */
.labo-ingredients-label[aria-describedby] { cursor: pointer; }

.labo-ingredients {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem;
  margin-bottom: .75rem;
}

.labo-chip {
  display: inline-flex;
  padding: .2rem .6rem;
  border-radius: 999px;
  background: var(--glass-row);
  border: 1px solid var(--glass-border);
  font-size: .75rem;
  font-weight: 600;
  color: var(--text-muted);
  /* L2: the "tomber" motion itself (laboPop, no-preference block below) -
     the DOM node exists and is copyable the instant it is created, this is
     decoration on top of already-real content, never a gate the seal
     button waits behind (D4's own "the copy never waits", proved at
     execution). Declared here only when this file is read as a base rule
     to override; the animation-name/-delay pair itself lives entirely
     inside @media (prefers-reduced-motion: no-preference) further down,
     paired one for one with the same selector's `none` under reduce. */
}

/* D4 (Erwann): the letter's own paper - cream in BOTH app themes (a page
   does not change colour when the room's lights do), :root's own comment
   for --paper/--paper-ink/--paper-muted. Wraps the titled sections AND the
   plain-text disclosure AND the v1 note, so every reading surface of the
   letter itself shares one card rather than three different backgrounds -
   and so nothing painted here ever reaches for --text/--text-muted/
   --text-faint, all three tuned for the app's OWN backgrounds, never for
   cream paper (measured: --text-muted's dark-theme value crushes to
   roughly 2:1 there). */
.labo-paper {
  max-width: 42rem;
  margin-top: .75rem;
  padding: 1rem 1.125rem .875rem;
  background: var(--paper);
  color: var(--paper-ink);
  border-radius: var(--radius);
  font-family: var(--font-read);
  /* --bg-sunken as a neutral drop-shadow tint - the same idiom this sheet
     already spends on .sheet/.jeu-session (a translucent surface used for
     its darkness rather than its hue), never a raw rgba() this file's own
     header forbids outside a token. */
  box-shadow: 0 10px 24px var(--bg-sunken);
}

.labo-sections { margin-top: .1rem; }

.labo-section {
  padding: .7rem 0;
  border-top: 1px solid var(--paper-muted);
}

.labo-section:first-child { border-top: none; padding-top: 0; }

/* L2: a <button> for every heading the assembler writes - the browser's own
   chrome reset back to exactly what this heading looked like as a <div>
   before this unit. `cursor: default` in the base rule and `pointer` only
   under `[aria-describedby]` (K-L2p2-6, below): app.js builds the <button>
   unconditionally but only wires a click, and only sets that attribute,
   for a heading that really has a margin note - so the hand follows the
   wiring rather than the tag. */
.labo-section-heading {
  display: block;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  text-align: left;
  cursor: default;
  position: relative;
  margin: 0 0 .3rem;
  font-size: .75rem;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--paper-muted);
}

button.labo-section-heading[aria-describedby] { cursor: pointer; }

/* The visible "?" beside an anchored heading/caption.

   K-L2p2-3 (Karim revue L2 passe 2, MAJEUR, defaut VIVANT): this comment
   used to say a pseudo-element "never enters the accessible-name
   computation", and used that claim to justify giving these buttons no
   `aria-label`. It is FALSE - accname 1.2 sec.4.3.2 (step 2F.ii, "CSS
   generated content") folds ::before/::after into the name, and Chrome,
   Firefox and WebKit all do it - so the five anchors this unit adds were
   really called "Context ?", "Data ?", "Ingredients for this brief ?" in
   all three languages. The idiom that fixes it is already written TWICE in
   this same sheet, with the comment that explains it: `content: "\2713" /
   ""` (.node-idx::after) - an EMPTY alternative text, which keeps the
   glyph painted and the name unchanged. Held by
   test_every_generated_content_string_in_the_sheet_decides_its_own_
   alternative_text (tests/test_front_end.py), which reads the whole sheet
   rather than these two selectors. No `aria-label` instead: it would
   OVERRIDE the visible name the reader sees, and put one more English
   string into the markup-attribute scans. Firefox before 128 ignores the
   `/ "alt"` syntax entirely and simply paints nothing here - the same
   clean degradation the two checkmarks above have shipped with for
   months.

   The button's own name therefore stays "Context"/"Ingredients for this
   brief", while `aria-describedby` (index.html/app.js) points at the note
   that explains it.

   K-L2p2-6 (Karim revue L2 passe 2, MINEUR): the heading half of this
   selector carries `[aria-describedby]`, and that is not decoration. app.js
   builds the <button> for EVERY heading the assembler writes, but sets that
   attribute - and wires the click - only for a heading that really has a
   margin note (laboNoteSummaryIdFor). Without the gate, a future engine
   section with no note yet would get its "?" badge, its pointer cursor and
   a dead click: the comment beside it in app.js promised "never a '?'
   invented beside a passage nobody marked" while the selector was
   unconditional. The code follows the prose now, and
   test_the_heading_note_switch_names_exactly_the_sections_the_assembler_
   writes plus test_a_heading_with_no_margin_note_renders_inert
   (tests/test_front_end_labo.py) hold both halves.

   K-L2p3-4 (Karim revue L2 passe 3, 2026-08-23, MAJEUR): this comment used
   to end "`.labo-ingredients-label` needs no gate: it is the STATIC
   caption, its `aria-describedby` is written in index.html and its wiring
   is unconditional." Every word of that was true and none of it was
   GUARDED - `_LABO_VISIBILITY_TARGETS` declares the attribute for this
   target but with `id_value = None`, so it never entered
   `_LABO_TARGETS_DESCRIBED_BY_ID` and was never once compared to the
   markup. Deleting that one attribute measured 2514 green, and because
   this badge was the only one of the five NOT gated, nothing on screen
   changed: the "?" kept painting, the click kept opening the note, and only
   the screen-reader link disappeared. A gate turns a silent defect back
   into a loud one. Held by test_every_labo_visibility_target_described_by_
   class_is_the_node_index_html_really_carries and
   test_the_ingredients_caption_attribute_and_its_wiring_are_one_decision
   (tests/test_paper_only_guard.py, tests/test_front_end_labo.py), plus
   test_no_note_badge_is_painted_without_its_aria_describedby_gate for this
   very selector. */
.labo-ingredients-label[aria-describedby]::after,
button.labo-section-heading[aria-describedby]::after {
  content: "?" / "";
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.125rem;
  height: 1.125rem;
  margin-left: .35rem;
  border-radius: 50%;
  border: 1px solid currentColor;
  /* The 12px readability floor (test_nothing_is_typed_below_the_
     readability_floor, tests/test_front_end.py) applies here too - a "?"
     is as much a read glyph as any letter. */
  font-size: .75rem;
  vertical-align: middle;
}

/* K-L1-4a: the letterhead (title, simulation notice, UTC stamp) has no
   "## " title of its own to render as a .labo-section-heading - reads
   smaller and muted, like a stamp above the four titled sections rather
   than a fifth one, on the same --paper-muted token this paper already
   uses for its rules and headings (never a new colour). */
.labo-section-preamble .labo-section-body {
  color: var(--paper-muted);
  font-size: .8125rem;
}

/* K-L1p4-2 (Karim revue L1 passe 4, 2026-08-22, MAJEUR): `white-space:
   pre-wrap` is not decoration here, it is D4's own promise. These bodies
   are `<p>` elements built by laboRenderSections, and a `<p>`'s own UA
   default collapses every newline to a space and every run of spaces to
   one - which destroys the journal tables the served letter holds together
   with spaces, and runs its paragraphs into each other. Removing this one
   line measured the FULL SUITE unchanged: the three byte-for-byte guards
   of D4 read `textContent`, which knows the characters and nothing about
   how they render. Held now, by name, by
   test_the_paper_keeps_the_letters_whitespace_at_render_time and
   test_no_rule_defeats_the_papers_own_whitespace
   (tests/test_paper_only_guard.py) - the value being checked against a
   PREDICATE, since `pre-line` would keep the newlines and collapse the
   spaces. */
.labo-section-body {
  margin: 0;
  font-size: .9375rem;
  line-height: 1.7;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

/* Structure only (D4): the classes and the mechanic a highlighted passage
   would need exist, but nothing marks a run today - grepped, /api/labo/
   recipe serves no "key passages" field for laboHighlightedRuns (app.js) to
   read (finding dispatched to the engine). --wash-accent rather than a new
   token: already declared, already measured, and a low-alpha accent wash
   reads on cream paper as readily as it does on this app's own glass rows. */
.labo-mark {
  background: var(--wash-accent);
  border-radius: 3px;
  padding: 0 .15em;
}

.labo-plain-text {
  margin-top: .5rem;
  padding-top: .5rem;
  border-top: 1px solid var(--paper-muted);
}

.labo-plain-text summary {
  cursor: pointer;
  font-size: .8125rem;
  font-weight: 600;
  color: var(--paper-muted);
}

.labo-plain-text[open] summary { margin-bottom: .5rem; }

/* K-L1p4-2: a <pre> already preserves whitespace, so this line is what
   makes it WRAP rather than what makes it faithful - and a rule declaring
   `white-space: normal` on it would take both away at once. Same two
   guards as .labo-section-body above. */
.labo-letter {
  margin: 0;
  padding: 0;
  background: transparent;
  border: none;
  font-family: var(--font-read);
  font-size: .9375rem;
  line-height: 1.6;
  color: inherit;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

.labo-paper-note {
  margin: .75rem 0 0;
  padding-top: .5rem;
  border-top: 1px solid var(--paper-muted);
  font-size: .75rem;
  color: var(--paper-muted);
}

/* The margin notes (U-labo.3) - native <details>, so there is no animation
   to neutralise here and no state for a script to hold. Same visual family
   as .labo-legend below, one step quieter: these explain the letter, they
   are not part of it. Over the bench's own scrim rather than the paper -
   these are the lab's notes ABOUT the letter, not the letter itself, so
   they stay in the app's own --text-muted/--text-faint vocabulary the
   scrim was measured for. */
.labo-notes {
  margin-top: .875rem;
  padding-left: .75rem;
  border-left: 2px solid var(--glass-border);
}

.labo-notes-label {
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 .35rem;
}

.labo-note + .labo-note { margin-top: .25rem; }

.labo-note > summary {
  cursor: pointer;
  font-size: .8125rem;
  font-weight: 600;
  color: var(--text-muted);
  padding: .15rem 0;
}

.labo-note > p {
  margin: .15rem 0 .5rem;
  max-width: 42rem;
  font-size: .8125rem;
  line-height: 1.5;
}

.labo-legend { margin-top: 1rem; }

.labo-legend-list {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  margin: .6rem 0 0;
}

.labo-legend-row {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: .5rem;
  row-gap: .1rem;
  padding-left: .9rem;
  position: relative;
}

.labo-legend-row::before {
  content: "";
  position: absolute;
  left: 0;
  top: .4rem;
  width: .5rem;
  height: .5rem;
  border-radius: 50%;
  background: var(--text-faint);
}

.labo-legend-row--validated::before,
.labo-legend-row--curriculum-complete::before { background: var(--up); }
.labo-legend-row--not-yet::before,
.labo-legend-row--ambiguous::before { background: var(--warn); }

.labo-legend-row dt {
  grid-column: 1 / -1;
  font-weight: 700;
  font-size: .875rem;
}

.labo-legend-row dd {
  grid-column: 1 / -1;
  margin: 0;
  font-size: .8125rem;
  color: var(--text-faint);
  line-height: 1.45;
}

/* L2: "Check reply" and the reply's own verdict (labo-verdict-tools,
   index.html) - between the recipes and the legend those seven states
   already belong to. */
.labo-verdict-tools { margin-top: 1rem; }

.labo-show-all-btn,
.labo-check-reply-btn {
  padding: .4rem .8rem;
  font-size: .75rem;
}

.labo-check-reply-btn { width: 100%; }

/* Opaque (--bg-raised) rather than another glass row deep inside an
   already-glass rail: --up/--warn/--accent/--down/--text-faint - the five
   hues the seven verdict tokens above borrow - are the ones this sheet's
   own contrast suite already measures against THIS exact surface
   (test_every_colour_pair_meets_its_contrast_floor, tests/test_front_end.py);
   --glass-row itself only ever proved --text/--text-muted, a pair this
   box does not paint. */
.labo-verdict {
  margin-top: .75rem;
  padding: .7rem .8rem;
  border-radius: var(--radius);
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
}

.labo-verdict-badge {
  display: flex;
  align-items: center;
  gap: .6rem;
}

.labo-verdict-label {
  margin: 0;
  font-size: .8125rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--text-faint);
}

/* "awaiting" (laboRenderVerdictAwaiting, app.js) sets this to the empty
   string on purpose - there is no badge to show yet, only the reminder
   sentence #labo-verdict-desc already carries. */
.labo-verdict-label:empty { display: none; }

.labo-verdict-desc { margin: .35rem 0 0; }

.labo-verdict-stamp {
  display: none;
  position: relative;
  flex: none;
  width: 40px;
  height: 40px;
  align-items: center;
  justify-content: center;
}

/* L2-B2 (Lea, revue L2, BLOQUANT -> fixed): the stamp is the STATE of the
   reply, never a reward for the one good outcome - all seven real verdicts
   show it, identically, in their own colour. Before this fix only
   `--validated` unhid it: `curriculum_complete` (a bigger milestone than an
   ordinary `not_yet`) got nothing more than an ordinary `not_yet` did,
   exactly the shape CHARTE_VOIX.md sec.2 ("she encourages soberly... never
   on outcomes") and CONSOLE_TRACKER.md sec.3 ("no casino blink") both
   forbid. Guarded by
   test_the_verdict_stamp_treats_all_seven_outcomes_identically
   (tests/test_paper_only_guard.py), which derives the seven names from
   this very comma-list rather than retyping them. */
.labo-verdict--validated .labo-verdict-stamp,
.labo-verdict--not-yet .labo-verdict-stamp,
.labo-verdict--ambiguous .labo-verdict-stamp,
.labo-verdict--other-lesson .labo-verdict-stamp,
.labo-verdict--no-line .labo-verdict-stamp,
.labo-verdict--curriculum-complete .labo-verdict-stamp,
.labo-verdict--no-curriculum .labo-verdict-stamp {
  display: inline-flex;
}

/* Same seven, each its OWN colour - `currentColor` is what lets the icon
   (`.labo-verdict-stamp-icon`) and the ink dots (`.labo-verdict-splat-dot`)
   below read it without a second per-state list. */
.labo-verdict--validated .labo-verdict-stamp { color: var(--verdict-validated); }
.labo-verdict--not-yet .labo-verdict-stamp { color: var(--verdict-not-yet); }
.labo-verdict--ambiguous .labo-verdict-stamp { color: var(--verdict-ambiguous); }
.labo-verdict--other-lesson .labo-verdict-stamp { color: var(--verdict-other-lesson); }
.labo-verdict--no-line .labo-verdict-stamp { color: var(--verdict-no-line); }
.labo-verdict--curriculum-complete .labo-verdict-stamp { color: var(--verdict-curriculum-complete); }
.labo-verdict--no-curriculum .labo-verdict-stamp { color: var(--verdict-no-curriculum); }

/* K-L2p2-4 (Karim revue L2 passe 2, MAJEUR, defaut VIVANT): the ink splash
   is hidden by the LAST FRAME of its own keyframe (`laboInkSplat`, 100% {
   opacity: 0 }, held by `both`) - not by any rule. So under
   `prefers-reduced-motion: reduce`, where `animation: none` below stops
   the keyframe from ever applying, the element fell back on its DECLARED
   style, which set neither `opacity` nor `transform`: five opaque green
   discs, 84x84px, absolutely positioned (therefore painted above the
   non-positioned `<p id="labo-verdict-label">` that precedes them), across
   the "Validated" badge, permanently, for the one reader who asked for
   LESS. The rest state is now the last frame's own picture, so
   `animation: none` lands on the same thing the animation ends on; during
   the .35s delay `both` applies the 0% frame (scale(0)), invisible too, so
   nothing changes for anyone else. Held by
   test_a_keyframe_that_ends_hidden_has_a_rest_state_that_is_hidden_too
   (tests/test_front_end.py) and, for the cascade,
   test_no_rule_defeats_the_verdict_splats_hidden_rest_state
   (tests/test_paper_only_guard.py). */
.labo-verdict-splat {
  position: absolute;
  inset: -22px;
  pointer-events: none;
  opacity: 0;
}

.labo-verdict-splat-dot { fill: currentColor; }

/* L2-B1 (Lea, revue L2, BLOQUANT -> fixed): this used to be an <img> of
   Vera sealing a letter (`labo-vera-seal.webp`, the SAME photograph her
   own dock shows while she seals a reply) reused as the icon for
   "VALIDATED" - the gesture that carries a reply became the gesture that
   judges one, exactly what CONCEPT_LABO_MENTOR.md sec.2 forbids ("Vera
   prepares, she never judges... the verdict shows as a stamped label,
   never a word of Vera's"). A neutral pictogram - a ring and a bar, no
   photograph, no likeness of anyone - reads as "stamped" without ever
   incarnating the parser's verdict as her. Guarded by
   test_veras_three_poses_are_never_used_outside_her_own_dock
   (tests/test_front_end_labo.py). */
.labo-verdict-stamp-icon {
  position: relative;
  width: 40px;
  height: 40px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.5;
}

.labo-verdict--validated .labo-verdict-label { color: var(--verdict-validated); }
.labo-verdict--not-yet .labo-verdict-label { color: var(--verdict-not-yet); }
.labo-verdict--ambiguous .labo-verdict-label { color: var(--verdict-ambiguous); }
.labo-verdict--other-lesson .labo-verdict-label { color: var(--verdict-other-lesson); }
.labo-verdict--no-line .labo-verdict-label { color: var(--verdict-no-line); }
.labo-verdict--curriculum-complete .labo-verdict-label { color: var(--verdict-curriculum-complete); }
.labo-verdict--no-curriculum .labo-verdict-label { color: var(--verdict-no-curriculum); }

/* L2: the tampon itself - a badge popping onto the page (laboStamp), the
   round photograph falling and settling into it (laboStampFall), an ink
   splash behind it (laboInkSplat). Declared ONLY under no-preference, the
   same "rien n'existe sous reduce" discipline as laboPop/laboFadeUp above -
   paired verbatim below inside @media (prefers-reduced-motion: reduce).
   K-L2-7 (Karim, revue L2 passe 1): this comment used to claim
   `.labo-verdict--awaiting` cancels the pop "by ordinary specificity" - no
   such rule exists anywhere in this sheet (grepped), and the comment thirty
   lines below already said the true thing: "awaiting" (an eighth class this
   selector list never spells) simply never matches the seven names below,
   so no override, by specificity or by `:not()`, is ever needed. */
@keyframes laboStamp {
  0% { opacity: 0; transform: scale(1.3) rotate(-6deg); }
  60% { opacity: 1; transform: scale(.96) rotate(2deg); }
  100% { opacity: 1; transform: scale(1) rotate(0deg); }
}

@keyframes laboStampFall {
  0% { opacity: 0; transform: translateY(-18px) scale(.6) rotate(-90deg); }
  60% { opacity: 1; transform: translateY(2px) scale(1.08) rotate(8deg); }
  100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
}

@keyframes laboInkSplat {
  0% { transform: scale(0); opacity: .8; }
  60% { transform: scale(1); opacity: .45; }
  100% { transform: scale(1.15); opacity: 0; }
}

/* The seven REAL modifiers, named rather than the class the box always
   carries too (`.labo-verdict`) - "awaiting" (an eighth class this
   selector list never spells) never matches, so it never needs an
   override to cancel back out of an animation it was never given. Two
   media blocks, IDENTICAL selector text either side, satisfy the pairing
   test the same way `.labo-chip`/`.labo-section` do above; a `:not()`-
   based cancel would have needed the cross-block reasoning
   `_css_rules_that_defeat_a_pinned_rule` has never been proven against. */
@media (prefers-reduced-motion: no-preference) {
  .labo-verdict--validated,
  .labo-verdict--not-yet,
  .labo-verdict--ambiguous,
  .labo-verdict--other-lesson,
  .labo-verdict--no-line,
  .labo-verdict--curriculum-complete,
  .labo-verdict--no-curriculum {
    animation: laboStamp .4s ease both;
  }
  .labo-verdict-stamp-icon { animation: laboStampFall .6s cubic-bezier(.34, 1.56, .64, 1) both; }
  .labo-verdict-splat { animation: laboInkSplat .4s ease-out .35s both; }
}

@media (prefers-reduced-motion: reduce) {
  .labo-verdict--validated,
  .labo-verdict--not-yet,
  .labo-verdict--ambiguous,
  .labo-verdict--other-lesson,
  .labo-verdict--no-line,
  .labo-verdict--curriculum-complete,
  .labo-verdict--no-curriculum {
    animation: none;
  }
  .labo-verdict-stamp-icon { animation: none; }
  .labo-verdict-splat { animation: none; }
}

/* Vera's three poses (L2, D7 - "oui aux photos, garde NOMINALE") - three
   real, vendored photographs (labo-vera-rest/-pour/-seal.webp,
   _IMAGE_ASSET_ALLOWLIST's own three new entries, D7's own boundary),
   shown and hidden by the SAME three classes laboSetPose already wrote
   when this ring held only an SVG. The drawing every other dock clones
   (below) is now the REPLY, painted only under
   .labo-vera-dock--photo-failed - the one class laboVeraPhotoFailed ever
   adds, and it never resets: no image is ever left on screen broken. */
#labo-vera-dock {
  display: inline-flex;
  border-radius: 50%;
  padding: .15rem;
  border: 2px solid var(--border);
  transition: border-color .2s ease;
}

#labo-vera-dock.labo-vera-dock--preparing { border-color: var(--accent); }
#labo-vera-dock.labo-vera-dock--sealed { border-color: var(--up); }

.labo-vera-photo {
  display: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  object-fit: cover;
}

.labo-vera-dock--idle .labo-vera-photo--rest,
.labo-vera-dock--preparing .labo-vera-photo--pour,
.labo-vera-dock--sealed .labo-vera-photo--seal {
  display: block;
}

/* The repli, in the direction that matters: once ANY photo has failed,
   every photo hides (a half-broken dock - two photos and a drawing - is
   worse than the honest, whole drawing this ring always showed before
   this unit) and the drawing below is all that paints.
   K-L2p3-3 (Karim revue L2 passe 3, 2026-08-23, MAJEUR): this rule used to
   be a bare class, (0,2,0) - exactly the specificity of the three pose
   rules just above it - so it won only by SOURCE ORDER. Grouping the repli
   above the poses, the most ordinary tidy-up there is, put the broken
   <img> back on top of the fallback drawing inside a 40px ring, and
   measured 2174 green. The `#labo-vera-dock` id makes it (1,2,0): it wins
   wherever it sits, exactly the choice K-L2-2 already made one rule below
   for the SVG half of this same cascade - the mirror half, closed in the
   same gesture rather than not at all (L5048).
   Held by test_no_rule_defeats_the_vera_photos_hiding_once_one_failed
   (tests/test_paper_only_guard.py), parametrized on the three photographs
   index.html really ships - a comment that states a property names the
   test that holds it (L4590). */
#labo-vera-dock.labo-vera-dock--photo-failed .labo-vera-photo { display: none; }

/* Third clone site of the one drawing initLabo (app.js) clones - sized
   here directly rather than by sharing the `.keeper` class, for the exact
   reason already written next to `#onboarding-keeper svg`, the second
   site: that class is guarded to appear exactly once in the shipped
   markup (test_the_keeper_is_drawn_once_and_shown_where_it_is_legible).
   Without its own width/height a viewBox-only SVG collapses to 0x0 inside
   this flex dock - measured at the real browser (9px dock, collapsed
   child, all three poses invisible; REVUE_VISUELLE_2026-08-19.md M-1). 40,
   not 64: the design plate's own certified floor for this artwork
   (`.dock-pill svg`, same comment as `.keeper svg` above) - chosen at the
   browser against the panel-head heading this dock sits beside, legible
   in all three poses.
   L2: hidden by default now that a real photograph is the usual content -
   shown only once .labo-vera-dock--photo-failed says one could not load.
   K-L2-2 (Karim, revue L2 passe 1): the override below carries the SAME
   #labo-vera-dock id as this base rule (never a bare class) precisely so
   its specificity (1,1,1) beats this rule's (1,0,1) - an id always wins
   over a class, in any source order, so a class-only override could never
   have painted the reply no matter where it lived in this file. */
#labo-vera-dock svg {
  width: 40px;
  height: 40px;
  display: none;
}

#labo-vera-dock.labo-vera-dock--photo-failed svg { display: block; }

@keyframes laboFadeUp {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}

/* L2 (CONCEPT_LABO_MENTOR.md sec.4, "the text assembles progressively,
   section by section" / "ingredients fall"): laboPop is the chips'
   own motion, laboFadeUp (above) is the letter's - both staggered by
   --labo-reveal-index, a custom property laboRenderBrief/laboRenderSections
   (app.js) set once per node at creation (`.style.setProperty`, the same
   idiom this sheet's own progress bars already use, never a `style=`
   attribute). Declared ONLY inside no-preference - CHECKLIST_CHANTIER's own
   "rien n'existe sous reduce" read as a DECLARATION site, not only a
   cancellation - with the identical selector repeated verbatim inside
   @media (prefers-reduced-motion: reduce) below as `none`:
   test_every_labo_rule_that_moves_has_a_reduced_motion_answer only ever
   compares what is OUTSIDE a reduce block to what is inside one, and a
   no-preference block still counts as outside. An element "Show all"
   (#labo-show-all, laboSkipAssemblyAnimation) has already touched carries
   its OWN `.style.animation = "none"` - a per-node inline override that
   outranks either media block by construction, never a third selector
   here. */
@keyframes laboPop {
  from { opacity: 0; transform: scale(.92) translateY(-3px); }
  to { opacity: 1; transform: scale(1) translateY(0); }
}

@media (prefers-reduced-motion: no-preference) {
  .labo-chip {
    animation: laboPop .3s ease both;
    animation-delay: calc(var(--labo-reveal-index, 0) * .08s);
  }
  .labo-section {
    animation: laboFadeUp .4s ease both;
    animation-delay: calc(var(--labo-reveal-index, 0) * .12s);
  }
}

@media (prefers-reduced-motion: reduce) {
  .labo-chip { animation: none; }
  .labo-section { animation: none; }
  #labo-vera-dock { transition: none; }
  /* L2-G2 (Lea, revue L2, note -> fixed): "Show all" only ever skips a
     reveal delay - under this preference nothing is ever delayed in the
     first place (laboPop/laboFadeUp above declare no rule at all here), so
     the button has nothing left to offer. Hidden by the SAME media query
     the animation itself is cancelled in, never a `.disabled`
     (caché-jamais-désactivé applies to a control with nothing to do the
     same way it applies to one already busy). By-design exemption:
     tests/test_paper_only_guard.py `_LABO_RULES_THAT_HIDE_BY_DESIGN`. */
  #labo-show-all { display: none; }
}

/* ===== The Atrium (U-atrium.front) =====================================
   Translated from docs/design/prototypes/atrium/atrium.html into the Home
   view. Every custom property below is one already declared at the top of
   this sheet - checked one by one, zero new token - with a single
   substitution: the prototype's own --shadow-soft (never spent by the
   real product) reads --bg-sunken here, the token .sheet already uses for
   the identical neutral elevation shadow. Buttons reuse .jeu-btn/
   .jeu-btn-primary/.jeu-btn-outline rather than a second button system;
   .panel carries the tab panels' surface the same way it carries every
   other card in this product. */

/* Bare on purpose (the comma trap - docs/design/prototype-jeu/CLASSES.md
   sec.3). Full explanation lives on the next rule instead - it carries no
   moving property of its own. */
.atrium-skip {
  position: fixed;
  top: -3rem;
  left: .75rem;
  z-index: 100;
  background: var(--bg-raised);
  color: var(--text);
  text-decoration: none;
  padding: .65rem 1rem;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  font-size: .875rem;
  font-weight: 600;
  transition: top .15s ease;
}

/* Gains-rapides U1.3: the prototype's own skip link (atrium.html:63-69),
   first focusable element in the document (static/index.html). Off-screen
   above the viewport until :focus-visible reveals it - never `display:
   none`, which would remove it from the tab order entirely, the one thing
   a skip link cannot do. z-index 100 clears every other fixed layer this
   sheet declares (`.jeu-overlay` at 70 is the highest below it - corrected
   here, Karim's revue-sabotage F7, gains-rapides corrections passe 1: this
   comment used to claim 60/`#console-shell`, contradicted by that very
   selector's OWN comment 850 lines earlier, which already named 70 as the
   ceiling it stays under. The conclusion - 100 clears every fixed layer -
   still holds; only the wrong runner-up was named. Known residual, F6 of
   the same review, left open rather than fixed here: a Shift-Tab from the
   first control of a full-screen `.jeu-overlay` (z-index 70) still reaches
   this skip link, which then paints ABOVE the overlay, because
   `setBackgroundInert` only neutralises it during the onboarding tour, not
   during a Z11/Z12/nodesheet session.) since a focused skip link has to
   sit above whatever else is on screen. */
.atrium-skip:focus-visible { top: .75rem; }

@media (prefers-reduced-motion: reduce) {
  .atrium-skip { transition: none; }
}

.atrium-eyebrow {
  font-size: .8125rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: .75rem;
}

/* --- Hero: the one-message promise + the decorative console --- */

.atrium-hero {
  display: flex;
  flex-direction: column;
  gap: clamp(1.5rem, 5vw, 2.75rem);
  padding: clamp(1.5rem, 6vw, 2.5rem) 0 2rem;
}

.atrium-hero-copy {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.35rem;
  text-align: center;
}

/* margin: 0 is load-bearing, not tidiness. This is an <h2>, so a browser
   gives it 0.83em top AND bottom by default - measured 25.19px each at
   375px wide - while .atrium-hero-copy already spaces its children with
   flex `gap`. The two ADD UP (a flex gap does not collapse a margin), so
   the promise carried 50.4px nobody wrote, and that is what pushed the one
   CTA of the page under the fixed tabbar on a short screen: measured
   2026-08-19 at 375x667, the button ran 597->641 while the bar began at
   595, and document.elementsFromPoint at its centre returned
   path -> svg -> button.tabbar-item -> nav#tabbar BEFORE .atrium-cta - a
   tap reached a nav item, never the button. No headless test in this repo
   can see that: it needs a real layout engine (the family already paid at
   U-console.2, U-cB.4 and U-cB.5). Font-size and line-height are untouched
   on purpose - the readability floor is never what gets traded away to win
   vertical space. */
.atrium-promise-h1 {
  margin: 0;
  max-width: 19ch;
  font-size: clamp(1.5rem, 1.1rem + 3.4vw, 2.125rem);
  line-height: 1.26;
  font-weight: 650;
  letter-spacing: -.01em;
  color: var(--text);
}

.atrium-cta { align-self: center; }

/* --- The console chassis: decorative art only, no market data, no money.
   role="img" + a translated aria-label carry the whole meaning; every
   number inside is unitless and the path is a fixed shape (built once at
   boot by paintAtriumConsoles, static/app.js), never fed by a route. --- */

.atrium-console {
  width: 100%;
  display: flex;
  align-items: flex-end;
  gap: .6rem;
}

.atrium-console--hero { height: clamp(120px, 42vw, 210px); }
.atrium-console--mini { height: 100px; }

.atrium-monitor {
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 100%;
  min-width: 0;
}

.atrium-monitor--main { flex: 1.7; }
.atrium-monitor--aux { flex: 1; height: 80%; }

.atrium-monitor-bezel {
  width: 100%;
  flex: 1;
  min-height: 0;
  padding: 5px;
  border-radius: 11px;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  box-shadow: 0 12px 26px var(--bg-sunken);
  display: flex;
}

.atrium-screen-inset {
  width: 100%;
  border-radius: 7px;
  overflow: hidden;
  background: var(--glass-track);
}

.atrium-chart { width: 100%; height: 100%; }
.atrium-chart-grid { stroke: var(--border); stroke-width: 1; }

/* Declared unconditionally and silenced under reduce below - this
   sheet's own house pattern rather than the prototype's own
   no-preference gate. Same result on screen. */
.atrium-chart-line {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.25;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
  opacity: 1;
  animation: atrium-line-draw 20s ease-in-out infinite;
}

.atrium-chart-stop {
  fill: none;
  stroke: var(--text-faint);
  stroke-width: 1.3;
  stroke-dasharray: 5 4;
  opacity: 1;
  animation: atrium-stop-appear 20s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .atrium-chart-line,
  .atrium-chart-stop {
    animation: none;
  }
}

@keyframes atrium-line-draw {
  0% { stroke-dashoffset: 1; opacity: 0; }
  4% { opacity: 1; }
  42% { stroke-dashoffset: 0; opacity: 1; }
  90% { stroke-dashoffset: 0; opacity: 1; }
  100% { stroke-dashoffset: 0; opacity: 0; }
}

@keyframes atrium-stop-appear {
  0%, 45% { opacity: 0; }
  55% { opacity: 1; }
  90% { opacity: 1; }
  100% { opacity: 0; }
}

.atrium-aux-rows {
  width: 100%;
  height: 100%;
  padding: 14%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 16%;
}

.atrium-aux-row { height: 11%; border-radius: 3px; background: var(--glass-track); }
.atrium-aux-row:nth-child(1) { width: 68%; }
.atrium-aux-row:nth-child(2) { width: 94%; }
.atrium-aux-row:nth-child(3) { width: 52%; }

.atrium-monitor-stand {
  width: 100%;
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 3px;
}

.atrium-monitor-stand .neck { width: 12%; height: 5px; background: var(--border-strong); }
.atrium-monitor-stand .foot { width: 36%; height: 4px; margin-top: 2px; border-radius: 2px; background: var(--border-strong); }

/* --- The three doors: a real, scrolling list of navigation buttons - not
   the prototype's fixed nav bar (scoping decision, index.html). --- */

.atrium-doors { padding: 1.5rem 0; }

.atrium-doors-list { border-top: 1px solid var(--border); }

.atrium-door-row {
  display: flex;
  width: 100%;
  align-items: center;
  gap: 1rem;
  padding: 1.1rem .25rem;
  border: 0;
  border-bottom: 1px solid var(--border);
  background: none;
  font: inherit;
  text-align: left;
  color: inherit;
  cursor: pointer;
  touch-action: manipulation;
}

.atrium-door-row:hover { background: var(--glass-row-hover); }

.atrium-door-icon {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--glass-row);
  color: var(--text-muted);
}

.atrium-door-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .2rem; }
.atrium-door-name { font-size: 1.0625rem; font-weight: 700; color: var(--text); }
.atrium-door-desc { font-size: .8125rem; color: var(--text-muted); }

.atrium-lab-teaser {
  margin-top: 1.5rem;
  padding: 1.1rem;
  border-radius: var(--radius-panel);
  border: 1px dashed var(--border-strong);
  background: var(--glass-row);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: .55rem;
}

.atrium-lab-kicker {
  /* .75rem, not the prototype's .6875rem: this sheet's own readability
     floor is 12px for everything but a small handful of NAMED exceptions
     (SMALL_LABELS_BELOW_THE_FLOOR, tests/test_front_end.py) - a kicker
     line is not one of them, and the difference is not worth arguing for
     a pixel. */
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.atrium-lab-copy { font-size: .9375rem; color: var(--text-soft); }

.atrium-lab-btn {
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  padding: .5rem .8rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-faint);
  font-size: .8125rem;
  font-weight: 600;
}

.atrium-badge-soon {
  font-style: normal;
  /* Same readability-floor note as .atrium-lab-kicker just above. */
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .03em;
  padding: .18rem .55rem;
  border-radius: 999px;
  background: var(--wash-accent);
  color: var(--accent-text);
  border: 1px solid var(--accent);
}

/* --- Tabs: WAI-ARIA APG tabs, three static panels toggled by `hidden`
   (initAtriumTabs, app.js) rather than one panel rebuilt from a template
   string - the discipline this sheet's own visibility tooling already
   expects of every screen. --- */

.atrium-tabs { padding: 1rem 0 2.5rem; }

.atrium-tablist {
  display: flex;
  gap: .3rem;
  border-bottom: 1px solid var(--border);
  margin-bottom: 1.1rem;
  overflow-x: auto;
}

.atrium-tab {
  display: flex;
  align-items: center;
  gap: .45rem;
  white-space: nowrap;
  padding: .75rem .9rem;
  margin-bottom: -1px;
  border: 0;
  border-bottom: 2px solid transparent;
  background: none;
  color: var(--text-muted);
  font: inherit;
  font-size: .9375rem;
  font-weight: 600;
  cursor: pointer;
}

.atrium-tab:hover { color: var(--text); }
.atrium-tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }

.atrium-tabpanel { padding: 1.25rem; min-height: 12rem; }

.atrium-panel-mini { display: flex; flex-direction: column; gap: 1.4rem; }

.atrium-panel-actions { display: flex; flex-wrap: wrap; gap: .6rem; }

/* --- Closing line: the four counts /api/meta serves (renderAtriumStats,
   app.js) - zero figure typed here. --- */

.atrium-close {
  padding: 3rem 0 4rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 1.6rem;
}

.atrium-stats {
  font-family: var(--font-mono);
  font-size: .875rem;
  color: var(--text-muted);
  letter-spacing: .01em;
}

@media (prefers-reduced-motion: no-preference) {
  .atrium-door-row,
  .atrium-tab {
    transition: background-color .15s ease, color .15s ease, box-shadow .15s ease;
  }
}

@media (min-width: 1024px) {
  .atrium-hero {
    display: grid;
    grid-template-columns: 1.05fr .95fr;
    align-items: center;
    gap: 3rem;
    padding-top: 1rem;
  }

  .atrium-hero-copy { align-items: flex-start; text-align: left; }
  .atrium-cta { align-self: flex-start; }
  .atrium-promise-h1 { font-size: clamp(2.125rem, 1.6rem + 1.6vw, 2.75rem); max-width: 16ch; }
  .atrium-console--hero { height: clamp(210px, 22vw, 320px); }

  .atrium-doors-list,
  .atrium-lab-teaser { max-width: 42rem; }

  .atrium-tabpanel { padding: 1.75rem; }

  .atrium-panel-mini {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1.75rem 2.5rem;
    align-items: start;
  }
}

/* ===== Le deck : coque partagee des cinq pages v1 -> v2 (RESTYLE U0) ======
   Chantier RESTYLE, unite Leagues, 2026-08-22. La coque que les six planches
   de docs/design/prototypes/v1-restyle posent au-dessus de chaque page :
   un bandeau d'etat, une navbar unique de neuf touches, et la page dessous.

   Geometrie copiee sur #console-shell (plus haut dans cette feuille), pas
   inventee : fixed / inset 0 / grille, sur --bg + --scene. z-index 61 pour
   les vues et 60 pour la coque : les deux ne sont jamais a l'ecran en meme
   temps que la Console (Trade exclut les cinq), et un empilement distinct
   rend la propriete lisible plutot que dependante de l'ordre du document.

   Aucune des cinq vues ne bouge d'un octet dans index.html : elles
   deviennent la troisieme rangee par la regle nominale ci-dessous. Deplacer
   ou inserer dans un `.view` est ce qui a casse cinq placements nth-child
   d'un coup a U-atrium.front, suite entierement verte. */
#deck-shell {
  position: fixed;
  /* P-04 : le deck aussi commence sous LA barre de navigation haute — sa
     navbar de neuf touches est morte avec l'unification, le bandeau reste. */
  inset: var(--pad-nav-top) 0 0 0;
  z-index: 60;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  grid-template-columns: minmax(0, 1fr);
  gap: .625rem;
  padding: .625rem
           max(.625rem, env(safe-area-inset-right))
           max(.625rem, env(safe-area-inset-bottom))
           max(.625rem, env(safe-area-inset-left));
  background-color: var(--bg);
  background-image: var(--scene);
}

/* Le bandeau : les sept choses que le masthead portait, plus la marque que
   l'absorption de la sidebar retirait de cinq ecrans. `flex-wrap` parce
   qu'a 375px la mention de simulation ne tient pas sur la meme ligne que
   les deux toplines - et un enfant flexible sans plancher se fait ecraser
   a zero par ses propres freres (CHECKLIST_CHANTIER sec.4). */
.deck-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .375rem .625rem;
  padding: .4rem .625rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
}

.deck-bar .notice {
  margin: 0;
  min-width: 12rem;
  font-size: .8125rem;
  color: var(--text-muted);
}

.deck-bar .topline { margin: 0; }

.deck-bar-spacer { flex: 1 1 1rem; }

.deck-wordmark {
  display: inline-flex;
  align-items: center;
  gap: .375rem;
  color: var(--text-muted);
}

.deck-wordmark-name {
  font-size: .8125rem;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* Les cinq pages du deck, posees sous la coque par une regle nominale.
   --pad-deck-top est ecrit par deckMeasure() a partir d'une mesure reelle ;
   la valeur de :root ne sert qu'a la premiere image, avant le script. */
.view[data-view="leagues"],
.view[data-view="history"],
.view[data-view="markets"],
.view[data-view="stats"],
.view[data-view="account"] {
  position: fixed;
  inset: var(--pad-deck-top) 0 0 0;
  z-index: 61;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0 max(.625rem, env(safe-area-inset-right))
             max(.625rem, env(safe-area-inset-bottom))
             max(.625rem, env(safe-area-inset-left));
}

/* ===== Le neon dore (RESTYLE U0) =========================================
   Demande d'Erwann du 2026-08-21/22, verbatim : « surtout des neons dores
   derriere les touches en hover ou au click ». Il renverse un arbitrage
   ecrit (CONSOLE_TRACKER.md : « un halo au repos = refuse ») et il est
   BORNE pour cela - RESTYLE_TRACKER.md sec.3.1, quatre bornes :

   1. jamais au repos : opacity 0 par defaut ; le halo n'existe que sous
      :hover / :active / [aria-pressed="true"] / [aria-expanded="true"] ;
   2. jamais sur l'ecran Trade : le neon est une classe decoratrice opt-in
      `.neon` (les navtabs qui portaient cette borne sont morts a P-04 -
      elle vaut pour tout controle de l'ecran Trade, nommement) ;
   3. jamais sur un controle d'ordre : aucun #ticket-*, aucun bouton
      d'achat d'offre. Un flare apres le clic sur un bouton qui engage est
      la definition meme de la recompense de casino ;
   4. jamais un signe : --neon-* dit touche active ou survolee. Rouge et
      vert restent le signe d'un nombre ou la direction d'un ordre.

   Et c'est une OMBRE, jamais une encre : aucun `color` ne lit --neon-*.
   Une encre neuve devrait passer l'audit de contraste ; une lueur non. */
.neon {
  position: relative;
  z-index: 0;
}

/* Le repos se dit `box-shadow: none`, jamais `opacity: 0` - et ce n'est pas
   du style, c'est une mesure. Le prototype eteint le halo par l'opacite ; ici
   la machinerie de visibilite du depot (_css_rules_that_could_hide,
   tests/test_paper_only_guard.py) retire les pseudo-elements du selecteur
   avant de comparer, donc `.neon::before { opacity: 0 }` se lit comme une
   regle qui cache LA TOUCHE - neuf faux rouges mesures. Eteindre l'ombre
   plutot que l'element dit exactement la meme chose a l'ecran et laisse la
   garde faire son travail sur les vraies regles. Meme raison pour l'absence
   de `pointer-events: none` que le prototype pose ici : le test de survol
   d'un pseudo-element est impute a son element d'origine, donc la
   declaration ne changeait rien - et elle faisait lire la touche comme un
   controle mort par _css_rules_that_could_disable_a_control. */
.neon::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  box-shadow: none;
  transition: box-shadow .18s ease;
}

/* LES DEUX COUCHES (R8, 2026-08-22). L'ordre des ombres EST la recette :
   une box-shadow est peinte de la premiere a la derniere, la premiere
   DEVANT. Donc l'anneau de 1px en or lumineux (--neon-mid) se pose sur le
   liseré de 2px en --neon-rim, et ce qui reste visible du liseré est le
   1px qui depasse - un cerne, dehors, qui donne un corps a l'anneau. En
   sombre les deux sont le meme or et cela se lit comme un seul trait ; en
   clair le liseré est l'or profond de U7, et c'est lui qui fait que le
   halo existe sur le papier pendant que le coeur reste lumineux. */
.neon:hover::before {
  box-shadow: 0 0 0 1px var(--neon-mid),
              0 0 0 2px var(--neon-rim),
              0 0 14px 2px var(--neon-mid),
              0 0 30px 6px var(--neon-soft);
}

.neon[aria-pressed="true"]::before {
  box-shadow: 0 0 0 1px var(--neon-mid),
              0 0 0 2px var(--neon-rim),
              0 0 10px 1px var(--neon-soft);
}

.neon[aria-expanded="true"]::before {
  box-shadow: 0 0 0 1px var(--neon-mid),
              0 0 0 2px var(--neon-rim),
              0 0 10px 1px var(--neon-soft);
}

.neon:active::before { animation: neon-flare .72s ease-out; }
.neon:active { transform: translateY(1px); }

/* Le 17 pour cent lit --neon-gold la ou le prototype lisait --neon-hot :
   l'anneau de 2px est le seul moment ou la couleur est franche - et c'est
   ce qui fait que les cinq tokens sont tous DEPENSES plutot que quatre
   depenses et un declare. Ecart au prototype ecrit au tracker. Les trois
   etapes portent le MEME nombre de couches (quatre) : une box-shadow ne
   s'interpole entre deux etapes que couche a couche, et une etape plus
   courte ferait sauter l'animation au lieu de la fondre. */
@keyframes neon-flare {
  0% {
    box-shadow: 0 0 0 1px var(--neon-mid),
                0 0 0 2px var(--neon-rim),
                0 0 10px 1px var(--neon-mid),
                0 0 22px 3px var(--neon-soft);
  }
  17% {
    box-shadow: 0 0 0 2px var(--neon-gold),
                0 0 0 3px var(--neon-rim),
                0 0 22px 5px var(--neon-hot),
                0 0 54px 14px var(--neon-mid);
  }
  100% {
    box-shadow: 0 0 0 1px var(--neon-mid),
                0 0 0 2px var(--neon-rim),
                0 0 14px 2px var(--neon-mid),
                0 0 30px 6px var(--neon-soft);
  }
}

@media (prefers-reduced-motion: reduce) {
  .neon::before { transition: none; }
  .neon:active::before { animation: none; }
  .neon:active { transform: none; }
}

/* ===== Leagues - le tableau d'affichage sous vitrine (RESTYLE U1) ========
   Metaphore de la planche elle-meme (Leagues.dc.html) : un cadre metal, une
   vitre, un feuillet epingle derriere, des plaques gravees. Rien n'est
   ecrit sur ce tableau parce que la page servie n'a AUCUNE donnee - le vide
   est le sujet du dessin, pas un accident. Aucune phrase n'a ete reecrite :
   chaque mot vient des cles leagues.* deja traduites. */
/* Les rangees coulent par defaut et la page defile : mesure au navigateur a
   375px, une troisieme rangee `minmax(0, 1fr)` dans un conteneur de hauteur
   fixe ecrase le cadre et COUPE la phrase du bas au milieu d'un mot - la
   planche est un dessin de bureau, et un dessin de bureau impose a 375px
   ment sur ce qu'il montre. La composition tenue de la planche revient a
   partir de 60rem, ou elle tient vraiment sans defilement. */
.view[data-view="leagues"] {
  display: flex;
  flex-direction: column;
  gap: .625rem;
}

/* `flex: none` and not the default, and it is a measurement: the view is a
   FIXED box of a definite height, so a row that may shrink does shrink -
   mesure a 375px, la grille donnait 373px au cadre pour 759px de contenu, le
   bas de la page passait sous la couture et la moitie du texte devenait
   illisible. Rien ici ne se comprime : la page defile. */
.view[data-view="leagues"] > * { flex: none; }

.deck-crown {
  position: relative;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  padding: 0 .25rem .625rem;
}

.deck-crown-title {
  margin: 0;
  font-size: 1.375rem;
  font-weight: 650;
  letter-spacing: -.01em;
  color: var(--text);
}

.deck-crown-right {
  display: flex;
  align-items: center;
  gap: .625rem;
}

.deck-crown-note {
  font-size: .75rem;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.deck-ticks {
  display: block;
  height: 4px;
  width: 7.5rem;
  background: repeating-linear-gradient(90deg, var(--border) 0 1px, transparent 1px 10%);
}

.deck-crown-rule {
  position: absolute;
  left: .25rem;
  right: .25rem;
  bottom: 0;
  height: 1px;
  background: linear-gradient(90deg, var(--border-strong), var(--border) 42%, transparent);
  transform-origin: left center;
  animation: deck-rule-draw .7s cubic-bezier(.2, .7, .2, 1) both;
}

@keyframes deck-rule-draw {
  from { transform: scaleX(0); opacity: 0; }
  to { transform: scaleX(1); opacity: 1; }
}

.lg-frame {
  position: relative;
  min-height: 18rem;
  display: flex;
  padding: .625rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
}

.lg-glass {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  border-radius: inherit;
  background: linear-gradient(112deg, var(--glass-row) 0%, transparent 26%, transparent 62%, var(--glass-row) 74%, transparent 88%);
  animation: lg-sheen 20s ease-in-out infinite;
}

@keyframes lg-sheen {
  0%, 100% { transform: translateX(-6%); opacity: .55; }
  50% { transform: translateX(6%); opacity: 1; }
}

.lg-board {
  position: relative;
  z-index: 1;
  flex: 1;
  min-width: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: .625rem;
}

/* Sans `overflow: hidden`, et c'est une mesure : un enfant qui coupe son
   propre debordement voit sa taille minimale automatique tomber a zero, donc
   la grille au-dessus de lui le laisse s'ecraser - mesure a 375px, le cork
   faisait 171px de haut pour 708px de contenu, et la moitie de la page etait
   simplement absente sans le moindre ascenseur. Les coins arrondis sont tenus
   par le border-radius, que le pseudo herite ci-dessous. */
.lg-cork,
.lg-aside {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1.25rem 1.375rem;
  background: var(--bg-sunken);
  border: 1px solid var(--border);
  border-radius: 12px;
}

.lg-cork::before,
.lg-aside::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: .5;
  background-image: radial-gradient(circle, var(--border) 1px, transparent 1.4px);
  background-size: 14px 14px;
}

.lg-cork > *,
.lg-aside > * {
  position: relative;
  z-index: 1;
}

/* La plaque vide : bordure pointillee = la chose existe et n'est pas
   encore ouverte. Le meme vocabulaire que le cadre en pointilles des
   planches Sessions et Markets. */
.lg-plate {
  display: flex;
  flex-direction: column;
  gap: .625rem;
  padding: 1.25rem 1.375rem;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
  transition: border-color .15s ease, background-color .15s ease;
}

.lg-plate:hover {
  border-color: var(--text-faint);
  background: var(--glass-row-hover);
}

.lg-plate-head {
  display: flex;
  align-items: center;
  gap: .625rem;
}

.lg-plate-icon {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--glass-row);
  color: var(--text-muted);
}

.lg-plate-empty {
  margin: 0;
  font-size: 1.0625rem;
  font-weight: 700;
  color: var(--text);
}

.lg-sec-title {
  margin: 0;
  font-size: .8125rem;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.lg-eyebrow {
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* LEAGUES A2 (2026-08-23): "your card" - the same dashed plate as
   .lg-plate, flex column, min-width 0 (the 375px collapse the comment above
   .lg-cork recounts), headings carry no UA margin on top of the gap. */
.lg-card {
  display: flex;
  flex-direction: column;
  gap: .625rem;
  padding: 1.25rem 1.375rem;
  min-width: 0;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
}
.lg-card .lg-sec-title { margin: 0; }
.lg-card-gestures, .lg-card-sources, .lg-card-tiers {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .375rem;
  min-width: 0;
}
.lg-card-gesture, .lg-card-sources li, .lg-card-tier {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  min-width: 0;
}
.lg-card-gesture-text, .lg-card-source-text, .lg-card-tier-text {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}
.lg-card-gesture-count, .lg-card-source-sum, .lg-card-tier-state {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}
.lg-card-tier.is-reached .lg-card-tier-state { color: var(--text); }
.lg-card-rate, .lg-card-rank, .lg-card-level { margin: 0; }
.lg-card-xp {
  display: flex;
  flex-direction: column;
  gap: .375rem;
  min-width: 0;
}
.lg-card-actions { display: flex; gap: .5rem; flex-wrap: wrap; }

.lg-sheet-block {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: .625rem;
  min-width: 0;
}

.lg-sheet-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: .75rem;
  flex-wrap: wrap;
}

.lg-sheet-title {
  display: flex;
  align-items: center;
  gap: .5rem;
}

.lg-pin {
  flex: none;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
  color: var(--on-accent);
  box-shadow: 0 4px 12px var(--shadow-accent);
}

.lg-sheet {
  position: relative;
  flex: 1;
  min-height: 9rem;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--bg-raised);
  box-shadow: 0 4px 12px var(--bg-sunken);
  transition: border-color .15s ease;
}

.lg-sheet:hover { border-color: var(--border-strong); }

.lg-sheet-rules {
  flex: 1;
  min-height: 2rem;
  margin: 0 var(--pad);
  background-image: repeating-linear-gradient(180deg, transparent 0 37px, var(--border) 37px 38px);
}

.lg-sheet .table-scroll { min-width: 0; }

.lg-sheet .grid td.empty {
  white-space: normal;
  font-family: var(--font-ui);
  color: var(--text-muted);
  font-size: .9375rem;
}

.lg-legend {
  margin: 0;
  display: flex;
  gap: .875rem;
  flex-wrap: wrap;
  font-size: .75rem;
  color: var(--text-faint);
}

.lg-legend-item {
  display: inline-flex;
  align-items: center;
  gap: .375rem;
}

.lg-dot {
  width: .5rem;
  height: .5rem;
  border-radius: 50%;
  background: var(--text-faint);
  flex: none;
}

.lg-dot--proof { background: var(--accent); }

.lg-cards {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .625rem;
}

.lg-card {
  padding: .75rem .875rem;
  border-left: 3px solid var(--accent);
  border-radius: 0 10px 10px 0;
  background: var(--glass-row);
  box-shadow: 0 4px 12px var(--bg-sunken);
  transition: transform .15s ease, box-shadow .15s ease;
}

.lg-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 9px 18px var(--bg-sunken);
}

.lg-card-key {
  display: block;
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: .3rem;
}

.lg-card-text {
  display: block;
  font-size: .875rem;
  line-height: 1.5;
  color: var(--text-soft);
}

.lg-seam {
  display: flex;
  align-items: center;
  gap: .375rem;
  flex-wrap: wrap;
  padding: .4rem .625rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
}

.lg-seam-cell {
  display: flex;
  flex-direction: column;
  gap: .05rem;
  padding: 0 .625rem;
  min-width: 0;
}

.lg-seam-label {
  display: block;
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
}

.lg-seam-val {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .8125rem;
  white-space: nowrap;
  color: var(--text);
}

.lg-seam-val--warn { color: var(--warn-text); }

.lg-seam-spacer { flex: 1 1 1rem; }

.lg-rv { animation: view-in .26s ease-out both; }
.lg-d1 { animation-delay: .04s; }
.lg-d2 { animation-delay: .10s; }
.lg-d3 { animation-delay: .16s; }
.lg-d4 { animation-delay: .22s; }
.lg-d5 { animation-delay: .28s; }
.lg-d6 { animation-delay: .34s; }
.lg-d7 { animation-delay: .40s; }

@media (min-width: 60rem) {
  .lg-board { grid-template-columns: minmax(0, 62fr) minmax(0, 38fr); }
  .view[data-view="leagues"] {
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
  }
  .lg-frame { overflow: hidden; }
}

@media (prefers-reduced-motion: reduce) {
  .deck-crown-rule { animation: none; }
  .lg-glass { animation: none; }
  .lg-rv { animation: none; }
  .lg-plate { transition: none; }
  .lg-sheet { transition: none; }
  .lg-card { transition: none; }
}

/* ===== Le chassis partage des planches du deck (RESTYLE U2) ==============
   Chantier RESTYLE, unite Sessions, 2026-08-22. Les six planches de
   docs/design/prototypes/v1-restyle posent le MEME mobilier sous des noms
   differents : une face (le panneau mat a coins arrondis), une couture
   d'etat, un titre de section, une pastille en pointilles, une cascade de
   reveal. L'unite Leagues a rendu ce mobilier sous son propre prefixe
   (`lg-seam`, `lg-sec-title`, `lg-rv`...) parce qu'elle etait seule ; a la
   deuxieme page, le recopier une seconde fois serait la premiere occurrence
   d'une divergence a quatre exemplaires.

   Ces classes-ci sont donc nommees `deck-` et non `hs-` : Markets, Stats et
   Account s'en servent sans rien redefinir. Les regles `lg-` de Leagues ne
   sont PAS renommees ici - une regle CSS deplacee est un renommage
   silencieux (CHECKLIST_CHANTIER sec.4, U-atrium.front), et surtout la vue
   Leagues est hors du perimetre de cette unite. La migration de Leagues sur
   ce chassis est une dette ecrite au RESTYLE_TRACKER, pas un effet de bord
   pris au passage. */
.deck-face {
  position: relative;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: .75rem;
  padding: 1.25rem 1.375rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  box-shadow: 0 4px 12px var(--bg-sunken);
}

.deck-silk {
  font-family: var(--font-mono);
  font-size: .75rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.deck-sec-title {
  margin: 0;
  font-size: .8125rem;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* La pastille en pointilles : le meme vocabulaire que le cadre en
   pointilles - la chose existe, elle n'est pas servie. Elle ne se clique
   pas et n'a jamais d'action : c'est une etiquette d'etat. */
.deck-chip-dashed {
  padding: .2rem .6rem;
  border: 1px dashed var(--border-strong);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: .75rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--text-faint);
  white-space: nowrap;
}

.deck-seam {
  display: flex;
  align-items: center;
  gap: .375rem;
  flex-wrap: wrap;
  padding: .4rem .625rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
}

.deck-seam-cell {
  display: flex;
  flex-direction: column;
  gap: .05rem;
  padding: 0 .625rem;
  min-width: 0;
}

.deck-seam-label {
  display: block;
  font-size: .75rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-faint);
}

.deck-seam-val {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .8125rem;
  white-space: nowrap;
  color: var(--text);
}

/* --warn dit "l'etat merite d'etre lu", jamais "agis" : la couture n'a
   aucun controle. Le vocabulaire des couleurs du depot est intact - le
   signe d'un nombre et la direction d'un ordre restent rouge et vert. */
.deck-seam-val--warn { color: var(--warn-text); }

.deck-seam-spacer { flex: 1 1 1rem; }

/* Le "comma trap" (V2_VOCABULAIRE sec.5.5) : aucune virgule ne peut vivre
   dans un commentaire pose juste au-dessus d'une regle qui anime - la garde
   de reduced-motion decoupe ses selecteurs sur la virgule AVANT de retirer
   les commentaires. Mesure ici meme : trois faux selecteurs et un rouge. Le
   pourquoi de la cascade est donc ecrit au-dessus de la regle inerte plus
   bas. */
.deck-rv { animation: view-in .26s ease-out both; }
/* La cascade de reveal ne coute pas une ligne de JS. showView bascule
   l'attribut `hidden`. La page repasse donc de display:none a affichee et le
   navigateur rejoue de lui-meme les animations de ses enfants. Un
   declencheur ecrit dans app.js aurait double un mecanisme qui marche deja
   et qui est deja coupe pour prefers-reduced-motion (RESTYLE_TRACKER
   sec.7.5). */
.deck-d1 { animation-delay: .04s; }
.deck-d2 { animation-delay: .10s; }
.deck-d3 { animation-delay: .16s; }
.deck-d4 { animation-delay: .22s; }
.deck-d5 { animation-delay: .28s; }
.deck-d6 { animation-delay: .34s; }
.deck-d7 { animation-delay: .40s; }
.deck-d8 { animation-delay: .46s; }

@media (prefers-reduced-motion: reduce) {
  .deck-rv { animation: none; }
}

/* ===== Sessions - le magnetoscope d'archive (RESTYLE U2) =================
   Metaphore de la planche elle-meme (Sessions.dc.html) : une baie a bobines
   ou l'on monterait la bande intraday d'une seance, la porte de la baie a
   gauche, les avertissements graves sur des plaques, et une couture d'etat.
   Aujourd'hui la baie est VIDE, et c'est le sujet de la page : le moteur ne
   sert aucune seance. Aucun chiffre, aucune seance, aucun compteur n'est
   dessine - la planche d'origine ne s'y autorisait pas non plus. */
/* Les rangees coulent et la page defile, comme Leagues et pour la meme
   mesure : une composition tenue imposee sous 60rem ecrase le cadre et
   coupe le texte. La composition de la planche revient a 60rem. */
.view[data-view="history"] {
  display: flex;
  flex-direction: column;
  gap: .625rem;
}

.view[data-view="history"] > * { flex: none; }

.hs-top {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: .625rem;
  min-height: 0;
}

.hs-door { justify-content: flex-start; }

.hs-empty {
  margin: 0;
  font-size: 1.375rem;
  line-height: 1.3;
  font-weight: 650;
  letter-spacing: -.01em;
  color: var(--text);
}

.hs-copy {
  max-width: 42rem;
  margin: 0;
  font-size: .9375rem;
  line-height: 1.6;
  color: var(--text-soft);
}

/* Le cadre en pointilles, et la phrase qui dit ce qu'il veut dire. Sans
   elle un lecteur devrait deviner ce que signifie une boite vide. */
.hs-pending {
  margin-top: auto;
  display: flex;
  align-items: center;
  gap: .875rem;
  padding: 1rem 1.125rem;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
  transition: border-color .15s ease, background-color .15s ease;
}

.hs-pending:hover {
  border-color: var(--text-faint);
  background: var(--glass-row-hover);
}

.hs-pending-icon {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--glass-row);
  color: var(--text-muted);
}

.hs-pending-text {
  display: flex;
  flex-direction: column;
  gap: .2rem;
  min-width: 0;
}

.hs-pending-name {
  font-size: 1.0625rem;
  font-weight: 700;
  color: var(--text);
}

.hs-pending-desc {
  font-size: .8125rem;
  line-height: 1.5;
  color: var(--text-muted);
}

.hs-bay { gap: .75rem; }

.hs-bay-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: .625rem;
  flex-wrap: wrap;
}

/* Sans `overflow: hidden` sur la face : un enfant qui coupe son propre
   debordement voit sa taille minimale automatique tomber a zero, et la
   grille au-dessus de lui le laisse s'ecraser (mesure a 375px sur Leagues,
   le meme piege exactement). Le puits coupe le sien parce que le balayage
   qui le traverse doit s'arreter au bord. */
/* SESSIONS S-1 (2026-08-23): the deck — one card per memorable day, the
   tapes as buttons; min-width 0 everywhere (the 375px collapse family). */
.hs-deck { display: flex; flex-direction: column; gap: .75rem; min-width: 0; }
.hs-deck-hint { margin: 0; color: var(--text-muted); }
.hs-days { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .75rem; min-width: 0; }
.hs-day {
  display: flex; flex-direction: column; gap: .4rem; min-width: 0;
  padding: .875rem 1rem; border: 1px dashed var(--border-strong);
  border-radius: var(--radius-panel); background: var(--glass-row);
}
.hs-day-head { display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; flex-wrap: wrap; min-width: 0; }
.hs-day-title { margin: 0; font-size: 1rem; min-width: 0; overflow-wrap: anywhere; }
.hs-day-date { font-variant-numeric: tabular-nums; color: var(--text-muted); white-space: nowrap; }
.hs-day-moment, .hs-day-why { margin: 0; }
.hs-day-moment { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.hs-tapes { display: flex; flex-wrap: wrap; gap: .4rem; min-width: 0; }
.hs-tape-chip { white-space: normal; }
/* V2 U3: a tape the engine can project stops being "built, not serving" —
   the dashed border is that vocabulary, so the ready chip's goes solid.
   The three mount/enter/remount buttons sit at their card's start edge so
   a full-width flex column never stretches a button into a bar. */
.hs-tape-ready { border-style: solid; color: var(--text-muted); }
.hs-day-project { align-self: flex-start; }
.hs-bay-enter { align-self: flex-start; }
.hs-debrief-run { margin: 0; color: var(--text-muted); }
.hs-debrief-figures {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .4rem .9rem; min-width: 0;
  font-variant-numeric: tabular-nums;
}
.hs-debrief-again { align-self: flex-start; }

.hs-bay-well {
  position: relative;
  flex: 1;
  min-height: 13.5rem;
  border-radius: 12px;
  border: 1px dashed var(--border);
  background: var(--bg-sunken);
  box-shadow: inset 0 2px 10px var(--bg-sunken);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  transition: border-color .15s ease;
}

.hs-bay-well:hover { border-color: var(--border-strong); }

.hs-bay-sweep {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: .6;
  background: linear-gradient(100deg, var(--glass-row) 0%, var(--wash-accent) 48%, var(--glass-row) 100%);
  animation: hs-sweep 20s ease-in-out infinite;
}

.hs-reels {
  position: relative;
  z-index: 1;
  max-width: 100%;
  height: auto;
}

.hs-reel-ring { fill: none; stroke: var(--border-strong); stroke-width: 1.6; }
.hs-reel-hub { fill: none; stroke: var(--text-faint); stroke-width: 1.6; }
.hs-reel-spoke { fill: none; stroke: var(--border); stroke-width: 1.6; stroke-linecap: round; }
.hs-head-block { fill: none; stroke: var(--text-faint); stroke-width: 1.6; }

.hs-tape-path {
  fill: none;
  stroke: var(--border);
  stroke-width: 1.6;
  stroke-dasharray: 4 7;
  stroke-linecap: round;
}

/* Un tour par minute est une horloge de veille et non une animation qui
   appelle le regard. Le second flasque tourne plus lentement parce que sur
   un vrai magnetoscope la bande s'enroule d'un cote pendant qu'elle se
   deroule de l'autre. (Aucune virgule ici : comma trap.) */
.hs-reel-spin {
  transform-box: fill-box;
  transform-origin: center;
  animation: hs-reel-turn 60s linear infinite;
}

.hs-reel-spin-slow { animation-duration: 84s; }

.hs-bay-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .625rem;
}

.hs-bay-foot .deck-ticks { flex: 1; width: auto; }

.hs-notices {
  display: flex;
  flex-direction: column;
  gap: .625rem;
  padding: 1rem 1.125rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
}

.hs-plates {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: .875rem;
}

.hs-plate {
  padding: .875rem 1rem;
  border-left: 3px solid var(--accent);
  border-radius: 0 10px 10px 0;
  background: var(--glass-row);
  transition: background-color .15s ease;
}

.hs-plate:hover { background: var(--glass-row-hover); }

.hs-plate p {
  margin: 0;
  font-size: .8125rem;
  line-height: 1.5;
  color: var(--text-soft);
}

/* SESSIONS v2 U4 (M3) — the enriched debrief: three lecterns, the XP bar,
   the stars, the fold, the moment card and the global-XP toast. The marks
   below are CSS shapes drawn with borders/pseudo-elements, never a literal
   check/cross/dash glyph — the emoji floor (U+2190) rules a decorative
   character out here exactly the way it rules an emoji out. */
.hs-obs-lectern { display: flex; flex-direction: column; gap: .375rem; min-width: 0; }
.hs-obs-heading {
  margin: 0;
  font-size: .8125rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--text-muted);
}
.hs-obs-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
.hs-obs { display: flex; align-items: baseline; gap: .5rem; min-width: 0; font-size: .8125rem; line-height: 1.4; }
.hs-obs-sentence { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text-soft); }
.hs-obs-xp { flex: none; font-variant-numeric: tabular-nums; color: var(--text-muted); }

.hs-obs-mark {
  flex: none;
  align-self: center;
  width: .875rem;
  height: .875rem;
  position: relative;
  border-radius: 2px;
  box-sizing: border-box;
}
.hs-obs-mark[data-state="held"] { border: 2px solid var(--up); }
.hs-obs-mark[data-state="held"]::after {
  content: "";
  position: absolute;
  left: .1rem;
  top: -.05rem;
  width: .3rem;
  height: .5rem;
  border-right: 2px solid var(--up);
  border-bottom: 2px solid var(--up);
  transform: rotate(40deg);
}
.hs-obs-mark[data-state="missed"] { border: 2px solid var(--down); }
.hs-obs-mark[data-state="missed"]::before,
.hs-obs-mark[data-state="missed"]::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: .65rem;
  height: 2px;
  background: var(--down);
}
.hs-obs-mark[data-state="missed"]::before { transform: translate(-50%, -50%) rotate(45deg); }
.hs-obs-mark[data-state="missed"]::after { transform: translate(-50%, -50%) rotate(-45deg); }
.hs-obs-mark[data-state="never"] { border: 2px solid var(--border-strong); }
.hs-obs-mark[data-state="never"]::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: .5rem;
  height: 2px;
  background: var(--text-faint);
  transform: translate(-50%, -50%);
}

.hs-debrief-xp { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
.hs-debrief-xp-label { margin: 0; font-size: .8125rem; font-weight: 700; color: var(--text-muted); }
.hs-xp-track { position: relative; height: .5rem; border-radius: 999px; background: var(--glass-row); overflow: hidden; }
/* The bar fills by a `width` TRANSITION, never a keyframe: a transition's
   rest state IS the served percentage regardless of prefers-reduced-motion
   (only its DURATION is stopped below), so the "both" trap the checklist
   names (a keyframe whose own last frame is hidden, defeated by `animation:
   none` falling back on an undeclared rest state) cannot apply to it. */
.hs-xp-fill { height: 100%; width: 0%; border-radius: 999px; background: var(--accent); }
.hs-xp-ticks { position: absolute; inset: 0; }
.hs-xp-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--bg); }
.hs-debrief-xp-readout { margin: 0; font-size: .8125rem; font-variant-numeric: tabular-nums; color: var(--text-muted); }

.hs-debrief-stars { display: flex; gap: .3rem; }
/* A five-point star, drawn with `clip-path` rather than a glyph (same
   reason as the marks above). Lit/unlit is a background-colour swap; the
   360deg spin at the threshold is visually IDENTICAL to 0deg at rest (a
   five-point star has no orientation), so the transitioned property's own
   rest state under reduced motion is indistinguishable from the animated
   one — nothing to hide, only the SPIN to stop (below). */
.hs-star {
  width: 1rem;
  height: 1rem;
  background: var(--border-strong);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.hs-star[data-lit="true"] { background: var(--accent); transform: rotate(360deg); }
.hs-star--small { width: .75rem; height: .75rem; }
.hs-day-stars { display: flex; gap: .25rem; margin: 0; }

.hs-debrief-desk-validation { margin: 0; font-size: .8125rem; font-weight: 700; color: var(--up); }
.hs-debrief-fold { margin: 0; font-size: .8125rem; color: var(--text-soft); }
.hs-debrief-fold-heading { font-weight: 700; color: var(--text-muted); }
.hs-debrief-best-kept,
.hs-debrief-moment-card,
.hs-debrief-lesson { margin: 0; font-size: .8125rem; color: var(--text-muted); }

@media (prefers-reduced-motion: no-preference) {
  .hs-xp-fill { transition: width .6s ease; }
  .hs-star { transition: background-color .3s ease, transform .3s ease; }
}

@media (prefers-reduced-motion: reduce) {
  .hs-xp-fill { transition: none; }
  .hs-star { transition: none; }
}

/* The XP-global toast (sessionsXpToast, static/app.js) — outside `.shell`,
   fixed to the viewport so it survives whatever view is on screen when it
   fires. z-index 67: above the onboarding tour (66) and below the game
   overlay (70) — the SAME ordering test that already holds the rest of
   this scale (test_the_bar_stacks_above_the_shells_and_below_the_immersive_
   surfaces, tests/test_front_end_tabbar.py) is amended to hold this rank
   too. */
.hs-xp-toast {
  position: fixed;
  left: 50%;
  bottom: 1.25rem;
  z-index: 67;
  transform: translateX(-50%);
  padding: .625rem 1.125rem;
  border-radius: 999px;
  border: 1px solid var(--glass-border);
  background: var(--bg-raised);
  color: var(--text);
  font-size: .875rem;
  font-weight: 700;
  opacity: 1;
}

/* The keyframe ends VISIBLE (opacity 1, at rest) and holds by `both` — never
   the checklist's trap, where a keyframe's OWN last frame is hidden and
   `animation: none` under reduce falls back on an undeclared rest state:
   the base rule above already IS that rest state, opacity 1 included, so
   `animation: none` changes nothing a reduced-motion reader needed to see. */
@keyframes hs-xp-toast-in {
  from { opacity: 0; transform: translate(-50%, .75rem); }
  to { opacity: 1; transform: translate(-50%, 0); }
}

@media (prefers-reduced-motion: no-preference) {
  .hs-xp-toast--in { animation: hs-xp-toast-in .35s ease both; }
}

@media (prefers-reduced-motion: reduce) {
  .hs-xp-toast--in { animation: none; }
}

@keyframes hs-sweep {
  0%, 100% { transform: translateX(-16%); }
  50% { transform: translateX(16%); }
}

@keyframes hs-reel-turn {
  to { transform: rotate(360deg); }
}

/* A partir de 60rem la composition tenue de la planche revient : quatre
   rangees et aucun defilement. Mesure a 1440x900 sans cette regle : les
   quatre blocs se tassaient en haut et laissaient 300px de vide sous la
   couture - une page qui a l'air inachevee plutot qu'une baie vide. La baie
   est la rangee qui prend ce qui reste parce que c'est elle que le dessin
   veut grande. */
/* La rangee de la baie est `minmax(min-content, 1fr)` et non `minmax(0,
   1fr)` : corrections apres la revue Karim (point N-5), defaut MESURE au
   navigateur a 960x560, la frontiere que l'unite n'avait vue ni a 1440x900
   ni a 375x812. Un plancher a ZERO autorise la rangee a se tasser sous son
   contenu ; la face porte `overflow: hidden` juste en dessous ; et la vue,
   qui est le seul element defilant de la planche (`position: fixed;
   overflow-y: auto`), croit que tout tient et ne donne PAS de barre.
   Resultat mesure : `.hs-door` a 97px de haut pour 317px de contenu - le
   pied de page tranche au milieu d'une ligne, le cadre en pointilles
   entierement absent, et rien a faire defiler pour aller les chercher. La
   page dont le sujet entier est ce qu'elle dit coupait sa propre phrase.
   Avec le plancher a `min-content`, meme mesure : plus rien de coupe, la vue
   defile (625/388) et la couture se rejoint. C'est le meme effondrement que
   l'unite Stats a paye sur ses cinq faces et que la plaque de Leagues avait
   paye avant elle - troisieme fois, ecrite ici avec son chiffre. */
@media (min-width: 60rem) {
  .view[data-view="history"] {
    display: grid;
    /* V2 U3: FIVE children now, not four - the debrief panel slid in
       between the top composition and the notices, and a grid written for
       the EXISTING children squeezed it to a 42px sliver (measured at the
       real browser; the L4467 family, paid here in its grid-rows form). */
    grid-template-rows: auto minmax(min-content, 1fr) auto auto auto;
  }
  .hs-top { grid-template-columns: minmax(0, 62fr) minmax(0, 38fr); }
  .deck-face { overflow: hidden; }
  /* V2 U3, mesuré au vrai navigateur : `overflow: hidden` (écrit pour que
     le balayage de la baie s'arrête au bord) met la contribution
     min-content d'une face à son seul padding — et dans CETTE grille à
     hauteur définie déjà débordée, une rangée `auto` reste à sa base :
     le debrief rendait 42px, ses quatre lignes tranchées (famille L6486).
     Aucun balayage ne traverse le debrief : il s'exempte, et la rangée
     retrouve son contenu. DEUX moitiés, mesurées séparément : la base
     `.deck-face` porte AUSSI `min-height: 0`, qui à lui seul refait le
     même 42px — le plancher `min-content` défait les deux. Même
     spécificité que les règles qu'elles battent — l'ORDRE de source
     décide, ces déclarations doivent rester APRÈS `.deck-face`. */
  .hs-debrief { overflow: visible; min-height: min-content; }
  .hs-plates { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (prefers-reduced-motion: reduce) {
  .hs-bay-sweep { animation: none; }
  .hs-reel-spin { animation: none; }
  .hs-reel-spin-slow { animation: none; }
  .hs-pending { transition: none; }
  .hs-bay-well { transition: none; }
  .hs-plate { transition: none; }
}

/* ===== Markets - le tableau grave de l'atelier (RESTYLE U3) =============
   Metaphore de la planche elle-meme (Markets.dc.html) : le mur de l'atelier
   porte UNE plaque d'aluminium gravee - la crete fraisee en haut - et chaque
   marche du registre y est une ligne usinee. Le vocabulaire est tenu de bout
   en bout : la plaque (le panneau) la ligne (une rangee) la couture (l'etat)
   et les plaques vissees au pied (les credits). Une plaque se LIT et ne
   s'actionne pas : rien ici n'est un graphique et rien n'est un ticket.

   Aucune composition tenue et aucune media query de bureau : la planche
   d'origine mesure 1860px de haut et le dit elle-meme - trente-quatre lignes
   ne tiennent pas dans 900px au plancher de lisibilite et aucune ligne n'a
   ete retiree pour faire semblant. La page coule et defile a toute largeur.
   C'est le meme raisonnement que Leagues et Sessions et la conclusion
   inverse pour la bonne raison : leur sujet est une boite VIDE. */
.view[data-view="markets"] {
  display: flex;
  flex-direction: column;
  gap: .625rem;
}

/* `flex: none` et pas le defaut : la vue est une boite FIXE de hauteur
   definie donc une rangee qui peut se comprimer se comprime - mesure sur
   Leagues a 375px la grille donnait 373px a un cadre de 759px de contenu.
   Rien ici ne se comprime : la page defile. */
.view[data-view="markets"] > * { flex: none; }

/* La barre de filtres. Elle vit au-dessus de la couture parce que c'est un
   CONTROLE et que la couture n'en porte jamais. */
.mk-filters {
  display: flex;
  align-items: center;
  gap: .5rem .625rem;
  flex-wrap: wrap;
  padding: .4rem .625rem;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
}

.mk-chips {
  display: flex;
  align-items: center;
  gap: .375rem;
  flex-wrap: wrap;
  min-width: 0;
}

/* La pastille de classe. Elle porte `.neon` comme les neuf touches de la
   navbar : c'est une touche a etat presse et le tableau du RESTYLE_TRACKER
   sec.3.4 l'y autorise nommement. Jamais un controle qui engage. */
.mk-chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  min-height: 1.75rem;
  padding: .2rem .7rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--glass-row);
  color: var(--text-muted);
  font: inherit;
  font-size: .8125rem;
  font-weight: 600;
  cursor: pointer;
  transition: color .15s ease, background-color .15s ease, border-color .15s ease;
}

.mk-chip:hover {
  color: var(--text);
  border-color: var(--text-faint);
}

.mk-chip[aria-pressed="true"] {
  color: var(--em);
  background: var(--glass-row-hover);
  border-color: var(--accent);
}

.mk-chip-word { white-space: nowrap; }

.mk-chip-n {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .75rem;
  color: var(--text-faint);
}

.mk-chip[aria-pressed="true"] .mk-chip-n { color: var(--accent-text); }

/* La plaque. `overflow: hidden` est ici volontaire et borne : il coupe les
   coins arrondis de la crete fraisee et rien d'autre - la hauteur n'est
   jamais contrainte au-dessus de lui (la vue coule) donc le piege de la
   taille minimale automatique tombee a zero ne s'ouvre pas ici. */
.mk-plate {
  position: relative;
  min-width: 0;
  background: var(--bg-raised);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-panel);
  overflow: hidden;
  box-shadow: 0 4px 12px var(--bg-sunken);
}

/* La crete fraisee du haut de la plaque : le meme trait que `.deck-ticks`
   sur toute la largeur plutot que sur 7.5rem. */
.mk-ticks {
  display: block;
  height: 4px;
  background: repeating-linear-gradient(90deg, var(--border) 0 1px, transparent 1px 10%);
}

.mk-scroll { min-width: 0; }

.mk-foot {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: .625rem;
}

.mk-plaques,
.mk-note { gap: .5rem; }

.mk-copy {
  margin: 0;
  font-size: .875rem;
  line-height: 1.6;
  color: var(--text-soft);
}

/* Le pied se met sur deux colonnes quand il y a la place : les credits a
   gauche - la largeur que la planche leur donne - et la note a droite. */
@media (min-width: 60rem) {
  .mk-foot { grid-template-columns: minmax(0, 34fr) minmax(0, 66fr); }
}

@media (prefers-reduced-motion: reduce) {
  .mk-chip { transition: none; }
}

/* La cellule qui porte une RAISON plutot qu'un chiffre. Trouvee au
   navigateur et par rien d'autre : la phrase du moteur sur les fixes de la
   BCE mesurait 896px de large a elle seule - une colonne sur neuf - et
   poussait les deux dernieres hors d'un ecran de 1440, dont celle ou vit la
   mention de gel. La phrase reste ENTIERE : elle passe a la ligne, elle
   n'est ni coupee ni raccourcie ni deplacee dans un `title`. */
.mk-plate table.grid td.reason {
  white-space: normal;
  /* merge #27 (2026-08-23) : le plancher de VERTES-2 (K-V3-P2-2), mesure
     au navigateur a 1280 sur les onze lignes hebdomadaires AVANT le restyle
     - 7rem tient, 8rem coupe la colonne du badge. Les deux chantiers
     avaient corrige le meme defaut chacun de leur cote
     (`#watchlist-body td.reason` chez VERTES-2) ; une seule regle, pour
     qu'aucune des deux ne "defasse" l'autre a la cascade. A re-mesurer
     au navigateur avec le restyle (Iris) avant tout changement de valeur. */
  min-width: 7rem;
  max-width: 16rem;
  font-size: .8125rem;
  line-height: 1.4;
  text-align: right;
  color: var(--text-faint);
}

/* La couronne se replie sous 45rem, et seulement sur CET ecran : mesure a
   375px, le titre et le sous-titre se partageaient la ligne et le
   sous-titre tombait sur trois lignes a cote d'un mot. `.deck-crown` est le
   chassis partage de Leagues et de Sessions - une regle posee sur lui
   toucherait deux vues hors du perimetre de cette unite. */
@media (max-width: 45rem) {
  .view[data-view="markets"] .deck-crown {
    flex-wrap: wrap;
    align-items: flex-start;
  }
}

/* ===== Stats - le pied a coulisse (RESTYLE U4) ===========================
   Metaphore de la planche elle-meme (Stats.dc.html) : un instrument de
   MESURE. La tete gravee dit sur quoi la machoire s'est refermee (la
   couture) la machoire porte les dix relevés la trace porte la courbe la
   graduation porte l'histogramme et la plaque de tolerance porte les phrases
   du moteur. Rien ici ne se presse : cet ecran n'a aucun controle.

   La composition de bureau n'est PAS redecidee. Celle qui existait etait
   MESUREE - la version naive rendait la vue 168px PLUS HAUTE que pas de
   colonnes du tout - et son raisonnement tient toujours : le relevé et
   l'histogramme grandissent quand on les retrecit donc ils gardent la
   pleine largeur ; la courbe est le seul bloc dont la hauteur ne depend pas
   de sa largeur donc elle prend la colonne etroite ; la table des marches
   garde la large parce qu'elle mesure 755px de largeur naturelle. Ce qui
   change est le MOBILIER autour de ces blocs pas l'endroit ou ils tombent.

   Les six blocs vivaient dans UN panneau et les colonnes allaient donc a
   l'interieur ; ils sont maintenant les enfants directs de la vue - donc le
   placement a la main les vise directement et aucun element neuf ne porte
   les colonnes. */
.view[data-view="stats"] {
  display: flex;
  flex-direction: column;
  gap: .625rem;
}

/* `flex: none` et pas le defaut : la vue est une boite de hauteur definie
   donc une rangee qui peut se comprimer se comprime. Rien ici ne se
   comprime : la page defile. */
.view[data-view="stats"] > * { flex: none; }

/* Les faces de cette planche ne CLIPPENT pas. `.deck-face` recoit
   `overflow: hidden` au-dessus de 60rem depuis l'unite Sessions - ou les
   plaques sont des boites vides - et cela ramene la taille minimale
   automatique d'un enfant de grille a ZERO : mesure au navigateur a 1440px
   les cinq faces tombaient a 139px de haut pour 274 336 et 267px de contenu
   la vue ne defilait pas puisque de son point de vue tout tenait et la
   moitie des dix relevés etait simplement absente. C'est le meme
   effondrement que celui paye sur la plaque de Leagues. La regle est scopee
   a cette vue : une classe partagee ne se recrit jamais globalement. */
.view[data-view="stats"] .deck-face {
  min-height: auto;
  overflow: visible;
}

.st-head {
  display: flex;
  flex-direction: column;
  gap: .15rem;
}

.st-hint {
  margin: 0;
  font-size: .875rem;
  line-height: 1.5;
  color: var(--text-muted);
}

/* La cellule de couture qui porte les exclusions du R est la seule de tout
   le deck a recevoir une PHRASE et non un chiffre. `.deck-seam-val` est en
   `nowrap` par defaut - c'est ce qu'il faut pour un nombre - et une phrase
   entiere y aurait pousse les cellules suivantes hors du cadre sans que
   rien ne deborde puisque la couture se renvoie a la ligne. C'est le defaut
   exact que l'unite Markets a mesure au navigateur sur une cellule de
   tableau. La phrase passe donc a la ligne ici et nulle part ailleurs. */
.st-seam-wide {
  flex: 1 1 20rem;
  min-width: 0;
}

.st-seam-wide .deck-seam-val { white-space: normal; }

/* La machoire. Sous la largeur de bureau les dix relevés coulent en une
   grille auto-remplie et se separent par le vide ; au-dessus ils reprennent
   les cinq colonnes de la planche et leurs traits graves - le premier de
   chaque rangee n'en porte pas. */
.view[data-view="stats"] .readout {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
  gap: .75rem 1rem;
  align-items: stretch;
}

/* Le libelle en haut de la rangee et la valeur en bas. Sans cela une case
   dont la valeur tient sur trois lignes - le moteur ecrit une PHRASE la ou
   il n'a pas de chiffre - remonte son propre libelle et la rangee devient
   illisible en escalier. */
.view[data-view="stats"] .readout .figure {
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: .15rem;
}

/* Le plateau de la trace : un creux dans la plaque. `flex: none` parce que
   `overflow: hidden` ramene la taille minimale automatique d'un enfant de
   flex a ZERO - la boite se laisserait alors ecraser par ses freres et la
   courbe disparaitrait sans le moindre ascenseur. La regle est portee par la
   vue et pas par la classe seule : les quatre regles `> *` du deck declarent
   la MEME chose avec la meme specificite, et une garde de cascade qui les
   voit comme des rivales ne peut plus rien dire d'utile. */
.view[data-view="stats"] .st-plot {
  flex: none;
  border-radius: var(--radius);
  background: var(--bg-sunken);
  overflow: hidden;
}

/* Les six en-tetes de colonne se renvoient a la ligne. `table.grid th` est
   en `nowrap` et les six titres espagnols - la plus longue des trois langues
   - reclamaient a eux seuls 595px : mesure a 1024px pour un cadre de 451, la
   table debordait avant meme d'avoir une ligne. Un titre sur deux lignes se
   lit ; une colonne hors du cadre ne se lit pas.

   Corrections apres la revue Karim (point N-2) : ce commentaire etait le
   SECOND de deux poses d'affilee, si bien que celui de `td.muted` surplombait
   cette regle-ci et que le lecteur attribuait a l'un les mesures de l'autre.
   Chaque commentaire est revenu sur SA regle. Et le nombre disait 592 ici
   pendant que le tracker, la CHECKLIST et le test qui l'epingle disaient
   tous les trois 595 : c'est 595 qui est retenu - une mesure ecrite quatre
   fois et lue differemment une fois est une mesure fausse une fois. */
.view[data-view="stats"] table.grid th { white-space: normal; }

/* La cellule ou le moteur REFUSE un taux et dit pourquoi. `table.grid td`
   est en `nowrap` - le defaut d'une cellule - et cette phrase-la mesurait
   454px a elle seule : mesure au navigateur a 1440px la table reclamait
   780px pour un cadre de 659 et la colonne Esperance sortait du cadre, la
   phrase coupee au milieu d'un mot. Rien ne debordait au sens du document :
   c'est `.table-scroll` qui absorbait tout, en silence. Exactement le defaut
   que l'unite Markets a mesure sur sa propre table. La prose passe donc a la
   ligne sous un plafond - jamais coupee, jamais abregee, jamais releguee
   dans un `title`. */
.view[data-view="stats"] table.grid td.muted {
  white-space: normal;
  max-width: 22rem;
  font-family: var(--font-ui);
  font-size: .8125rem;
}

/* La graduation sous les barres : le meme trait grave que la crete de la
   plaque des marches, aligne sur la COLONNE DES BARRES de l'histogramme.

   Corrections apres la revue Karim (point N-1) : les deux marges se
   DERIVENT de `.histogram` (`grid-template-columns: 8.5rem 1fr 3rem`,
   `gap: .25rem .75rem`) et ne sont plus deux nombres poses a la main. A
   gauche, la colonne des libelles plus le gap : 8.5 + .75 = 9.25rem - c'etait
   juste. A droite, la colonne des comptes plus le MEME gap : 3 + .75 =
   3.75rem - et c'est la que le trait depassait d'un gap la colonne qu'il
   pretend graduer. `tests/test_paper_only_guard.py` derive desormais les deux
   de la regle `.histogram` plutot que de les relire ici.

   L'ecart entre deux crans est un QUART de la piste, et c'est une grandeur
   reelle : une barre fait `count / most` de la piste, donc un cran a 50 % se
   lit « la moitie du seau le plus rempli ». L'ecart precedent (12.5 %,
   huit crans) ne comptait rien du tout - ni les seaux, ni une fraction
   lisible - et une graduation qui ne gradue rien est un ornement qui se fait
   passer pour une mesure. */
.st-ticks {
  display: block;
  height: 4px;
  margin: .625rem 3.75rem 0 9.25rem;
  background: repeating-linear-gradient(90deg, var(--border) 0 1px, transparent 1px 25%);
}

/* L'etat vide en pointilles : la grammaire du deck - la chose existe elle
   n'est simplement pas servie. */
.st-empty {
  margin: 0;
  padding: 1rem 1.25rem;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-panel);
  background: var(--glass-row);
}

@media (min-width: 1024px) {
  /* Ce qui va ou est decide par ce que MESURE chaque bloc et pas par le
     dessin. La table par marche porte six colonnes de chiffres et ses
     en-tetes espagnols - la plus longue des trois langues - reclament 595px
     a eux seuls : mesure a 1024px, la moitie d'un ecran ne les tient pas et
     la table defilait a l'interieur de sa propre boite, en silence, avec la
     phrase par laquelle le moteur REFUSE un taux a moitie hors du cadre.
     Elle prend donc la pleine largeur a toute largeur de bureau.

     Restent apparies les deux blocs dont la hauteur ne depend pas de leur
     largeur : la trace et la graduation. Le relevé garde la pleine largeur
     parce qu'il grandit quand on le retrecit - le raisonnement MESURE de la
     composition d'avant cette unite, repris tel quel et non redecide. */
  .view[data-view="stats"] {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-content: start;
  }

  .view[data-view="stats"] > .deck-crown { grid-area: 1 / 1 / 2 / -1; }
  .view[data-view="stats"] > .deck-seam { grid-area: 2 / 1 / 3 / -1; }
  .view[data-view="stats"] > .st-jaw { grid-area: 3 / 1 / 4 / -1; }
  /* Corrections apres la revue Karim (point N-3) : la table etait rangee 4
     et la trace rangee 5, alors que le balisage - donc l'ordre du telephone,
     donc l'ordre de la cascade `deck-d3`..`deck-d6` - donne la trace AVANT
     la table. La cascade de reveil remontait l'ecran a partir de 1024px. Les
     deux rangees sont echangees : l'ordre visuel du bureau est desormais
     celui du balisage a toute largeur, et la table garde la pleine largeur
     que sa mesure exige. */
  .view[data-view="stats"] > .st-trace { grid-area: 4 / 1 / 5 / 2; }
  .view[data-view="stats"] > .st-gauge { grid-area: 4 / 2 / 5 / 3; }
  .view[data-view="stats"] > .st-market { grid-area: 5 / 1 / 6 / -1; }
  .view[data-view="stats"] > .st-tolerance { grid-area: 6 / 1 / 7 / -1; }
  .view[data-view="stats"] > .st-empty { grid-area: 7 / 1 / 8 / -1; }

  .view[data-view="stats"] .readout {
    grid-template-columns: repeat(5, minmax(0, 1fr));
    gap: .75rem 0;
  }

  .view[data-view="stats"] .readout .figure {
    padding: 0 .875rem;
    border-left: 1px solid var(--border);
  }

  .view[data-view="stats"] .readout .figure:nth-child(5n + 1) {
    padding-left: 0;
    border-left: 0;
  }
}

/* ===== Account - le cartonnier (RESTYLE U5) ==============================
   Metaphore de la planche elle-meme (Account.dc.html) : le meuble a TIROIRS
   du laboratoire, celui ou vivent les papiers de ce compte. Le vocabulaire
   est tenu de bout en bout : un TIROIR par section, une PLAQUE gravee qui le
   nomme, une POIGNEE qu'on effleure, et le corps qui coulisse hors du chemin
   quand on ne le lit pas.

   La plaque EST la commande : un `<h2>` qui garde le rang et un `<button
   aria-expanded>` dedans qui garde le geste - le motif du depliant
   accessible, jamais un `<div>` a qui l'on colle un `role`. Les cinq
   tiroirs arrivent OUVERTS : replier est un geste du lecteur, jamais un etat
   d'arrivee, et #data-panel est deja cache tant que le deploiement n'est pas
   heberge (un tiroir replie par-dessus serait un second niveau
   d'invisibilite sur le meme bloc).

   Le mobilier partage est celui du chassis `deck-*` des unites Sessions,
   Markets et Stats (couronne, couture, cascade de reveal, face) ; seul ce
   qui appartient a ce meuble porte le prefixe `ac-`. */
.view[data-view="account"] {
  display: flex;
  flex-direction: column;
  gap: .625rem;
}

/* `flex: none` et pas le defaut : la vue est une boite de hauteur definie
   donc une rangee qui peut se comprimer se comprime. Rien ici ne se
   comprime : la page defile. */
.view[data-view="account"] > * { flex: none; }

/* La cellule des marches negociables recoit une LISTE, pas un chiffre : une
   offre payante rend les trente-quatre ids du registre. `.deck-seam-val` est
   en `nowrap` par defaut - c'est ce qu'il faut pour un nombre - et une liste
   entiere y pousserait les cellules suivantes hors du cadre sans que rien ne
   deborde, puisque la couture se renvoie a la ligne. Meme defaut exact que
   celui mesure au navigateur par l'unite Markets sur une cellule de tableau,
   et meme remede que `.st-seam-wide`. */
.ac-seam-wide {
  flex: 1 1 20rem;
  min-width: 0;
}

/* Un compound de droite PROPRE a cette page, et une specificite qui gagne
   vraiment - les deux, et c'est une mesure prise dans les deux sens.

   La machinerie de cascade du depot compare le compound le plus a DROITE d'un
   selecteur a UN element et ne regarde jamais les ancetres. Ecrite
   `.view[data-view="account"] .deck-seam-val`, cette regle a le meme compound
   de droite et la meme specificite que celle de Stats : la garde de Stats la
   lit comme une rivale qui gagne (1 faux rouge mesure) alors qu'aucune des
   deux ne peut matcher l'element de l'autre. Ecrite `.ac-seam-val` toute
   seule, elle est invisible a la garde de Stats mais c'est MA garde qui lit
   alors la regle de Stats comme une rivale plus specifique (1 faux rouge de
   plus). La forme qui tient les deux bouts : un compound de droite que cette
   page est seule a porter, dans un selecteur assez specifique pour gagner
   pour de vrai. */
.view[data-view="account"] .ac-seam-val { white-space: normal; }

/* Le tiroir : la face partagee SANS son rembourrage - la plaque et le corps
   portent le leur, pour que le trait grave entre les deux aille d'un bord a
   l'autre.

   Et les faces de ce meuble ne CLIPPENT pas et ne se laissent pas ecraser.
   `.deck-face` porte `min-height: 0` en base et `overflow: hidden` au-dessus
   de 60rem depuis l'unite Sessions - ou les plaques sont des boites VIDES -
   et les deux ramenent a ZERO la taille minimale automatique d'un enfant. Un
   tiroir a du contenu : mesure faite sur Stats, cinq faces tombaient a 139px
   de haut pour 274px de contenu, sans le moindre ascenseur. Le selecteur
   suit la meme regle que `.ac-seam-val` ci-dessus, et pour la meme mesure
   prise dans les deux sens (2 faux rouges chaque fois) : un compound de
   droite que cette page est seule a porter, dans un selecteur assez
   specifique pour gagner pour de vrai. */
.view[data-view="account"] .ac-drawer {
  padding: 0;
  gap: 0;
  min-height: auto;
  overflow: visible;
}

.ac-plate {
  display: flex;
  align-items: center;
  gap: .6rem;
  flex-wrap: wrap;
  padding: .35rem var(--pad);
  border-bottom: 1px solid var(--border);
}

.ac-plate-title { margin: 0; }

.ac-plate-spacer { flex: 1 1 1rem; }

/* La poignee. Un dessin, jamais une cible : c'est la PLAQUE qui s'appuie, et
   la poignee suit le survol du tiroir. `aria-hidden` dans le markup pour la
   meme raison. */
.ac-pull {
  flex: none;
  width: 34px;
  height: 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  /* Corrections apres la revue Karim de l'unite Account (C5) : le repos etait
     `--glass-row-hover` et l'arrivee `--glass-track` - deux jetons qui valent
     TOUS LES DEUX rgba(255, 255, 255, 0.08) en theme sombre. Le survol ne
     changeait donc rien du tout pour la moitie des lecteurs, seul le trait
     bougeait : exactement le defaut (d) trouve au navigateur par cette meme
     unite, un decor dont la valeur d'arrivee est la valeur de destination.
     Le repos descend au jeton de repos, et les deux valeurs different
     desormais dans les DEUX themes (0.04 -> 0.08 en sombre, 0.05 -> 0.1 en
     clair). Une garde le tient en RESOLVANT les jetons, jamais en epinglant
     un hex. */
  background: var(--glass-row);
}

.ac-drawer:hover .ac-pull {
  border-color: var(--border-strong);
  background: var(--glass-row-hover);
}

/* Le lettrage grave : exactement celui que `.panel-head h2` portait avant
   cette unite, pose sur le bouton qui ouvre et ferme. Il porte `.neon`, ce
   que le tableau ferme du RESTYLE_TRACKER sec.3.4 autorise nommement pour
   une poignee de meuble - un etat presse, jamais un controle qui engage. */
.ac-handle {
  min-height: 2.75rem;
  padding: .35rem .6rem;
  border: 0;
  border-radius: var(--radius);
  background: none;
  font: inherit;
  font-size: .8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--text-muted);
  text-align: left;
  cursor: pointer;
  transition: color .15s ease;
}

.ac-handle:hover { color: var(--text); }

.ac-handle[aria-expanded="true"] { color: var(--text); }

/* Le halo d'ETAT s'eteint sur cet ecran - nommement et seulement au repos.
   Deux mesures au navigateur le demandent. Les cinq plaques arrivent
   OUVERTES par decision, donc "deplie" EST l'etat de repos de cette page :
   cinq halos allumes a l'arrivee ne distinguent rien et sont tres exactement
   le halo au repos que la borne 1 du neon refuse. Et il coutait la REPONSE
   au geste : `.neon[aria-expanded="true"]::before` et `.neon:hover::before`
   ont la meme specificite et l'etat vient plus bas dans la feuille, donc
   survoler une plaque ouverte ne changeait rien du tout - mesure, le meme
   `box-shadow` de 10px avant et pendant le survol. Le `:not(:hover)` rend la
   main au survol et au clic, qui gardent le leur. */
.view[data-view="account"] .ac-handle[aria-expanded="true"]:not(:hover):not(:active)::before {
  box-shadow: none;
}

.ac-body {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  padding: .75rem var(--pad) .875rem;
}

/* Un bouton pose directement dans le corps garde la largeur de ses mots. Le
   corps est une colonne flex - ce qu'il faut pour que les paragraphes prennent
   toute la largeur - et un bouton s'y etirait de bord a bord : mesure au
   navigateur, "Restart the introduction" faisait 375px de large pour trois
   mots. Les boutons ranges dans une ligne (`.brief-actions`, `.field
   actions`) ne sont pas concernes : ils ne sont pas enfants directs. */
.ac-body > button { align-self: flex-start; }

@media (prefers-reduced-motion: reduce) {
  .ac-handle { transition: none; }
}

@media (min-width: 1024px) {
  /* Le placement que l'unite E10 avait MESURE, repris et non redecide : le
     tiroir des offres est le grand - il porte cinq fiches - donc il prend une
     colonne a lui et les trois courts s'empilent a cote. Ce qui change est
     que les cinq tiroirs sont places A LA MAIN : avant cette unite seuls
     trois l'etaient et les deux derniers tombaient au curseur de la grille,
     qui ne recule jamais. */
  .view[data-view="account"] {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-content: start;
  }

  .view[data-view="account"] > .deck-crown { grid-area: 1 / 1 / 2 / -1; }
  .view[data-view="account"] > .deck-seam { grid-area: 2 / 1 / 3 / -1; }
  .view[data-view="account"] > .ac-offers { grid-area: 3 / 1 / 4 / -1; }
  .view[data-view="account"] > .ac-account { grid-area: 4 / 1 / 5 / 2; }
  .view[data-view="account"] > .ac-data { grid-area: 4 / 2 / 5 / 3; }
  .view[data-view="account"] > .ac-sessions { grid-area: 5 / 1 / 6 / 2; }
  .view[data-view="account"] > .ac-help { grid-area: 5 / 2 / 6 / 3; }

  /* Les fiches d'offre en trois colonnes, comme la planche les dessine. Ce
     n'est pas de la decoration : mesure au navigateur a 1440px, la liste en
     UNE colonne rendait le tiroir des offres haut de 1086px pour 436px au
     tiroir d'a cote, et l'ecart tombait en 415px de vide sous le compte -
     une rangee etiree par un element qui l'enjambe. Les fiches a plat
     rendent le tiroir large et court, et la composition n'a plus de trou. */
  .view[data-view="account"] .offers {
    grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
    align-items: start;
  }
}

/* fidelite.C4 - the Positions detail sheet (prototype console.html, "shared
   detail sheet"): a generic sheet whose first consumer is the Positions
   fiche. It floats INSIDE the app article - never over the whole shell - so
   openConsoleApp/showView hide it with everything else, and its stacking
   stays local to the one app that owns it. Every colour below is an existing
   token (text on --bg-raised is already contrast-measured at the top of this
   file); no animation, so there is nothing for reduced-motion to pair. */
#console-app-positions { position: relative; }

.console-detail-scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim);
  z-index: 5;
}

.console-detail-sheet {
  position: absolute;
  left: .625rem;
  right: .625rem;
  bottom: .75rem;
  z-index: 6;
  max-height: calc(100% - 1.5rem);
  overflow: auto;
  overscroll-behavior: contain;
  background: var(--bg-raised);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-panel);
  box-shadow: 0 14px 40px var(--scrim);
  padding: .75rem .875rem;
}

.console-detail-heading {
  margin: 0 0 .5rem;
  font-size: .9375rem;
}

.console-detail-side {
  text-transform: uppercase;
  font-size: .75rem;
  letter-spacing: .08em;
  color: var(--text-muted);
  margin-right: .25rem;
}

/* Tighter than the account panel's own .readout: the sheet is a card, not a
   full-width panel, and 2rem gaps push its 13 figures onto too many rows at
   375px. Same two-part guard family as every scoped override in this file. */
#console-detail-kv.readout { gap: .75rem 1.25rem; }
#console-detail-kv .figure { min-width: 6rem; }

.console-detail-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin-top: .75rem;
}

/* fidelite.C4 - the pager under the Positions table (prototype pagerHtml).
   Hidden whole while one page holds everything; each arrow hides - never
   greys - at its own bound. Tokens only, no animation. */
.console-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .5rem;
  margin-top: .375rem;
}

.console-pager-count {
  font-size: .8125rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

/* The label under the symbol, and the freeze badge beside it: the table's
   own .market-name-sub rule is scoped `table.grid` and does nothing in
   this heading (measured live at the browser: the label rendered inline,
   at heading size, glued to the symbol). Same look as the table's, scoped
   to this heading. */
.console-detail-heading .market-name-sub {
  display: block;
  white-space: normal;
  font-size: .8125rem;
  font-weight: 400;
  color: var(--text-muted);
}

.console-detail-heading .note {
  font-size: .75rem;
  font-weight: 400;
}

/* ===== ACHAT U3 : la popup d'achat ===== */
.upgrade-sheet { max-width: 34rem; }
.upgrade-benefits, .upgrade-roadmap { margin: 0.4rem 0 0.8rem; padding-left: 1.1rem; }
.upgrade-benefits li, .upgrade-roadmap li { margin: 0.35rem 0; }
.upgrade-sheet h4 { margin: 0.6rem 0 0.2rem; }

/* ===== VITRINE V3 : les liens légaux de la porte ===== */
.vitrine-legal-links a { margin-right: 0.8rem; }
