/* ──────────────────────────────────────────────────────────────────────
 * controlsfreak.dev — shared design system
 *
 * Every page links this file (<link rel="stylesheet" href="/styles.css">).
 * It holds the whole vocabulary: the :root custom properties (the theme —
 * change colors here, not in pages) and the component classes (.tool-card,
 * .tabs, .form-row, .result-panel, .site-nav, …). Pages should be built
 * from these, not freshly styled.
 *
 * Page-specific CSS still lives inline on the page that needs it
 * (e.g. contact.html's honeypot/intro rules) — only genuinely shared rules
 * belong here. The 11ty build templates the HTML chrome and passthrough-
 * copies this file unchanged; the browser loads it directly, no bundler or
 * preprocessor in the pipeline.
 * ────────────────────────────────────────────────────────────────────── */

/* ── THEME TOKENS — dual, DARK-DEFAULT, two registers ──────────────────
 * `:root` is the DARK theme (the default); `:root[data-theme="light"]`
 * below overrides it back to ≈ the pre-redesign look. A before-paint
 * bootstrap in _includes/head.njk sets data-theme from cf_theme, else
 * prefers-color-scheme; the nav toggle + /scripts/theme.js flip it at
 * runtime. See CLAUDE.md "Design system" for the two-register model.
 *
 * SOFTWARE REGISTER = the default chrome (carries the whole site):
 * "Niagara AX with a dark mode" — cool blue-slate, AX-SHARP (square
 * corners via --rail:0, hard 1px seams, flat fills, no floating
 * shadows). Green = brand/action; blue = data/selection.
 *
 * EQUIPMENT REGISTER = the --dev-* / --lcd-* tokens at the bottom of
 * this block: a warm device face + positive-mode dot-matrix character
 * LCD, used ONLY where a page depicts real hardware (VFD/DMM sims,
 * device widgets, the one readout in a tool that shows a field value).
 * These stay CONSTANT across themes — a device is a device either way,
 * so the light override deliberately does NOT touch them (nor --rail).
 * ──────────────────────────────────────────────────────────────────── */
/* ── SELF-HOSTED FONTS ──
   Latin woff2 subsets, served from our origin (audit-2026-06 #33,
   owner decision 2026-06-10): the render-blocking Google Fonts CSS
   made FCP hostage to fonts.googleapis.com (measured 624ms → 3.4s
   with a slow fonts origin), and it was the only non-essential
   third-party origin on the site. Both families are OFL-licensed
   (see assets/fonts/LICENSE.txt). Glyph coverage is identical to what
   Google served this site before: no greek/math subset existed, so
   Δ / ≈ / → always came from system fallbacks and still do. The
   unicode-range is Google's latin subset range, kept so fallback
   behavior matches exactly. Overpass ships as one variable file
   covering weights 300–700; IBM Plex Mono as five static instances
   (the 700 landed 2026-07-26 — until then the site's font-weight:700
   mono rules silently resolved to the 600 file; see #216).
   FONT FILES ARE IMMUTABLE BY NAME — if a font is ever re-subset,
   rename the file (the Worker serves /assets/fonts/* with
   max-age=31536000, immutable). */
@font-face {
    font-family: 'Overpass';
    font-style: normal;
    font-weight: 300 700;
    font-display: swap;
    src: url(/assets/fonts/overpass-latin-var.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url(/assets/fonts/ibm-plex-mono-latin-400.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'IBM Plex Mono';
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url(/assets/fonts/ibm-plex-mono-latin-400-italic.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url(/assets/fonts/ibm-plex-mono-latin-500.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url(/assets/fonts/ibm-plex-mono-latin-600.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url(/assets/fonts/ibm-plex-mono-latin-700.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
    color-scheme: dark;

    /* software register — cool blue-slate (the default chrome) */
    --bg:           #13161b;   /* cool slate, near-black — the page floor */
    --bg-2:         #181c22;   /* gutter / recessed chassis */
    --surface:      #1e232b;   /* content panes / software panel face */
    --surface-2:    #283038;   /* panel headers, raised header strips, insets */
    --surface-3:    #181c22;   /* recessed reference / context panels (= --bg-2) */
    --well:         #11141a;   /* deepest recessed field (tree / wiresheet / gauge wells) */
    --border:       #3c4757;   /* cool gray-blue seam — crisp 1px edges */
    --border-faint: #2b333d;   /* lighter hairline for inner row dividers */
    --bevel-hi:     rgba(170,195,230,0.06);  /* 1px inset highlight on raised chrome */
    --bevel-lo:     rgba(0,0,0,0.45);        /* 1px shade under it */
    --accent:       #6cb23a;   /* the green — brand / action (refined for dark, not neon) */
    --accent-bright:#86cf4d;
    --accent-ink:   #86cf4d;   /* accent-colored TEXT that must read at small sizes on the
                                  --accent-dim wash (.tool-tag / .ok-pill) — --accent itself
                                  sits at ~4.1:1 there, an AA small-text fail. The fix is
                                  asymmetric per theme (codebase-issues #166), so it lives in
                                  a purpose token: dark rides --accent-bright's value; light
                                  needs its own deeper green. Dots/borders stay --accent
                                  (3:1 UI floor passes). */
    --accent-dim:   rgba(108,178,58,0.14);
    --accent-glow:  rgba(108,178,58,0.24);
    --accent-soft:  rgba(108,178,58,0.07);  /* one notch under --accent-dim — status-pill / inset backdrops */
    --text:         #c5cdd8;   /* cool light-gray body copy */
    --text-bright:  #eef2f8;   /* headings */
    --text-dim:     #919cab;   /* labels, secondary text — bumped one step 2026-06-10: #828d9b sat at 3.97:1 on --surface-2 where TH cells and nav-card pills carry real content (codebase-issues #81) */
    --blue:         #4aa3dd;   /* data readouts / selection (Niagara steel-azure); also "supply water" in diagrams */
    --blue-bright:  #74c2f0;
    --blue-ink:     #4aa3dd;   /* blue TEXT at small sizes, incl. over the --accent-dim /
                                  selection washes. Dark --blue already clears AA on every
                                  surface it lands on (measured), so this rides its value
                                  unchanged; LIGHT needs a deeper step. Same purpose-token
                                  shape as --accent-ink (codebase-issues #166) and
                                  --red-text. Strokes / fills stay --blue (3:1 UI floor). */
    --blue-cool:    #6f97aa;   /* muted companion to --blue — "return water" in diagrams (paired with a dashed line so it reads without colour too) */
    --blue-dim:     rgba(74,163,221,0.16);
    --blue-glow:    rgba(74,163,221,0.24);
    --red:          #e85d4f;   /* fault / alarm */
    --red-dim:      rgba(232,93,79,0.14);   /* faint red wash — quiz wrong-answer fill (parallels --accent-dim) */
    --red-text:     #f07a6d;   /* alarm TEXT on surfaces — --red reads 3.89:1 on
                                  --surface-2 (AA fail at small sizes); this
                                  lighter step computes 4.91:1. Borders/strokes
                                  keep --red (3:1 UI threshold passes). */
    --heat:         #e8884a;   /* warm orange — "heating" on the psychrometric chart's process lines; companion to --blue for cooling */
    --heat-fill:    #e8884a;   /* = --heat — NEVER FOR TEXT. First member of the -fill
                                  family; the family rule:

                                    A -fill token is OBJECT PAINT — the paint a drawn
                                    thing is made of, the mirror of -ink (the paint TYPE
                                    is made of). It answers WCAG 1.4.11's 3:1 NON-TEXT
                                    floor, not 1.4.3's 4.5:1 small-text floor, so it may
                                    run brighter than its base token. Put one in a
                                    color: declaration and you have shipped 3.1:1 body
                                    text. Legal in fill / stroke / border-color /
                                    background; nowhere else. Enforced by
                                    tests/fill-token-misuse.spec.js.

                                    A -fill twin exists ONLY where the light-theme text
                                    floor pushed a hue out of its own name — dark yellow
                                    is olive, dark desaturated orange is brown, but dark
                                    green is still green and dark blue is still blue.
                                    --accent (-20.5 L*) and --blue (-22.4 L*) are
                                    darkened just as hard in light and stay themselves,
                                    so they get no twin. Do not add one for symmetry: an
                                    alias token is a drift generator (the -ink family is
                                    asymmetric for the same reason — there is no
                                    --heat-ink).

                                  Component identity on the DDC equipment graphics —
                                  a heating serpentine, a valve body. Dark rides --heat
                                  unchanged: 6.95:1 on --bg, 5.13:1 on --surface-2, i.e.
                                  1.7-2.3x the non-text floor, and already the warm
                                  orange the convention names, so there is nothing to
                                  fix. Light moves (below). One theme moves and the other
                                  rides is exactly --blue-ink's shape. codebase-issues
                                  #230. */
    --teal:         #46b3ac;   /* teal — data/comm traces on the background schematic (BACnet, IP); also outside-air ducts in the education air diagrams */
    --teal-dim:     rgba(70,179,172,0.14);
    --teal-glow:    rgba(70,179,172,0.24);
    --teal-ink:     #5bbfb9;   /* per-section --accent-ink twin (--amber/--plum below too):
                                  the nav-card titlebar pill recolors .ok-pill TEXT via
                                  --section-accent, and the raw hues compute 3.6-4.5:1 on
                                  their -dim washes — an AA small-text fail (codebase-issues
                                  #171, same mechanism as #166). Dots/borders stay on the
                                  raw hue (3:1 UI floor passes). */
    --amber:        #e0a94a;   /* amber — energized control wiring on the background schematic; section accent for /practice/ */
    --amber-dim:    rgba(224,169,74,0.14);
    --amber-glow:   rgba(224,169,74,0.26);
    --amber-ink:    #e0a94a;   /* = --amber — dark amber already clears AA (4.86:1);
                                  light needs its own deeper step below */
    --amber-fill:   #e0a94a;   /* = --amber — NEVER FOR TEXT. Object paint for component
                                  identity (a modulating damper's frame and blades); see
                                  the family rule at --heat-fill. Dark rides --amber
                                  unchanged — 8.58:1 on --bg, 6.33:1 on --surface-2,
                                  2.1-2.9x the 3:1 non-text floor, and already the bright
                                  yellow the convention names. Light moves (below). */
    --plum:         #b783c0;   /* plum — logic-signal lines on the background schematic */
    --plum-dim:     rgba(183,131,192,0.14);
    --plum-glow:    rgba(183,131,192,0.26);
    --plum-ink:     #c8a0cf;

    /* blueprint graticule (body background) — dark gets a faint grid +
       a subtle cool radial wash; light overrides below to the stronger
       1.5px grid with no wash (preserves today's drafting-paper feel). */
    --grid-line-c:  var(--border-faint);
    --grid-line-w:  1px;
    --grid-wash:    rgba(74,163,221,0.05);

    /* equipment register — warm device face + dot-matrix LCD. CONSTANT
       across themes (the light override leaves these untouched). */
    --dev-face:     #16140f;   /* warm near-black instrument face */
    --dev-bezel:    #0c0b08;
    --dev-edge:     #2c2a20;   /* warm bezel seam */
    --dev-hi:       rgba(220,210,170,0.08);
    /* Status-LED inks for the device face. Frozen here rather than taken
       from the software-register hues these used to share (--accent /
       --teal / --amber / --red), because those RETUNE in light theme —
       which gave the one register ruled constant across themes a
       per-theme LED colour (codebase-issues #280). The values ARE the
       dark hues, the set the face was designed and measured against, so
       the lit look is the one PR #495 characterised and the change is a
       no-op in dark theme. Measured against --dev-face: run 7.07:1,
       comm 7.28:1, warn 8.71:1, alarm 5.35:1 — all clear of WCAG
       1.4.11's 3:1 non-text floor (an LED is drawn geometry, not type).
       Like the --dev-* / --lcd-* tokens above, these are defined ONCE:
       no light override, and none in the @media print block either — a
       device prints as a device. */
    --led-run:      #6cb23a;
    --led-comm:     #46b3ac;
    --led-warn:     #e0a94a;
    --led-alarm:    #e85d4f;
    --lcd-bg:       #adba4e;   /* lit olive-green backlight — BMS character LCD */
    --lcd-bg-2:     #9aa840;   /* backlight gradient / vignette */
    --lcd-ink:      #1b2410;   /* dark pixel text */
    --lcd-grid:     rgba(18,26,6,0.18);  /* unlit pixel-mesh gaps */

    --mono:         'IBM Plex Mono', monospace;
    --sans:         'Overpass', sans-serif;
    --rail:         0px;       /* AX-sharp: square corners site-wide (both themes) */
}

/* LIGHT THEME — ≈ the pre-redesign look. Flips the software register
   back to warm white panels on light gray-green. The equipment
   --dev-* / --lcd-* tokens and --rail are deliberately NOT overridden. */
:root[data-theme="light"] {
    color-scheme: light;
    --bg:           #eef1ec;
    --bg-2:         #e8ece4;
    --surface:      #ffffff;
    --surface-2:    #f2f6f1;
    --surface-3:    #e8ece4;
    --well:         #e8ece4;
    --border:       #ccd7c8;
    --border-faint: #e3e8df;
    --bevel-hi:     rgba(255,255,255,0.8);
    --bevel-lo:     rgba(0,0,0,0.05);
    --accent:       #3a7a14;
    --accent-bright:#428a16;
    --accent-ink:   #356e12;   /* NOT --accent-bright here — brighter WORSENS light-theme
                                  contrast (3.48:1); small accent text needs a deeper green */
    --accent-dim:   rgba(58,122,20,0.10);
    --accent-glow:  rgba(58,122,20,0.22);
    --accent-soft:  rgba(58,122,20,0.06);
    --text:         #38423a;
    --text-bright:  #1d251f;
    --text-dim:     #636b63;   /* darkened one step 2026-07-20: #666e66 computed 4.40:1 on the
                                  #e8ece4 surfaces (--surface-3 / --bg-2 / --well share that hex),
                                  an AA small-text fail carrying ~600 elements across 47 selector
                                  shapes — .ref-note worked-example prose and .ps-section-label
                                  headings on 30 pages each, plus widget labels and dense-table
                                  cells. This step computes 4.60:1 there and lifts the other
                                  light surfaces to 4.83-5.51:1. Deliberately the MINIMUM nudge
                                  that clears the floor: dim captions are an intentional scan
                                  hierarchy (codebase-issues #168), so the gap to --text (8.74:1
                                  on the same surface) is preserved. Dark --text-dim already
                                  passes everywhere (4.81-6.63:1) and is untouched.
                                  SCOPE: the sweep behind those numbers compares computed color
                                  against the resolved background, so it only clears elements
                                  whose contrast is determined by color-vs-background alone. It
                                  is blind to a separate `opacity` on the text element — see the
                                  .bit-idx note. Retuning this token does NOT clear those. */
    --blue:         #11679f;
    --blue-bright:  #146ea9;   /* darkened one step 2026-07-20: #1573b2 computed 4.47:1 on --bg,
                                  an AA small-text fail on the home hero's setpoint readout. This
                                  step reads 4.81:1 there / 5.48:1 on --surface. Kept as its own
                                  token rather than folded into --blue-ink because "bright" is the
                                  EMPHASIS step, and in dark (#74c2f0, 9.25:1) it is what separates
                                  the operable readout from the quiet --blue-ink demo chrome. */
    --blue-ink:     #0f5c8f;   /* deeper than --blue: --blue reads 4.42-4.48:1 as small text
                                  on the selection washes (#d3dfdd hero tree row, #d7e1cf
                                  psy selected row). This step clears 5.28:1 at the worst
                                  of those. It is the QUIET blue-text step; --blue-bright is
                                  the emphasis one and carries its own AA note above. */
    --blue-cool:    #4d7286;
    --blue-dim:     rgba(17,103,159,0.10);
    --blue-glow:    rgba(17,103,159,0.22);
    --red:          #c4382f;
    --red-dim:      rgba(196,56,47,0.10);
    --red-text:     #bb352c;   /* darkened 2026-07-20: #c4382f (= light --red) computed
                                  4.44:1 on --surface-3, so the old "light --red already
                                  clears AA on light surfaces" claim was true only of the
                                  brighter surfaces. This step reads 4.80:1 there. */
    --heat:         #9c5a14;
    --heat-fill:    #b85400;   /* NEVER FOR TEXT (family rule at the dark --heat-fill).
                                  Light --heat is dragged to #9c5a14 by the 4.5:1 text
                                  floor, which is a dark orange-BROWN — brown is literally
                                  desaturated orange, so the correction here is CHROMA, not
                                  lightness: C* 52.7 -> 68.0, L* only 44.8 -> 47.7. Holds
                                  the dark hue angle exactly (57deg; base --heat drifts
                                  57 -> 65deg chasing the text floor), which is what a
                                  reader recognises a colour by. Non-text contrast: 4.88:1
                                  on --surface, 4.28:1 on --bg, 4.08:1 on the strictest
                                  light surface #e8ece4 (--bg-2 / --surface-3 / --well) —
                                  all well over the 3:1 floor. Tuned against #e8ece4 and
                                  not merely against the white the graphic mostly sits on,
                                  because --well-painted ducts are a real backdrop in that
                                  drawing; so the token carries no "only safe on white"
                                  caveat. */
    --teal:         #377070;
    --teal-dim:     rgba(55,112,112,0.10);
    --teal-glow:    rgba(55,112,112,0.22);
    --teal-ink:     #346b6b;
    --amber:        #83641f;
    --amber-dim:    rgba(131,100,31,0.10);
    --amber-glow:   rgba(131,100,31,0.22);
    --amber-ink:    #7b5e1d;
    --amber-fill:   #af7b00;   /* NEVER FOR TEXT (family rule at the dark --heat-fill).
                                  Light --amber is dragged to #83641f by the 4.5:1 text
                                  floor, which is an OLIVE — yellow only exists bright, so
                                  the correction here is LIGHTNESS: L* 44.3 -> 55.4 (chroma
                                  follows, 42.2 -> 62.5). Opposite correction to
                                  --heat-fill's, which is why the pair's lightness gap
                                  opens for free: dL* 0.6 -> 7.7, dE76 18.0 -> 26.6 (dark
                                  is 6.8 / 23.1, so light now separates BETTER than dark).
                                  That gap is the greyscale, print and protanope budget —
                                  at dL* 0.6 the light pair really was one colour for a
                                  protanope. Holds the dark hue angle (79deg; base --amber
                                  drifts to 83deg). Non-text contrast: 3.71:1 on --surface,
                                  3.26:1 on --bg, 3.10:1 on the strictest light surface
                                  #e8ece4 — and that last one is an OPERATIVE ratio, not a
                                  safety cushion: the relief damper sits bodily inside a
                                  duct riser painted --well, so a real share of this
                                  token's paint lands on #e8ece4 (measured on the rendered
                                  graphic: ~90 % of the amber pixels are on white, ~8 % on
                                  #e8ece4). The tight one — 0.10 of margin there, deliberate
                                  (the 2026-07-20 --text-dim retune shipped at 4.60:1
                                  against 4.50 on the same surface). Do not brighten
                                  further without re-measuring #e8ece4. */
    --plum:         #8a5e7e;
    --plum-dim:     rgba(138,94,126,0.10);
    --plum-glow:    rgba(138,94,126,0.22);
    --plum-ink:     #7f5774;
    --grid-line-c:  var(--border);
    --grid-line-w:  1.5px;
    --grid-wash:    transparent;
}

/* Print always uses the LIGHT token set, regardless of the on-screen
   theme (audit-2026-06 #54: browsers drop background fills by default
   when printing, so the dark theme's light-on-dark text became
   near-white text on unprinted white paper — body 1.60:1, headings
   1.12:1, an effectively blank page). The theme is attribute-driven,
   so the browser's forced-light print mode can't flip it; this block
   does. Duplicates the light set above — keep the two in sync when
   retuning light tokens. */
@media print {
    :root, :root[data-theme="dark"] {
        color-scheme: light;
        --bg:           #eef1ec;
        --bg-2:         #e8ece4;
        --surface:      #ffffff;
        --surface-2:    #f2f6f1;
        --surface-3:    #e8ece4;
        --well:         #e8ece4;
        --border:       #ccd7c8;
        --border-faint: #e3e8df;
        --bevel-hi:     rgba(255,255,255,0.8);
        --bevel-lo:     rgba(0,0,0,0.05);
        --accent:       #3a7a14;
        --accent-bright:#428a16;
        --accent-ink:   #356e12;
        --accent-dim:   rgba(58,122,20,0.10);
        --accent-glow:  rgba(58,122,20,0.22);
        --accent-soft:  rgba(58,122,20,0.06);
        --text:         #38423a;
        --text-bright:  #1d251f;
        --text-dim:     #636b63;   /* kept in sync with the light block above */
        --blue:         #11679f;
        --blue-bright:  #146ea9;   /* kept in sync with the light block above */
        --blue-ink:     #0f5c8f;
        --blue-cool:    #4d7286;
        --blue-dim:     rgba(17,103,159,0.10);
        --blue-glow:    rgba(17,103,159,0.22);
        --red:          #c4382f;
        --red-dim:      rgba(196,56,47,0.10);
        --red-text:     #bb352c;
        --heat:         #9c5a14;
        --heat-fill:    #b85400;   /* kept in sync with the light block above */
        --teal:         #377070;
        --teal-dim:     rgba(55,112,112,0.10);
        --teal-glow:    rgba(55,112,112,0.22);
        --teal-ink:     #346b6b;
        --amber:        #83641f;
        --amber-dim:    rgba(131,100,31,0.10);
        --amber-glow:   rgba(131,100,31,0.22);
        --amber-ink:    #7b5e1d;
        --amber-fill:   #af7b00;   /* kept in sync with the light block above — and for the
                                      -fill family that sync is load-bearing rather than
                                      tidy: :root still applies under @media print, so a
                                      -fill token missing HERE resolves to the DARK value on
                                      paper. #e0a94a on white is 2.11:1, under the 3:1
                                      non-text floor — dampers would print washed out.
                                      tests/fill-token-misuse.spec.js pins this block
                                      against the light one. */
        --plum:         #8a5e7e;
        --plum-dim:     rgba(138,94,126,0.10);
        --plum-glow:    rgba(138,94,126,0.22);
        --plum-ink:     #7f5774;
        --grid-line-c:  var(--border);
        --grid-line-w:  1.5px;
        --grid-wash:    transparent;
    }
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }

body {
    background-color: var(--bg);
    /* Blueprint graticule behind everything — the grid only reads in the
       gutters around/below content, since nav / hero / cards / footer all
       paint opaque surfaces over it. Token-driven per theme (the --grid-*
       tokens in :root): DARK = a faint 1px grid in --border-faint under a
       subtle cool radial wash; LIGHT = the stronger 1.5px --border grid,
       no wash (the drafting-paper weight tuned on earlier feedback — 1px /
       --border-faint was too quiet, 3-4px crossed into graph-paper
       foreground). Hidden on print via the @media print block below. */
    background-image:
        radial-gradient(130% 80% at 50% -20%, var(--grid-wash), transparent 60%),
        linear-gradient(to right,  var(--grid-line-c) var(--grid-line-w), transparent var(--grid-line-w)),
        linear-gradient(to bottom, var(--grid-line-c) var(--grid-line-w), transparent var(--grid-line-w));
    background-size: auto, 40px 40px, 40px 40px;
    color: var(--text);
    font-family: var(--sans);
    font-size: 18px;
    line-height: 1.6;
    min-height: 100vh;
    /* sticky footer: stack nav / (hero?) / main / footer in a flex column
       and let main grow, so the footer hugs the viewport bottom on short pages
       instead of leaving a big gap underneath. */
    display: flex;
    flex-direction: column;
    /* Stacking context for the gutter schematic SVGs (.schematic-bg),
       which sit at z-index:-1 — above body's bg-image grid but below
       every content surface (nav, hero, main, footer). Without
       isolation:isolate the SVGs would fall behind body's background
       and disappear entirely. */
    position: relative;
    isolation: isolate;
}
main { flex: 1; }

/* Hide the blueprint grid on print — a printable page wants a clean
   white background, not drafting paper. (Same posture browsers take
   for background-image: most won't print it by default anyway, but
   the explicit rule keeps the behavior unambiguous and disables it
   under any future print-CSS overrides too.) */
@media print {
    body { background-image: none; }
}

/* ──────────────────────────────────────────────────────────────────
 * SCHEMATIC BACKGROUND — gutter as-builts
 *
 * Two narrow vertical strips, one in each side gutter, holding
 * inline-SVG schematic motifs (piping, control wiring, function
 * blocks). Each motif draws itself in via stroke-dashoffset as it
 * scrolls into view — see /scripts/schematic-bg.js for the
 * IntersectionObserver that toggles .is-drawn.
 *
 * The strips paint at z-index:-1 inside body's stacking context, so
 * they sit above body's grid background but below every content
 * surface (nav, hero, tool-cards, footer). Width tracks the gutter
 * via clamp() so the strips stay narrow on narrow viewports and
 * never push into the 1120px content column. Below 1240px the
 * gutter gets too cramped to read — strips drop out entirely there.
 *
 * Hidden in print (matches the grid). Reduced-motion: the global
 * block at the bottom of this file collapses transition-duration
 * to 0.01ms, and schematic-bg.js draws every motif synchronously
 * on load instead of observing — so reduced-motion users see the
 * end state with no reveal. */
.schematic-bg {
    position: absolute;
    top: 0;
    bottom: 0;
    z-index: -1;
    pointer-events: none;
    /* Fill (almost) all of the gutter on wide displays so motifs scale
       up via the SVG's width:100% and read across the strip, not just
       the outer half. Capped at 340px so ultra-wide displays don't
       balloon them into competing with content. */
    width: min(340px, calc((100vw - 1180px) / 2));
    overflow: hidden;
}
.schematic-bg--left  { left: 8px; }
.schematic-bg--right { right: 8px; }

.sbg-motif {
    position: absolute;
    left: 0;
    top: var(--sbg-y, 0);
    width: 100%;
    height: auto;
    opacity: 0;
    transition: opacity 700ms ease;
}
/* `fill: none` on stroked paths is set as an inline presentation
   attribute in the partial — left over from when motifs used <use>
   shadow trees; kept inline because it costs nothing.

   Draw-in via a generous fixed dasharray. The largest motif path
   here is ~200 user units (the BACnet chip's perimeter); 600 puts
   us comfortably above that with margin to spare, so dashoffset:600
   parks every path fully invisible and the transition to 0 sweeps
   the whole path on for every motif. Three earlier approaches all
   failed:

     - `getTotalLength()`-derived `--sbg-len` CSS variable — hit
       Chromium quirks on Bezier paths and circles; they refused to
       draw fully even with offset=0.
     - `pathLength="1"` normalization on every stroke — same Chromium
       quirk on Bezier paths and circles.
     - `pathLength="1"` normalization opt-in only on safe straight
       elements (lines and L-only paths), with the bulk-fallback
       still fixed-600 — this DID render those elements correctly
       in the path-length sense, but Chromium does not honor
       pathLength="1" for `stroke-dasharray` computation: a value of
       `1` is treated as 1 actual pixel, not as 1 unit of the
       normalized path. Result: a 104px line ends up with 52
       alternating 1px dashes/gaps and renders as a near-invisible
       speckle. Verified with `getTotalLength()` returning the
       geometric length (104) while `stroke-dasharray` resolves to
       `1px` (commit b8dae2b shipped this and was reverted in the
       next commit).

   A fixed dasharray well above any path length sidesteps all three
   bugs. Trade-off accepted: drawing is no longer proportional to
   path length — short signal wires finish in ~10% of the transition
   and then sit still while long pipe runs continue drawing. The
   ease-out timing softens the disparity, and correctness beats
   uniformity here. See codebase-issues#69 for the full history and
   the revisit trigger. */
.sbg-motif [data-sbg-stroke] {
    stroke-width: 1.4;
    vector-effect: non-scaling-stroke;
    stroke-dasharray: 600;
    stroke-dashoffset: 600;
}
.sbg-motif.is-drawn { opacity: 1; }
.sbg-motif.is-drawn [data-sbg-stroke] {
    stroke-dashoffset: 0;
    transition: stroke-dashoffset 3000ms ease-out;
}

.sbg-label {
    font-family: var(--mono);
    font-size: 9px;
    fill: var(--text-dim);
    letter-spacing: 0.04em;
}

@media (max-width: 1240px) {
    .schematic-bg { display: none; }
}
@media print {
    .schematic-bg { display: none; }
}

/* ── Skip-to-content link — keyboard users tab here first on every
       page and can Enter to jump past the nav. Positioned off-screen
       via negative top so it stays in the DOM (tabbable) but doesn't
       show; slides into view when focused. Same idiom as the well-
       known WebAIM pattern. */
.skip-link {
    position: absolute;
    top: -100px;
    left: 0.5rem;
    z-index: 200;
    padding: 0.6rem 1rem;
    background: var(--accent);
    color: var(--surface);
    font-family: var(--mono);
    font-size: 0.85rem;
    text-decoration: none;
    border-radius: 0;
    transition: top 0.15s;
}
.skip-link:focus { top: 0; outline: 2px solid var(--accent-glow); outline-offset: 2px; }

/* ── SITE NAV (every page carries the same nav markup) ── */
.site-nav {
    position: sticky;
    top: 0;
    z-index: 100;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    padding: 0.9rem 2rem;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.4rem 1.75rem;
}
.site-nav-brand {
    font-family: var(--mono);
    font-size: 1rem;
    font-weight: 600;
    color: var(--text);
    letter-spacing: 0.01em;
    text-decoration: none;
}
.site-nav-brand span {
    color: var(--accent);
    animation: breathe 4s ease-in-out infinite;
}
.site-nav-brand:hover { color: var(--text-bright); }
.site-nav-links { display: flex; flex-wrap: wrap; gap: 1.75rem; }
.site-nav-links a {
    font-family: var(--sans);
    font-size: 0.95rem;
    color: var(--text-dim);
    text-decoration: none;
    padding-bottom: 0.15rem;
    border-bottom: 2px solid transparent;
    transition: color 0.18s, border-color 0.18s;
}
.site-nav-links a:hover { color: var(--text); border-bottom-color: var(--accent); }
.site-nav-links a.active { color: var(--text-bright); border-bottom-color: var(--accent); }
/* "Start here" newcomer affordance (G-006). A distinct accent-colored entry
   point placed early in the bar so a left-to-right newcomer scan finds the
   on-ramp before the jargon-heavy Tools/Simulators — WITHOUT reordering the
   sections (the experienced-first order is a deliberate v3 decision). Styled
   with color (accent = brand/action) + weight, not a box, so it reads as
   distinct yet inherits the desktop-row and mobile-sheet link layout cleanly.
   Specificity (0,2,1) out-ranks the base .site-nav-links a (0,1,1). */
.site-nav-links a.nav-start-here { color: var(--accent); font-weight: 600; }
.site-nav-links a.nav-start-here:hover { color: var(--accent); border-bottom-color: var(--accent); }

/* ── NAV SECTION DROPDOWNS ───────────────────────────────────────────
   Tools / Simulators / Education each pair their top-level link with a
   disclosure toggle that opens a panel of direct links (nav-menu.js).
   The panel links are <a> inside .site-nav-links, so they'd inherit the
   nav-link rules above — the .nav-menu-scoped selectors below outrank
   them on specificity. */
.nav-item--has-menu {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
}
.nav-menu-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Roomier hit area than the old fiddly 0.15rem caret, but the negative
       margin cancels the added box in layout so the link bar keeps its
       compact width (otherwise it wraps below the brand at ~1280px). */
    padding: 0.45rem 0.5rem;
    margin: -0.3rem -0.35rem;
    background: transparent;
    border: none;
    color: var(--text-dim);
    cursor: pointer;
    transition: color 0.18s;
}
.nav-menu-toggle:hover { color: var(--text); }
.nav-caret { width: 11px; height: 7px; transition: transform 0.18s; }
.nav-menu-toggle[aria-expanded="true"] .nav-caret { transform: rotate(180deg); }
.nav-menu {
    position: absolute;
    top: calc(100% + 0.5rem);
    left: 0;
    z-index: 120;
    min-width: 210px;
    display: flex;
    flex-direction: column;
    background: var(--surface);
    border: 1px solid var(--border);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
    /* The Tools dropdown crossed a short viewport's height at 18 entries
       (2026-06): cap and scroll internally so the last items stay
       reachable. The mobile sheet overrides this — it already scrolls. */
    max-height: calc(100dvh - 7rem);
    overflow-y: auto;
    overscroll-behavior: contain;
}
.nav-menu[hidden] { display: none; }
.nav-menu .nav-menu-item {
    font-family: var(--sans);
    font-size: 0.9rem;
    color: var(--text-dim);
    text-decoration: none;
    /* Taller rows — easier pointer targets than the old 0.5rem/0.85rem. */
    padding: 0.6rem 1rem;
    border-bottom: 1px solid var(--border-faint);
    white-space: nowrap;
    transition: color 0.18s, background 0.18s;
}
.nav-menu .nav-menu-item:last-child { border-bottom: none; }
.nav-menu .nav-menu-item:hover { color: var(--text-bright); background: var(--accent-dim); }
/* G-011: a one-line "what's in here" blurb at the top of each dropdown so a
   newcomer can tell from the nav that Simulators/Practice are approachable,
   not advanced — before clicking through to the landing intro. */
.nav-menu-blurb {
    margin: 0;
    padding: 0.55rem 1rem;
    font-family: var(--mono);
    /* 0.72rem, not the 0.78rem running-prose floor (#290): this blurb sits
       inside a fixed-measure dropdown panel, and the floor's extra step
       re-flows the capped 240px text box. Promoted to --text with the rest
       of the pass — a sentence is never both small and dim. */
    font-size: 0.72rem;
    line-height: 1.45;
    letter-spacing: 0.02em;
    color: var(--text);
    white-space: normal;
    /* Cap the text measure so the blurb wraps to a readable column and
       doesn't drive the panel width. The separating rule lives on the
       next element (below), not this box, so it spans the whole panel
       even when an expanded category widens it past 240px (#140). */
    max-width: 240px;
}
/* Rule under the blurb: carried by the following element (the first
   category group, or the first link in a flat section) so it reaches the
   panel's right edge when an expanded level-2 category — or the mobile
   sheet — widens the panel past the blurb's capped text box. Moving it
   off the blurb is what fixes the short rule (codebase-issues #140). */
.nav-menu-blurb + * { border-top: 1px solid var(--border-faint); }
/* Topic-cluster hub link pinned near the top of the Tools + Education
   dropdowns (rendered by nav-dropdown.njk). Accent-tinted so it reads as
   the marquee entry, above the category rows. */
.nav-menu-featured {
    display: block;
    padding: 0.6rem 1rem;
    border-bottom: 1px solid var(--border);
    font-family: var(--mono);
    font-size: 0.78rem;
    letter-spacing: 0.03em;
    color: var(--accent);
    text-decoration: none;
    background: var(--accent-dim);
    transition: color 0.18s, background 0.18s;
}
.nav-menu-featured:hover { color: var(--text-bright); background: var(--accent-glow); }

/* ── NAV CATEGORY GROUPS (level-2 disclosure) ────────────────────────
   Inside a categorized section's .nav-menu (Tools / Education /
   Practice), pages sit under .nav-group-toggle category rows that expand
   their .nav-submenu (scripts/nav-menu.js). The toggle reads as a quiet
   category header (mono eyebrow, caret right); submenu links indent on a
   faintly inset fill so the nesting is legible. Simulators has no groups
   and is unaffected. */
.nav-menu-group { border-bottom: 1px solid var(--border-faint); }
.nav-menu-group:last-child { border-bottom: none; }
.nav-group-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    width: 100%;
    font-family: var(--mono);
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-dim);
    padding: 0.6rem 1rem;
    background: transparent;
    border: none;
    cursor: pointer;
    transition: color 0.18s, background 0.18s;
}
.nav-group-toggle:hover { color: var(--text); background: var(--accent-dim); }
.nav-group-toggle[aria-expanded="true"] { color: var(--text-bright); }
.nav-group-toggle .nav-caret { flex: none; transition: transform 0.18s; }
.nav-group-toggle[aria-expanded="true"] .nav-caret { transform: rotate(180deg); }
.nav-submenu { display: flex; flex-direction: column; }
.nav-submenu[hidden] { display: none; }
.nav-submenu .nav-menu-item {
    padding-left: 1.75rem;
    border-bottom: none;
    background: var(--surface-2);
}
@media (prefers-reduced-motion: reduce) {
    .nav-caret { transition: none; }
}

/* ── MOBILE HAMBURGER ────────────────────────────────────────────────
   Desktop never shows it (display:none); at the narrow-nav breakpoint it
   becomes the top-bar control that collapses .site-nav-links. The two
   icons swap on aria-expanded. See nav-menu.js. */
.nav-hamburger {
    display: none;
    align-items: center;
    justify-content: center;
    padding: 0.35rem;
    background: var(--surface);
    border: 1px solid var(--border);
    color: var(--text);
    cursor: pointer;
    transition: color 0.18s, border-color 0.18s;
}
.nav-hamburger:hover { color: var(--accent); border-color: var(--accent); }
.nav-burger-icon,
.nav-close-icon { width: 18px; height: 18px; }
.nav-close-icon { display: none; }
.nav-hamburger[aria-expanded="true"] .nav-burger-icon { display: none; }
.nav-hamburger[aria-expanded="true"] .nav-close-icon { display: inline; }
/* Compact icon-only search for the mobile top bar; the full labelled
   search stays in the desktop link bar. Compound selector so it beats the
   base .nav-search-btn { display: inline-flex } regardless of source order.
   Desktop hides this one; the mobile breakpoint flips it on. */
.nav-search-btn.nav-search-btn--mobile { display: none; gap: 0; padding: 0.35rem; }

/* ── UNITS + THEME TOGGLES ───────────────────────────────────────────
   Two sibling pills inside .site-nav-links (rightmost children) — same
   shape, so they share these rules. Each active button gets the
   accent-dim tint via an attribute on <html>: [data-units] for the
   units pill, [data-theme] for the theme pill. A tiny inline head
   script sets BOTH attributes before first paint to avoid flashing the
   wrong button. Runtime drivers: /scripts/units.js (per-page) and
   /scripts/theme.js (site-wide). The button .active class is a backstop;
   the attribute selectors are the canonical drivers. */
.units-toggle,
.theme-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
}
.units-label,
.theme-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.08em;
}
.units-toggle-pill,
.theme-toggle-pill {
    display: inline-flex;
    border: 1px solid var(--border);
    border-radius: 0;
    overflow: hidden;
    background: var(--surface);
}
.units-btn,
.theme-btn {
    font-family: var(--mono);
    font-size: 0.7rem;
    padding: 0.22rem 0.6rem;
    background: transparent;
    color: var(--text-dim);
    border: none;
    cursor: pointer;
    letter-spacing: 0.03em;
    transition: color 0.18s, background 0.18s;
}
.units-btn + .units-btn,
.theme-btn + .theme-btn { border-left: 1px solid var(--border); }
.units-btn:hover,
.theme-btn:hover { color: var(--text); }
html[data-units="us"]     .units-btn[data-units="us"],
html[data-units="metric"] .units-btn[data-units="metric"],
html[data-theme="dark"]   .theme-btn[data-theme-set="dark"],
html[data-theme="light"]  .theme-btn[data-theme-set="light"] {
    background: var(--accent-dim);
    color: var(--accent);
    font-weight: 600;
}

/* ── SITE SEARCH (command palette) ───────────────────────────────────
   The nav trigger mirrors the units/theme pill vocabulary. The palette is
   the site's only floating overlay (z-index above the sticky nav, which is
   100); it lives at end-of-body so it escapes the nav stacking context.
   Opened by / or Ctrl/⌘-K or .nav-search-btn — see scripts/search.js. */
.nav-search-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-family: var(--mono);
    font-size: 0.72rem;
    padding: 0.26rem 0.6rem;
    background: var(--surface);
    color: var(--text-dim);
    border: 1px solid var(--border);
    border-radius: 0;
    cursor: pointer;
    letter-spacing: 0.03em;
    transition: color 0.18s, border-color 0.18s;
}
.nav-search-btn:hover { color: var(--text); border-color: var(--accent); }
.nav-search-icon { width: 13px; height: 13px; flex: none; }
.nav-search-kbd {
    font-family: var(--mono);
    font-size: 0.66rem;
    line-height: 1;
    padding: 0.12rem 0.3rem;
    color: var(--text-dim);
    background: var(--surface-2);
    border: 1px solid var(--border);
}

.palette {
    position: fixed;
    inset: 0;
    z-index: 1000;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding: 12vh 1rem 1rem;
    background: rgba(0, 0, 0, 0.55);
}
.palette[hidden] { display: none; }
body.palette-open { overflow: hidden; }
.palette-box {
    width: 100%;
    max-width: 560px;
    max-height: 70vh;
    display: flex;
    flex-direction: column;
    background: var(--surface);
    border: 1px solid var(--border);
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
    animation: palette-in 0.14s ease-out;
}
@keyframes palette-in {
    from { opacity: 0; transform: translateY(-6px); }
    to   { opacity: 1; transform: translateY(0); }
}
.palette-field {
    display: flex;
    align-items: center;
    gap: 0.55rem;
    padding: 0.7rem 0.85rem;
    border-bottom: 1px solid var(--border);
    background: var(--surface-2);
}
.palette-icon { width: 16px; height: 16px; flex: none; color: var(--text-dim); }
.palette-input {
    flex: 1;
    min-width: 0;
    background: transparent;
    border: none;
    outline: none;
    font-family: var(--sans);
    font-size: 1rem;
    color: var(--text);
}
.palette-input::placeholder { color: var(--text-dim); }
.palette-hint {
    font-family: var(--mono);
    font-size: 0.66rem;
    color: var(--text-dim);
    padding: 0.12rem 0.32rem;
    border: 1px solid var(--border);
    background: var(--surface);
}
.palette-results {
    list-style: none;
    margin: 0;
    padding: 0;
    overflow-y: auto;
}
.palette-result {
    display: grid;
    grid-template-columns: auto 1fr;
    grid-template-areas:
        "tag title"
        "tag desc";
    gap: 0 0.7rem;
    align-items: baseline;
    padding: 0.6rem 0.85rem;
    border-bottom: 1px solid var(--border-faint);
    cursor: pointer;
}
.palette-result:last-child { border-bottom: none; }
.palette-result[aria-selected="true"] { background: var(--accent-dim); }
.palette-result-tag {
    grid-area: tag;
    align-self: center;
    font-family: var(--mono);
    font-size: 0.6rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    white-space: nowrap;
    color: var(--accent);
    background: var(--accent-soft);
    border: 1px solid var(--accent-glow);
    padding: 0.12rem 0.36rem;
}
.palette-result-title {
    grid-area: title;
    font-family: var(--sans);
    font-size: 0.95rem;
    color: var(--text-bright);
}
.palette-result-desc {
    grid-area: desc;
    font-size: 0.8rem;
    color: var(--text-dim);
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 1;
    -webkit-box-orient: vertical;
}
.palette-status {
    margin: 0;
    padding: 0.5rem 0.85rem;
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    border-top: 1px solid var(--border);
    background: var(--surface-2);
}
@media (prefers-reduced-motion: reduce) {
    .palette-box { animation: none; }
}

/* ── HERO — workstation-panel frame (v3.0.0 "seam" hero).
       A bordered surface: faux BAS title bar above, live status line
       below, and the live "seam" instrument + copy between (those are
       laid out in the home page's own inline head styles, since they're
       home-only). Only the home page carries a .hero; sub-pages don't.
       The .console-* / .ok-pill chrome here is shared (nav-cards mirror
       the frame). */
.hero {
    position: relative;
    z-index: 1;
    padding: 0;
    max-width: 1120px;
    margin: 2.5rem auto 2rem;
    width: 100%;   /* don't shrink-to-fit inside body's flex column */
    text-align: left;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    overflow: hidden;
    animation: fadeUp 0.5s ease both;
}
.console-titlebar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.55rem 1rem;
    border-bottom: 1px solid var(--border);
    background: var(--surface-2);
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text);
    text-transform: uppercase;
    letter-spacing: 0.08em;
}
.console-titlebar .titlebar-dots {
    color: var(--text-dim);
    margin: 0 0.4rem;
}
/* Three-column grid (1fr · auto · 1fr) so the middle item lands at the
   true visual center of the card — flex + space-between distributes by
   midpoint between the outer items' inside edges, which drifts off
   true-center when the OK pill and LAST BUILT have different widths
   (and shifts the version label off the hero h1's center axis above).
   The outer 1fr cells get equal width; the middle auto cell sizes to
   content and centers itself between them. */
.console-statusline {
    padding: 0.7rem 1rem;
    border-top: 1px solid var(--border);
    background: var(--surface-2);
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    gap: 0.5rem 1.25rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    font-variant-numeric: tabular-nums;
}
.console-statusline > :first-child  { justify-self: start; }
.console-statusline > :nth-child(2) { justify-self: center; }
.console-statusline > :last-child   { justify-self: end; }
/* The ISO build date would otherwise soft-wrap at its internal
   hyphens at phone widths ("2026-07-" / "08"). */
.console-statusline time { white-space: nowrap; }
.console-statusline > span strong {
    color: var(--text);
    font-weight: 600;
    margin-left: 0.35rem;
}

/* The .ok-pill: a small accent pill with a leading dot. Used in the
   title bar, status line, footer, and anywhere a "live / running"
   marker is wanted. */
.ok-pill {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    background: var(--accent-dim);
    color: var(--accent-ink);
    border: 1px solid var(--accent-glow);
    padding: 0.1rem 0.5rem;
    border-radius: 0;
    font-family: var(--mono);
    font-size: 0.62rem;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    font-weight: 600;
}
.ok-pill::before {
    content: "";
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--accent);
}

/* ── MAIN ── */
/* The home page puts a .hero between the nav and main, so main doesn't need
   its own top padding there — .hero already provides air. Sub-pages have no
   hero, so main supplies the breathing room itself. The adjacent-sibling
   selector below zeroes out the top padding when a hero comes first. */
main {
    position: relative;
    z-index: 1;
    max-width: 1120px;
    margin: 0 auto;
    padding: 2.5rem 2rem 4rem;
    width: 100%;
}
.hero + main { padding-top: 0; }

.section-header {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    margin-bottom: 1.5rem;
}
.section-label {
    font-family: var(--mono);
    font-size: 0.82rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--accent);
    white-space: nowrap;
    margin: 0;
}
/* The trailing hairline that used to follow every section-label is
   hidden in v2.0.0 — the SEC:NNN section-number prefix on h2 labels
   carries enough left-side weight that a trailing line is noise. The
   markup stays in existing pages as a no-op so a future tweak can flip
   it back without retroactive edits; new pages can omit it. */
.section-line { display: none; }

/* h2.section-label is the section-divider variant (vs h1.section-label
   which is a landing-page title). One BAS-instrument prefix mode:

   - `data-stage="N"` renders `STAGE:N · LABEL`. Evokes equipment
     staging (Stage 1 / Stage 2 cooling, lead/lag pumps) — used on
     the home page where the sections read as a staged sequence:
     Stage 1 first, then Stage 2 if you want more, then Stage 3 for
     backstory.

   The accent color attaches to the prefix, not the label — the prefix
   becomes the wayfinding mark, the label reads as a quiet workstation
   heading. Pages without the attribute are unaffected (no prefix;
   existing accent label color preserved). */
h2.section-label[data-stage] {
    font-size: 0.78rem;
    letter-spacing: 0.06em;
    color: var(--text-bright);
}
h2.section-label[data-stage]::before {
    color: var(--accent);
    font-variant-numeric: tabular-nums;
    margin-right: 0.1rem;
    font-weight: 600;
    content: "STAGE:" attr(data-stage) " · ";
}

/* ── TOOL CARD ── */
.tool-card {
    position: relative;            /* anchor for the .tool-card-fullscreen-btn */
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    overflow: hidden;
    margin-bottom: 1.5rem;
}
/* Entrance stagger — approximate by design. :nth-of-type counts DIV
   siblings of ANY class, not .tool-card siblings, so every interleaved
   div.section-header (and any other sibling div) takes an index and
   shifts which cards match: a lone card behind its section-header
   lands at index 2 (the 0.16s step), and on a multi-card page later
   cards can fall past index 4 and get no fade at all (pid-basics
   today), so the order is not a clean 1-2-3-4. Accepted as such
   (2026-10-05, codebase-issues #309): it is a cosmetic 0.5s fade, so
   the selector stays and this comment records the quirk rather than
   claiming a four-step stagger. */
.tool-card:nth-of-type(1) { animation: fadeUp 0.5s 0.08s ease both; }
.tool-card:nth-of-type(2) { animation: fadeUp 0.5s 0.16s ease both; }
.tool-card:nth-of-type(3) { animation: fadeUp 0.5s 0.24s ease both; }
.tool-card:nth-of-type(4) { animation: fadeUp 0.5s 0.32s ease both; }

.tool-card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.9rem 1.5rem;
    border-bottom: 1px solid var(--border);
    background: var(--surface-2);
}
.tool-card-title {
    font-family: var(--mono);
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-bright);
    display: flex;
    align-items: center;
    gap: 0.7rem;
    margin: 0;
}
.tool-tag {
    font-family: var(--mono);
    font-size: 0.62rem;
    padding: 0.15rem 0.5rem;
    background: var(--accent-dim);
    color: var(--accent-ink);
    border: 1px solid var(--accent-glow);
    border-radius: 0;
    text-transform: uppercase;
    letter-spacing: 0.07em;
}
.tool-body { padding: 1.5rem; }
.tool-body p { font-size: 0.95rem; line-height: 1.8; color: var(--text); }
.tool-body a { color: var(--accent); }

/* ── LESSON PROSE RHYTHM ────────────────────────────────────────────
   The global reset zeroes every margin, so a <p> immediately after
   another <p> renders flush against it. Lessons compensated by hand
   with inline `style="margin-top:1.25rem;"` on each follow-on
   paragraph — which is exactly why the rhythm drifts: any page that
   forgets the inline style ships with its prose run together, and
   13 of the 40 education lessons had (codebase-issues #179).

   SCOPE — `body.education-page` (emitted by layouts/page.njk for
   `nav: education` frontmatter) narrowed to `.tool-body`, i.e. lesson
   body prose. `.tool-body` alone does NOT scope: tools, simulators,
   and practice pages use the same class, and their follow-on
   paragraphs are reference/preamble copy tuned per page. Those are
   deliberately OUTSIDE this rule, as are `/privacy.html` and the
   section landings. The education landing carries the body class too
   but has no `.tool-body`, so it is unaffected.

   `:not([class])` keeps the rule to unclassed prose. The prose
   utilities (p.ref-note, p.bit-hint, p.pid-note, p.tool-preamble)
   own their own spacing and must keep it — this rule would otherwise
   out-specify them.

   Inline `style="margin-top:…"` still wins. The 201 redundant
   declarations this rule reached were stripped once it landed
   (codebase-issues #190), including the 51 that sat at 1.1rem and
   the two lone one-offs at 0.9rem / 1rem (owner decision
   2026-07-20) — 1.25rem is the single house rhythm now. An inline
   margin-top that survives on a lesson paragraph is deliberate (and
   commented at the call site), or outside this selector (where it is
   the only thing setting the gap). New lessons write plain <p>. */
body.education-page .tool-body p + p:not([class]) { margin-top: 1.25rem; }
/* Body-prose lists — same font triplet as .tool-body p. Per-list margin
   stays inline (it varies). A page that wants a tighter small-text list
   overrides font-size/line-height inline; the inline style wins. */
.tool-body ul { font-size: 0.95rem; line-height: 1.8; color: var(--text); }

/* Mono small-caps preamble caption that sits under a .tool-card-header.
   Element-qualified to tie .tool-body p's (0,0,1,1) specificity and win
   on cascade order, matching p.bit-hint / p.pid-note / p.ref-note.
   Padding/margin stays per-site inline — value varies by whether the
   preamble sits outside .tool-body (between header and the next block)
   or inside .tool-body (where .tool-body's own padding applies). */
p.tool-preamble {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    line-height: 1.7;
}

/* T-011 seeded-demo cue: every calculator ships pre-seeded with a worked
   example that computes a live answer before you touch anything, and the
   seeded inputs ARE the worked example. This one line, between the preamble
   and the inputs, marks the values as demo data so a fast reader doesn't
   mistake the seeded answer for one computed from their own numbers. Mono at
   the preamble's size and ink; padded to align with the card content inset. */
.ps-seed-note {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    padding: 0.6rem 1.5rem 0.2rem;
    margin: 0;
}

/* T-010 collapsible "what this does": the one orienting sentence is the
   always-visible <summary> (so a newcomer is never left without context); the
   rest expands behind "▸ more", keeping the Input/Output grid near the top of
   the card for the repeat expert. Native <details>, mirroring the .pid-spoiler
   ▸/▾ idiom; shares p.tool-preamble's small mono type. Per-tool inline
   padding stays on the <details> (varies by inside/outside .tool-body). */
details.tool-preamble > summary {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    line-height: 1.7;
    cursor: pointer;
    list-style: none;
}
details.tool-preamble > summary::-webkit-details-marker { display: none; }
details.tool-preamble > summary::after {
    content: "▸ more";
    color: var(--accent);
    margin-left: 0.5rem;
    white-space: nowrap;
    font-size: 0.7rem;
    letter-spacing: 0.04em;
}
details.tool-preamble[open] > summary::after { content: "▾ less"; }
details.tool-preamble > summary:hover { color: var(--text-bright); }
details.tool-preamble > .preamble-rest {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    line-height: 1.7;
    margin-top: 0.5rem;
}

/* ── PROSE FOLD ── section-grain disclosure for BACKGROUND prose on a
   dense page (owner direction 2026-08-09: background collapses per
   section, live-reading prose stays visible). Third member of the ▸/▾
   native-<details> family — details.tool-preamble above is card-grain
   ("what this tool does"), .pid-spoiler is a titled box, this one wraps
   a run of body paragraphs that already carry their own class.

   The split it encodes: prose answering "what is this thing / where do
   these numbers come from" may fold; prose answering "what is this
   machine doing right now" must not. That is per-note editorial
   judgement, not something a selector can decide — see the fold call
   sites on the two DDC Workbench pages.

   Register: a flat control BAR — --surface-2 fill, --text summary ink,
   --border seam, 1.05rem accent marker — sitting between
   details.tool-preamble (quietest of the family) and .pid-spoiler (a
   titled filled box). It SHIPPED quieter than this (hairline
   --border-faint seam, --text-dim summary, no fill, text-size marker)
   on the theory that the fold should recede the way the background it
   hides does — and the pilot's first live read (owner, 2026-08-11)
   found that styling was itself the defect: nothing said "this is
   clickable", so folded content was undiscoverable. Ruled louder from
   four mocked variants (glossary-arc.md D-log). The BODY stays
   background; the bar is a control and now reads as one. NO
   transitions — instant toggle, AX-sharp, so there is nothing for
   prefers-reduced-motion to squash. Ships closed and holds no state:
   /scripts/details-print.js force-opens it for print (CSS cannot open
   a closed <details>) and nothing else, so no cf_* key and no
   privacy.html surface. The ▸ marker is pseudo content with no
   letter or digit in it, so the contrast sweep skips it as decoration
   — same standing as .pid-spoiler's. */
details.prose-fold {
    border: 1px solid var(--border);
    margin: 0 0 0.25rem;
}
details.prose-fold > summary {
    font-family: var(--mono);
    font-size: 0.72rem;
    letter-spacing: 0.04em;
    color: var(--text);
    background: var(--surface-2);
    padding: 0.45rem 0.65rem;
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
}
details.prose-fold > summary::-webkit-details-marker { display: none; }
details.prose-fold > summary::before { content: "▸"; color: var(--accent); font-size: 1.05rem; line-height: 1; }
details.prose-fold[open] > summary::before { content: "▾"; }
details.prose-fold > summary:hover { color: var(--text-bright); }
details.prose-fold[open] > summary { border-bottom: 1px solid var(--border); }
details.prose-fold > .prose-fold-body { padding: 0.5rem 0.65rem 0.35rem; }

/* First-use acronym gloss (G-008). A quiet dotted underline cues "there's
   a definition here"; the title shows the expansion on desktop hover. The
   hover is a BONUS, not load-bearing — touch devices have no hover, so a
   genuinely newcomer-critical term gets a parenthetical in the prose
   instead; <abbr> carries the broader incidental acronym load. Theme-aware
   (the underline tracks --text-dim). cursor:help signals the hover. */
abbr[title] {
    text-decoration: underline dotted var(--text-dim);
    text-underline-offset: 0.18em;
    cursor: help;
}

/* ── GLOSS TOOLTIP ──
   The keyboard- and touch-operable sibling of the abbr[title] gloss
   above. That one is a hover-only BONUS by design; this one exists
   precisely because `title` is not keyboard-reachable, not touch-
   operable and not stylable. Runtime: /scripts/gloss.js. Definitions:
   html/_data/glossary.js. Both the panel markup and the script tag are
   injected by the `gloss` transform in .eleventy.js — an author writes
   ONE attribute in prose and nothing else:

       <button type="button" data-gloss="sr-latch">SR latch</button>

   so the selectors here key off the attribute, not a class the author
   could forget. A real <button> (not a span+role, not <dfn> — a trigger
   marks a USE, and §7.4 suppresses marking on the defining page, so by
   construction no trigger is a defining instance; the PANEL's headword
   is the <dfn>).

   AFFORDANCE: exactly the abbr[title] decoration — --text-dim dots on
   the baseline, lifting to a solid --accent underline on hover / while
   open. No color change on the text, no background, no icon, no
   superscript marker. However quiet one marker is, forty of them stripe
   a lesson; the control for that is MARKING DENSITY (a curation policy,
   docs/glossary-arc.md), never a louder or quieter style here.

   The --text-dim dotted underline is a component indicator under WCAG
   1.4.11's 3:1 non-text floor, which it clears on every house surface
   by a wide margin — it is body-label ink at 5.5:1+ (see the
   `label, .field-label` comment for the measured pair).

   TOUCH: deliberately NOT in the TOUCH-TARGET FLOOR block. A dotted-
   underlined word in body prose is ~17px tall and padding it up would
   wreck the line box — which is exactly WCAG 2.5.8's inline exception
   for a target "in a sentence, or whose size is otherwise constrained
   by the line-height of non-target text", the same standing body-prose
   links already have here. The omission is a decision; see the note in
   the TOUCH-TARGET FLOOR block.

   MOTION: none, in any mode. Instant show/hide matches the hard-seam
   register (nothing fades on this site's chrome), so
   prefers-reduced-motion needs no branch and neither the contrast sweep
   nor a screenshot can catch a panel mid-transition. ── */
button[data-gloss] {
    display: inline;
    font: inherit;
    color: inherit;
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    text-align: inherit;
    text-decoration: underline dotted var(--text-dim);
    text-decoration-thickness: 1px;
    text-underline-offset: 0.18em;
    cursor: help;
}
button[data-gloss]:hover,
button[data-gloss].gloss-open {
    text-decoration-color: var(--accent);
    text-decoration-style: solid;
}

/* The panel. Square corners, 1px seam, flat fill, no shadow, no caret
   arrow — AX-sharp register. Positioned by gloss.js in VIEWPORT
   coordinates (position: fixed), which is why it lives at body end and
   why scrolling dismisses it rather than costing a per-frame reposition.
   z-index sits above body.has-fullscreen-tool main (400) and below the
   command palette (1000).

   ⚠️ The --surface-2 background must stay FULLY OPAQUE. It is
   load-bearing for tests/contrast-sweep.spec.js: the sweep force-opens
   `.gloss-tip` (COLLAPSED_CHROME) and resolves each ink source's
   backdrop by walking ANCESTORS, not the painted stack — so resolution
   stops at this background, and force-opened panels overlapping each
   other at odd spots changes no ratio. A translucent panel skin would
   silently change what the guard measures. */
.gloss-tip {
    position: fixed;
    z-index: 900;
    left: 0;
    top: 0;
    max-width: min(21rem, calc(100vw - 2rem));
    max-height: calc(100dvh - 2rem);
    overflow-y: auto;
    padding: 0.55rem 0.8rem 0.65rem;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-left: 2px solid var(--blue);
    border-radius: 0;
}
.gloss-tip-term {
    margin: 0 0 0.25rem;
    font-family: var(--mono);
    font-size: 0.66rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
}
.gloss-tip-term dfn { font-style: normal; }
.gloss-tip-def {
    margin: 0;
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--text);
}
.gloss-tip-def strong { color: var(--text-bright); font-weight: 600; }
.gloss-tip-def code {
    font-family: var(--mono);
    font-size: 0.9em;
    background: var(--surface-3);
    padding: 0.02em 0.3em;
    border-radius: 0;
}

/* Glosses must not print as mystery underlines, and a panel could be
   open when the reader hits Ctrl-P. Definitions simply don't print —
   the prose reads as prose. */
@media print {
    button[data-gloss] { text-decoration: none; cursor: auto; }
    .gloss-tip { display: none !important; }
}

/* Lead paragraph on Education pages — the opening prose paragraph that
   sets up the lesson. Slightly larger / wider line-height than the body
   .tool-body p shape. Spans the full .tool-body width so the intro
   doesn't read as marooned in the left half of the card. Placed before
   .callout p (cascade order) so .callout p's tighter font shape stays
   authoritative inside callouts. */
.page-intro {
    font-size: 1.0rem;
    line-height: 1.85;
    color: var(--text);
    margin-bottom: 1.75rem;
}

/* Lead paragraph on section landings (/tools/, /simulators/,
   /education/) — opens with a single intro paragraph above the
   filter chips / card grid. Lighter font-weight than .page-intro
   (which is for education content pages); spans the full content
   column so it visually lines up with the card grid below rather
   than reading as a half-width fragment. The accent-coloured anchor
   scoped below covers inline links in landing leads without needing
   per-link inline styles. */
.landing-intro {
    font-weight: 300;
    font-size: 1.0rem;
    color: var(--text);
    margin-bottom: 2rem;
    line-height: 1.8;
}
.landing-intro a { color: var(--accent); }

/* ── TOPIC-HUB LANDINGS ── */
/* Group / sub-group headings and the numbered "start here" reading-path
   line for the pillar-hub landings (/bacnet/, /forced-air/, and future
   topic clusters). Promoted here from the BACnet hub's page-local block
   once a second hub landing needed the same chrome. Heading scale
   mirrors practice/index.html's .practice-section-heading. */
.hub-group {
    font-family: var(--mono);
    font-size: 0.95rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-bright);
    margin: 2.4rem 0 0.35rem;
}
.hub-group-intro {
    font-size: 0.94rem;
    line-height: 1.7;
    color: var(--text);
    margin: 0 0 1.1rem;
    max-width: 56rem;
}
.hub-sub {
    font-family: var(--mono);
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: var(--accent);
    margin: 1.5rem 0 0.65rem;
}
.hub-path {
    font-family: var(--mono);
    font-size: 0.9rem;
    line-height: 1.95;
    margin: 0 0 1.4rem;
}
.hub-path a { color: var(--accent); text-decoration: none; }
.hub-path a:hover { color: var(--text-bright); }
.hub-path .hub-step { color: var(--text-dim); }
.hub-path .hub-arrow { color: var(--text-dim); margin: 0 0.35rem; }

/* ── TABS ── */
/* flex-wrap + nowrap buttons: at phone widths whole buttons wrap onto a
   second row instead of the row clipping inside the overflow-hidden
   .tool-card (4 tabs on electrical-quick-calc) or the labels soft-
   wrapping into 2–3-line buttons ("HEX →" / "IP"). Inert wherever the
   row fits. */
.tabs {
    display: flex;
    flex-wrap: wrap;
    border-bottom: 1px solid var(--border);
    margin-bottom: 1.5rem;
}
.tab-btn {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    white-space: nowrap;
    padding: 0.55rem 1.1rem;
    border: none;
    background: none;
    color: var(--text-dim);
    cursor: pointer;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    transition: color 0.18s, border-color 0.18s;
}
.tab-btn.active { color: var(--accent-ink); border-bottom-color: var(--accent); }
.tab-btn:hover:not(.active) { color: var(--text); }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* Print reaches every pane, not just the active one (codebase-issues
   #286). Paper has no tab buttons to click, so a hidden pane is content
   the reader cannot get at all; details-print.js opening a fold inside an
   inactive pane was moot until this lifted. Panes stack in DOM order with
   a hairline seam between them. The .tabs button row still prints as-is —
   it reads as a table of contents for the stacked panes.
   One exception: the DDC workbench's live Function-Block Editor
   (.fbe-live) mounts LAZILY, on the first Wiresheet open
   (ensureEditor() in ddcw-shell.js). In a pane that is not active it has
   usually never been built, so lifting the pane would print its simbar
   and ~1000px of empty palette / canvas / inspector frames. Keep it off
   paper there; the pane's prose and folds still print. A Wiresheet the
   reader is ON has a mounted editor and prints as rendered. */
@media print {
    .tab-pane { display: block !important; }
    .tab-pane + .tab-pane {
        margin-top: 1.5rem;
        padding-top: 1rem;
        border-top: 1px solid var(--border);
    }
    .tab-pane:not(.active) .fbe-live { display: none !important; }
}

/* ── FORM ── */
.form-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1rem;
    margin-bottom: 1rem;
}
.field { display: flex; flex-direction: column; gap: 0.4rem; }

/* Form labels are DELIBERATELY the quiet ink: quiet caption + loud value is
   the scan hierarchy this system wants, not an oversight, and it is not a
   WCAG failure — --text-dim on --surface measures 5.67:1 dark / 5.51:1
   light against a 4.5:1 small-text AA floor (the light figure moved with
   the 2026-07-20 --text-dim retune; recompute it here whenever that token
   moves, since .fbe-block-tag's note cites this pair). Standing answer to
   codebase-issues #168 (owner, 2026-07-20: working as designed). Do NOT
   "fix" this site-wide; it would flatten the hierarchy on all ~47
   label-bearing pages to solve a non-problem. The escape hatch is
   page-local instead: a page
   whose control block is dense enough that the captions ARE the scan
   target lifts them itself, leaving size / casing / values alone — see
   the .rl-controls / .rl-presets / .rl-mode block in
   simulators/refrigerant-loop.html. */
label,
.field-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-dim);
}

input[type="number"],
input[type="text"],
input[type="email"],
textarea,
select {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.55rem 0.75rem;
    font-family: var(--mono);
    font-size: 0.9rem;
    color: var(--text-bright);
    width: 100%;
    transition: border-color 0.18s, box-shadow 0.18s;
    -webkit-appearance: none;
}
textarea { resize: vertical; line-height: 1.6; min-height: 6rem; }
input:focus, textarea:focus, select:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-dim);
}
select option { background: var(--surface); }
/* Placeholder text is content, not chrome — WCAG 1.4.3 applies to it,
   and left to the UA default Chromium painted every .ps-input hint at
   4.04:1 on --bg across ~40 tool pages (a seeded example value like
   "e.g. 260" or "1200" is often the only clue to the expected format).
   --text-dim reads 6.52:1 dark / 4.83:1 light on the --bg field face
   both these blocks use, and keeps the hint visibly quieter than the
   --text-bright value the visitor types over it. Element-level so a
   component that wants a different hint colour still out-specifies it
   (.palette-input below already does). */
input::placeholder, textarea::placeholder { color: var(--text-dim); }

/* ── RESULT PANEL ── */
.result-panel {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 1.25rem 1.5rem;
    margin-top: 1rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
}
.result-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-dim);
    margin-bottom: 0.2rem;
}
.result-value {
    font-family: var(--mono);
    font-size: 2rem;
    font-weight: 600;
    color: var(--blue);
    letter-spacing: -0.02em;
    font-variant-numeric: tabular-nums;
    transition: color 0.18s;
}
.result-value.error { color: var(--red); }
.result-value.muted { color: var(--text-dim); }

.result-formula {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    margin-top: 0.75rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--border);
    font-style: italic;
    word-break: break-all;
}
/* .result-formula modifiers — formula blocks that need different layout
   chrome than the default. .flush drops the top margin and border so the
   formula sits flat under a tabbed Output panel (bacnet, signal-scaling
   slope/offset, thermistor). .wrap relaxes the default break-all so
   long readable expressions break on word boundaries instead of
   mid-token (signal-scaling Forward + Reverse panels). */
.result-formula.flush { margin: 0; padding: 0.7rem 1.25rem; }
.result-formula.wrap  { word-break: normal; overflow-wrap: break-word; }

/* ── RANGE BAR ── */
.range-bar-wrap { margin-top: 1rem; }
.range-bar-labels {
    display: flex;
    justify-content: space-between;
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    margin-bottom: 0.35rem;
}
.range-bar {
    height: 5px;
    background: var(--border);
    border-radius: 0;
    overflow: hidden;
}
.range-bar-fill {
    height: 100%;
    background: linear-gradient(90deg, var(--accent), var(--blue));
    border-radius: 0;
    transition: width 0.2s ease;
    width: 0%;
}

/* ── BIT VIEWER ── */
/* p.bit-hint (not .bit-hint) so the rule ties .tool-body p on
   specificity and wins via cascade order. All current uses are on
   `<p>` — see also p.pid-note and p.ref-note below. */
p.bit-hint {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    margin-bottom: 0.85rem;
}

.bit-grid {
    display: grid;
    grid-template-columns: repeat(16, 1fr);
    gap: 4px;
    margin-bottom: 1rem;
}

.bit {
    aspect-ratio: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 0;
    cursor: pointer;
    transition: background 0.12s, border-color 0.12s;
    user-select: none;
    /* The cells are real <button>s now (keyboard/AT access) — undo the
       UA button box so the grid metrics don't shift. */
    padding: 0;
    font: inherit;
}
.bit:hover { border-color: var(--text-dim); }
.bit.on { background: var(--accent-dim); border-color: var(--accent); }

.bit-val {
    font-family: var(--mono);
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-dim);
    line-height: 1;
}
.bit.on .bit-val { color: var(--accent); }
/* The per-cell bit number. INFORMATIONAL, not chrome: it is the only
   visible thing that tells a sighted reader which bit a cell is (the
   button's aria-label carries it for AT, but nothing else paints it),
   and making someone count cells to find bit 11 reinstates exactly the
   arithmetic this tool exists to remove.
   Full opacity, and NOT --text-dim, both deliberate (codebase-issues
   #192). It used to be --text-dim at opacity 0.45, which reads 4.6:1
   DECLARED but composites to 1.8:1 — every prior contrast audit missed
   it because they all read declared colour. --text-dim at full opacity
   does not rescue it either: on an .on cell the --accent-dim wash lifts
   the backdrop and it lands at 4.496:1, just under AA. --text clears
   4.5:1 in all four combinations (dark/light x off/on, worst case
   7.8:1), and the size + weight gap against .bit-val keeps the index
   visually subordinate without leaning on colour. */
.bit-idx {
    font-family: var(--mono);
    font-size: 0.48rem;
    color: var(--text);
    margin-top: 2px;
}

.bit-readouts {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.75rem;
}
.readout {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.65rem 0.85rem;
}
.readout-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-dim);
    margin-bottom: 0.25rem;
}
.readout-value {
    font-family: var(--mono);
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-bright);
    word-break: break-all;
    font-variant-numeric: tabular-nums;
}

/* ── FOOTER — v2.0.0 status-bar treatment.
       Mono caps, --surface-2 background, an [OK] pill + site name on the
       left and version / build datestamp / heartbeat dot on the right.
       The dot pulses on a 2-second keyframe — GPU-cheap, honored by
       prefers-reduced-motion via the global block at the bottom of this
       file. */
footer {
    position: relative;
    z-index: 1;
    border-top: 1px solid var(--border);
    background: var(--surface-2);
    padding: 0.85rem 2rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;   /* full-bleed status bar — matches .site-nav */
    gap: 1rem;
}
.footer-left,
.footer-right {
    display: inline-flex;
    align-items: center;
    gap: 0.6rem;
    flex-wrap: wrap;
}
.footer-text {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-variant-numeric: tabular-nums;
}
.footer-text strong {
    color: var(--text);
    font-weight: 600;
}
.heartbeat-dot {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--accent);
    animation: heartbeat 2s ease-in-out infinite;
}

/* ── ANIMATIONS ── */
@keyframes fadeUp {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: translateY(0); }
}
/* v2.0.0 — `heartbeat` powers the footer dot and the About-card status
   row's live indicator. 2-second cycle, eased — a quiet "this system is
   running" cue. `breathe` is the slower 4-second version used on the
   .site-nav-brand span (.dev) — peripheral, never the focal point.
   Both are squashed by the global prefers-reduced-motion block at the
   bottom of this file. */
@keyframes heartbeat {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.35; }
}
@keyframes breathe {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.55; }
}

/* ── RESPONSIVE ──
   Source order is load-bearing: an override in here only wins against
   base rules defined ABOVE this block (equal specificity — last wins).
   Components defined later in the file keep their narrow-width
   overrides in a per-component @media block next to their base rules
   (.pid-controls, .sim-canvas-wrap, .sim-legend, .ref-table); do the
   same for any new late-defined component instead of adding it here. */
@media (max-width: 620px) {
    .form-row        { grid-template-columns: 1fr; }
    .bit-grid        { grid-template-columns: repeat(8, 1fr); }
    .bit-readouts    { grid-template-columns: 1fr 1fr; }
    .site-nav        { padding: 0.7rem 1rem; }
    main             { padding-left: 1rem; padding-right: 1rem; }
    .hero            { margin-left: 1rem; margin-right: 1rem; width: auto; }
    .console-titlebar,
    .console-statusline { padding: 0.5rem 0.75rem; font-size: 0.62rem; }
    footer           { padding: 0.85rem 1rem; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
    /* Long section-header labels would otherwise force horizontal scroll
       on narrow viewports because of the desktop white-space:nowrap.
       Allow wrapping below the mobile breakpoint — the trailing
       .section-line still sits centered alongside the wrapped label. */
    .section-label   { white-space: normal; }
    /* Collapse the whole link bar behind the hamburger so the header isn't
       half the screen. Top bar = brand + search + hamburger; the links sheet
       drops to its own full-width column below, only when .nav-open. */
    .site-nav-brand { margin-right: auto; }   /* group search + burger at right */
    .nav-hamburger { display: inline-flex; }
    .nav-search-btn.nav-search-btn--mobile { display: inline-flex; }
    /* The full search lives in the sheet on desktop; on mobile the top-bar
       icon button covers it, so drop the in-sheet copy to avoid two. */
    #nav-search-btn { display: none; }
    .site-nav-links {
        display: none;
        order: 3;                 /* after the search btn + hamburger */
        flex-basis: 100%;         /* its own row when shown */
        flex-direction: column;
        /* Override the desktop flex-wrap:wrap — with the capped height a
           wrapping column breaks into a SECOND column off to the right,
           which was the sideways-scroll. Keep it one scrolling column. */
        flex-wrap: nowrap;
        align-items: stretch;
        gap: 0;                   /* rows carry their own separators + padding */
        margin-top: 0.5rem;
    }
    /* Cap the open sheet to the space under the top bar and give it its own
       smooth scroll. Without this the sticky .site-nav grows taller than the
       viewport on long lists, so the page scrolls behind the pinned bar
       instead of the menu — the jumpy, unresponsive scrolling. dvh tracks
       the real mobile viewport (vh is the fallback); overscroll-behavior
       stops the scroll from chaining to the page at the ends. */
    .site-nav.nav-open .site-nav-links {
        display: flex;
        max-height: calc(100vh - 5rem);
        max-height: calc(100dvh - 5rem);
        /* overflow-x must be explicit: setting only overflow-y:auto makes
           the browser compute overflow-x to auto too, which let the
           nowrap menu names scroll the sheet sideways. */
        overflow-x: hidden;
        overflow-y: auto;
        overscroll-behavior: contain;
    }
    /* Belt-and-suspenders: with the sheet open, the page itself doesn't
       scroll — only the sheet does. Mobile-scoped so a resize past the
       breakpoint releases the lock. */
    body.nav-sheet-open { overflow: hidden; }
    /* Inside the open sheet each section + its sub-menu stacks full width. */
    .nav-item--has-menu { flex-wrap: wrap; width: 100%; }
    .nav-menu {
        position: static;
        z-index: auto;
        min-width: 0;
        width: 100%;
        box-shadow: none;
        margin-top: 0.3rem;
        /* The sheet itself scrolls — an inner cap here would double-scroll. */
        max-height: none;
        overflow-y: visible;
    }
    /* Long tool names wrap in the full-width sheet instead of running off
       the side (the desktop dropdown keeps them on one line). */
    .nav-menu .nav-menu-item { white-space: normal; }
    /* Sheet reads as a tidy list, not floating text. Every top-level row is
       full-width with a hairline separator; dropdown rows push their caret
       to the right edge and make the label a full-width tap target (tap
       label → landing, tap caret → expand); open sub-menus indent under
       their parent. The .site-nav.nav-open prefix out-ranks the touch-floor
       block, so these hold whether or not the device reports hover:none. */
    .site-nav.nav-open .site-nav-links > a,
    .site-nav.nav-open .site-nav-links > .nav-item--has-menu {
        border-bottom: 1px solid var(--border-faint);
    }
    .site-nav.nav-open .site-nav-links > a { padding: 0.6rem 0.25rem; }
    .site-nav.nav-open .nav-item--has-menu { justify-content: space-between; }
    .site-nav.nav-open .nav-item--has-menu > a { flex: 1 1 auto; padding: 0.6rem 0.25rem; }
    .site-nav.nav-open .nav-item--has-menu > .nav-menu-toggle { margin: 0; padding: 0.6rem 0.6rem; }
    .site-nav.nav-open .nav-menu { margin-top: 0; }
    .site-nav.nav-open .nav-menu .nav-menu-item { padding-left: 1.25rem; }
    /* Level-2 in the sheet: category rows are full-width with the caret at
       the right edge (a tap target the width of the row); their pages
       indent a second step so the hierarchy reads. */
    .site-nav.nav-open .nav-group-toggle { padding: 0.6rem 0.25rem; }
    .site-nav.nav-open .nav-submenu .nav-menu-item { padding-left: 2.25rem; }
    .site-nav.nav-open .units-toggle,
    .site-nav.nav-open .theme-toggle { padding: 0.5rem 0.25rem; }
}
/* ── COPY BUTTON ── */
.copy-btn {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    padding: 0.2rem 0.55rem;
    background: none;
    border: 1px solid var(--border);
    border-radius: 0;
    color: var(--text-dim);
    cursor: pointer;
    transition: color 0.15s, border-color 0.15s, background 0.15s;
    white-space: nowrap;
}
.copy-btn:hover { border-color: var(--accent); color: var(--accent); }
.copy-btn.copied { border-color: var(--blue); color: var(--blue); }
.copy-btn.active { border-color: var(--accent); color: var(--accent); }

/* ── FULLSCREEN MODE ──
   Toggle button in the .tool-card corner pins the card to the viewport via
   .is-fullscreen. Wired by /scripts/fullscreen-toggle.js — pages opt in by
   adding a [data-fullscreen-target] button anywhere in the card. ESC exits.
   Add the .fs-desktop-only modifier on tools that don't make sense below
   the 1000px breakpoint OR on a touch-primary device (e.g. drag-wiring
   editors — same gate those pages use to swap the sim for a tips panel). */
.tool-card-fullscreen-btn {
    position: absolute;
    top: 0.55rem;
    right: 0.55rem;
    z-index: 2;
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-family: var(--mono);
    font-size: 0.68rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    padding: 0.32rem 0.6rem 0.32rem 0.55rem;
    background: var(--surface);
    border: 1px solid var(--accent);
    border-radius: 0;
    color: var(--accent);
    cursor: pointer;
    transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.tool-card-fullscreen-btn:hover { background: var(--accent-soft); }
.tool-card-fullscreen-btn svg { width: 0.85rem; height: 0.85rem; display: block; }
.tool-card-fullscreen-btn .fs-icon-exit,
.tool-card-fullscreen-btn .fs-label-exit { display: none; }
.tool-card.is-fullscreen .tool-card-fullscreen-btn .fs-icon-enter,
.tool-card.is-fullscreen .tool-card-fullscreen-btn .fs-label-enter { display: none; }
.tool-card.is-fullscreen .tool-card-fullscreen-btn .fs-icon-exit  { display: block; }
.tool-card.is-fullscreen .tool-card-fullscreen-btn .fs-label-exit { display: inline; }
@media (max-width: 999px), (hover: none) and (pointer: coarse) {
    .tool-card-fullscreen-btn.fs-desktop-only { display: none; }
}

/* ── Phone-width header clearance ──
   The button is absolutely positioned in the card's top-right corner and
   nothing else reserves its footprint, so on a narrow viewport it paints
   OVER the title row where the two meet. Re-measured at 375 with this
   rule disabled (#272, 2026-08-12): the AHU workbench's "Air handler"
   tag runs 8.7px under the button, and the FCU's "Air-side" clears it by
   11.4px — thin enough that a root-font bump closes it, which is why
   both pay rather than only the one that collides. (The original
   2026-08-03 figures, 18px and 2px, were taken before those two strings
   changed; the shape of the finding survived, the numbers did not.)
   Identical under touch emulation, where the TOUCH-TARGET FLOOR grows
   the button 31.2 → 44px: it grows DOWNWARD, so the horizontal story is
   unchanged. The DDC Workbench pages are the only consumers that keep
   the button at phone width, and each carried its own copy of this rule
   until #272.
   Two halves, both needed: reserve the button's footprint, AND let the
   title row wrap. .tool-card-title is a nowrap flex row, so the padding
   alone would only slide the tag beneath the button — the wrap is the
   half that acts. 8.5rem = the button's font-driven ~119px + its 0.55rem
   inset + a seam, in rem so it tracks the button's own type size.

   ⚠ THE :not(.fs-desktop-only) IS THE WHOLE SCOPE, and it is coupled to
   the rule directly above: a .fs-desktop-only button is display:none at
   every width ≤999px, so its card must NOT reserve 8.5rem of header it
   will never use. Only cards holding a button that SURVIVES to phone
   width pay. If a future modifier also hides the button down here, add
   it to this :not() list or that page reserves dead space.
   :has() takes the specificity of its most specific argument, so this
   lands at (0,4,0) — comfortably over anything a page head would set,
   which is why the two page-local copies could simply be deleted rather
   than re-scoped. */
@media (max-width: 480px) {
    .tool-card:has(.tool-card-fullscreen-btn:not(.fs-desktop-only)) .tool-card-header {
        padding-right: 8.5rem;
    }
    .tool-card:has(.tool-card-fullscreen-btn:not(.fs-desktop-only)) .tool-card-title {
        flex-wrap: wrap;
    }
}

.tool-card.is-fullscreen {
    position: fixed;
    inset: 0;
    z-index: 300;                  /* above .site-nav (100) and .skip-link (200) */
    margin: 0;
    border: 0;
    border-radius: 0;
    max-width: none;
    overflow: auto;
}
/* The "← All tools" / "← All simulators" back-link sits as a sibling of
   .tool-card inside <main>. Hide it when its preceding sibling is the
   fullscreen card — back-navigation makes no sense in fullscreen. */
.tool-card.is-fullscreen + .back-link,
.tool-card.is-fullscreen ~ .back-link { display: none; }

body.has-fullscreen-tool { overflow: hidden; }
/* In fullscreen the .tool-card overlay covers everything, but leave the
   nav and footer in place in the DOM — they're hidden by the overlay,
   not removed. The <main> element creates a stacking context of its own
   (position: relative + z-index: 1) so a fixed-positioned card inside it
   can't out-rank .site-nav (z-index: 100) on its own merits — its z-index
   only applies *within* main's context. Lift main above the nav (and
   footer, z-index: 1) while a tool is fullscreen. */
body.has-fullscreen-tool main { z-index: 400; }

/* ── SUBHEAD (within a tool body) ── */
.subhead {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--accent-ink);
    margin: 2rem 0 0.9rem;
}
.subhead:first-child { margin-top: 0; }

/* ── PID SIM ── */
.pid-controls {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;
    gap: 1.25rem 1.5rem;
    margin-bottom: 1.25rem;
}
/* Phone: one full-width column so the three tuning sliders get real
   track length + hit height. Lives here, not in the RESPONSIVE block —
   that block sits earlier in the file and would lose on source order. */
@media (max-width: 620px) {
    .pid-controls { grid-template-columns: 1fr; }
}
.slider-field { display: flex; flex-direction: column; gap: 0.45rem; }
.slider-head {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: baseline;
    gap: 0.15rem 0.5rem;
}
.slider-val { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); }
.slider-sub {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    letter-spacing: 0.02em;
}

input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    width: 100%;
    height: 4px;
    background: var(--border);
    border-radius: 0;
    outline: none;
    cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: var(--accent);
    border: 2px solid var(--bg);
    box-shadow: 0 0 0 1px var(--accent);
}
input[type="range"]::-moz-range-thumb {
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--accent);
    border: 2px solid var(--bg);
    box-shadow: 0 0 0 1px var(--accent);
}

/* ── SCREEN-READER-ONLY UTILITY ──
   Text for assistive tech that must not paint — the standard
   clip-rect recipe (display:none / visibility:hidden would silence
   the screen reader too). First consumer: the pid-tuner's throttled
   live region for its three metrics readouts. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* ── FOCUS INDICATORS ──
   Every custom-styled interactive with a :hover needs a paired
   :focus-visible — the browser default outline is suppressed by the
   `outline: none` on .skip-link / inputs / the range track. Add new
   custom interactives to this block. */
.copy-btn:focus-visible,
.tab-btn:focus-visible,
.units-btn:focus-visible,
.theme-btn:focus-visible,
.nav-search-btn:focus-visible,
.nav-start-here:focus-visible,
.nav-hamburger:focus-visible,
.nav-menu-toggle:focus-visible,
.nav-group-toggle:focus-visible,
.nav-menu-item:focus-visible,
.nav-menu-featured:focus-visible,
.cta-button:focus-visible,
.hero-btn:focus-visible,
.hero-qt:focus-visible,
.back-link:focus-visible,
.filter-chip:focus-visible,
.nav-card:focus-visible,
.related-links a:focus-visible,
.faq-a a:focus-visible,
.lesson-seq-link:focus-visible,
.tool-card-fullscreen-btn:focus-visible,
.quiz-choice:focus-visible,
.quiz-action:focus-visible,
.quiz-reset-best:focus-visible,
.quiz-restart-now:focus-visible,
.quiz-next-link:focus-visible,
.bit:focus-visible,
.psy-pill:focus-visible,
#th-rt-scroll:focus-visible,
#fbe-canvas:focus-visible,
.fbe-insp-row input:focus-visible,
.fbe-insp-row select:focus-visible,
.bo-id.bo-copyable:focus-visible,
.bvid-copyable:focus-visible,
.bec-id.bec-copyable:focus-visible,
.bpri-val:focus-visible,
.bpri-release:focus-visible,
.ddcw-resumed-fresh:focus-visible,
.widget-try button:focus-visible,
.ah-w-mode-btn:focus-visible,
.aid-w-mode-btn:focus-visible,
.bp-w-mode-btn:focus-visible,
.ds-w-mode-btn:focus-visible,
.vav-w-mode-btn:focus-visible,
.bec-mode:focus-visible,
details.tool-preamble > summary:focus-visible,
/* The gloss trigger is an INLINE word inside a sentence, so the shared
   3px offset below sits looser around it than around a padded control.
   It joins the shared declaration anyway rather than forking a one-off
   rule — one consolidated block is the point of this section, and the
   ring still reads as a ring on a word. */
button[data-gloss]:focus-visible,
details.prose-fold > summary:focus-visible,
.pid-spoiler > summary:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 3px;   /* v2.0.0 — was 2px; lifted so the accent ring no longer kisses the border on .nav-card */
}
/* Moved here from the FBE section (#197) so every :focus-visible rule in
   this stylesheet lives in this block. It keeps its own declaration rather
   than joining the list above — unlike the gloss trigger, which took the
   looser shared ring. A 3px offset on a 2px ring extends 5px past the border
   box, and the palette packs tighter than that in BOTH its layouts: the
   default rail is an `overflow-y: auto` scroll box stacking buttons 0.25rem
   (4px) apart, and the fullscreen rail is a wrapped flex row at
   `gap: 0.35rem 0.4rem` (5.6/6.4px) with the button margin zeroed. So the
   shared ring would clip against the rail and overlap its neighbours. 1px is
   what this control has carried since the page was authored; folding it into
   the list would be a visual change, not the placement cleanup #197 asked
   for. */
.fbe-palette-btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
}
/* Moved here from the DDC Workbench section (#308), for the reason #197
   moved the rule above it: this block claims every :focus-visible rule in
   the sheet lives here, and a rule parked beside its component under a
   comment asserting the OPPOSITE convention made that claim contradict
   itself. Consolidation won — a deviating ring keeps its own declaration
   inside this block rather than earning an exemption from it.
   Why it deviates: the ring is INSET where the house default is a 3px
   outset. The two unit links sit against one shared seam inside a single
   bordered pill, so an outset ring would be drawn over the sibling link
   (and over the pill's own border). At -2px it lands inside the focused
   link's own border box, which is the only place it can read as marking
   ONE of the two. */
a.ddcw-unit-link:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
}
input[type="range"]:focus-visible::-webkit-slider-thumb {
    box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-glow);
}
input[type="range"]:focus-visible::-moz-range-thumb {
    box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-glow);
}

/* ── TOUCH-TARGET FLOOR ──
   On touch devices (coarse pointer, no hover), the interactive
   controls are padded to a >=44px hit area (WCAG 2.5.5 / Apple HIG).
   Scoped to (hover: none) so pointer users keep the compact desktop
   workstation density. The .quiz-choice answers clear 44px natively;
   everything else measured under the floor lives here — including
   .copy-btn (22px natively — the audit-2026-06 #24 measurement
   disproved this block's old "copy/submit buttons already clear 44px"
   claim), .filter-chip (28px), .quiz-action (39px), the fullscreen
   button (26px), and the form-control family (#164 measured
   input.ps-input AND select.ps-input at 29px and .field selects at
   38.6px — this block's old ".ps-input fields clear 44px natively"
   claim was equally false; #172 caught the .field INPUTS the #164
   pass missed, at 38.6px). Like FOCUS INDICATORS above, this is
   the single consolidated home for the floor — add new interactives
   here rather than scattering per-component min-heights. Shared-class
   selectors only: widget INTERNALS with no shared class (e.g. the
   vfds source selects) get a page-local (hover: none) floor in their
   own head block instead.
   See codebase-issues #74 / #164 / #172 / ux-audit #3 / #8 / #9 +
   audit-2026-06 #24 / #25 for the why.
   DELIBERATELY ABSENT: button[data-gloss], the inline gloss trigger.
   It is a target "in a sentence, [whose] size is otherwise constrained
   by the line-height of non-target text" — WCAG 2.5.8's inline
   exception, the same standing body-prose links have — and padding a
   ~17px underlined word up to 44px would wreck the line box it sits in.
   Recorded here so the omission reads as a decision, not a miss; the
   reasoning lives in full in the GLOSS TOOLTIP block. */
@media (hover: none) {
    .site-nav-links a,
    .units-btn,
    .theme-btn,
    .nav-search-btn,
    .nav-menu-toggle,
    .nav-menu-item,
    .hero-qt,
    .tab-btn,
    .copy-btn,
    .filter-chip,
    .bec-mode,
    .tool-card-fullscreen-btn,
    .quiz-action,
    .quiz-reset-best,
    .ddcw-resumed-fresh {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-height: 44px;
    }
    /* The display:inline-flex above beats the UA-default
       `[hidden] { display: none }` — the same trap .nav-card[hidden]
       and the .rt-stack tr[hidden] re-assert against elsewhere.
       Re-assert it here, or the refrigerant-loop sim's mode-filtered
       preset row leaks BOTH modes' scenario buttons on touch. */
    .copy-btn[hidden] { display: none; }
    /* The burger gets min-height ONLY — sharing the display
       declaration above overrode its base display:none at ALL widths
       under hover:none, rendering a stray non-functional hamburger
       beside the full link bar on touch tablets (audit-2026-06 #25).
       The ≤620px media block is what reveals it. */
    .nav-hamburger { min-height: 44px; }
    /* Category toggles get min-height ONLY — they're already
       display:flex with the label left + caret right (the shared center
       declaration above would override that justify-content). */
    .nav-group-toggle { min-height: 44px; }
    /* flex-start (not the group's center above): on the desktop bar the
       links are content-width so this is moot, but in the full-width mobile
       sheet the stretched links would otherwise center. Left-align them to
       match the dropdown rows. */
    .site-nav-links a { padding-inline: 0.2rem; justify-content: flex-start; }
    .quiz-settings-select { min-height: 44px; }
    /* Form-control family (#164 / #172): min-height ONLY — replaced
       elements grow their box directly and vertically center their
       text; the group's inline-flex display above would fight native
       select / input rendering. Floored together per the 2026-07-18
       owner call: flooring only the selects would leave 44px selects
       towering over 29px inputs in the same property sheet. #172
       added .field input — #164 floored the .field selects but never
       the inputs, which sat at 38.6px on contact / pid-tuner /
       staging-sequencer. The contact honeypot is .hp-field, not
       .field, so the floor can't catch it, and `input` never matches
       the textareas — contact's message box and textarea.ps-input
       (duct-traverse readings) are multi-row and clear the floor
       natively. */
    .field input,
    .field select,
    input.ps-input,
    select.ps-input { min-height: 44px; }
    /* Disclosure summaries get min-height ONLY — they're left-aligned text
       lines, so the group's center declaration would mis-align them. */
    .pid-spoiler > summary,
    details.tool-preamble > summary,
    details.prose-fold > summary { min-height: 44px; }
    /* Range sliders: the input element IS the painted 4px track (its
       background is the track color), so bare padding would paint a
       fat gray bar. border-box height 44 − 2×20 padding leaves a 4px
       content row; background-clip keeps the paint there while the
       whole 44px box takes the touch (audit #24 measured 4px-tall hit
       boxes on the PID tuning sliders). */
    input[type="range"] {
        height: 44px;
        padding-block: 20px;
        background-clip: content-box;
    }
    /* Dropdown items are full-width rows, not compact chrome — keep them
       left-aligned while still clearing the 44px floor above. */
    .nav-menu .nav-menu-item { justify-content: flex-start; }
}

/* default top spacing matches the dominant calc/converter shape:
   data row above, button row below. The .ps-row exception keeps
   toggle groups (Look up by, Chart range) vertically centered with
   their label sibling — .ps-row's grid + align-items: center would
   otherwise offset the buttons by ~0.5rem. */
.btn-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 1rem; }
.ps-row > .btn-row { margin-top: 0; }

.sim-canvas-wrap {
    position: relative;            /* anchor for absolute-positioned overlays (e.g. psychrometric chart's state-point chip) */
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.75rem 0.5rem;
    margin-bottom: 0.75rem;
}
.sim-canvas-wrap canvas { display: block; width: 100%; height: 260px; }
@media (max-width: 620px) {
    .sim-canvas-wrap canvas { height: 200px; }
}
/* PID playhead — a no-padding relative box so the overlay cursor canvas
   covers #pid-canvas with a shared (0,0) origin (no border/padding offset
   math). The overlay is sized to the chart's box; its backing buffer is set
   in JS to clientWidth*dpr. */
.pid-canvas-stack { position: relative; }
.pid-canvas-stack #pid-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
}

/* ── PID equipment strip — the live process visual above the chart:
   controller (output gauge) → actuator/process scene → sensor (live PV),
   with a feedback line beneath. aria-hidden chrome; the chart + metrics
   carry the accessible story. Responsive stacking is in the breakpoints. */
.pid-eq-strip {
    background: var(--surface);
    border: 1px solid var(--border);
    padding: 0.85rem 0.75rem 0.55rem;
    margin-bottom: 0.75rem;
}
.pid-eq-row { display: flex; align-items: center; gap: 0.85rem; }
.pid-eq-controller,
.pid-eq-sensor { flex: none; width: 160px; }
.pid-eq-scene-host { flex: 1; min-width: 0; }
.pid-eq-scene { display: block; width: 100%; height: auto; }
.pid-eq-feedback {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    margin-top: 0.55rem;
    font-family: var(--mono);
    font-size: 0.6rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-dim);
}
.pid-eq-feedback .pid-eq-fb-line { flex: 1; border-top: 1px dashed var(--blue); }
/* The arrow points back along the strip: left while the loop lays out
   horizontally, up once the ≤700px breakpoint stacks it vertically
   (controller on top, sensor at the bottom). */
.pid-eq-fb-arrow::before { content: "← "; }
@media (max-width: 700px) {
    .pid-eq-fb-arrow::before { content: "↑ "; }
}

/* ── PID "loop details" spoiler — native <details> so you can tune the
   process by feel first, then reveal the loop-speed numbers + cheat sheets
   to check your read. Summary spans the full width over the body padding. */
.pid-spoiler {
    border: 1px solid var(--border);
    background: var(--surface);
    margin: 1.75rem 0 0;
    padding: 0 0.95rem 0.95rem;
}
.pid-spoiler > summary {
    margin: 0 -0.95rem;
    padding: 0.7rem 0.95rem;
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: center;
    gap: 0.55rem;
    font-family: var(--mono);
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-bright);
}
.pid-spoiler > summary::-webkit-details-marker { display: none; }
.pid-spoiler > summary::before { content: "▸"; color: var(--accent); }
.pid-spoiler[open] > summary::before { content: "▾"; }
.pid-spoiler[open] > summary { border-bottom: 1px solid var(--border); margin-bottom: 0.4rem; }
.pid-spoiler > summary:hover { color: var(--accent); }
.pid-spoiler-tag {
    margin-left: auto;
    color: var(--amber);
    border: 1px solid var(--amber);
    border-radius: var(--rail);
    padding: 0.04rem 0.4rem;
    font-size: 0.58rem;
    letter-spacing: 0.1em;
}

/* Equipment strip — stack the controller → scene → sensor row vertically on
   phones (the horizontal loop would otherwise crowd or sideways-scroll). The
   viewBox SVGs scale down with width; the chart's own narrow-width note already
   covers the canvas below. */
@media (max-width: 700px) {
    .pid-eq-row { flex-direction: column; align-items: stretch; gap: 0.6rem; }
    .pid-eq-controller,
    .pid-eq-sensor { width: auto; }
}
.sim-legend {
    display: flex;
    gap: 1.5rem;
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    margin-bottom: 1rem;
    padding-left: 0.25rem;
}
.sim-legend .swatch {
    display: inline-block;
    width: 18px;
    height: 0;
    vertical-align: middle;
    margin-right: 0.45rem;
}
.sim-legend .swatch.pv { border-top: 2px solid var(--accent); }
.sim-legend .swatch.sp { border-top: 2px dashed var(--text-dim); }
@media (max-width: 620px) {
    .sim-legend { gap: 1rem; }
}

.readout-value.warn { color: var(--red); }

/* ── REFERENCE TABLE (property-sheet style) ── */
.ref-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
.ref-table th, .ref-table td {
    text-align: left;
    padding: 0.6rem 0.75rem;
    border-bottom: 1px solid var(--border);
    vertical-align: top;
    line-height: 1.55;
}
.ref-table thead th {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-dim);
    font-weight: 400;
    background: var(--surface-2);
    border-bottom: 1px solid var(--border);
}
.ref-table tbody tr:nth-child(even) { background: var(--surface-2); }
.ref-table tbody tr:hover { background: var(--blue-dim); }
.ref-table td:first-child { color: var(--text-bright); }
.ref-table tbody tr:last-child td { border-bottom: none; }
@media (max-width: 620px) {
    .ref-table       { font-size: 0.78rem; }
    .ref-table th, .ref-table td { padding: 0.5rem 0.5rem; }
}
/* Scroll guard for tables whose min-content width can exceed a narrow
   card (e.g. the pid-tuner spoiler's 4-column reference at <375px
   viewports): the wrapper scrolls instead of the table slicing through
   the card border. Inert (no scrollbar) wherever the table fits. */
.table-scroll { overflow-x: auto; }

/* ── CALLOUT CARDS (PID-term cards, worked-example asides, discovery
   prompts, Easter-egg reveals — same visual treatment everywhere) ── */
.callout-grid { display: grid; gap: 1rem; }
.callout {
    background: var(--bg);
    border: 1px solid var(--border);
    border-left: 2px solid var(--accent);
    border-radius: 0;
    padding: 1rem 1.25rem;
}
.callout h3 {
    font-family: var(--mono);
    font-size: 0.8rem;
    color: var(--text-bright);
    margin: 0 0 0.45rem;
    font-weight: 600;
}
.callout h3 span { color: var(--text-dim); font-weight: 400; }
.callout p { font-size: 0.86rem; line-height: 1.7; }
.callout em { color: var(--text-bright); font-style: normal; }

/* ── NARROW-WIDTH ORIENTATION NOTE — hidden by default; pages flip to
   display:block inside their own @media at whatever breakpoint their
   layout shifts. Frames a layout change as mental-model orientation,
   NOT as apology. Voice reference: pid-tuner.html. ── */
.narrow-width-note {
    display: none;
    font-size: 0.8rem;
    color: var(--text);
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.6rem 0.8rem;
    margin: 0.6rem 0;
}

/* ── DESKTOP-ONLY SIM FALLBACK — shown in place of a drag-wiring editor
   (controller-wiring, function-block-editor) on a screen that can't run
   it: below the 1000px / .fs-desktop-only line, OR any touch-primary
   device (a finger can't both drag a block and pan the canvas, so these
   are pointer-only by design). The page hides its live workspace and
   flips this panel visible in the SAME media query — the gate lives on
   the page, this is just the shared shell. Carries a "come back on a
   desktop" line plus a few field tips so the visit still teaches. Hidden
   by default; pages opt in per the .narrow-width-note pattern. Voice:
   orientation, not apology. ── */
.desktop-only-sim {
    display: none;
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-left: 3px solid var(--accent);
    border-radius: 0;
    padding: 0.95rem 1.15rem 1.05rem;
    margin: 1rem 0 1.25rem;
}
.desktop-only-sim .dos-label {
    font-family: var(--mono);
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--accent);
    margin: 0 0 0.5rem;
}
.desktop-only-sim p {
    font-size: 0.9rem;
    line-height: 1.65;
    color: var(--text);
    margin: 0 0 0.75rem;
}
.desktop-only-sim ul { list-style: none; margin: 0; padding: 0; }
.desktop-only-sim li {
    position: relative;
    font-size: 0.88rem;
    line-height: 1.6;
    color: var(--text);
    padding: 0.25rem 0 0.25rem 1.1rem;
}
.desktop-only-sim li::before {
    content: "▸";
    position: absolute;
    left: 0;
    color: var(--accent);
}
.desktop-only-sim strong { color: var(--text-bright); }
.desktop-only-sim .dos-more { margin: 0.85rem 0 0; font-size: 0.86rem; }

p.pid-note {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    line-height: 1.75;
    margin-top: 1.25rem;
}

/* ──────────────────────────────────────────────────────────────────────
 * LANDING-PAGE CARDS — the clickable tiles on the home page and the
 * Tools / Education landings. Link-shaped cards with a tag, title,
 * and short description.
 * ────────────────────────────────────────────────────────────────────── */
.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: 1rem;
    margin-bottom: 2.5rem;
}
.card-grid.two { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }

/* Filter chip row above the .card-grid on /tools/, /simulators/,
   /education/ and /practice/ — all four chip-bearing landings.
   Each chip toggles which .nav-card[data-category] is visible; URL hash
   tracks state. Chips are <button>s with a mono pill shape — same
   family as the .nav-card-pill statusline items, just larger and
   stateful. */
.filter-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-bottom: 1.5rem;
}

/* ── CHIP-ROW PREAMBLE ── Soft one-liner above a .filter-chips row.
   Element-qualified p. per site convention so .tool-body p doesn't
   out-cascade it. Mirrors p.bit-hint's shape (small mono dim text)
   with tighter bottom-margin since it sits flush above the chips. */
p.chip-row-preamble {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    margin-bottom: 0.5rem;
}
.filter-chip {
    display: inline-flex;
    align-items: baseline;
    gap: 0.4rem;
    font-family: var(--mono);
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.35rem 0.75rem;
    cursor: pointer;
    transition: border-color 0.18s, background 0.18s, color 0.18s;
}
.filter-chip:hover { border-color: var(--accent); color: var(--accent-ink); }
.filter-chip.active {
    color: var(--accent-ink);
    background: var(--accent-dim);
    border-color: var(--accent-glow);
}
.filter-chip-count {
    font-size: 0.65rem;
    color: var(--text-dim);
}
/* The old pairing here was --accent at opacity 0.75, which composited to
   2.67:1 light / 3.39:1 dark on the chip's --accent-dim wash — a real AA
   fail at 10.4px, so the opacity had to go. It leaves the count and the
   chip label both on --accent-ink, i.e. no tonal step between them; the
   size step (11.52px vs 10.4px) is all that marks the count as
   subordinate. That flattening is NOT the preferred look, it is the only
   one available: on the light active chip --accent-ink measures 4.79:1,
   4.26:1 for --text-dim, so there is no green or grey quieter than this
   that still clears 4.5:1. Restoring the step needs a lighter chip wash
   or a smaller/lighter count — an owner visual call, tracked, not made
   here. */
.filter-chip.active .filter-chip-count { color: var(--accent-ink); }
/* The .nav-card rule below sets display:block, which would override the UA
   `hidden { display: none }` and leave hidden cards consuming grid slots.
   Re-assert display: none for the [hidden] case. */
.nav-card[hidden] { display: none; }

/* v2.1.0 — nav cards moved from a flat surface+padding shape to a
   faux-instrument frame that mirrors the home hero (.console-titlebar
   + body + .console-statusline). The outer .nav-card supplies the
   chrome; the inner .nav-card-body carries the prose. Section accent
   (--section-accent) defaults to --accent and is overridden by the
   --tools / --education / --simulators / --home modifier so the
   hover border, name color, and status pill all track the section's
   identity color (matches the gutter motif palette). The -ink twin
   carries the pill's TEXT (see the .ok-pill note below); it defaults
   to --accent-ink, so the green sections need no override. See
   _includes/nav-card.njk for the macro that emits the chrome — pages
   call it instead of hand-rolling the markup. */
.nav-card {
    --section-accent:      var(--accent);
    --section-accent-dim:  var(--accent-dim);
    --section-accent-glow: var(--accent-glow);
    --section-accent-ink:  var(--accent-ink);
    display: flex;
    flex-direction: column;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0;
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    transition: border-color 0.18s, transform 0.18s;
}
/* Featured / marquee card — spans the full grid row. Applied via the
   navCard() `wide` param (e.g. the BACnet hub atop the home Browse grid). */
.nav-card--wide { grid-column: 1 / -1; }
.nav-card--tools      { --section-accent: var(--accent); --section-accent-dim: var(--accent-dim); --section-accent-glow: var(--accent-glow); }
.nav-card--education  { --section-accent: var(--plum);   --section-accent-dim: var(--plum-dim);   --section-accent-glow: var(--plum-glow);  --section-accent-ink: var(--plum-ink); }
.nav-card--simulators { --section-accent: var(--teal);   --section-accent-dim: var(--teal-dim);   --section-accent-glow: var(--teal-glow);  --section-accent-ink: var(--teal-ink); }
.nav-card--practice   { --section-accent: var(--amber);  --section-accent-dim: var(--amber-dim);  --section-accent-glow: var(--amber-glow); --section-accent-ink: var(--amber-ink); }
.nav-card--home       { --section-accent: var(--accent); --section-accent-dim: var(--accent-dim); --section-accent-glow: var(--accent-glow); }
.nav-card--reference  { --section-accent: var(--accent); --section-accent-dim: var(--accent-dim); --section-accent-glow: var(--accent-glow); }

.nav-card:hover { border-color: var(--section-accent); transform: translateY(-2px); }

/* Card chrome — sized down from the hero (.console-titlebar /
   .console-statusline) so a grid of cards stays readable. Smaller
   padding, smaller font, same vocabulary. */
.nav-card-titlebar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.5rem;
    padding: 0.45rem 0.85rem;
    border-bottom: 1px solid var(--border);
    background: var(--surface-2);
    font-family: var(--mono);
    font-size: 0.62rem;
    color: var(--text);
    text-transform: uppercase;
    letter-spacing: 0.08em;
}
/* Title text must single-line — the 4-col card grid at 1920px viewport
   only leaves ~195px for the title beside the right-side status pill,
   and a wrapped titlebar pushes the body down inconsistently across
   the grid. titleShort values in the partial are trimmed to fit; this
   ellipsis safety catches anything that still spills over (a long
   future name, an unforeseen viewport). `min-width:0` is required for
   text-overflow to take effect inside a flex parent — without it the
   span won't shrink below its intrinsic width. */
.nav-card-titlebar > span:first-child {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}
.nav-card-titlebar .titlebar-dots {
    color: var(--text-dim);
    margin: 0 0.35rem;
}
.nav-card-titlebar .ok-pill {
    /* Per-section status-pill tint. Same .ok-pill shape as the hero;
       the colors come from the .nav-card--<section> tokens above.
       TEXT rides the -ink twin — the raw section hues compute
       3.6-4.5:1 on their -dim washes at this 0.62rem size, an AA
       small-text fail in 6 of 8 accent x theme combos (codebase-issues
       #171; same mechanism as --accent-ink, #166). The dot + border
       stay on the raw hue (the 3:1 UI floor passes everywhere). */
    background: var(--section-accent-dim);
    color: var(--section-accent-ink);
    border-color: var(--section-accent-glow);
}
.nav-card-titlebar .ok-pill::before {
    background: var(--section-accent);
}

.nav-card-body {
    padding: 1.1rem 1.2rem;
    flex: 1;
}

.nav-card-statusline {
    padding: 0.5rem 1rem;
    border-top: 1px solid var(--border);
    background: var(--surface-2);
    font-family: var(--mono);
    font-size: 0.6rem;
    color: var(--text-dim);
    letter-spacing: 0.07em;
    text-transform: uppercase;
    font-variant-numeric: tabular-nums;
}
.nav-card-pill {
    /* inline-block: each pill is an atomic inline, so the statusline
       wraps between pills instead of mid-pill ("… · 6" / "SIMULATORS",
       a stranded "~" from "~ 5 MIN"). */
    display: inline-block;
}
.nav-card-pill:not(:last-child)::after {
    content: '•';
    margin: 0 0.35rem;
    color: var(--border);
}
/* Platform marker (navCard desktopOnly) — amber + bold so it reads as a
   constraint, not another feature pill. The page gates its canvas on
   touch (the .desktop-only-sim swap); this warns before the tap-in. */
.nav-card-pill--desktop { color: var(--amber); font-weight: 600; }

.nav-card-name {
    font-family: var(--mono);
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-bright);
    margin-bottom: 0.4rem;
    transition: color 0.18s;
}
.nav-card:hover .nav-card-name { color: var(--section-accent); }
.nav-card-desc {
    font-size: 0.95rem;
    color: var(--text);
    line-height: 1.65;
}

/* ──────────────────────────────────────────────────────────────────────
 * ABOUT-CARD REBUILD — v2.0.0 home page only
 * The home's About panel reframes the wall-of-prose pattern as a BAS-
 * inspector view: a property-sheet header (role / location / industry /
 * verified / status) followed by a recessed prose block (`.about-prose`)
 * with a blue left rule — the same shape as `.widget-anecdote`. The
 * `.live` modifier on a `.ps-value` adds a pulsing accent dot to signal
 * a "running" state, reused from the same `heartbeat` keyframe that
 * drives the footer dot.
 * Only `html/index.html` uses these today. Internals here are the
 * decoration layered on top of the shared `.ps-row` / `.ps-label` /
 * `.ps-value` vocabulary.
 * ────────────────────────────────────────────────────────────────────── */
.about-rebuild .ps-row {
    padding: 9px 1.25rem;
}
.about-rebuild .ps-row:first-child { padding-top: 1rem; }
.about-rebuild .ps-row:last-of-type {
    border-bottom: 1px solid var(--border);
}
.about-rebuild .ps-value.live {
    color: var(--accent);
    font-weight: 600;
    font-size: 0.92rem;   /* pin: this is a decorative "Active" status, not a tool result — opt out of the T-009 .ps-value.live size lift */
}
.about-rebuild .ps-value.live::before {
    content: "";
    display: inline-block;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--accent);
    margin-right: 0.4rem;
    vertical-align: middle;
    animation: heartbeat 2s ease-in-out infinite;
}
.about-prose {
    margin: 1rem 1.25rem 1.25rem;
    padding: 0.85rem 1rem;
    background: var(--bg);
    border: 1px solid var(--border);
    border-left: 3px solid var(--blue);
    border-radius: 0;
    font-size: 0.92rem;
    line-height: 1.75;
    color: var(--text);
}
.about-prose p + p { margin-top: 0.85rem; }
.about-prose a { color: var(--accent); }
.about-prose .colophon {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    margin-top: 1rem;
}

/* ──────────────────────────────────────────────────────────────────────
 * BACK LINK — the small "← All tools" / "← Back to …" anchor that lives
 * under a tool's .tool-card. Kept as its own class so the size and color
 * are consistent across pages (and easy to bump from one place).
 * ────────────────────────────────────────────────────────────────────── */
.back-link {
    display: inline-block;
    margin-top: 1.75rem;
    font-family: var(--mono);
    font-size: 0.85rem;
    color: var(--accent);
    text-decoration: none;
    transition: color 0.18s;
}
.back-link:hover { color: var(--text-bright); }

/* ──────────────────────────────────────────────────────────────────────
 * LESSON SEQUENCE PAGER (E-016) — visible prev/next on curriculum lessons.
 * Rendered in layouts/page.njk from educationSequence.js (the same source
 * head.njk uses for rel=prev/next). Sits below the page's back-link as a
 * continue-the-series bar; width + horizontal padding match `main`.
 * ────────────────────────────────────────────────────────────────────── */
.lesson-seq {
    max-width: 1120px;
    width: 100%;
    margin: 0 auto;
    padding: 0 2rem 3.5rem;
    display: flex;
    justify-content: space-between;
    gap: 1rem;
}
.lesson-seq-col { display: flex; flex: 1 1 0; min-width: 0; }
.lesson-seq-col--next { justify-content: flex-end; }
.lesson-seq-link {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    width: 100%;
    min-width: 0;
    padding: 0.7rem 1rem;
    border: 1px solid var(--border);
    background: var(--surface);
    text-decoration: none;
    transition: border-color 0.12s;
}
.lesson-seq-link:hover { border-color: var(--accent); }
.lesson-seq-next { align-items: flex-end; text-align: right; }
.lesson-seq-dir {
    font-family: var(--mono);
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-dim);
}
.lesson-seq-title {
    font-size: 0.9rem;
    color: var(--accent);
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
@media (max-width: 620px) {
    .lesson-seq { flex-direction: column; padding-left: 1rem; padding-right: 1rem; }
    .lesson-seq-next { align-items: flex-start; text-align: left; }
}

/* ──────────────────────────────────────────────────────────────────────
 * RELATED LINKS — end-of-page cross-links to topically related tools,
 * simulators, and lessons. SEO topic-clustering signal that also helps
 * a reader navigate sideways instead of bouncing back to a landing.
 * Rendered by html/_includes/related-links.njk; sits inside <main>
 * directly above .back-link.
 * ────────────────────────────────────────────────────────────────────── */
.related-links {
    margin-top: 2rem;
    padding: 1.1rem 1.5rem 1.2rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: 1.1rem 2.5rem;
}
.related-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--accent);
    margin: 0 0 0.6rem;
}
.related-links ul {
    list-style: none;
    padding: 0;
    margin: 0;
    font-size: 0.9rem;
    line-height: 1.7;
}
.related-links li {
    padding-left: 0.95rem;
    position: relative;
}
.related-links li::before {
    content: "→";
    position: absolute;
    left: 0;
    color: var(--accent);
    font-family: var(--mono);
}
.related-links a {
    color: var(--text);
    text-decoration: none;
    border-bottom: 1px dotted var(--border);
    transition: color 0.18s, border-color 0.18s;
}
.related-links a:hover {
    color: var(--accent);
    border-color: var(--accent);
}

/* ──────────────────────────────────────────────────────────────────────
 * FAQ BLOCK — end-of-page hand-written Q&A on tool pages. Rendered by
 * html/_includes/faq.njk from the page's `faqs:` frontmatter, which also
 * feeds the FAQPage JSON-LD (faqJsonLd filter) — one source, so the
 * on-page copy and the structured data can't drift. Questions are <h3>
 * so the searched phrase lands in a heading (an on-page ranking lever).
 * Sits inside <main> near the end, above relatedLinks / .back-link.
 * ────────────────────────────────────────────────────────────────────── */
.faq-block {
    margin-top: 2rem;
}
.faq-q {
    font-family: var(--sans);
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-bright);
    line-height: 1.4;
    margin: 1.3rem 0 0.35rem;
}
.faq-q:first-of-type {
    margin-top: 0.9rem;
}
.faq-a {
    font-family: var(--sans);
    font-size: 0.85rem;
    color: var(--text);
    line-height: 1.6;
    margin: 0;
}
.faq-a a {
    color: var(--accent);
    text-decoration: none;
    border-bottom: 1px dotted var(--border);
}
.faq-a a:hover {
    border-color: var(--accent);
}

/* ──────────────────────────────────────────────────────────────────────
 * PRACTICE / QUIZ — the engine in /scripts/quiz-engine.js renders the
 * shell described here inside the page's <div id="quiz"></div>. Pages
 * provide only the empty container + the questions array + the call to
 * Quiz.mount. The accent for the Practice section comes from --amber
 * (see .nav-card--practice above).
 *
 * Visual vocabulary: choices are .quiz-choice buttons styled in the
 * same family as .filter-chip / .units-btn — hairline border, mono
 * caps off here since choice text is sentence-case. Selected and post-
 * reveal correct / wrong states get accent / red borders. Reveal panel
 * borrows the .callout left-bar treatment with --correct (accent) and
 * --incorrect (red) variants. Results card uses .ref-table-dense for
 * the miss list to match the rest of the site's reference-table look.
 * ────────────────────────────────────────────────────────────────────── */

.quiz { display: block; }

.quiz-settings {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.6rem 1rem;
    margin-bottom: 1.25rem;
    padding-bottom: 0.85rem;
    border-bottom: 1px solid var(--border-faint);
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text-dim);
}
.quiz-settings-label {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
}
.quiz-settings-select {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.2rem 0.5rem;
}
.quiz-best-readout {
    margin-left: auto;          /* push to the right on wide; wraps below on narrow */
    font-size: 0.74rem;
    letter-spacing: 0.04em;
    color: var(--text);
    font-variant-numeric: tabular-nums;
}
.quiz-reset-best {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-dim);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.25rem 0.6rem;
    cursor: pointer;
    transition: border-color 0.18s, color 0.18s;
}
.quiz-reset-best:hover { border-color: var(--accent); color: var(--accent); }

.quiz-dirty-notice {
    margin: 0 0 1rem;
    padding: 0.55rem 0.85rem;
    background: var(--amber-dim);
    border: 1px solid var(--amber-glow);
    border-radius: 0;
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--text);
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex-wrap: wrap;
}
.quiz-restart-now {
    font-family: var(--mono);
    font-size: 0.72rem;
    color: var(--amber);
    background: transparent;
    border: 1px solid var(--amber);
    border-radius: 0;
    padding: 0.2rem 0.55rem;
    cursor: pointer;
    transition: color 0.18s, background 0.18s;
}
.quiz-restart-now:hover { color: var(--surface); background: var(--amber); }
/* The dirty-notice and numeric rows below set display: flex / inline-flex,
   which would override the UA-default `[hidden] { display: none }`. Same
   trap that .nav-card[hidden] solves above — re-assert hidden for the
   elements the engine toggles via the `hidden` attribute. */
.quiz-dirty-notice[hidden],
.quiz-numeric[hidden] { display: none; }

.quiz-progress {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin-bottom: 1.4rem;
}
.quiz-progress-text {
    font-family: var(--mono);
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-dim);
    margin: 0;
}
.quiz-progress-bar {
    height: 4px;
    background: var(--surface-3);
    border-radius: 0;
    overflow: hidden;
}
.quiz-progress-fill {
    height: 100%;
    width: 0%;
    background: var(--amber);
    /* Global @media (prefers-reduced-motion: reduce) below squashes
       transitions to 0.01ms — no defensive selector needed here. */
    transition: width 220ms ease;
}

.quiz-question-pane {
    display: flex;
    flex-direction: column;
    gap: 1.1rem;
}
.quiz-prompt {
    font-size: 1.05rem;
    line-height: 1.7;
    color: var(--text);
}
.quiz-prompt code {
    font-family: var(--mono);
    font-size: 0.92em;
    background: var(--surface-3);
    padding: 0.05em 0.35em;
    border-radius: 0;
}

/* Figure slot — the engine clones a question's `figure` SVG out of the
   page's static figure bank into here (see quiz-engine.js's schema
   header). Framed like .quiz-snippet below because it plays the same
   role: a block of evidence the prompt refers to. No margin: the parent
   .quiz-question-pane is a flex column with gap, which owns the spacing
   for every slot (.quiz-snippet-slot zeroes its margin for the same
   reason) — a margin here double-counts and frames the figure
   asymmetrically.
   ⚠️ width:100% does NOT make a dense figure phone-safe, it only stops
   it overflowing. A viewBox-scaled SVG shrinks its labels with the
   frame: an 8.5px label in a 980-unit viewBox renders ~3.3px on a 375px
   phone and ~2.8px at 320px — unreadable. responsive.spec.js cannot
   catch this (it flags an element only when scrollWidth > clientWidth
   AND overflow-x is hidden/clip, which a width:100% SVG never trips),
   so a figure that is too dense to read on a phone ships green. Keep
   figure viewBoxes small and their type large, or give the figure a
   min-width and let overflow-x scroll it. Verify by eye at 320px. */
.quiz-figure {
    padding: 0.75rem;
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 0;
    overflow-x: auto;
}
.quiz-figure svg {
    display: block;
    width: 100%;
    height: auto;
    margin: 0;
}

.quiz-snippet-slot { margin: 0; }
.quiz-snippet {
    font-family: var(--mono);
    font-size: 0.82rem;
    line-height: 1.55;
    color: var(--text);
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.85rem 1rem;
    white-space: pre;
    overflow-x: auto;
}

.quiz-choices {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
}
.quiz-choice {
    display: block;
    width: 100%;
    text-align: left;
    font-family: var(--sans);
    font-size: 0.95rem;
    line-height: 1.55;
    color: var(--text);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.7rem 0.95rem;
    cursor: pointer;
    transition: border-color 0.18s, background 0.18s, color 0.18s;
}
.quiz-choice code {
    font-family: var(--mono);
    font-size: 0.9em;
    background: var(--surface-3);
    padding: 0.02em 0.3em;
    border-radius: 0;
}
.quiz-choice:hover { border-color: var(--amber); }
.quiz-choice[aria-checked="true"],
.quiz-choice.selected {
    border-color: var(--amber);
    background: var(--amber-dim);
    color: var(--text-bright);
}
.quiz-choice[aria-disabled="true"],
.quiz-choice:disabled {
    cursor: default;
    opacity: 0.7;
}
.quiz-choice.correct {
    border-color: var(--accent);
    background: var(--accent-dim);
    color: var(--text-bright);
    opacity: 1;
}
.quiz-choice.wrong {
    border-color: var(--red);
    background: var(--red-dim);
    color: var(--text-bright);
    opacity: 1;
}

.quiz-numeric {
    display: inline-flex;
    align-items: baseline;
    gap: 0.5rem;
}
.quiz-numeric-input {
    font-family: var(--mono);
    font-size: 1rem;
    color: var(--text);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 0;
    padding: 0.5rem 0.75rem;
    width: 9rem;
}
.quiz-numeric-input:focus {
    outline: none;
    border-color: var(--amber);
    box-shadow: 0 0 0 2px var(--amber-glow);
}
.quiz-numeric-unit {
    font-family: var(--mono);
    font-size: 0.85rem;
    color: var(--text-dim);
}
.quiz-numeric.correct .quiz-numeric-input { border-color: var(--accent); background: var(--accent-dim); }
.quiz-numeric.wrong .quiz-numeric-input { border-color: var(--red); background: var(--red-dim); }

.quiz-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 0.6rem;
    margin-top: 1.4rem;
}
.quiz-action {
    font-family: var(--mono);
    font-size: 0.85rem;
    letter-spacing: 0.02em;
    padding: 0.6rem 1.2rem;
    border-radius: 0;
    cursor: pointer;
    transition: color 0.18s, background 0.18s, border-color 0.18s;
}
.quiz-action-primary {
    color: var(--surface);
    background: var(--amber);
    border: 1px solid var(--amber);
}
.quiz-action-primary:hover { background: var(--text-bright); border-color: var(--text-bright); }
.quiz-action-primary:disabled {
    cursor: not-allowed;
    opacity: 0.45;
}
/* Results-card "Next quiz →" link — an <a> wearing the action chrome
   (secondary flavor), so anchor defaults need resetting. */
.quiz-next-link {
    display: inline-flex;
    align-items: center;
    text-decoration: none;
    color: var(--accent);
    background: var(--surface);
    border: 1px solid var(--accent);
}
.quiz-next-link:hover { background: var(--accent-dim); color: var(--text-bright); }
.quiz-action-secondary {
    color: var(--text-dim);
    background: var(--surface);
    border: 1px solid var(--border);
}
.quiz-action-secondary:hover { color: var(--text); border-color: var(--text-dim); }
.quiz-action-secondary:disabled {
    cursor: not-allowed;
    opacity: 0.45;
}

.quiz-reveal {
    margin-top: 1.2rem;
    padding: 0 0 0 1rem;
    border-left: 2px solid transparent;
    /* The panel exists in the DOM at all times so aria-live announces
       on content change, not on insertion. Empty state has no visible
       chrome — only the .--correct / .--incorrect modifiers paint a
       border. */
}
.quiz-reveal:empty { padding: 0; border-left-color: transparent; }
.quiz-reveal--correct { border-left-color: var(--accent); }
.quiz-reveal--incorrect { border-left-color: var(--red); }
.quiz-reveal-status {
    font-family: var(--mono);
    font-size: 0.78rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    margin: 0 0 0.55rem;
    color: var(--text-dim);
}
.quiz-reveal--correct .quiz-reveal-status { color: var(--accent); }
.quiz-reveal--incorrect .quiz-reveal-status { color: var(--red); }
.quiz-reveal-right {
    font-size: 0.9rem;
    color: var(--text);
    margin: 0 0 0.6rem;
}
.quiz-reveal-explain {
    font-size: 0.92rem;
    line-height: 1.65;
    color: var(--text);
}
.quiz-reveal-explain p + p { margin-top: 0.6rem; }
.quiz-reveal-explain code {
    font-family: var(--mono);
    font-size: 0.9em;
    background: var(--surface-3);
    padding: 0.05em 0.35em;
    border-radius: 0;
}
.quiz-reveal-learn-more {
    font-family: var(--mono);
    font-size: 0.78rem;
    margin-top: 0.7rem;
    color: var(--text-dim);
}
.quiz-reveal-learn-more a {
    color: var(--accent);
    text-decoration: none;
    border-bottom: 1px dotted var(--border);
}
.quiz-reveal-learn-more a:hover {
    border-color: var(--accent);
}

.quiz-results {
    padding: 1.5rem 0.25rem 0.5rem;
    text-align: center;
}
.quiz-results-headline {
    font-family: var(--mono);
    font-size: 1.6rem;
    font-weight: 700;
    color: var(--text-bright);
    margin: 0 0 0.35rem;
    font-variant-numeric: tabular-nums;
}
.quiz-results-sub {
    font-family: var(--mono);
    font-size: 0.82rem;
    color: var(--text-dim);
    margin: 0 0 1.6rem;
}
.quiz-results-newbest {
    color: var(--amber);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 0.74rem;
}
/* A perfect run that didn't beat a longer stored best still gets a green
   'flawless' nod (P-006). Distinct hue from the amber new-best so the two
   acknowledgements read as different things. */
.quiz-results-flawless {
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 0.74rem;
}
.quiz-results-misses-heading {
    font-family: var(--mono);
    font-size: 0.78rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-dim);
    margin: 0 0 0.65rem;
    text-align: left;
}
.quiz-results-misses {
    text-align: left;
    margin-bottom: 1.4rem;
}
.quiz-results-misses td { vertical-align: top; }
.quiz-results-misses a { color: var(--accent); text-decoration: none; border-bottom: 1px dotted var(--border); }
.quiz-results-misses a:hover { border-color: var(--accent); }
.quiz-results-actions { justify-content: center; }
.quiz-restart-btn { background: var(--amber); border-color: var(--amber); color: var(--surface); }
.quiz-restart-btn:hover { background: var(--text-bright); border-color: var(--text-bright); }

/* Narrow viewports — stack the action row full-width so a thumb can
   reach both buttons; settings wrap onto multiple lines. The
   .quiz-best-readout flex auto-margin already drops it below on wrap. */
@media (max-width: 560px) {
    .quiz-actions {
        flex-direction: column;
        align-items: stretch;
    }
    .quiz-action { width: 100%; }
}

/* ──────────────────────────────────────────────────────────────────────
 * CTA BUTTON — a prominent in-page link styled like a primary button.
 * Used for "Try it for yourself →" and other end-of-page calls to action.
 * ────────────────────────────────────────────────────────────────────── */
.cta-button {
    display: inline-block;
    font-family: var(--mono);
    font-size: 0.85rem;
    font-weight: 500;
    letter-spacing: 0.02em;
    padding: 0.7rem 1.4rem;
    background: var(--surface);
    border: 1px solid var(--accent);
    border-radius: 0;
    color: var(--accent);
    text-decoration: none;
    transition: background 0.18s, color 0.18s;
}
.cta-button:hover { background: var(--accent); color: var(--surface); }

/* ──────────────────────────────────────────────────────────────────────
 * THREE-COLUMN PROPERTY-SHEET LAYOUT
 *
 * A denser "workstation-tool" layout for tools where input / output /
 * reference make sense as three distinct surfaces side by side, rather
 * than stacked. Borrows Niagara's property-sheet grammar: label-LEFT /
 * value-RIGHT rows with hairline dividers, small-caps section headers, a
 * recessed reference panel ALONGSIDE the active tool, dense tables. See
 * site-ideas-and-friction.md → "Visual design — two products, one
 * codebase" for the higher-level design philosophy this pattern serves.
 *
 * Adopters: tools/bacnet-ip-converter.html, tools/signal-scaling.html,
 * tools/modbus-register-viewer.html. The PID tuner (tools/pid-tuner.html)
 * deliberately keeps its custom stacked layout — its simulator (canvas +
 * sliders + parameter-style selector + preset chips + metrics) doesn't fit
 * the Input / Output / Reference shape without forcing it; only its
 * symptom → tuning-move cheat sheet adopts the .ref-table-dense styling.
 * ────────────────────────────────────────────────────────────────────── */

/* the grid: three equal columns, butting against the card edges (no gap).
   Each section carries its own padding; the last one is the recessed
   reference panel. Lives directly inside a .tab-pane (or .tool-card), not
   inside a padded .tool-body. */
.tool-body-3col {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;
}
.tool-body-3col > section {
    padding: 1.25rem 1.25rem 1.5rem;
    border-right: 1px solid var(--border);
}
.tool-body-3col > section:last-child {
    border-right: none;
    background: var(--surface-3);
}

/* same shape, two equal columns — for tools whose reference content lives
   in a sibling .tool-card rather than the third column (e.g. the BACnet
   converter). No recessed last-child since there is no reference panel. */
.tool-body-2col {
    display: grid;
    grid-template-columns: 1fr 1fr;
}
.tool-body-2col > section {
    padding: 1.25rem 1.25rem 1.5rem;
    border-right: 1px solid var(--border);
}
.tool-body-2col > section:last-child { border-right: none; }

/* Full-width sibling row that sits below a .tool-body-2col grid. Same
   recessed surface + top border as the .tool-body-3col reference column —
   used when a sparse Output column would leave a dense reference / worked-
   example column visually orphaned at the right. No grid template to
   collapse, so no responsive variant needed; the parent (.tab-pane or
   .tool-card) already handles viewport-width changes. */
.tool-body-row {
    padding: 1.25rem 1.25rem 1.5rem;
    border-top: 1px solid var(--border);
    background: var(--surface-3);
}

/* tabs sitting directly above a .tool-body-3col: inset the buttons to line
   up with the column padding, drop the bottom margin so the grid butts
   right up under the tab strip. (The .tab-btn underline indicator is
   unchanged — see the .tabs / .tab-btn rules above.) */
.tabs.tabs-flush { padding: 0 1.25rem; margin-bottom: 0; }

/* small-caps column header inside a 3-col body (INPUT / OUTPUT / …).
   (The redesign mockup called this `.section-label`; renamed to
   .ps-section-label to avoid colliding with the existing page-level
   .section-label used in .section-header.) */
.ps-section-label {
    font-family: var(--sans);
    font-size: 0.7rem;
    font-weight: 700;
    color: var(--text-dim);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    margin: 0 0 0.85rem;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--border);
}

/* property-sheet row: label on the left, value or input on the right,
   faint hairline between rows. The Niagara-grammar replacement for the
   stacked layout's label-above-input .form-row / .field. */
.ps-row {
    display: grid;
    grid-template-columns: minmax(90px, 38%) 1fr;
    gap: 12px;
    padding: 9px 0;
    border-bottom: 1px solid var(--border-faint);
    align-items: center;
    min-height: 38px;
}
.ps-row:last-of-type { border-bottom: none; }
/* --text-dim here is the same deliberate quiet-caption choice as the shared
   `label, .field-label` rule — see the standing-answer comment above that
   rule for the rationale, the measured ratios, and the page-local escape
   hatch (codebase-issues #168). */
.ps-label {
    font-family: var(--sans);
    font-size: 0.82rem;
    color: var(--text-dim);
    text-transform: none;
    letter-spacing: normal;
}
.ps-value {
    font-family: var(--mono);
    font-size: 0.82rem;
    color: var(--text);
    font-variant-numeric: tabular-nums;
}
/* a live / actionable output value. (The mockup called this modifier
   `.readout`; renamed to `.live` because there is already a global
   `.readout` box class — see the bit-viewer / PID-metrics section above —
   that an element with both classes would inherit.) */
.ps-value.live {
    color: var(--blue);
    font-weight: 600;
    font-size: 1.1rem;       /* T-009: lifted from 0.92rem so the answer outweighs the 0.82rem label — size, not just colour, now carries the hierarchy */
    overflow-wrap: anywhere;
}
/* T-009 primary headline: the single solved-for value a tool exists to produce
   (signal-scaling output, coil total capacity, valve Cv …). The marker sits on
   the STATIC .ps-row — setLive() rewrites the value element's className
   wholesale (`el.className = 'ps-value live'`), so a modifier on the value would
   be wiped; the row is never touched by the result JS. */
.ps-row.primary .ps-value.live { font-size: 1.4rem; }
.ps-value.error { color: var(--red); }  /* out-of-range output (placed after .live so it wins the colour) */
.ps-value.muted {                       /* a note, an absent value, a placeholder */
    color: var(--text-dim);
    font-family: var(--sans);
    font-style: italic;
    font-size: 0.78rem;
}
/* the input variant for a .ps-row's value cell — works on <input>, <select>
   and <textarea>. The element-qualified selectors (not bare `.ps-input`)
   outrank the global `input[type=…]` / `select` block above so the denser
   sizing wins. */
input.ps-input, select.ps-input, textarea.ps-input {
    width: 100%;
    padding: 5px 8px;
    border: 1px solid var(--border);
    background: var(--bg);
    font-family: var(--mono);
    font-size: 0.82rem;
    color: var(--text-bright);
    border-radius: 0;
    -webkit-appearance: none;
}
input.ps-input:focus, select.ps-input:focus, textarea.ps-input:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: none;                   /* the dense layout uses a border change, not the glow */
}

/* dense reference table — tighter rows than .ref-table, sans-caps headers,
   for lookup data that lives in a side panel rather than the main flow. */
.ref-table-dense {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.78rem;
}
.ref-table-dense th {
    text-align: left;
    font-family: var(--sans);
    font-size: 0.7rem;
    font-weight: 700;
    color: var(--text-dim);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    padding: 6px 8px;
    border-bottom: 1px solid var(--border);
    background: var(--surface-2);
}
.ref-table-dense td {
    padding: 5px 8px;
    border-bottom: 1px solid var(--border-faint);
    font-family: var(--mono);
    color: var(--text);
}
.ref-table-dense td.use {               /* a prose "typical use" cell, not data */
    font-family: var(--sans);
    font-size: 0.78rem;
    color: var(--text-dim);
}
.ref-table-dense tbody tr:nth-child(even) td { background: var(--surface-2); }
.ref-table-dense tbody tr:last-child td { border-bottom: none; }

/* the "nothing matched" row every filter-driven lookup table keeps as a
   permanent <tr hidden>, unhidden when a filter empties its tbody. Shared
   because the message cell is table chrome, not a widget internal — five
   pages had defined it identically page-locally (#304). Sits after both
   table families so it out-orders their equal-specificity td colour; the
   .rt-stack cell rules are (0,3,3) and set neither of the two declarations
   that do the work here. Pages keep their own <prefix>-empty class on the
   same <tr> — that one is the filter script's and the specs' hook. */
.ref-empty td {
    color: var(--text);
    font-style: italic;
    text-align: center;
}

/* stacked-row opt-in (.rt-stack) for lookup tables whose last column is
   prose: below 620px each row stacks — the leading cells (id, name)
   share a header line and the prose cell drops to a full-width line
   beneath — so nothing clips inside the overflow-hidden .tool-card and
   nothing needs a sideways scroll. Works on .ref-table and
   .ref-table-dense alike; pair with a .table-scroll wrapper as an
   inert safety net. Mechanics that are load-bearing here:
   - tr[hidden] must be restated — the author display:flex beats the UA
     [hidden] rule, else the bacnet-objects filter stops hiding rows;
   - the cell rules ride the two-class tbody-tr-td shape (0,2,3) to
     out-rank page-local column classes (.bo-name's nowrap is (0,2,0));
   - zebra moves from the cells to the row (the dense base paints td
     backgrounds — killed by the later background:none at equal
     specificity). */
@media (max-width: 620px) {
    .rt-stack thead { display: none; }
    .rt-stack tbody tr {
        display: flex;
        flex-wrap: wrap;
        align-items: baseline;
        gap: 0 0.6rem;
        padding: 0.5rem 0.5rem;
        border-bottom: 1px solid var(--border-faint);
    }
    .rt-stack tbody tr[hidden] { display: none; }
    .rt-stack tbody tr:last-child { border-bottom: none; }
    .ref-table.rt-stack tbody tr td,
    .ref-table-dense.rt-stack tbody tr td {
        display: block;
        padding: 0;
        border-bottom: none;
        background: none;
        white-space: normal;
    }
    .ref-table.rt-stack tbody tr td:last-child,
    .ref-table-dense.rt-stack tbody tr td:last-child {
        flex-basis: 100%;
        font-family: var(--sans);
        font-size: 0.78rem;
        line-height: 1.5;
        color: var(--text);
        margin-top: 0.2rem;
    }
    .rt-stack tbody tr:nth-child(even) { background: var(--surface-2); }
}

/* a short explanatory note under a reference table. Element-qualified, and
   that `p` is load-bearing beyond the usual specificity reason: the variants
   below include ol.ref-note.worked-list, whose ~85 worked-example <li> render
   at body size on --text precisely BECAUSE this rule cannot reach them. Never
   de-qualify it to a bare .ref-note (#290). */
p.ref-note {
    font-family: var(--sans);
    font-size: 0.78rem;
    color: var(--text);
    line-height: 1.55;
    margin-top: 0.85rem;
    padding-top: 0.85rem;
    border-top: 1px solid var(--border-faint);
}
/* worked-example pair — .worked-intro paragraph + .worked-list <ol>
   sit together inside a "Worked example" .tool-body-row section. The
   intro paragraph kills the base ref-note's top spacing (the section
   label above already provides it); the list adds its own indent. */
p.ref-note.worked-intro { margin-top: 0; }
ol.ref-note.worked-list { padding-left: 1.2em; }
/* compact ref-note variant for mini-sim captions sitting tight under
   a canvas readout — drops the base rule's border + heavy top spacing. */
p.ref-note.compact {
    margin-top: 0.5rem;
    padding-top: 0;
    border-top: none;
}

/* mobile / responsive — collapse the multi-column grids to a single stack
   (Input → Output → Reference). 3-col collapses earlier (≤1000px) than
   2-col (≤900px): three columns at the new 1120px main cap still leave
   each column ~340px, but at viewport ~1000px (1120px container minus
   side padding minus borders) each column is back to today's cramped
   ~290px. Collapsing 3-col at 1000px keeps psychrometric-chart /
   thermistor-calculator readable on narrow desktops and tablets. 2-col
   tools have headroom and only need to collapse at the tablet break.
   The "two products, one codebase" plan eventually wants a purpose-built
   mobile experience for these tools; this is the conservative pass.
   // mobile-first treatment of this tool is a future task — see site-ideas-and-friction.md */
@media (max-width: 1000px) {
    .tool-body-3col { grid-template-columns: 1fr; }
    /* min-width: 0 kills the grid item's automatic minimum — without it
       a 1fr track can't shrink below the section's min-content, so a
       wide table's width punches through its .table-scroll wrapper and
       the card clips it (the wrapper only scrolls when the section is
       allowed to be narrower than the table). */
    .tool-body-3col > section { border-right: none; border-bottom: 1px solid var(--border); min-width: 0; }
    .tool-body-3col > section:last-child { border-bottom: none; }
}
@media (max-width: 900px) {
    .tool-body-2col { grid-template-columns: 1fr; }
    .tool-body-2col > section { border-right: none; border-bottom: 1px solid var(--border); min-width: 0; }
    .tool-body-2col > section:last-child { border-bottom: none; }
}

/* ──────────────────────────────────────────────────────────────────────
 * EDUCATION-PAGE SVG DIAGRAMS
 *
 * Shared block + legend for hand-written inline schematics on Education
 * pages with pipe-flow diagrams. Consolidated from the per-page
 * .hd-svg / .lp-svg rules once the third page with pipe-flow diagrams
 * (education/pump-control.html) landed — the trigger called out in the
 * friction file's load-piping entry.
 *
 * Supply piping is solid + --blue; return piping is dashed + --blue-cool
 * — distinguishable without colour. Element-level fallbacks (literal
 * hex colours alongside the var()) live in the SVG markup itself, so a
 * stylesheet failure still leaves a legible black-on-white diagram.
 *
 * The flow-active override below: while the flow-engine particle layer
 * is running on an SVG, drop the dashed-return stroke pattern.
 * Direction-of-motion alone distinguishes supply from return at that
 * point, and the stationary dash pattern visually competes with the
 * moving particles. flow-active is added by flow-engine.js only after
 * its reduced-motion bail-out, so reduced-motion keeps the dashes;
 * @media screen scopes the override so a printout still gets the
 * dashed encoding even if the page was animating on screen. Selector
 * intentionally targets any element type (paths and lines both carry
 * data-flow). The .edu-legend return swatch mirrors this: solid by
 * default (matching the animated diagram), dashed again under
 * @media print / prefers-reduced-motion, so the key always agrees
 * with what that visitor actually sees. Page-specific structural
 * diagrams without supply/return (e.g. the VFDs block diagram) stay
 * separate from this family — see vfds.html for the .vfd-svg precedent.
 *
 * Air ducts (education/air-handlers.html onward) ride the same family
 * with data-flow="air": particle fill follows each duct's stroke
 * attribute (OA --teal, RA --blue-cool, SA --blue, EA --text-dim), so
 * there is no per-stream engine type. Exhaust/relief ducts are dashed
 * — the non-color encoding, mirroring the water return — and drop
 * their dashes while animated exactly like return piping. The .sw.oa /
 * .sw.ra / .sw.sa / .sw.ea legend swatches below follow the same
 * solid-while-animated / dashed-when-static agreement as .sw.return.
 * ────────────────────────────────────────────────────────────────────── */
.edu-svg {
    display: block;
    width: 100%;
    height: auto;
    margin: 1.5rem 0;
}
/* Flow-main directional captions on the pipe-flow diagrams ("supply
   main →" / "← return main"). Promoted from per-<text> presentation
   attributes that carried the same shape on hydronic-loops /
   load-piping / pump-control (codebase-issues #141): 11px mono, normal
   weight, var(--text) — inherited from the parent svg's mono. Only the
   font/fill move here; the positional attrs (x / y / text-anchor) stay
   inline since they vary per caption. The smaller inset-diagram pair
   (10px / var(--text-dim)) is a deliberately quieter treatment and
   keeps its own inline attrs. */
.edu-caption {
    font-family: var(--mono);
    font-size: 11px;
    font-weight: 400;
    fill: var(--text);
}
.edu-legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem 1.5rem;
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    margin: -0.5rem 0 0;
    padding-left: 0.25rem;
}
.edu-legend .sw { display: inline-block; width: 26px; height: 0; vertical-align: middle; margin-right: 0.4rem; }
.edu-legend .sw.supply { border-top: 3px solid var(--blue); }
.edu-legend .sw.return { border-top: 3px solid var(--blue-cool); }
.edu-legend .sw.oa { border-top: 3px solid var(--teal); }
.edu-legend .sw.ra { border-top: 3px solid var(--blue-cool); }
.edu-legend .sw.sa { border-top: 3px solid var(--blue); }
.edu-legend .sw.ea { border-top: 3px solid var(--text-dim); }

@media screen {
    svg.flow-active [data-flow="return"],
    svg.flow-active [data-flow="air"][stroke-dasharray] {
        stroke-dasharray: none;
    }
}

@media print, (prefers-reduced-motion: reduce) {
    .edu-legend .sw.return { border-top-style: dashed; }
    .edu-legend .sw.ea { border-top-style: dashed; }
}

/* ──────────────────────────────────────────────────────────────────────
   BLOCK-AND-BYTE DIAGRAMS — static labeled-box schematics on the
   protocol Education pages (.mb-svg: Modbus Basics / Decoding;
   .bac-svg: BACnet Basics / Networking / MS/TP). Consolidated from
   per-page {% block head %} copies when the third BACnet-family page
   landed (the trigger both families recorded). Distinct from the
   pipe-flow .edu-svg family (no data-flow, no animation) and from
   the page-local .fb-svg / .vfd-svg structural diagrams (single
   consumers). The four per-page copies had drifted to a mix of 740px
   and 760px on .wide — unified here at 760 (the variance was copy
   drift, not intent; a 20px max-width delta is invisible in practice).
   ────────────────────────────────────────────────────────────────────── */
.bac-svg,
.mb-svg {
    display: block;
    width: 100%;
    height: auto;
    max-width: 620px;
    margin: 1.4rem auto 0.6rem;
}
.bac-svg.wide,
.mb-svg.wide { max-width: 760px; }

/* ──────────────────────────────────────────────────────────────────────
   WIDGET CHROME — shared "interactive panel" vocabulary used by the
   five widget pages: pump-control (×2 widgets), balancing, vfds,
   vfd-mock, hydronic-loops (d3 twin-T injection-pump widget).

   The widget shell is the recessed --surface-3 panel + the standard
   bits inside it: a "Try this:" prompt-link row, mono caps section
   labels, slider with label/value head, blue-readout pairs, the
   anecdote callout (with min-height to reserve space so reveals
   don't reflow the page below), and the 5-blade fan icon shared
   between the pump-control and vfds widgets. Page-specific
   INTERNALS (LCD, keypad, branch states, valve pills, temperature
   swatches, pump-curve canvas) stay in each page's {% block head %}
   since only that page uses them.

   Default anecdote left rule is --blue (the "extreme-state reward"
   variant on pump-control / vfds / hydronic-loops). The .warn
   modifier flips it to --red for failure callouts (balancing).
*/

.widget {
    margin-top: 1.25rem;
    padding: 1.1rem 1.25rem;
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 0;
}

.widget-try {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    margin-bottom: 0.85rem;
    line-height: 1.7;
}
.widget-try .lbl { letter-spacing: 0.05em; text-transform: uppercase; margin-right: 0.6rem; }
/* Try-row chips are real <button>s for keyboard/AT reach (#142/#143);
   every row on the site uses them, so new try-rows should too. */
.widget-try button {
    font: inherit;
    color: var(--accent-ink);
    background: none;
    border: none;
    border-bottom: 1px dashed var(--accent-dim);
    padding: 0;
    margin-right: 0.85rem;
    cursor: pointer;
}
.widget-try button:hover { color: var(--text-bright); border-bottom-color: var(--accent); }

.widget-section-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    color: var(--text-dim);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    margin-bottom: 0.5rem;
}

.widget-slider-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    font-family: var(--mono);
    font-size: 0.72rem;
    color: var(--text-dim);
    margin-bottom: 0.1rem;
}
.widget-slider-head .v {
    color: var(--blue);
    font-weight: 600;
    font-size: 0.95rem;
}
.widget-slider { width: 100%; margin: 0.15rem 0 0.4rem; }

.widget-readout-label {
    font-family: var(--mono);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-dim);
    margin-bottom: 0.15rem;
}
.widget-readout-value {
    font-family: var(--mono);
    font-size: 1.0rem;
    font-weight: 600;
    color: var(--blue);
    line-height: 1.15;
    font-variant-numeric: tabular-nums;
}
.widget-readout-value .unit {
    font-size: 0.65em;
    color: var(--text-dim);
    font-weight: 500;
    margin-left: 0.2rem;
}

.widget-anecdote-wrap { min-height: 1.5rem; margin-top: 0.6rem; }
.widget-anecdote {
    background: var(--bg);
    border: 1px solid var(--border);
    border-left: 4px solid var(--blue);
    border-radius: 0;
    padding: 0.75rem 1rem;
    font-size: 0.86rem;
    line-height: 1.7;
    color: var(--text);
}
.widget-anecdote strong { color: var(--text-bright); font-weight: 600; }
.widget-anecdote.warn { border-left-color: var(--red); }

/* ── FAILURE-STATE CALLOUT — sits below a .ps-row result when the input
   branch is invalid (result-value goes muted '—', this callout names
   the failure mode AND the action). Sibling to .widget-anecdote (same
   shell shape, different domain: tool outputs, not education widgets).
   Warm-orange left rule matches the in-use failure-warning hue on
   the .status-pill.warn state below. ── */
.failure-callout {
    margin-top: 0.6rem;
    padding: 0.75rem 1rem;
    background: var(--bg);
    border: 1px solid var(--border);
    border-left: 4px solid var(--heat);
    border-radius: 0;
    font-size: 0.86rem;
    line-height: 1.7;
    color: var(--text);
}
.failure-callout strong { color: var(--text-bright); font-weight: 600; }

/* ── STATUS PILL — multi-state verdict chip shown below a tool's result
   rows (feasibility / coil-direction / saturation-pin checks). Mono,
   recessed --surface-2 face; the .ok / .warn / .error modifiers tint the
   border (and the fill, for .ok) per state. Distinct from .failure-callout
   above — that's a single static warn callout with a left rule; this is a
   stateful inline pill. Shared by economizer-ratio, air-mixing,
   coil-sizing, and refrigerant-pt (each formerly carried its own
   page-local copy — .er-feas / .am-status / .cs-status / .rf-status);
   dew-point-calculator rides the base with page-local deltas
   (.dew-verdict — pad, .ok brightness, amber .edge state). ── */
.status-pill {
    margin-top: 1rem;
    padding: 0.5rem 0.75rem;
    border-radius: var(--rail);
    font-family: var(--mono);
    font-size: 0.82rem;
    line-height: 1.5;
    border: 1px solid var(--border);
    background: var(--surface-2);
    color: var(--text);
}
.status-pill.ok    { border-color: var(--accent); background: var(--accent-dim); }
.status-pill.warn  { border-color: var(--heat);   color: var(--heat); }
.status-pill.error { border-color: var(--red);    color: var(--red-text); }

/* 5-blade fan icon — visual handle for "the drive is energising
   something." Used by the run/speed widget on vfds and both
   pump-control widgets. The SVG markup (center + 4 ellipses at
   72°/144°/216°/288° around 50,50) lives inline on each page; the
   per-frame rAF loop writes a rotate transform onto the .widget-
   fan-blades group. Pages add their own positioning/sizing
   alongside (e.g. .vfd-w-status-row .widget-fan or .pc-w-fan-wrap
   .widget-fan).

   Reduced-motion: the JS engine short-circuits when the OS pref
   is set, leaving blade angle at 0. The colour swap below still
   communicates running vs stopped. The !important override on
   .widget-fan-blades is belt-and-braces against a stale transform
   attribute lingering on the group. */
.widget-fan ellipse {
    fill: var(--blue-cool);
    transition: fill 0.25s;
}
.widget-fan.spinning ellipse {
    fill: var(--blue);
}

/* ──────────────────────────────────────────────────────────────────────
 * BAS FLARE — "BAS-instrument quiet" widget polish.
 *
 * Subtle panel-instrument hooks reused across widgets:
 *   .bas-led / .bas-led.active / .bas-led.fault — 6px round status
 *     dot. Neutral by default; .active gets the accent fill + a soft
 *     pulsing ring; .fault swaps to --red and pulses faster. aria-
 *     hidden in pages since the adjacent text-state already conveys
 *     status to screen readers.
 *   .lcd-flicker — one-shot 150ms opacity dip; JS adds the class
 *     then removes it on a setTimeout to trigger the dip on every
 *     LCD value update.
 *   .bas-state-flash — one-shot 350ms outer accent-glow ring around
 *     a row/card. JS adds the class on a state transition and
 *     removes it on a setTimeout.
 *   .bas-breathe — slow box-shadow breathing accent ring (token-
 *     driven, reads on both themes). The psychrometric state-point
 *     chip uses a tighter page-local variant; this shared form is
 *     kept for reuse.
 *
 * Reduced motion: the global @media (prefers-reduced-motion: reduce)
 * block below already disables all keyframe animations site-wide, so
 * these rules don't need their own guard. LEDs still color-code state
 * — only the pulse goes quiet.
 * ────────────────────────────────────────────────────────────────────── */
.bas-led {
    display: inline-block;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--text-dim);
    border: 1px solid var(--border);
    vertical-align: middle;
    flex-shrink: 0;
}
.bas-led.active {
    background: var(--accent);
    border-color: var(--accent-glow);
    box-shadow: 0 0 0 2px var(--accent-soft);
    animation: bas-led-pulse 2s ease-in-out infinite;
}
.bas-led.fault {
    background: var(--red);
    border-color: rgba(196,56,47,0.32);
    box-shadow: 0 0 0 2px rgba(196,56,47,0.08);
    animation: bas-led-pulse 1.2s ease-in-out infinite;
}
.bas-led.warn {
    background: var(--heat);
    border-color: rgba(200,120,42,0.32);
    box-shadow: 0 0 0 2px rgba(200,120,42,0.08);
    animation: bas-led-pulse 1.6s ease-in-out infinite;
}
@keyframes bas-led-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.55; }
}

@keyframes lcd-flicker {
    0%   { opacity: 1; }
    45%  { opacity: 0.68; }
    100% { opacity: 1; }
}
.lcd-flicker { animation: lcd-flicker 150ms ease-in-out 1; }

@keyframes bas-state-flash {
    0%   { box-shadow: 0 0 0 2px var(--accent-glow), 0 0 0 4px var(--accent-soft); }
    100% { box-shadow: 0 0 0 2px transparent,        0 0 0 4px transparent; }
}
.bas-state-flash { animation: bas-state-flash 350ms ease-out 1; }

@keyframes bas-breathe {
    0%, 100% { box-shadow: 0 0 0 6px var(--accent-dim); }
    50%      { box-shadow: 0 0 0 11px var(--accent-soft); }
}
.bas-breathe { animation: bas-breathe 1.8s ease-in-out infinite; }

/* ──────────────────────────────────────────────────────────────────────
 * SOFTWARE-REGISTER MOTIFS — Niagara nav-tree + trend sparkline.
 *
 * The software counterparts to the equipment kit: a point-tree (the
 * Niagara/SCADA station cue) and a self-drawing trend line (data-driven
 * motion). Shared so the home-page seam hero and /styleguide.html read
 * from one source. The function-block wiresheet is deliberately NOT
 * promoted here — it has no second consumer yet (codebase-issues#78).
 * ────────────────────────────────────────────────────────────────────── */
.tree { font-family: var(--mono); font-size: 0.78rem; }
.tree .row { display: flex; align-items: center; gap: 0.45rem; padding: 0.28rem 0.4rem; color: var(--text); }
.tree .row.sel { background: var(--blue-dim); color: var(--text-bright); box-shadow: inset 2px 0 0 var(--blue); }
.tree .ic { width: 11px; height: 11px; flex: none; }
.tree .indent { padding-left: 1.2rem; }
.tree .indent2 { padding-left: 2rem; }
.tree .ic.station { background: var(--blue); }
.tree .ic.point { background: var(--accent); }
.tree .ic.alarm { background: var(--red); }
.tree .ic.folder { background: var(--text-dim); }

/* trend sparkline — the line draws itself in once (data-driven motion). A
   consumer that updates the line live (the hero seam) turns the draw-in
   off via its own override. The gradient id `trend-grad` is defined in
   each consuming page's SVG <defs>. */
.trend { width: 100%; height: 54px; display: block; }
.trend .grid-l { stroke: var(--border-faint); stroke-width: 1; }
.trend .area { fill: url(#trend-grad); opacity: 0.4; }
.trend .line {
    fill: none;
    stroke: var(--blue);
    stroke-width: 2;
    vector-effect: non-scaling-stroke;
    stroke-dasharray: 600;
    stroke-dashoffset: 600;
    animation: trend-draw 2.2s ease-out forwards;
}
@keyframes trend-draw { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) {
    .trend .line { stroke-dashoffset: 0; }   /* snap drawn — animation is off */
}

/* ──────────────────────────────────────────────────────────────────────
 * EQUIPMENT REGISTER — warm device face + positive-mode dot-matrix LCD.
 *
 * The second of the two semantic registers (see the :root THEME TOKENS
 * comment). Used ONLY where a page depicts real hardware: VFD/DMM sims,
 * sensor/device widgets, and the one readout in a software tool that
 * shows a physical field value. Warm, dimensional, lit — because it's a
 * device. These tokens (--dev-* / --lcd-*) and look stay CONSTANT across
 * light/dark; a device is a device in either theme.
 *
 * Distilled from design-spike.html (the locked reference). No production
 * page consumes these yet — /styleguide.html is the living consumer, and
 * the VFD mock is the first page slated to adopt them (Phase 3). Keep the
 * register clearly distinct from the flat slate software .panel/.tool-card
 * chrome so the texture *means* "this is hardware."
 * ────────────────────────────────────────────────────────────────────── */

/* Device status LED — glowing, sits on a device face. Distinct from the
   flat software-widget .bas-led: the colour rides a local --c so each
   modifier just reassigns it (no var(--x, #hex) fallback).

   Every lit state reads a frozen --led-* constant from :root rather than
   the software-register hue it used to share, so the device face shows
   one LED colour in both themes (codebase-issues #280). The base --c
   default is frozen too, but it is a FALLBACK, not a rendered state:
   every caller names a modifier, so no element on the site paints a bare
   `class="led"`. An earlier version of this comment claimed the blown
   fuse on simulators/controller-wiring.html did — it never did. That
   page's own rule out-specified this default and painted the lamp red
   the whole time; only the JS line LOOKED bare (codebase-issues #289,
   premise disproven 2026-08-11 and the class now named explicitly).
   Treat a bare .led as unreachable, not as green. --off stays a literal
   because it is an UNLIT lamp rather than a hue — it has no
   software-register twin to drift against. */
.led {
    --c: var(--led-run);
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex: none;
    display: inline-block;
    background: var(--c);
    box-shadow: 0 0 5px 0 var(--c);
}
.led--run   { --c: var(--led-run); }
.led--alarm { --c: var(--led-alarm); animation: blink 1.2s steps(1) infinite; }
.led--comm  { --c: var(--led-comm); }
.led--warn  { --c: var(--led-warn); }
.led--off   { --c: #2f3640; box-shadow: inset 0 0 2px rgba(0,0,0,0.7); }
@keyframes blink { 50% { opacity: 0.25; box-shadow: none; } }

/* Device shell — bezel + recessed face. The box-shadow is the physical
   bezel ring + a 2px bottom edge (an equipment dimensional cue, not a
   floating software shadow). */
.device {
    position: relative;
    background: var(--dev-face);
    border: 1px solid var(--dev-edge);
    border-radius: var(--rail);
    padding: 4px;
    box-shadow: 0 0 0 1px var(--dev-bezel), 0 2px 0 rgba(0,0,0,0.5);
}
.device-inner {
    border: 1px solid var(--dev-bezel);
    border-radius: var(--rail);
    padding: 0.7rem;
    background: linear-gradient(180deg, rgba(255,235,180,0.025), transparent 40%), var(--dev-face);
}
.device-tb { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.6rem; }
.device-tb .title {
    font-family: var(--mono);
    font-size: 0.66rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: #b8b29a;            /* warm gray, legible on the dark device face */
}
.device-tb .badge {
    font-family: var(--mono);
    font-size: 0.56rem;
    color: #6f6a55;
    margin-left: auto;
    letter-spacing: 0.08em;
}

/* The LCD well — lit olive backlight, recessed. The dot-matrix pixel
   mesh (::after, multiply-blended) is the character-LCD tell on BMS /
   VFD keypads. Positive-mode: dark ink on lit field. NO scanlines, NO
   glow — that was the rejected phosphor look. */
.lcd {
    position: relative;
    overflow: hidden;
    background: linear-gradient(180deg, var(--lcd-bg), var(--lcd-bg-2));
    border: 1px solid #20240f;
    border-radius: var(--rail);
    padding: 0.5rem 0.65rem;
    box-shadow: inset 0 0 0 1px rgba(0,0,0,0.3), inset 0 1px 4px rgba(0,0,0,0.4);
}
.lcd::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    mix-blend-mode: multiply;
    background-image: radial-gradient(circle at center, transparent 36%, var(--lcd-grid) 41%);
    background-size: 3px 3px;
}
.lcd .k {
    font-family: var(--mono);
    font-size: 0.54rem;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    /* 0.85 (was 0.72): the caption computed 3.69:1 against the darker
       backlight-gradient stop — AA fail at this size. 0.85 → 4.75:1. */
    color: rgba(27,36,16,0.85);
}
.lcd .v {
    font-family: var(--mono);
    font-weight: 700;
    font-size: 1.55rem;
    line-height: 1.05;
    color: var(--lcd-ink);
    margin-top: 0.12rem;
}
.lcd .v small { font-size: 0.66rem; color: rgba(27,36,16,0.85); margin-left: 0.12rem; }   /* 0.85 (was 0.6, a 2.91:1 AA fail) — see .lcd .k */

/* Physical keypad — embossed keycaps. Warm key colours are equipment
   constants (the key is plastic, not themed); .go is the run/enter key. */
.keypad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin-top: 0.6rem; }
.key {
    font-family: var(--mono);
    font-size: 0.64rem;
    color: #cfc8ad;
    text-align: center;
    padding: 0.5rem 0;
    border-radius: var(--rail);
    background: linear-gradient(180deg, #2a281e, #1b1913);
    border: 1px solid #000;
    box-shadow: inset 0 1px 0 var(--dev-hi);
    cursor: pointer;
}
.key:active { background: #17150f; box-shadow: inset 0 1px 3px #000; }
.key.go { color: #0a1305; background: linear-gradient(180deg, var(--accent-bright), var(--accent)); border-color: #3f6e1d; }

/* Device LED status row — led + caps label clusters under a device. */
.dev-leds { display: flex; gap: 0.9rem; margin-top: 0.6rem; }
.dev-leds span {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-family: var(--mono);
    font-size: 0.62rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: #8a8369;
}

/* Bar gauge — used in both registers, tinted per register. Software (.gauge)
   = blue data fill with a small data-glow; equipment (.gauge.eq) = the lit
   olive dot-matrix field with a segmented dark-ink fill. */
.gauge { display: flex; align-items: center; gap: 0.55rem; }
.gauge-label {
    flex: none;
    width: 4em;
    font-family: var(--mono);
    font-size: 0.66rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--text-dim);
}
.gauge .track {
    flex: 1;
    height: 10px;
    background: var(--well);
    border: 1px solid var(--border);
    border-radius: var(--rail);
    overflow: hidden;
}
.gauge .fill { height: 100%; width: var(--pct, 60%); background: var(--blue); box-shadow: 0 0 6px var(--blue-glow); }
.gauge .pct { font-family: var(--mono); font-size: 0.72rem; color: var(--text-bright); width: 3.2em; text-align: right; }
.gauge.eq .gauge-label { color: #8a8369; }
.gauge.eq .track {
    position: relative;
    background: linear-gradient(180deg, var(--lcd-bg), var(--lcd-bg-2));
    border-color: #20240f;
}
.gauge.eq .track::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    mix-blend-mode: multiply;
    background-image: radial-gradient(circle at center, transparent 36%, var(--lcd-grid) 41%);
    background-size: 3px 3px;
}
.gauge.eq .fill { background: repeating-linear-gradient(90deg, var(--lcd-ink) 0 4px, rgba(27,36,16,0.22) 4px 7px); box-shadow: none; }
.gauge.eq .pct { color: #c2cf72; }

/* Utility: hide an element that JS toggles into view. display:none
   drops it from layout and the accessibility tree. Placed after the
   component rules so it wins the specificity tie against a
   single-class display rule (e.g. .result-panel's display:flex). */
.hidden { display: none; }

/* ──────────────────────────────────────────────────────────────────────
 * FUNCTION-BLOCK EDITOR (fbe-*) — the wiresheet tool's own chrome.
 *
 * A three-region workspace (palette rail · canvas · inspector) that
 * doesn't fit the Input/Output/Reference column grids; same precedent as
 * the PID tuner and VFD mock keeping their own simulator layouts. The
 * .widget-* shell isn't used — this is a full custom tool, not a recessed
 * teaching widget — but the example-program chip row reuses the shared
 * .widget-try idiom. Lifted here from the page's head block when the
 * editor JS moved to /scripts/fbe-editor.js, so a second consumer (the
 * "DDC Workbench") reads one source. Desktop-only by design — the @media
 * gate at the end hides the live workspace below 1000px / on touch. Both of
 * this section's focus rings — #fbe-canvas:focus-visible and
 * .fbe-palette-btn:focus-visible — sit in the FOCUS INDICATORS block (#197).
 * ────────────────────────────────────────────────────────────────────── */
.fbe-workspace {
    display: grid;
    grid-template-columns: 9.5rem 1fr;
    gap: 0.9rem;
    margin: 1rem 0 0.9rem;
}

/* ── Palette rail ── */
.fbe-palette {
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 3px;
    padding: 0.55rem 0.5rem;
    overflow-y: auto;
    max-height: 30rem;
}
.fbe-palette-label {
    font-family: var(--mono);
    font-size: 0.6rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--text-dim);
    margin: 0.7rem 0 0.3rem;
}
.fbe-palette-label:first-child { margin-top: 0.1rem; }
.fbe-palette-btn {
    display: block;
    width: 100%;
    text-align: left;
    font-family: var(--mono);
    font-size: 0.68rem;
    color: var(--text-bright);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 3px;
    padding: 0.28rem 0.45rem;
    margin-bottom: 0.25rem;
    cursor: pointer;
}
.fbe-palette-btn:hover { border-color: var(--accent); color: var(--accent); }

/* ── Canvas ── */
.fbe-canvas {
    position: relative;
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 3px;
    height: 30rem;
    overflow: auto;
}
/* 900×480 is the no-JS default; fbe-editor.js inline-sizes this div
   (and the wire layer below) when a host passes canvasSize. */
.fbe-canvas-inner {
    position: relative;
    width: 900px;
    height: 480px;
}
/* Must match .fbe-canvas-inner — same no-JS default, same JS inline
   override. A size mismatch letterboxes every wire (the SVG viewBox
   is the canvas coordinate space). */
.fbe-wire-layer {
    position: absolute;
    top: 0;
    left: 0;
    width: 900px;
    height: 480px;
    pointer-events: none;
}
.fbe-wire { fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.fbe-wire-num { stroke: var(--blue); }
.fbe-wire-bool-on { stroke: var(--accent); }
/* Off wires must still be traceable — --border was 1.24:1 in light
   (you could not see where the wire ran). --text-dim follows the
   balancing-diagram stroke convention (audit-2026-06 #14). */
.fbe-wire-bool-off { stroke: var(--text-dim); }
.fbe-wire-hit { fill: none; stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; }
.fbe-wire-sel { stroke: var(--text-bright); stroke-dasharray: 5 3; }

/* Live-signal flow — while the sim runs (.fbe-running on the
   canvas, toggled by setRunning), energized wires march from
   source to sink: wire paths are drawn output → input, so a
   negative dashoffset walks the dashes in true signal direction.
   Digital TRUE pulses briskly; analog crawls with a long dash so
   the line stays traceable as a line. FALSE wires stay still — a
   dead wire doesn't pulse. Both dash periods are 12 so they share
   one keyframe (offset −12 = exactly one cycle, seamless loop).
   Gated on no-preference rather than relying on the global
   reduce block: that block only freezes animation, and frozen
   dashes would read as the selection dash pattern — reduced
   motion should keep fully SOLID wires. Selected wires opt out
   (selection owns the dash pattern). */
@media (prefers-reduced-motion: no-preference) {
    .fbe-canvas.fbe-running .fbe-wire-bool-on {
        stroke-dasharray: 7 5;
        animation: fbe-signal-flow 0.45s linear infinite;
    }
    .fbe-canvas.fbe-running .fbe-wire-num {
        stroke-dasharray: 9 3;
        animation: fbe-signal-flow 1.1s linear infinite;
    }
    .fbe-canvas.fbe-running .fbe-wire-sel {
        animation: none;
        stroke-dasharray: 5 3;
    }
}
@keyframes fbe-signal-flow {
    to { stroke-dashoffset: -12; }
}

/* ── Blocks ── */
.fbe-block {
    position: absolute;
    width: 8.5rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 3px;
    user-select: none;
    box-shadow: 0 1px 0 var(--bevel-lo);
}
.fbe-block-sel { border-color: var(--accent); }
/* The head carries either the block TYPE's label ("SR LATCH") or, once
   the instance has a name, `TAG · Name` ("SR · Trip Latch") on ONE line.
   nowrap + ellipsis is LOAD-BEARING, not polish. Pins live in
   .fbe-block-body, after the head in DOM order, so anything that grows
   the head moves every pin and every wire endpoint below it — and the
   AHU workbench stacks ~89.72px blocks on a 90px row pitch — 0.28px
   of clearance, the tightest column on any workbench sheet (no FCU
   column comes within 5px).
   Measured on the public sheet: a 19-character head wraps and grows
   its block 72.97 → 88.84px. It had never fired only because every
   type label happened to fit — the longest are 10 characters
   ("ANALOG OUT", "BINARY OUT"), and they do have a space to break at,
   they just never reach the width where it matters. A name is
   arbitrary length AND arbitrary spaces, so the accident ends here.
   The budget is 18 characters INCLUDING tag and separator — 8.5rem of
   block less the 1px borders box-sizing puts inside it, less the
   0.9rem of padding, over a 0.62rem mono advance at 0.04em tracking;
   measured in-browser at root fonts 12–32px and stable across all of
   them, which the px border term is a reason to CHECK rather than a
   reason to expect. Past that the name clips with an ellipsis and
   fbe-editor.js's `title` attribute is the recovery. */
.fbe-block-head {
    font-family: var(--mono);
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    color: var(--text-bright);
    background: var(--surface-2);
    border-bottom: 1px solid var(--border);
    padding: 0.22rem 0.45rem;
    border-radius: 2px 2px 0 0;
    cursor: move;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* --text-dim on --surface-2 measures 4.81:1 dark / 5.04:1 light —
   over the 4.5:1 small-text floor in both themes (the CLAUDE.md
   figures are on --surface; this pair sits on the raised header
   strip). .fbe-block-name deliberately carries NO colour of its own:
   inheriting the head keeps it --text-bright normally AND --accent
   when the block is selected, which is what leaves the tag as the
   only dim part in either state. */
.fbe-block-tag { color: var(--text-dim); }
/* The separator is CSS, not a text node, so it is not selectable. Its
   alternative text is a SPACE, not empty: the tag and the name are
   adjacent spans with no whitespace between them, so an empty alt
   deletes the only word boundary and a screen reader (or a copy) gets
   "AIOAT". A space alt keeps the dot itself unannounced while leaving
   the boundary — "AI OAT". Two branches ship, both acceptable: an
   engine honouring alt text reads the space; one that ignores it falls
   back to the bare `content` declaration above and reads the '·',
   which is the pre-alt behaviour. The bare line must stay — dropping
   it costs the separator entirely wherever alt text is unsupported. */
.fbe-block-tag::after {
    content: '·';
    content: '·' / ' ';
    margin: 0 0.3em;
}
.fbe-block-sel .fbe-block-head { background: var(--accent-dim); color: var(--accent); }
/* Selection repaints the whole head accent — the tag gives up its dim
   step rather than introduce a second ink colour on the accent wash. */
.fbe-block-sel .fbe-block-head .fbe-block-tag { color: inherit; }
.fbe-block-body {
    display: flex;
    justify-content: space-between;
    padding: 0.3rem 0;
}
.fbe-pins { display: flex; flex-direction: column; gap: 0.12rem; }
.fbe-pins-out { align-items: flex-end; }
.fbe-pin {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    font-family: var(--mono);
    font-size: 0.58rem;
    color: var(--text-dim);
    cursor: crosshair;
}
.fbe-pin-label { pointer-events: none; }
.fbe-pin-dot {
    width: 0.62rem;
    height: 0.62rem;
    border-radius: 50%;
    border: 1.5px solid var(--text-dim);
    background: var(--surface);
    flex: none;
}
.fbe-pin-in .fbe-pin-dot { margin-left: -0.36rem; }
.fbe-pin-out .fbe-pin-dot { margin-right: -0.36rem; }
.fbe-pin[data-kind="number"] .fbe-pin-dot { border-color: var(--blue); }
.fbe-pin[data-kind="number"].fbe-on .fbe-pin-dot { background: var(--blue); }
.fbe-pin[data-kind="bool"].fbe-on .fbe-pin-dot {
    background: var(--accent);
    border-color: var(--accent);
}
.fbe-pin-target .fbe-pin-dot {
    box-shadow: 0 0 0 3px var(--accent-glow);
}
.fbe-block-val {
    font-family: var(--mono);
    font-size: 0.62rem;
    text-align: center;
    color: var(--blue);
    border-top: 1px solid var(--border-faint);
    padding: 0.18rem 0.3rem;
    background: var(--surface-2);
}
.fbe-val-off { color: var(--text-dim); }
.fbe-val-on { color: var(--accent); }
.fbe-toggle { cursor: pointer; }
.fbe-toggle:hover { color: var(--text-bright); }

/* ── Inspector — a full-width strip below the canvas. The
      selected block's parameters lay out as a horizontal row of
      stacked label/field pairs (every block has ≤ 4 params). ── */
.fbe-inspector {
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: 3px;
    padding: 0.5rem 0.7rem;
    margin-bottom: 1.25rem;
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 0.4rem 1.1rem;
    min-height: 3.4rem;
}
.fbe-insp-title {
    font-family: var(--mono);
    font-size: 0.7rem;
    font-weight: 600;
    color: var(--text-bright);
    align-self: center;
}
.fbe-insp-empty { font-size: 0.78rem; color: var(--text); align-self: center; }
.fbe-insp-keys {
    font-family: var(--mono);
    font-size: 0.62rem;
    color: var(--text-dim);
    align-self: center;
    opacity: 0.85;
}
.fbe-insp-row { display: flex; flex-direction: column; }
.fbe-insp-row label {
    font-family: var(--mono);
    font-size: 0.6rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-dim);
    margin-bottom: 0.15rem;
}
.fbe-insp-row input[type="number"],
.fbe-insp-row input[type="text"],
.fbe-insp-row select {
    width: 7rem;
    font-family: var(--mono);
    font-size: 0.74rem;
    padding: 0.25rem 0.35rem;
    border: 1px solid var(--border);
    border-radius: 3px;
    background: var(--surface);
    color: var(--text-bright);
}
/* Wider than the numeric fields: a block NAME has an 18-character head
   budget (tag + separator included), and the field should show what
   will fit rather than scroll it. */
.fbe-insp-row input[type="text"] { width: 9.5rem; }
.fbe-insp-check {
    flex-direction: row;
    align-items: center;
    gap: 0.4rem;
    align-self: center;
}
.fbe-insp-check input { width: 0.95rem; height: 0.95rem; }
.fbe-insp-check label {
    margin: 0;
    text-transform: none;
    letter-spacing: normal;
    font-size: 0.74rem;
    font-family: var(--sans);
    color: var(--text);
}
.fbe-insp-del { margin-left: auto; align-self: center; }

/* ── Sim bar + status ── */
.fbe-simbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 0.2rem;
}
.fbe-status {
    font-family: var(--mono);
    font-size: 0.66rem;
    color: var(--text-dim);
    margin-left: 0.3rem;
}
.fbe-legend {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
    font-family: var(--mono);
    font-size: 0.62rem;
    color: var(--text-dim);
    margin-top: 0.4rem;
}
.fbe-legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
.fbe-swatch { width: 1.4rem; height: 0; border-top-width: 3px; border-top-style: solid; }
.fbe-sw-num { border-color: var(--blue); }
.fbe-sw-on { border-color: var(--accent); }
.fbe-sw-off { border-color: var(--border); }

/* Page-local modifier — the preamble sits right above the example
   chips and wants a smaller gap than the global rule's default. */
p.tool-preamble.tight { margin-bottom: 0.85rem; }

/* Desktop-only interaction by design — drag-wiring wants room and a
   pointer, and on touch a finger can't both drag a block and pan the
   sheet. Below 1000px OR on a touch-primary device the whole live
   workspace is hidden and the shared .desktop-only-sim tips panel
   takes its place (the @media gate is at the end of this block). */
/* Fullscreen overrides — let the workspace use the bigger viewport. The
   grid flips from [palette | canvas] to a single column with the canvas
   on top filling the freed-up horizontal space and the palette wrapped
   horizontally below (uses the dead space and keeps every block in
   sight without the side rail's scroll). The page's IIFE lifts
   INNER_W / INNER_H and updates the wire-layer viewBox on the
   'fullscreenchange' event from /scripts/fullscreen-toggle.js. */
.tool-card.is-fullscreen .fbe-workspace {
    grid-template-areas: "canvas" "palette";
    grid-template-columns: 1fr;
    grid-template-rows: 1fr auto;
    height: calc(100vh - 23rem);
    min-height: 30rem;
    gap: 0.55rem;
}
.tool-card.is-fullscreen .fbe-canvas { grid-area: canvas; height: auto; min-height: 0; }
.tool-card.is-fullscreen .fbe-canvas-inner { width: 100%; height: 100%; }
.tool-card.is-fullscreen .fbe-wire-layer { width: 100%; height: 100%; }
.tool-card.is-fullscreen .fbe-palette {
    grid-area: palette;
    max-height: none;
    overflow: visible;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.35rem 0.4rem;
    padding: 0.5rem 0.6rem;
}
.tool-card.is-fullscreen .fbe-palette-label {
    margin: 0;
    padding: 0 0.5rem 0 0.55rem;
    border-left: 1px solid var(--border);
    align-self: stretch;
    display: flex;
    align-items: center;
}
.tool-card.is-fullscreen .fbe-palette-label:first-child {
    border-left: 0;
    padding-left: 0;
}
.tool-card.is-fullscreen .fbe-palette-btn {
    display: inline-block;
    width: auto;
    margin: 0;
    padding: 0.3rem 0.55rem;
}

@media (max-width: 999px), (hover: none) and (pointer: coarse) {
    .fbe-live { display: none; }
    .desktop-only-sim { display: block; }
}

/* ──────────────────────────────────────────────────────────────────────
 * DDC WORKBENCH SHELL (ddcw-*) — the workbench pages' unit-agnostic
 * chrome: the persistent statusbar (live IO chips + program picker),
 * the off-program window, and the wiresheet intro note. Drawn by
 * /scripts/ddcw-shell.js against the fixed ddcw-* markup skeleton (the
 * MARKUP CONTRACT in its header); the tab panes reuse the shared
 * .tabs / .tab-btn / .tab-pane and the .fbe-* wiresheet section above.
 * Lifted here from simulators/ddc-workbench-fcu.html's head block when
 * the shell JS moved to /scripts/ddcw-shell.js, so a second unit page
 * reads one source — same precedent as the FBE section above. The
 * SENSOR-GLYPH vocabulary and the chip pulse joined it when the AHU
 * page landed (their own comment named that as the trigger); they sit
 * at the end of this section.
 *
 * The unit-specific rules (fcu-* / ahu-*) stay page-inline, and so do
 * two ddcw-* control-pattern rules whose cascade is coupled to unit
 * markup — and BOTH WORKBENCH PAGES NOW CARRY THEIR OWN COPY, which is
 * the resolution codebase-issues #220 anticipated rather than an
 * oversight. The load-bearing one is the .ddcw-tracked disabled
 * dimming, scoped under .fcu-controls / .ahu-controls respectively: a
 * rule scoped to one page reaches none of the other page's controls,
 * so an unscoped rule here would need both pages re-verified.
 * label.ddcw-null is the softer case — it must be declared AFTER the
 * page's own `<container> label` rule to win their specificity tie, but
 * MEASURED 2026-07-30 the only thing that tie decides is a 0.8px gap:
 * the fan-enable label is a flex item inside a `.field`, so the
 * page rule's `inline-flex` blockifies to `flex` and the earlier note's
 * claim that graduating it would flip the NULL box back onto one line
 * was wrong. See each page's head block.
 * ────────────────────────────────────────────────────────────────────── */
.ddcw-statusbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.6rem 1.25rem;
    padding: 0.7rem 0 0.85rem;
    border-bottom: 1px solid var(--border);
    margin-bottom: 1.1rem;
}
.ddcw-io-strip { display: flex; flex-wrap: wrap; gap: 0.4rem 0.5rem; }
.ddcw-chip {
    display: inline-flex;
    align-items: baseline;
    gap: 0.35rem;
    font-family: var(--mono);
    font-size: 0.68rem;
    padding: 0.22rem 0.5rem;
    background: var(--surface-2);
    border: 1px solid var(--border);
    border-radius: 3px;
    /* The pulse transition (see .ddcw-chip-hilite below) is merged into
     * this rule rather than declared after it — a second `.ddcw-chip {}`
     * block would be a duplicate selector for one property. */
    transition: border-color 0.3s, box-shadow 0.3s;
}
/* Chip pulse — highlightChip (ddcw-shell.js) holds the class ~1.2 s; the
 * transition above eases it in and back out. Transient only: nothing
 * animates while idle (these pages' idle cost is profiled —
 * tests/perf-profile.mjs). Inset ring, not a floating shadow — the
 * AX-sharp seam idiom. */
.ddcw-chip.ddcw-chip-hilite {
    border-color: var(--accent);
    box-shadow: inset 0 0 0 1px var(--accent);
}
.ddcw-chip-cap {
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.ddcw-chip-val { color: var(--text-bright); font-weight: 700; }
.ddcw-chip-val.on { color: var(--accent-ink); }
.ddcw-chip-val.off { color: var(--text-dim); }
.ddcw-status-right {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem 1.1rem;
}
.ddcw-prog { display: flex; align-items: center; gap: 0.45rem; }
.ddcw-prog-lbl {
    font-family: var(--mono);
    font-size: 0.68rem;
    color: var(--text);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.ddcw-prog select { min-width: 12rem; }

/* ── Unit selector (ddcw-unit-*) ── the statusbar's "which machine am
   I looking at" pair: two workbench pages, one plain anchor each way.
   Shell chrome shared by both unit pages. It lived duplicated in their
   page heads while the pages were hidden (this sheet ships to every
   live page, and back then no live page could see the control); it
   graduated here with the pages themselves (Phase 8, 2026-08-04).

   Shape is the site's own pick-one-of-two idiom — the nav units /
   theme pill (.units-toggle / .units-toggle-pill / .units-btn) —
   retuned to statusbar scale so the caption matches .ddcw-prog-lbl
   standing next to it. */
.ddcw-unit-sel { display: flex; align-items: center; gap: 0.45rem; }
.ddcw-unit-sel-lbl {
    font-family: var(--mono);
    font-size: 0.68rem;
    color: var(--text);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.ddcw-unit-pill {
    display: inline-flex;
    border: 1px solid var(--border);
    background: var(--surface);
}
/* Element-qualified `a.` per the house specificity idiom (the same
   reason p.ref-note and friends carry their element): the plain
   class ties nothing, and `.tool-body a { color: var(--accent) }`
   earlier in this sheet out-specifies it — measured, the unselected
   link rendered ACCENT GREEN, i.e. indistinguishable from the page
   you are already on. Qualified, it ties at (0,1,1) and wins on
   declaration order. */
a.ddcw-unit-link {
    display: flex;
    align-items: center;
    font-family: var(--mono);
    font-size: 0.68rem;
    letter-spacing: 0.03em;
    padding: 0.22rem 0.6rem;
    color: var(--text-dim);
    text-decoration: none;
    transition: color 0.18s, background 0.18s;
}
a.ddcw-unit-link + a.ddcw-unit-link { border-left: 1px solid var(--border); }
a.ddcw-unit-link:hover { color: var(--text-bright); }
/* The page you are on. --accent-ink, NOT the .units-btn pill's
   --accent: this text lands on the --accent-dim wash at 10.88px,
   where --accent is the documented AA small-text fail and the
   -ink step is the documented fix — and the blocking contrast
   sweep measures exactly this now that the pages carry canonicals. */
a.ddcw-unit-link[aria-current="page"] {
    background: var(--accent-dim);
    color: var(--accent-ink);
    font-weight: 600;
}
/* a.ddcw-unit-link:focus-visible sits in the FOCUS INDICATORS block (#308). */
/* Touch floor kept beside the component: it needs justify-content
   on top of the TOUCH-TARGET FLOOR boilerplate. Measured natively
   at 24.42px tall, so the floor is doing real work — and 41–42px
   WIDE, the exact case codebase-issues #262 names, so the floor
   sets both dimensions. */
@media (hover: none) {
    a.ddcw-unit-link {
        justify-content: center;
        min-height: 44px;
        min-width: 44px;
    }
}

/* ── Off-program window ── every actuator point whose resolved command
   is NOT the sequence at slot 16: a hand value in slot 8, or a released
   point resting on Relinquish_Default. Mono at chip scale; the label is
   the quiet element, entries the loud ones. Amber left rule = the same
   "operator is holding something" register the workbench pages use on
   their input-override chrome. Collapses via .is-empty when empty
   (rule below). */
.ddcw-offprog {
    margin: -0.4rem 0 1.1rem;
    padding: 0.5rem 0.65rem;
    border: 1px solid var(--border);
    border-left: 3px solid var(--amber);
    background: var(--surface-2);
}
/* Empty = visually gone but STILL RENDERED (the sr-only collapse
   recipe), never the hidden attribute / display:none: a live region
   only announces mutations made while it is IN the accessibility
   tree, and a region that enters the tree already populated announces
   nothing — which silenced the window's primary teaching event (the
   box is hidden whenever empty, so every first hand-take / first
   release-to-default landed on the empty→non-empty edge). The second
   selector out-specifies the fullscreen margin/padding retune further
   down, so the collapse holds on both surfaces. */
.ddcw-offprog.is-empty,
.tool-card.is-fullscreen .ddcw-offprog.is-empty {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    border: 0;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}
.ddcw-offprog-lbl {
    font-family: var(--mono);
    font-size: 0.68rem;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.ddcw-offprog-list {
    list-style: none;
    margin: 0.3rem 0 0;
    padding: 0;
}
.ddcw-offprog-list li {
    font-family: var(--mono);
    font-size: 0.72rem;
    line-height: 1.55;
    color: var(--text-bright);
    overflow-wrap: anywhere;
}

/* ── Session notice ── the one-shot line a restored workbench shows
   (codebase-issues #275). Software register, and BLUE rather than the
   off-program window's amber: amber is this page's "an operator is
   holding something" hue and the notice is not a hold, it is the shell
   telling the reader what it just did. Same seam vocabulary otherwise,
   so the two read as one family of shell chrome.

   ⚠ THE [hidden] ARM IS LOAD-BEARING, NOT DEFENSIVE. The box ships with
   the `hidden` attribute and the shell removes it; an author `display`
   declaration beats the UA stylesheet's `[hidden] { display: none }`
   whatever the specificity, so without the arm below every visitor
   would see the notice on every load. */
.ddcw-resumed {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.45rem 1rem;
    margin: 0 0 1.1rem;
    padding: 0.5rem 0.65rem;
    border: 1px solid var(--border);
    border-left: 3px solid var(--blue);
    background: var(--surface-2);
}
.ddcw-resumed[hidden] { display: none; }
/* Element-qualified per the prose-utility house rule — `.tool-body p`
   sets a size and a colour this would otherwise tie with and lose to on
   declaration order. */
p.ddcw-resumed-msg {
    /* min-width:0 with flex-basis so a long sentence wraps inside the
       row instead of forcing it wider than the card — the phone arm of
       responsive.spec.js measures exactly that. */
    flex: 1 1 18rem;
    min-width: 0;
    margin: 0;
    font-family: var(--mono);
    font-size: 0.78rem;
    line-height: 1.55;
    color: var(--text-bright);
    overflow-wrap: anywhere;
}
.ddcw-resumed-fresh {
    flex: none;
    font-family: var(--mono);
    font-size: 0.68rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    padding: 0.3rem 0.75rem;
    color: var(--blue-ink);
    background: var(--surface);
    border: 1px solid var(--blue);
    cursor: pointer;
    transition: background 0.18s, color 0.18s;
}
.ddcw-resumed-fresh:hover {
    background: var(--blue-dim);
    color: var(--text-bright);
}
/* Fullscreen: same compaction the off-program window takes, for the
   same reason — fixed chrome between the tabs and the panes must not
   eat the instrument canvas. */
.tool-card.is-fullscreen .ddcw-resumed {
    flex: none;
    margin: 0 0 0.6rem;
    padding: 0.35rem 0.65rem;
}

/* ── Sheet note ── a short intro line above the wiresheet, tying it
   to the unit. */
p.ddcw-sheet-note {
    font-family: var(--mono);
    font-size: 0.78rem;
    line-height: 1.5;
    color: var(--text);
    margin: 0 0 0.25rem;
}


/* ── The wiresheet pane's one-line phone truth ── (owner ruling
   2026-08-03: the Unit tab IS the limited mobile version, and the
   wiresheet says so honestly.) Hidden wherever the live workspace
   shows; revealed by the SAME OR-gate that hides .fbe-live
   (max-width 999px / touch-primary), so the note and the workspace
   can never show together. It sits FIRST in the pane — the
   .desktop-only-sim panel carries the fuller version, but it sits
   where the workspace would be, below the sheet notes, and a phone
   visitor deserves the truth at tap-in, not at the bottom of a
   read. Element-qualified per the prose-utility house rule. */
p.ddcw-sheet-mobile-note { display: none; }
@media (max-width: 999px), (hover: none) and (pointer: coarse) {
    p.ddcw-sheet-mobile-note {
        display: block;
        font-family: var(--mono);
        font-size: 0.78rem;
        line-height: 1.5;
        color: var(--text);
        border: 1px solid var(--border);
        background: var(--surface-2);
        padding: 0.5rem 0.65rem;
        margin: 0 0 0.85rem;
    }
}

/* ── Fullscreen retunes ── shell chrome inside a page's fullscreen
   cockpit (.tool-card.is-fullscreen layouts stay page-local): the
   statusbar and the off-program window are fixed chrome between the
   tabs and the panes — kept compact so the instrument view keeps its
   canvas — and the reading-only sheet note drops out. */
.tool-card.is-fullscreen .ddcw-statusbar { flex: none; }
.tool-card.is-fullscreen .ddcw-offprog {
    flex: none;
    margin: -0.6rem 0 0.6rem;
    padding: 0.35rem 0.65rem;
}
.tool-card.is-fullscreen p.ddcw-sheet-note { display: none; }
/* The folds go with them. Dropping only the p.ddcw-sheet-note children
   would leave orphaned summary bars — a "Background — …" toggle over
   nothing — stacked above the instrument canvas. */
.tool-card.is-fullscreen details.prose-fold { display: none; }

/* ── Sensor glyphs ── the physical sensing devices drawn where they
   actually live on a unit graphic: a wall plate on the room boundary, an
   insertion probe in a duct, a shielded head on an outdoor face, an
   averaging element strung across a plenum. ddcw-* names because the
   concept is shell-shared — any unit graphic may carry glyphs.

   GRADUATED HERE FROM ddc-workbench-fcu.html WHEN THE AHU PAGE LANDED,
   which is the trigger that block's own comment named: two unit pages
   now draw the same vocabulary, and the house rule keeps only
   single-page pieces inline. The set is the FCU's four classes plus the
   two the AHU depiction added (-solid, -cap); the FCU page draws neither
   today and that is fine — one shared vocabulary, drawn from as needed.

   NO GEOMETRY MAY CARRY AN INLINE fill= OR stroke=. That is
   load-bearing rather than tidy: the hover / focus lift below is what
   makes a glyph feel like the drill-down seam it will become, and one
   inline attribute would freeze that part at the dim ink while its
   siblings lift to the accent.

   ⚠ THE :focus-visible ARMS ARE FOR A FOCUSABLE GLYPH, AND WHETHER A
   GLYPH IS FOCUSABLE IS A PAGE DECISION. The FCU's glyphs carry
   tabindex + role="button" and the shell binds them generically. The AHU
   graphic carries role="img" on the SVG root — which makes its whole
   subtree presentational, so nothing inside it may be focusable — and
   moves the activation affordance to real HTML buttons outside the
   drawing (codebase-issues #227b). Both are correct; these rules serve
   either. */
.ddcw-sensor { cursor: pointer; }
.ddcw-sensor-hit { fill: transparent; stroke: none; }
.ddcw-sensor-body {
    fill: var(--surface);
    stroke: var(--text-dim);
    stroke-width: 1.5;
    transition: stroke 0.15s;
}
.ddcw-sensor-stem,
.ddcw-sensor-slot {
    fill: none;
    stroke: var(--text-dim);
    stroke-width: 1.5;
    stroke-linecap: butt;
    transition: stroke 0.15s;
}
/* SOLID glyph members. The four classes above are OUTLINE forms: -body
   fills with var(--surface), which on a unit graphic is the panel behind
   the drawing, so a shape given -body reads as HOLLOW no matter how the
   drawing spec words it. A radiation shield is a stack of solid discs
   seen edge-on, and thin open chevrons on a vertical rod read as arrow
   fletching instead. Reach for -solid whenever a glyph member's
   SILHOUETTE is the thing being communicated; keep -body for members
   whose outline is. */
.ddcw-sensor-solid {
    fill: var(--text-dim);
    stroke: none;
    transition: fill 0.15s;
}
.ddcw-sensor:hover .ddcw-sensor-solid,
.ddcw-sensor:focus-visible .ddcw-sensor-solid {
    fill: var(--accent);
}
/* An averaging element's capillary. Round caps and joins are the
   FLEXIBILITY CUE: this is tubing bent around standoffs, not the
   mitered sheet-metal geometry everything else on a unit graphic uses.
   Weight stays in the 1.5 sensor family — resizing an averaging element
   changes its pass EXTENTS, never this stroke width and never the pass
   count, because a scale() would thin the family weight. */
.ddcw-sensor-cap {
    fill: none;
    stroke: var(--text-dim);
    stroke-width: 1.5;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: stroke 0.15s;
}
.ddcw-sensor:hover .ddcw-sensor-body,
.ddcw-sensor:focus-visible .ddcw-sensor-body,
.ddcw-sensor:hover .ddcw-sensor-stem,
.ddcw-sensor:focus-visible .ddcw-sensor-stem,
.ddcw-sensor:hover .ddcw-sensor-slot,
.ddcw-sensor:focus-visible .ddcw-sensor-slot,
.ddcw-sensor:hover .ddcw-sensor-cap,
.ddcw-sensor:focus-visible .ddcw-sensor-cap {
    stroke: var(--accent);
}
.ddcw-sensor:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}
/* FORCED-SENSOR MARKER — a dashed ring around a glyph whose point is
   being forced; renderUnit toggles .is-forced per override-map entry.
   ACCENT, NOT AMBER: inside the workbench SVGs amber means damper,
   permanently, and the accent ring is reserved for exactly this job.
   Hidden until the class lands, so an unforced drawing carries no extra
   ink. Graduated from the two page heads (codebase-issues #273). */
.ddcw-forced-mark { fill: none; stroke: none; }
.ddcw-sensor.is-forced .ddcw-forced-mark {
    stroke: var(--accent);
    stroke-width: 1.5;
    stroke-dasharray: 3 2;
}

@media (prefers-reduced-motion: reduce) {
    .widget-fan-blades { transform: none !important; }
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}
