/* assets/mtel.css — MTEL PaaS Shared Design System */
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');

/* ─── TOKENS ─────────────────────────────────────────────── */
:root {
  /* Brand */
  --mtel-red:        #E30613;
  --mtel-navy:       #1A2456;
  --mtel-navy-dk:    #0D1533;

  /* Neutrals */
  --mtel-white:      #FFFFFF;
  --mtel-gray-50:    #F8F9FA;
  --mtel-gray-100:   #F1F3F5;
  --mtel-gray-200:   #E9ECEF;
  --mtel-gray-400:   #CED4DA;
  /* F-V43 (WCAG AA contrast audit, 2026-08-06): -500 and -600 were 2.07:1 and
     4.69:1 against white (4.45:1 against --mtel-gray-50 — already a latent
     failure before this audit measured it). Both consumers of each token
     (checked first, per this file's own header comment above) are TEXT only
     — .mtel-kpi__label/__sub, .s-panel__section-title, .s-timeline__time,
     one guide.html inline caption — never a border or a decorative fill, so
     darkening carries no non-text side effect. -300/-400 stay as-is: -300 is
     borders only, -400 (prefixed) has no consumer at all. */
  --mtel-gray-500:   #5A6572;
  --mtel-gray-600:   #48525E;
  --mtel-gray-700:   #495057;
  --mtel-gray-800:   #343A40;
  --mtel-gray-900:   #212529;

  /* Semantic */
  --mtel-success:    #28A745;
  --mtel-warning:    #FFC107;
  --mtel-orange:     #FD7E14;
  --mtel-danger:     #DC3545;

  /* Typography */
  --mtel-font:       'IBM Plex Sans', system-ui, -apple-system, sans-serif;
  --mtel-font-mono:  'IBM Plex Mono', ui-monospace, Menlo, Consolas, monospace;
  /* ── THE TYPE SCALE (OBS-16, 2026-08-10) ────────────────────────────────
     The user, on the whole app: "lastly, all of the font is too small."

     A SEVEN-RUNG SCALE ALREADY LIVED HERE — 11/13/14/15/16/20/26px — AND WAS
     BEING IGNORED. Measured before this change: 517 font-size declarations in
     the app, of which 484 were hardcoded px and 33 used a token — and all 33
     token users were inside THIS FILE. Not one of the seven HTML pages
     referenced the scale even once. So raising the old rungs would have moved
     33 declarations out of 517 and changed almost nothing on screen, which is
     the trap this comment exists to stop the next person walking into.

     THE RUNGS ARE DERIVED, NOT INVENTED. The app was using 26 distinct sizes;
     each rung below absorbs a measured band of them, with its population:

        3xs  11px  <- 9, 9.5            (9)
        2xs  12px  <- 10, 10.5         (64)
        xs   13px  <- 11, 11.5        (103)
        sm   14px  <- 12, 12.5        (123)   <- the single largest band
        base 15px  <- 13, 13.5         (52)
        md   16px  <- 14               (19)
        lg   18px  <- 15, 16, 17       (18)
        xl   21px  <- 18, 19, 20        (9)
        2xl  26px  <- 22, 23, 24       (10)
        3xl  30px  <- 26, 28            (3)
        4xl  36px  <- 32, 36            (2)

     The 9-13px cluster was 82% of every declaration in the app — that cluster
     IS the complaint — so the uplift is ~15-20% there and tapers to zero at the
     display sizes nobody called small. 48px (survey's fullscreen kWh hero) and
     80px (closure's success checkmark) stay off the scale as decorative glyphs;
     both are allow-listed with reasons in verify/ui/type-scale.test.mjs.

     THE NAMES WERE RE-INDEXED AND THAT IS DELIBERATE. Every name now denotes a
     larger size than it did, and the 33 in-file token users were rewritten
     through the same px->rung mapping as everything else, so ONE rule governs
     all 517 declarations: a thing that rendered at N px before renders at
     mapping(N) now. The alternative — keeping each name's value and adding new
     rungs around them — would have left `sm` and `base` unable to move at all,
     i.e. the shared components in this very file frozen at their old size while
     every page around them grew.

     11px IS A FLOOR. Nothing renders below it. Nothing here may ever be
     lowered: verify/ui/type-scale.test.mjs pins these values, their ascending
     order and the floor, and .shell-sidebar__logo's rung is pinned separately
     against the WCAG large-text boundary (see index.html). */
  --mtel-fs-3xs:     11px;
  --mtel-fs-2xs:     12px;
  --mtel-fs-xs:      13px;
  --mtel-fs-sm:      14px;
  --mtel-fs-base:    15px;
  --mtel-fs-md:      16px;
  --mtel-fs-lg:      18px;
  --mtel-fs-xl:      21px;
  --mtel-fs-2xl:     26px;
  --mtel-fs-3xl:     30px;
  --mtel-fs-4xl:     36px;

  /* Spacing */
  --mtel-sp-1: 4px;  --mtel-sp-2: 8px;  --mtel-sp-3: 12px;
  --mtel-sp-4: 16px; --mtel-sp-5: 20px; --mtel-sp-6: 24px;
  --mtel-sp-8: 32px;

  /* ── Field-group rhythm (Task 8, OBS-9 items 5 and 7) ───────────────────
     ONE token for the vertical distance between consecutive field groups in
     a stacked form. It exists because the rhythm was being set in two places
     at once and the two disagreed: the form's flex `gap` said 10px, but a
     `<label class="s-label">` ALSO carries `margin-bottom: var(--mtel-sp-1)`
     and margins add to gap, so a label-wrapped group got 14px while a
     `__split` row or a bare button got 10px.

     MEASURED at 1440x900 before this token — the gaps between consecutive
     children, in source order:
       Tambah form  14 · 14 · 10 · 10 · 10 · 10 · 14   (spread 4px)
       SITE Info    14 · 10 · 10 · 10 · 20 · 16 · 14   (spread 10px)
     The Info form's 20 and 16 are `.site-hint`'s own `margin: 6px 0` piling
     on top of the same gap. The visible symptom was the one OBS-9 names:
     `⌖ Pilih Titik di Peta` sits tighter under Lat/Lng than any other group,
     because it is the one group with no label above it to contribute the
     extra 4px.

     THE FIX IS THE TOKEN PLUS ZEROING THE CHILDREN'S BLOCK MARGINS, not
     per-element tuning — with margins in play a token would just be a third
     opinion. Consumers zero `margin-block` on their DIRECT children so `gap`
     is the single authority; see `.fleet-form` / `.site-form` in
     planning.html. 12px is the midpoint of the 10-14 the forms were already
     oscillating between, so neither form's overall height jumps.

     IT EQUALS --mtel-sp-3 BY COINCIDENCE, NOT BY DERIVATION. This value came
     out of the measured 10-14px spread above; --mtel-sp-3 is a rung on the
     4px spacing scale. They agree today at 12px and are free to diverge —
     re-measuring the forms must be able to move this without dragging every
     sp-3 consumer with it. Do NOT "de-duplicate" it into
     `var(--mtel-sp-3)`: that would silently couple two constants whose only
     relationship is that they currently hold the same number. */
  --mtel-field-gap: 12px;

  /* ── Layout (spec 2026-08-08 §2.2) ───────────────────────────────────────
     THE PRINCIPLE: constrain the PARAGRAPH, not the PAGE. The six per-page
     `max-width` caps this replaces (800/1024/1080/1140/1180/1249px) sat on the
     page container, so they throttled tables, card grids and prose identically
     — although only prose has a readability limit. That one mistake produced
     BOTH symptoms at once: empty margins AND wrapping table headers.

     --mtel-page-max is near-full-bleed by design. The 1680px ceiling stops an
     ultrawide monitor producing a 2500px table; above ~1712px the gutters
     absorb the surplus instead.

     --mtel-measure is applied to TEXT BLOCKS ONLY. Applying it to a container
     re-creates the defect this replaces. */
  --mtel-page-gutter: clamp(16px, 2.5vw, 40px);
  --mtel-page-max:    min(100% - 2 * var(--mtel-page-gutter), 1680px);
  --mtel-measure:     68ch;

  /* Radii — spec 2026-08-08 §4.2. Measured before this change: 12 distinct
     radius values across the app, of which `10px` (25x) and `4px` (23x) were
     not tokens at all. Three tiers only. */
  --mtel-r-sm: 3px;   /* controls: inputs, chips, badges */
  --mtel-r-md: 6px;   /* cards, panels */
  --mtel-r-lg: 12px;  /* modal only */
  --mtel-r-full: 9999px;

  /* Elevation — uniform soft drop shadows on every card is the "floating card"
     look, and it flattens hierarchy because everything floats equally. Cards
     use borders; shadow is reserved for surfaces that genuinely float. */
  --mtel-shadow-sm: none;
  --mtel-shadow-md: 0 2px 8px rgba(13, 21, 51, .10);
  --mtel-shadow-lg: 0 20px 60px rgba(13, 21, 51, .28);

  /* Transitions */
  --mtel-ease: .2s ease;

  /* ── Unprefixed aliases (used by Plans 4 and 5 inline styles) ── */
  --primary:       #E30613;
  --primary-light: #ff4d57;
  --navy:          #1A2456;
  --white:         #FFFFFF;
  --gray-50:       #F8F9FA;
  --gray-100:      #F1F3F5;
  --gray-200:      #E9ECEF;
  --gray-300:      #DEE2E6;
  /* F-V43: -400/-500/-600 are consumed as TEXT ONLY, exclusively by
     review.html/closure.html and their JS (assets/review.js,
     assets/closure.js — both reference the token by name in inline styles,
     so this single change resolves those too, no per-call-site edit
     needed). Confirmed by a full-repo consumer search before changing:
     mapping.html/survey.html never reference these three tokens at all, so
     nothing sibling-owned moves. -300 is unchanged — every one of its
     consumers is a border or a fill, never text. */
  --gray-400:      #5A6572;
  --gray-500:      #5A6572;
  --gray-600:      #48525E;
  --gray-700:      #495057;
  --gray-800:      #343A40;
  --gray-900:      #212529;
}

/* ── TASK 15 (OBS-11) — one point that rescales all eleven rungs ─────────
   `planning.html`'s config panel offers a user-facing text-size control.
   The requirement it has to satisfy without rewriting any of the 515
   call sites that already read `var(--mtel-fs-*)`: ONE variable that
   moves every rung together.

   Rewriting the eleven declarations above to `calc(11px * var(...))`
   directly was rejected: verify/ui/type-scale.test.mjs's first assertion
   scans THIS FILE'S RAW TEXT for `--mtel-fs-NAME: <number>px` and pins the
   exact 11 values — a calc() there would stop matching and the guard
   would report the scale as gone, when it would still be present, just
   indirected. Custom properties cascade like any other declaration, so a
   SECOND `:root` rule at the same specificity, declared AFTER the first,
   wins for the COMPUTED value while leaving the pinned literals above
   completely untouched for the guard to read. `--mtel-font-scale`
   defaults to 1 here, so an untouched page computes to the exact same px
   as before — the override is inert until planning.js's config panel
   writes a different value onto :root via
   `documentElement.style.setProperty('--mtel-font-scale', ...)`.

   RANGE IS ENFORCED IN JS (assets/planning.js — FONT_SCALE_MIN/MAX), NOT
   HERE, and it is deliberately ENLARGE-ONLY: 1.0-1.2, never below 1.0.
   Two floors have to survive any value the control can produce:
     - --mtel-fs-3xs (11px) is the type scale's OWN floor (OBS-16) —
       11px * 1.0 = 11px, so a scale that can only multiply by >=1.0 can
       never render smaller than the unscaled rung. Shrinking was
       considered and rejected: OBS-16 raised this exact scale because the
       app measured as "too small" everywhere, so a control able to
       re-shrink it would let a user undo that fix by accident.
     - --mtel-fs-xl (21px) drives .shell-sidebar__logo, pinned in
       index.html at >=18.66px (the WCAG large-text boundary for
       #E30613-on-#1A2456's 3.01:1 contrast). Scaling it by the same
       enlarge-only multiplier only ever grows it further above that
       floor, so no separate exclusion is needed here (a scale range that
       could ever go below 1.0 would need one). */
:root {
  --mtel-font-scale: 1;
  --mtel-fs-3xs:  calc(11px * var(--mtel-font-scale));
  --mtel-fs-2xs:  calc(12px * var(--mtel-font-scale));
  --mtel-fs-xs:   calc(13px * var(--mtel-font-scale));
  --mtel-fs-sm:   calc(14px * var(--mtel-font-scale));
  --mtel-fs-base: calc(15px * var(--mtel-font-scale));
  --mtel-fs-md:   calc(16px * var(--mtel-font-scale));
  --mtel-fs-lg:   calc(18px * var(--mtel-font-scale));
  --mtel-fs-xl:   calc(21px * var(--mtel-font-scale));
  --mtel-fs-2xl:  calc(26px * var(--mtel-font-scale));
  --mtel-fs-3xl:  calc(30px * var(--mtel-font-scale));
  --mtel-fs-4xl:  calc(36px * var(--mtel-font-scale));
}

/* ─── RESET ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* THE ROOT FONT SIZE IS THE `rem` REFERENCE, NOT A RUNG — deliberately off the
   scale, and allow-listed in verify/ui/type-scale.test.mjs for that reason.

   OBS-16's transform initially put this on var(--mtel-fs-md) (16px) along with
   everything else. That is wrong, and it was not wrong in a way anything would
   have said out loud: `rem` is used here for 8 PADDING values and 17 glyph
   font-sizes, so moving the root 14px -> 16px silently multiplied all of them
   by 1.143. MEASURED consequence — `.s-btn`'s `padding: .5rem 1rem` grew 4px
   per button, which pushed `Mulai Perencanaan` and `Simpan Perubahan` onto two
   lines in the 340-400px dock and failed
   tests/planning_action_semantics.spec.js's hold-the-line assertion that the
   two primaries share one row (measured 37,19px apart). lisensi.html and
   closure.html also crossed their depth caps by <1%.

   A type-scale task must change TYPE. Tying the rem reference to a rung would
   also mean any future re-valuing of --mtel-fs-md silently rescales every
   padding in the app — a multiplier hidden behind a font token. So this stays
   at 14px and the scale sizes text explicitly.

   KNOWN, NAMED RESIDUE: the 17 `font-size: Nrem` declarations therefore did
   NOT grow with the rest of the app. All but three are glyphs (close buttons,
   ticket arrows, photo/doc icons, the success-popup mark); the exceptions are
   the password gate's own heading, subtitle and footnote. None is body text
   and none was part of the "too small" complaint. */
html { font-size: 14px; -webkit-font-smoothing: antialiased; }
body {
  font-family: var(--mtel-font);
  font-size: var(--mtel-fs-md);
  color: var(--mtel-gray-900);
  background: var(--mtel-gray-50);
  line-height: 1.5;
}
a { color: inherit; text-decoration: none; }
button { font-family: var(--mtel-font); cursor: pointer; }
input, select, textarea { font-family: var(--mtel-font); }

/* ─── PASSWORD GATE ──────────────────────────────────────── */
body.locked .mtel-topbar,
body.locked .topbar,
body.locked .mtel-page,
body.locked .review-layout,
body.locked .closure-page,
/* planning.html and survey.html matched NONE of the selectors above until
   2026-08-10, so the two pages a demo actually opens painted their content
   UNDER the gate instead of behind a hidden container. The gate still worked
   - .mtel-gate is opaque and fixed at z-index 9999 - but "covered" is not
   "hidden": the content is in the layout, focusable, and visible for the
   frame before the overlay lays out.
   .guide-page, .plan-shell, .mob-header and .screen are each used on exactly
   ONE page, so listing them here cannot reach anything else. guide.html is
   the third and least obvious: its `.topbar` was covered and its `<main>` was
   not, and guide.html is the page the shell loads FIRST on unlock. Pinned by
   tests/gate_content_hidden.spec.js, which asserts each root EXISTS and is
   hidden while locked AND visible after unlocking - a bare toBeHidden()
   passes for a selector that matches nothing. */
body.locked .guide-page,
body.locked .plan-shell,
body.locked .mob-header,
body.locked .screen,
/* .perb-page — perbandingan.html, the extracted comparison page (2026-08-11,
   walkthrough remediation Task 13). Added WITH the page rather than after it:
   OBS-1 Finding E lists this rule as one of the five by-name registers a new
   page is invisible to, and the consequence of missing it is the same paint
   defect the four selectors above were added to fix. It could have been
   avoided entirely by reusing lisensi.html's `.mtel-page` hook, and that was
   the wrong trade — a root class of its own is what makes the enrolment
   load-bearing and therefore mutation-testable, and the enrolment was proved
   by deleting this line and watching tests/gate_content_hidden.spec.js fail. */
body.locked .perb-page,
/* .laporan-page — laporan.html, the print report page (Task 6, 2026-08-21,
   DM-10). Added WITH the page, same reason .perb-page above was: a root
   class of its own is what makes the enrolment mutation-testable. */
body.locked .laporan-page,
/* Mtel.demoBanner() injects on DOMContentLoaded, which fires whether or not the
   session is unlocked - so without this the Fase Lapangan strip renders behind
   the password gate. It has to be listed HERE rather than relying on
   `.s-alert[hidden]`, because the banner is never `hidden`: it is permanently
   visible content whose only invisible state is "not past the gate yet". */
body.locked .mtel-demo-banner { display: none; }

.mtel-gate {
  position: fixed; inset: 0; z-index: 9999;
  background: var(--mtel-navy);
  display: flex; align-items: center; justify-content: center;
  transition: opacity .5s ease, visibility .5s ease;
}
.mtel-gate.hidden { opacity: 0; visibility: hidden; pointer-events: none; }

.mtel-gate__box {
  text-align: center; width: 90%; max-width: 360px;
}
.mtel-gate__icon {
  font-size: 2rem; margin-bottom: var(--mtel-sp-4);
  display: flex; align-items: center; justify-content: center;
  width: 64px; height: 64px; border-radius: 50%;
  background: rgba(255,255,255,.08);
  margin: 0 auto var(--mtel-sp-5);
}
.mtel-gate__title {
  font-size: var(--mtel-fs-3xl); font-weight: 700; color: var(--mtel-white);
  letter-spacing: .15em; margin-bottom: var(--mtel-sp-1);
}
.mtel-gate__sub {
  font-size: var(--mtel-fs-base); color: rgba(255,255,255,.6);
  margin-bottom: var(--mtel-sp-6);
}
.mtel-gate__box input[type="password"] {
  width: 100%; padding: .75rem 1rem;
  background: rgba(255,255,255,.08);
  border: 1.5px solid rgba(255,255,255,.2);
  border-radius: var(--mtel-r-md); color: var(--mtel-white);
  font-size: var(--mtel-fs-md); text-align: center; letter-spacing: .1em;
  transition: border-color var(--mtel-ease);
}
.mtel-gate__box input[type="password"]::placeholder { color: rgba(255,255,255,.35); }
.mtel-gate__box input[type="password"]:focus {
  outline: none; border-color: rgba(255,255,255,.6);
}
.mtel-gate__box input.shake { animation: s-shake .4s ease; }
.mtel-gate__box button[type="submit"] {
  width: 100%; margin-top: var(--mtel-sp-3);
  padding: .75rem; background: var(--mtel-red);
  color: var(--mtel-white); border: none;
  border-radius: var(--mtel-r-md); font-size: var(--mtel-fs-md);
  font-weight: 600; transition: background var(--mtel-ease);
}
.mtel-gate__box button[type="submit"]:hover { background: #c30511; }
.mtel-gate__error {
  font-size: var(--mtel-fs-xs); color: #ff8a8a;
  margin-top: var(--mtel-sp-2); min-height: 1.2em;
}
/* F-V43: was rgba(255,255,255,.3) — 2.61:1 on the navy gate background at
   its settled (post-transition) opacity, i.e. a genuine failure, not the
   timing artifact the gate's own show/hide transition otherwise produces
   mid-fade (see tests/contrast_wcag.spec.js's unlock() — it waits out that
   transition before measuring anything under #mtel-gate). */
.mtel-gate__footer {
  font-size: .7rem; color: rgba(255,255,255,.6); margin-top: var(--mtel-sp-6);
}

@keyframes s-shake {
  0%,100% { transform: translateX(0); }
  20%      { transform: translateX(-8px); }
  40%      { transform: translateX(8px); }
  60%      { transform: translateX(-5px); }
  80%      { transform: translateX(5px); }
}

/* ─── TOPBAR ─────────────────────────────────────────────── */
.mtel-topbar,
.topbar {
  height: 56px; background: var(--mtel-navy);
  display: flex; align-items: center; padding: 0 var(--mtel-sp-6);
  gap: var(--mtel-sp-4); position: relative; z-index: 100;
  box-shadow: 0 2px 8px rgba(0,0,0,.2);
}
.mtel-topbar__brand { display: flex; align-items: baseline; gap: var(--mtel-sp-2); }
.mtel-topbar__logo {
  font-size: var(--mtel-fs-xl); font-weight: 700; color: var(--mtel-white);
  letter-spacing: .15em;
}
/* F-V43: was rgba(255,255,255,.45) — 4.12:1, under the 4.5:1 floor. */
.mtel-topbar__tagline {
  font-size: var(--mtel-fs-xs); color: rgba(255,255,255,.6);
  font-weight: 400; letter-spacing: .02em; white-space: nowrap;
}
.mtel-topbar__spacer { flex: 1; }
.mtel-topbar__actions { display: flex; align-items: center; gap: var(--mtel-sp-3); }
.mtel-topbar__icon-btn {
  width: 34px; height: 34px; border-radius: var(--mtel-r-full);
  background: rgba(255,255,255,.08); border: none;
  color: rgba(255,255,255,.7); font-size: 1rem;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--mtel-ease);
}
.mtel-topbar__icon-btn:hover { background: rgba(255,255,255,.16); }
.mtel-topbar__avatar {
  width: 34px; height: 34px; border-radius: var(--mtel-r-full);
  background: var(--mtel-red); color: var(--mtel-white);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--mtel-fs-base); font-weight: 600; cursor: pointer;
}
.mtel-topbar__avatar svg { width: 18px; height: 18px; }
/* F-V43: was rgba(255,255,255,.4) — 3.56:1. */
.mtel-topbar__mitratel {
  font-size: var(--mtel-fs-xs); color: rgba(255,255,255,.6);
  font-weight: 500; letter-spacing: .06em; border-left: 1px solid rgba(255,255,255,.15);
  padding-left: var(--mtel-sp-3);
}

/* Topbar inner elements — used by modules 4 & 5 */
.topbar-brand { display: flex; align-items: baseline; gap: var(--mtel-sp-2); }
.topbar-logo  { font-size: 1.3rem; }
.topbar-title {
  font-size: var(--mtel-fs-xl); font-weight: 700; color: var(--mtel-white);
  letter-spacing: .15em;
}
.topbar-sub {
  font-size: var(--mtel-fs-xs); color: rgba(255,255,255,.45);
  font-weight: 400; white-space: nowrap;
}
.topbar-nav {
  margin-left: auto;
  display: flex; align-items: center; gap: 2px;
}
.topbar-link {
  font-size: var(--mtel-fs-base); color: rgba(255,255,255,.6);
  text-decoration: none; padding: 5px 11px;
  border-radius: 6px;
  transition: color .15s, background .15s;
}
.topbar-link:hover { color: #fff; background: rgba(255,255,255,.1); }
.topbar-link.active { color: #fff; background: rgba(255,255,255,.12); font-weight: 600; }

/* ─── KPI CARDS ──────────────────────────────────────────── */
/* Layout restructure 2026-08-08 §3.8 (D6). The row used to be a single white
   panel grouping four cards, absolutely positioned over the map at top:68px.
   Both are gone: the consuming page places these in a right-hand column, and
   they are separated by rules rather than each being a floating card. The base
   class keeps NO positioning — THE PAGE owns placement. That page was
   dashboard.html; since Task 12 deleted it the consumer is assets/planning.js's
   FLEET Armada pane, which builds `.mtel-kpi` / `.mtel-kpi__*` inside the dock. */
.mtel-kpi-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 0;
  background: none;
  border: none;
  box-shadow: none;
  padding: 0;
}
.mtel-kpi {
  padding: var(--mtel-sp-3) 0;
  border: none;
  border-bottom: 1px solid var(--mtel-gray-200);
  background: none;
  border-radius: 0;
}
.mtel-kpi:last-child { border-bottom: none; }
/* Carried over from the pre-restructure rules: the --red/--green/--warn
   modifiers used to colour a 3px border-LEFT (the floating-card accent). The
   base rule no longer declares a border-left at all (border: none resets it),
   so that colour would now be inert. Adapted per the task brief: colour the
   border-BOTTOM the row already draws, instead of deleting the modifiers.

   MEASURED 2026-08-09 (Task 12): dashboard.html was the only markup applying
   these three classes, and `.mtel-kpi-row` its only consumer. Both are now
   UNUSED — planning.js builds `.mtel-kpi` cards without the row wrapper or the
   status modifiers. They are kept rather than deleted because they are generic
   and correct, but do not read them as evidence that something styles this way
   today: nothing does. Deleting them is a clean separate cleanup. */
.mtel-kpi--red   { border-bottom-color: var(--mtel-red); }
.mtel-kpi--green { border-bottom-color: var(--mtel-success); }
.mtel-kpi--warn  { border-bottom-color: var(--mtel-warning); }
.mtel-kpi__label {
  font-size: var(--mtel-fs-xs); font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; color: var(--mtel-gray-500);
}
.mtel-kpi__value {
  font-size: var(--mtel-fs-xl); font-weight: 700; color: var(--mtel-navy);
  font-variant-numeric: tabular-nums; line-height: 1.2;
}
.mtel-kpi__sub { font-size: var(--mtel-fs-xs); color: var(--mtel-gray-500); margin-top: 2px; }

/* ─── BUTTONS ────────────────────────────────────────────── */
.s-btn {
  display: inline-flex; align-items: center; gap: var(--mtel-sp-2);
  padding: .5rem 1rem; border-radius: var(--mtel-r-md);
  font-size: var(--mtel-fs-base); font-weight: 600;
  border: none; transition: background var(--mtel-ease), opacity var(--mtel-ease);
  cursor: pointer; white-space: nowrap;
}
/* ── RED MEANS ONE THING: DESTRUCTION (Task 8, OBS-9 items 1-3) ──────────
   This rule used to be `background: var(--mtel-red)` — #E30613 — sitting
   beside `.s-btn--danger`'s #DC3545. MEASURED at 1440x900 in SITE -> Info:
   sRGB distance 69.0 between `Simpan Perubahan` and `Hapus Situs`, both
   filled, both white-on-saturated-red, adjacent in the same wrapping row,
   while `Mulai Perencanaan ->` — the product's forward action — was the
   grey secondary and therefore the FAINTEST control of the three.

   Fill colour is the only channel this UI has for "what will this do", and
   it was spending that one channel on two opposite answers. Nothing threw
   and nothing failed; the failure mode is a human firing
   DELETE /api/sites/<id>, which also removes var/sites/<id>/.

   THE CHANGE IS ON THE FAMILY, NOT ON A ROW. `.s-btn--primary` has 15 call
   sites across five pages and three JS modules (survey/closure wizard
   `Lanjut`, the tour's `Lanjut`/`Selesai`, `Simpan Situs`, `Impor N Valid`,
   `Lengkapi Data Situs`, `Terima`, `#btnSavePlan`, and both segmented-control
   toggles) — every one of them constructive or forward, NOT ONE destructive,
   which is what makes recolouring the class safe rather than a mass edit of
   call sites. Restyling one row and leaving the rest is the half-applied fix
   this file records for the Solar/PLTS label family.

   `.s-btn--navy` IS GONE — not merged into a selector list, DELETED. It was
   a second name for what is now the same colour, and two names for one thing
   is the fork this very comment argues against: the CSS could not drift once
   they shared a rule, but the NAMES still implied a distinction that no
   longer existed, so the next author had to guess which to reach for. Its 5
   call sites (guide.html's four module launchers, `+ Tambah Situs`) were all
   forward/constructive actions that simply predated the rename, and they now
   say `.s-btn--primary` like every other one.

   NOT CHANGED, deliberately: `.mtel-gate__box button[type="submit"]` above
   is still brand red. It is the ONLY control on a full-screen overlay, has
   no destructive sibling anywhere near it, and white-on-#E30613 is 4,88:1 —
   there is no action to mistake it for, so it costs nothing and it is the
   brand's one red moment. A navy button on the navy gate box would also be
   near-invisible. Named here so it reads as a decision, not an oversight.

   White on #1A2456 measures 14,68:1 — see tests/contrast_wcag.spec.js. */
.s-btn--primary   { background: var(--mtel-navy);    color: var(--mtel-white); }
.s-btn--primary:hover { background: var(--mtel-navy-dk); }
.s-btn--secondary { background: var(--mtel-gray-100); color: var(--mtel-gray-700); border: 1px solid var(--mtel-gray-200); }
.s-btn--secondary:hover { background: var(--mtel-gray-200); }
/* F-V43: white on var(--mtel-success) was 3.13:1. Same darkened green as
   .badge-success above, kept consistent between the two "success" text
   pairings; --mtel-success itself is untouched (border-only elsewhere). */
.s-btn--success   { background: #1C7530;  color: var(--mtel-white); }
.s-btn--success:hover { background: #145423; }
.s-btn--danger    { background: var(--mtel-danger);   color: var(--mtel-white); }
.s-btn--danger:hover { background: #c82333; }
/* F-V43: white-on-var(--mtel-orange) was 2.57:1. Darkened only in this
   rule (not the shared --mtel-orange token, which is still used decoratively
   elsewhere — e.g. as a KPI border-left colour — where AA text contrast
   does not apply and an unrelated colour shift would be pure restyling). */
.s-btn--orange    { background: #A4520D;   color: var(--mtel-white); }
.s-btn--orange:hover { background: #8B450B; }
.s-btn--lg { padding: .75rem 1.5rem; font-size: var(--mtel-fs-lg); }
.s-btn--sm { padding: .35rem .75rem; font-size: var(--mtel-fs-xs); }
.s-btn--full { width: 100%; justify-content: center; }
.s-btn:disabled, .s-btn[disabled] {
  opacity: .45; cursor: not-allowed; pointer-events: none;
  filter: grayscale(35%);
}

/* ─── BADGES ─────────────────────────────────────────────── */
.s-badge {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 3px 10px; border-radius: var(--mtel-r-full);
  font-size: var(--mtel-fs-xs); font-weight: 600;
}
.s-badge--red,    .badge-error   { background: #fde8ea; color: #842029; }
/* F-V43: var(--mtel-success) as text on this light fill was 2.76:1 — same
   reasoning as the orange badge above: the shared token stays put (its
   other consumer, .mtel-kpi--green's border-left, has no text-contrast
   requirement), only this text-bearing rule darkens. */
.s-badge--green,  .badge-success { background: #e6f4ea; color: #1C7530; }
.s-badge--yellow, .badge-warning { background: #fff8e1; color: #856404; }
/* F-V43: var(--mtel-orange) as text on this light fill was 2.34:1 — same
   darkened value as .s-btn--orange above, for the same reason (the shared
   --mtel-orange token itself is left alone; only text-bearing consumers
   move). Consumer: survey.html's "Butuh Revisi" badge. */
.s-badge--orange                 { background: #fff3e0; color: #A4520D; }
.s-badge--blue,   .badge-info    { background: #e3f2fd; color: #1565c0; }
.s-badge--gray                   { background: var(--mtel-gray-100); color: var(--mtel-gray-600); }
/* Shared .badge base (Plans 4 uses .badge .badge-*) */
.badge {
  display: inline-flex; align-items: center;
  padding: 2px 8px; border-radius: var(--mtel-r-full);
  font-size: var(--mtel-fs-xs); font-weight: 600; white-space: nowrap;
}
.s-badge__dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: currentColor; flex-shrink: 0;
}

/* ─── FORM ELEMENTS ──────────────────────────────────────── */
.s-input {
  width: 100%; padding: .625rem .875rem;
  background: var(--mtel-white); border: 1.5px solid var(--mtel-gray-300, #dee2e6);
  border-radius: var(--mtel-r-md); font-size: var(--mtel-fs-md); color: var(--mtel-gray-900);
  transition: border-color var(--mtel-ease), box-shadow var(--mtel-ease);
}
.s-input:focus { outline: none; border-color: var(--mtel-navy); box-shadow: 0 0 0 3px rgba(26,36,86,.1); }
.s-input--error { border-color: var(--mtel-danger); }
.s-input--warn  { border-color: var(--mtel-warning); }
.s-label { font-size: var(--mtel-fs-base); font-weight: 500; color: var(--mtel-gray-700); margin-bottom: var(--mtel-sp-1); display: block; }
.s-select {
  width: 100%; padding: .625rem .875rem; appearance: none;
  background: var(--mtel-white) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236C757D' stroke-width='1.5' fill='none'/%3E%3C/svg%3E") no-repeat right .875rem center;
  border: 1.5px solid var(--mtel-gray-300, #dee2e6); border-radius: var(--mtel-r-md);
  font-size: var(--mtel-fs-md); color: var(--mtel-gray-900);
  transition: border-color var(--mtel-ease);
}
.s-select:focus { outline: none; border-color: var(--mtel-navy); }

/* Range slider */
.s-slider { width: 100%; height: 6px; appearance: none; background: var(--mtel-gray-200); border-radius: var(--mtel-r-full); outline: none; }
.s-slider::-webkit-slider-thumb { appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--mtel-navy); cursor: pointer; box-shadow: var(--mtel-shadow-sm); }
.s-slider::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: var(--mtel-navy); cursor: pointer; border: none; }

/* ─── PANEL / DRAWER ─────────────────────────────────────── */
.s-panel {
  position: fixed; top: 56px; bottom: 0;
  width: 380px; background: var(--mtel-white);
  box-shadow: var(--mtel-shadow-lg); overflow-y: auto;
  transition: transform var(--mtel-ease);
  z-index: 200;
}
.s-panel--right { right: 0; transform: translateX(100%); }
.s-panel--left  { left: 0;  transform: translateX(-100%); }
.s-panel.open   { transform: translateX(0); }
.s-panel__header {
  padding: var(--mtel-sp-5) var(--mtel-sp-5) var(--mtel-sp-3);
  border-bottom: 1px solid var(--mtel-gray-200);
  position: sticky; top: 0; background: var(--mtel-white); z-index: 1;
}
.s-panel__close {
  position: absolute; top: var(--mtel-sp-4); right: var(--mtel-sp-4);
  background: var(--mtel-gray-100); border: none; border-radius: var(--mtel-r-full);
  width: 28px; height: 28px; font-size: 1rem; color: var(--mtel-gray-600);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
}
.s-panel__body { padding: var(--mtel-sp-5); }
.s-panel__section { margin-bottom: var(--mtel-sp-5); }
.s-panel__section-title {
  font-size: var(--mtel-fs-xs); font-weight: 600; color: var(--mtel-gray-500);
  text-transform: uppercase; letter-spacing: .07em; margin-bottom: var(--mtel-sp-3);
}
.s-panel__footer {
  padding: var(--mtel-sp-4) var(--mtel-sp-5);
  border-top: 1px solid var(--mtel-gray-200);
  position: sticky; bottom: 0; background: var(--mtel-white);
  display: flex; gap: var(--mtel-sp-2);
}

/* A quiet caveat under a panel's own call-to-action — for the case where the
   button is about to do something slightly different from what its label
   promises (F3: "Buka Mapping Potensi" always opens BTS-JKT-002). Empty by
   default so a footer that has nothing to caveat costs no vertical space. */
.s-panel__hint {
  margin: 0; font-size: var(--mtel-fs-xs); line-height: 1.5; color: var(--mtel-gray-600);
}
.s-panel__hint:empty { display: none; }

/* ─── TABS ───────────────────────────────────────────────── */
.s-tabs { display: flex; border-bottom: 2px solid var(--mtel-gray-200); gap: 0; }
.s-tab {
  padding: .625rem var(--mtel-sp-5); font-size: var(--mtel-fs-base); font-weight: 500;
  color: var(--mtel-gray-600); border: none; background: none; cursor: pointer;
  border-bottom: 2px solid transparent; margin-bottom: -2px;
  transition: color var(--mtel-ease), border-color var(--mtel-ease);
}
.s-tab.active { color: var(--mtel-navy); border-bottom-color: var(--mtel-navy); font-weight: 600; }
.s-tab-panel { display: none; padding: var(--mtel-sp-5); }
.s-tab-panel.active { display: block; }

/* ─── CARD ───────────────────────────────────────────────── */
.s-card {
  background: var(--mtel-white); border: 1px solid var(--mtel-gray-200);
  border-radius: var(--mtel-r-lg); padding: var(--mtel-sp-4);
  box-shadow: var(--mtel-shadow-sm);
}
.s-card:hover { box-shadow: var(--mtel-shadow-md); }

/* ─── TIMELINE ───────────────────────────────────────────── */
.s-timeline { list-style: none; }
.s-timeline__item { display: flex; gap: var(--mtel-sp-3); padding-bottom: var(--mtel-sp-4); position: relative; }
.s-timeline__item::before {
  content: ''; position: absolute; left: 7px; top: 20px;
  bottom: 0; width: 2px; background: var(--mtel-gray-200);
}
.s-timeline__item:last-child::before { display: none; }
.s-timeline__dot {
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--mtel-navy); flex-shrink: 0; margin-top: 2px;
}
.s-timeline__text { font-size: var(--mtel-fs-base); color: var(--mtel-gray-700); }
.s-timeline__time { font-size: var(--mtel-fs-xs); color: var(--mtel-gray-500); }

/* ─── ALERT BANNER ───────────────────────────────────────── */
.s-alert {
  padding: var(--mtel-sp-3) var(--mtel-sp-4); border-radius: var(--mtel-r-md);
  font-size: var(--mtel-fs-base); display: flex; gap: var(--mtel-sp-2); align-items: flex-start;
}
/* `display: flex` OVERRIDES the UA's `[hidden] { display: none }`, so every
   `<div class="s-alert" hidden>` in this app rendered as a permanently visible
   empty coloured bar — measured 24px — and, worse, made `toBeVisible()` on one
   return true before anything had happened. A form could show an empty pink
   error bar and an empty green success bar simultaneously. This repo already
   adds this guard 13 times for the same reason (`.ws-dock__empty[hidden]` and
   friends); the shared component itself needs it once. */
.s-alert[hidden] { display: none; }
.s-alert--warn  { background: #fff8e1; color: #856404; border-left: 3px solid var(--mtel-warning); }
.s-alert--error { background: #fde8ea; color: #842029; border-left: 3px solid var(--mtel-danger); }
.s-alert--info  { background: #e3f2fd; color: #1565c0; border-left: 3px solid #1976d2; }
.s-alert--success { background: #e6f4ea; color: #1e4620; border-left: 3px solid var(--mtel-success); }

/* Fase Lapangan banner (D-7, spec §11). A LAYOUT modifier on .s-alert, never a
   second notice component: the strip must read as the same kind of object as
   every other caveat in the app, and a parallel component is how two notice
   styles drift apart.

   IT MUST STAY BELOW THE .s-alert--* VARIANTS. `border-left: none` here and
   `border-left: 3px solid` on --warn have IDENTICAL specificity (0,1,0), so
   source order is the only thing deciding which wins. Written above them (the
   first draft did) the amber left stub silently comes back and the strip stops
   reading as full-bleed - a rule that is present, correct-looking and inert.

   flex-shrink:0 because survey.html's <body> IS a flex column: the default
   `flex-shrink:1` lets the banner compress toward nothing on the 375px frame,
   which is a disclaimer that quietly stops disclaiming. */
.mtel-demo-banner {
  border-radius: 0;
  border-left: none;
  border-bottom: 1px solid rgba(133, 100, 4, .22);
  flex-shrink: 0;
  justify-content: center;
  padding: 6px var(--mtel-sp-4);
  font-size: var(--mtel-fs-xs);
  font-weight: 600;
}

/* ─── PHOTO GRID ─────────────────────────────────────────── */
.s-photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--mtel-sp-3); }
.s-photo-slot {
  aspect-ratio: 4/3; border: 2px dashed var(--mtel-gray-300, #dee2e6);
  border-radius: var(--mtel-r-md); display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: var(--mtel-sp-2);
  cursor: pointer; transition: border-color var(--mtel-ease), background var(--mtel-ease);
  overflow: hidden; position: relative; background: var(--mtel-gray-50);
}
.s-photo-slot:hover { border-color: var(--mtel-navy); background: var(--mtel-gray-100); }
.s-photo-slot__icon { font-size: 1.5rem; }
.s-photo-slot__label { font-size: var(--mtel-fs-xs); font-weight: 500; color: var(--mtel-gray-600); text-align: center; padding: 0 var(--mtel-sp-2); }
.s-photo-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.s-photo-slot.filled { border-style: solid; border-color: var(--mtel-success); }

/* ─── DOC UPLOAD ─────────────────────────────────────────── */
.s-doc-upload {
  border: 1.5px dashed var(--mtel-gray-300, #dee2e6); border-radius: var(--mtel-r-md);
  padding: var(--mtel-sp-4); display: flex; align-items: center; gap: var(--mtel-sp-3);
  cursor: pointer; transition: border-color var(--mtel-ease); background: var(--mtel-gray-50);
}
.s-doc-upload:hover { border-color: var(--mtel-navy); }
.s-doc-upload.done { border-color: var(--mtel-success); background: #e6f4ea; }
.s-doc-upload__icon { font-size: 1.5rem; }
.s-doc-upload__name { font-size: var(--mtel-fs-base); font-weight: 500; color: var(--mtel-gray-700); flex: 1; }
.s-doc-upload__check { font-size: 1.2rem; color: var(--mtel-success); }

/* ─── OVERLAY BACKDROP ───────────────────────────────────── */
.s-backdrop {
  position: fixed; inset: 0; background: rgba(0,0,0,.3);
  z-index: 150; opacity: 0; visibility: hidden;
  transition: opacity var(--mtel-ease), visibility var(--mtel-ease);
}
.s-backdrop.active { opacity: 1; visibility: visible; }

/* ─── DIVIDER ────────────────────────────────────────────── */
.s-divider { border: none; border-top: 1px solid var(--mtel-gray-200); margin: var(--mtel-sp-4) 0; }

/* ─── SUCCESS POPUP ──────────────────────────────────────── */
.s-success-popup {
  position: fixed; inset: 0; z-index: 9999;
  background: rgba(0,0,0,.5);
  display: flex; align-items: center; justify-content: center;
  opacity: 0; visibility: hidden; transition: opacity .3s ease;
}
.s-success-popup.show { opacity: 1; visibility: visible; }
.s-success-popup__box {
  background: var(--mtel-white); border-radius: var(--mtel-r-xl);
  padding: var(--mtel-sp-8); text-align: center; max-width: 360px; width: 90%;
  box-shadow: var(--mtel-shadow-lg);
}
.s-success-popup__icon { font-size: 3rem; margin-bottom: var(--mtel-sp-4); }
.s-success-popup__title { font-size: var(--mtel-fs-xl); font-weight: 700; color: var(--mtel-success); margin-bottom: var(--mtel-sp-2); }
.s-success-popup__msg { font-size: var(--mtel-fs-base); color: var(--mtel-gray-600); margin-bottom: var(--mtel-sp-6); }

/* ── POC v2 source chips ─────────────────────────────────────────── */
.mtel-chip {
  display: inline-block; padding: 1px 6px; margin-left: 6px;
  border-radius: 999px; font-size: var(--mtel-fs-2xs); font-weight: 600;
  letter-spacing: .02em; text-transform: uppercase; vertical-align: middle;
  border: 1px solid transparent; cursor: help;
}
.mtel-chip--live    { background: #E8F5E9; color: #1B5E20; border-color: #A5D6A7; }
.mtel-chip--cached  { background: #FFF8E1; color: #7A5A00; border-color: #FFE082; }
.mtel-chip--bundled { background: #ECEFF1; color: #37474F; border-color: #CFD8DC; }
.mtel-chip--none    { background: #FFEBEE; color: #B71C1C; border-color: #EF9A9A; }

/* ── POC v2 attribution footer ───────────────────────────────────── */
/* F-V43 (2026-08-06): every #5A6572 introduced from here to the end of the
   file replaces one of four near-illegible blue-greys this section used
   interchangeably as "muted secondary text" — #90A4AE (2.59:1), #78909C
   (3.35:1), #607D8B (4.37:1) and #B0BEC5 (1.91:1), all against white or a
   near-white card. One consistent, AA-passing shade (5.0-5.9:1 measured
   against every background actually used here) replaces all four rather
   than four separate near-misses, per "preserve the design language, fix
   the failing pairs" — the muted-blue-grey register survives, only the
   floor moves. #546E7A (5.40:1, e.g. .mtel-prov__inputs th two lines
   below) already passed and is untouched. */
/* .mtel-attrib / .mtel-attrib__note lived here until 2026-08-07. They styled
   index.html's standing attribution footer, which was position:fixed and cost
   62px of every module page at every viewport (F14). The required notices moved
   to lisensi.html, which carries its own chrome; nothing else in the app used
   these three rules, so this is a deletion rather than a relocation. Removed
   rather than left dead so the next person searching for the footer finds this
   note instead of live-looking CSS for an element that no longer exists. */

/* ── POC v2 provenance card ──────────────────────────────────────── */
.mtel-prov__btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 15px; height: 15px; margin-left: 5px; padding: 0;
  border: none; border-radius: 50%; background: transparent;
  color: #5A6572; font-size: var(--mtel-fs-xs); line-height: 1; cursor: pointer;
  vertical-align: super; transition: color .15s, background .15s;
}
.mtel-prov__btn:hover { color: var(--mtel-red, #E30613); background: #FFEBEE; }
.mtel-prov { position: fixed; inset: 0; z-index: 9000; }
.mtel-prov[hidden] { display: none; }
.mtel-prov__backdrop { position: absolute; inset: 0; background: rgba(17,24,39,.55); }
.mtel-prov__panel {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  /* Was `min(560px, calc(100vw - 32px))` — frozen at 560px while the viewport
     tripled, so at 1920 the card used 33% of the content area AND still
     scrolled internally. Fluid, with a ceiling so it never becomes an
     unreadable full-width slab. */
  width: min(1200px, calc(100vw - 2 * var(--mtel-page-gutter)));
  max-height: 92vh; overflow: auto;
  background: #fff; border-radius: var(--mtel-r-lg); padding: 22px 24px;
  box-shadow: 0 20px 60px rgba(0,0,0,.28);
}
/* Two-column body at >=1280 — Rumus+Nilai left, Standar+Sumber right (design
   spec §7). renderFormula()'s markup is a FLAT list of siblings inside
   .mtel-prov__body (no wrapper divs — mtel-provenance.js builds this via
   innerHTML and other specs locate elements inside it, so none were added
   here). The split point is always the same: the "Standar yang mengatur" <h4>
   is the only heading immediately followed by a .mtel-prov__box--std sibling,
   in EVERY formula card, regardless of which optional blocks (Nilai table,
   Sumber box, parity line, note) are present. :has() finds that h4 and every
   sibling from it onward moves to column 2; everything before it (the head
   banner aside, which spans both) defaults to column 1. A plain 2-column grid
   with no explicit placement would auto-flow row-by-row and scramble every
   heading away from the content it labels — see the commit message / CLAUDE.md
   Task 6 note for why that was rejected.
   renderConstant() never emits .mtel-prov__box--std, so the :has() guard on
   .mtel-prov__body itself never matches for a constant card — it stays a
   single natural-flow column instead of leaving an empty, "lonely" 2nd
   column. */
@media (min-width: 1280px) {
  /* CSS MULTI-COLUMN WITH A FORCED BREAK, not grid. Grid was tried first and
     measured: because auto-placed items occupy shared implicit rows, the tall
     .mtel-prov__inputs table in column 1 set a row height column 2 had to
     honour, stranding "Standar yang mengatur" with a 132px gap under it.
     `grid-auto-rows: min-content` did not decouple them either — the coupling is
     the shared row track itself.

     Multi-column has no shared rows, so each column packs independently. Plain
     `columns: 2` alone is wrong here (it balances by HEIGHT, which would split
     mid-section), but an explicit `break-before: column` overrides the balance
     point — giving the deterministic split the design asks for AND independent
     packing. The break goes on the one heading always immediately followed by
     .mtel-prov__box--std, which holds for every formula entry regardless of
     which optional blocks are present. */
  .mtel-prov__body:has(> h4 + .mtel-prov__box--std) {
    columns: 2;
    column-gap: var(--mtel-sp-6);
  }
  /* The header banner spans the full width above both columns. */
  .mtel-prov__body:has(> h4 + .mtel-prov__box--std) > .mtel-prov__head { column-span: all; }
  /* Nothing may be sliced across the column boundary, and no heading may be
     separated from the block it labels. */
  .mtel-prov__body:has(> h4 + .mtel-prov__box--std) > * { break-inside: avoid; }
  .mtel-prov__body:has(> h4 + .mtel-prov__box--std) > h4 { break-after: avoid; }
  .mtel-prov__body > h4:has(+ .mtel-prov__box--std) { break-before: column; }
}
.mtel-prov__x { position: absolute; top: 10px; right: 12px; border: none; background: none;
  font-size: var(--mtel-fs-2xl); line-height: 1; color: #5A6572; cursor: pointer; }
.mtel-prov__besaran { font-size: var(--mtel-fs-lg); font-weight: 700; color: #0F172A; }
.mtel-prov__satuan { font-size: var(--mtel-fs-sm); color: #5A6572; }
.mtel-prov__status { display: inline-block; margin-top: 6px; padding: 2px 8px;
  border-radius: 999px; font-size: var(--mtel-fs-2xs); font-weight: 700; text-transform: uppercase; }
.mtel-prov__status--ok      { background: #E8F5E9; color: #1B5E20; }
.mtel-prov__status--warn    { background: #FFF8E1; color: #7A5A00; }
.mtel-prov__status--none    { background: #ECEFF1; color: #37474F; }
.mtel-prov__status--missing { background: #FFEBEE; color: #B71C1C; }

/* Detail card card boxes & list styling */
.mtel-prov__box {
  margin: 4px 0 12px; padding: 10px 14px; background: #F8FAFC;
  border: 1px solid #E2E8F0; border-left: 3px solid var(--mtel-red, #E30613);
  border-radius: 4px; font-size: var(--mtel-fs-sm); color: #1E293B; line-height: 1.55;
}
.mtel-prov__box--std {
  background: #FFF5F6; border-color: #FEE2E2; border-left-color: var(--mtel-red, #C8102E);
  color: var(--gray-800, #2B2B2B);
}
.mtel-prov__list {
  margin: 0; padding-left: 20px; list-style-type: disc; list-style-position: outside;
  color: #334155; font-size: var(--mtel-fs-sm); line-height: 1.55;
}
.mtel-prov__list li { margin-bottom: 5px; }
.mtel-prov__list li:last-child { margin-bottom: 0; }
.mtel-prov__list a { color: var(--mtel-red, #E30613); font-weight: 500; text-decoration: none; }
.mtel-prov__list a:hover { text-decoration: underline; }

.mtel-prov__nilai { margin: 0 0 12px; padding: 10px 12px; background: #F8FAFC;
  border-left: 3px solid var(--mtel-red, #E30613); border-radius: 4px;
  font-size: var(--mtel-fs-lg); color: #0F172A; }
.mtel-prov__nilai-satuan { font-size: var(--mtel-fs-sm); font-weight: 500; color: #5A6572; }
.mtel-prov__catatan { font-size: var(--mtel-fs-sm); line-height: 1.6; color: #37474F; }
.mtel-prov__std, .mtel-prov__hukum {
  margin: 0 0 8px; padding: 8px 12px; border-left: 3px solid var(--mtel-red, #C8102E);
  background: #FFF5F6; font-size: var(--mtel-fs-sm); color: var(--gray-800, #2B2B2B);
  border-radius: 0 4px 4px 0;
}
.mtel-prov__std-group { margin-bottom: 10px; }
.mtel-prov__std-group:last-child { margin-bottom: 0; }
.mtel-prov__std-label {
  display: block; font-size: var(--mtel-fs-xs); font-weight: 700; color: #5A6572;
  text-transform: uppercase; letter-spacing: .03em; margin-bottom: 4px;
}
.mtel-prov__srcnote {
  margin-top: 3px; font-size: var(--mtel-fs-xs); line-height: 1.4; color: #546E7A;
  font-style: italic;
}
.mtel-prov__hukum-label {
  display: inline-block; margin-right: 6px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em; font-size: var(--mtel-fs-2xs);
  color: var(--mtel-red, #C8102E);
}
.mtel-prov__tahun { color: var(--gray-500, #6B7280); font-size: var(--mtel-fs-xs); }
.mtel-prov__arsip {
  margin-left: 6px; padding: 0 5px; border: 1px solid var(--gray-300, #D1D5DB);
  border-radius: 3px; font-size: var(--mtel-fs-2xs); text-transform: uppercase;
  letter-spacing: .03em; color: var(--gray-600, #4B5563); text-decoration: none;
}
.mtel-prov__arsip:hover { background: var(--gray-100, #F3F4F6); }
.mtel-prov__sources { margin: 0; padding-left: 20px; font-size: var(--mtel-fs-sm); color: #37474F; }
.mtel-prov__sources a { color: var(--mtel-red, #E30613); }
.mtel-prov__akses { color: #5A6572; font-size: var(--mtel-fs-xs); }
.mtel-prov__parity { margin-top: 16px; padding: 8px 12px; border-radius: 6px;
  background: #E8F5E9; color: #1B5E20; font-size: var(--mtel-fs-sm); font-weight: 600; }
.mtel-prov__parity--unknown { background: #FFF8E1; color: #7A5A00; }
.mtel-prov__note { margin-top: 10px; font-size: var(--mtel-fs-sm); color: #5A6572; font-style: italic; }
.mtel-prov__err { padding: 12px; background: #FFEBEE; color: #B71C1C;
  border-radius: 6px; font-size: var(--mtel-fs-base); }

.mtel-prov h4 { margin: 16px 0 6px; font-size: var(--mtel-fs-xs); text-transform: uppercase;
  letter-spacing: .06em; color: #5A6572; }
.mtel-prov__formula { margin: 0; padding: 10px 12px; background: #F8FAFC;
  border-left: 3px solid var(--mtel-red, #E30613); border-radius: 4px;
  font-family: var(--mtel-font-mono); font-size: var(--mtel-fs-sm);
  white-space: pre-wrap; color: #0F172A; }
.mtel-prov__result { margin-top: 8px; font-size: var(--mtel-fs-base); color: #0F172A; }
/* R6 §7b — "Langkah Perhitungan", the worked calculation. Same mono stack as
   .mtel-prov__formula above (var(--mtel-font-mono), NOT a new family — the
   font-tokens guard watches seven declaration shapes) because these lines are
   substitutions into that formula and must align digit-for-digit beside it.
   `white-space: pre-wrap` for the same reason it is on the formula: a caller
   may align a multi-term substitution with spaces, and collapsing them would
   destroy the alignment that makes the arithmetic checkable. One rung smaller
   than the formula so the working reads as subordinate to it.

   THE TWO-COLUMN SPLIT POINT ABOVE IS UNAFFECTED, and that was checked rather
   than assumed. The @media rule finds the column break with
   `h4:has(+ .mtel-prov__box--std)` — the ONE heading immediately followed by a
   standards box. This block adds a second <h4> as a direct sibling, but it is
   followed by <ol class="mtel-prov__steps">, so the :has() still matches only
   "Standar yang mengatur" and the split lands where it always did. The generic
   `> * { break-inside: avoid }` and `> h4 { break-after: avoid }` then apply to
   this pair for free, keeping the heading with its working. Anyone adding a
   THIRD optional block here must re-check that same uniqueness. */
.mtel-prov__steps { margin: 4px 0 10px; padding-left: 20px; }
.mtel-prov__steps li { margin: 2px 0; font-family: var(--mtel-font-mono);
  font-size: var(--mtel-fs-xs); line-height: 1.55; color: #0F172A; white-space: pre-wrap; }
.mtel-prov__inputs { width: 100%; border-collapse: collapse; font-size: var(--mtel-fs-sm); }
.mtel-prov__inputs th { text-align: left; font-weight: 600; color: #546E7A;
  padding: 4px 8px 4px 0; white-space: nowrap; vertical-align: top; }
.mtel-prov__inputs td { padding: 4px 0; }
.mtel-prov__at { color: #5A6572; font-size: var(--mtel-fs-xs); }
/* F-V40: a per-input qualifier (ASUMSI vs terukur, or a band's own ±spread%).
   Subordinate to the value in size/weight, but never merely a colour cue —
   the text itself always says what kind of note it is. `--assumed` reuses the
   same amber pairing as .mtel-chip--cached ("not fully live") so an assumption
   reads as one at a glance, not only when the sentence is read closely. */
.mtel-prov__inputnote { display: block; margin-top: 2px; font-size: var(--mtel-fs-xs);
  font-style: italic; color: #5A6572; }
.mtel-prov__inputnote--assumed { display: inline-block; margin-top: 3px;
  padding: 1px 7px; border-radius: 999px; font-size: var(--mtel-fs-2xs); font-weight: 700;
  font-style: normal; letter-spacing: .02em;
  background: #FFF8E1; color: #7A5A00; border: 1px solid #FFE082; }
/* A constant has no formula and no loss chain — it has a value, a reason and a
   place it is displayed. Its value gets the emphasis the formula block gets on
   the other layout, because for a constant the value IS the claim. */
.mtel-prov__nilai-satuan { font-size: var(--mtel-fs-sm); font-weight: 500; color: #5A6572; }
.mtel-prov__catatan { font-size: var(--mtel-fs-sm); line-height: 1.6; color: #37474F; }
.mtel-prov__dipakai { margin: 0; padding-left: 18px; font-size: var(--mtel-fs-sm); color: #546E7A; }
.mtel-prov__std, .mtel-prov__hukum {
  margin: 0 0 8px; padding: 8px 12px 8px 26px; border-left: 3px solid var(--mtel-red, #C8102E);
  background: #FFF5F6; font-size: var(--mtel-fs-sm); color: var(--gray-800, #2B2B2B);
  border-radius: 0 4px 4px 0;
}
.mtel-prov__std li { margin-bottom: 3px; }
.mtel-prov__std li:last-child { margin-bottom: 0; }
.mtel-prov__std-group { margin-bottom: 10px; }
.mtel-prov__std-group:last-child { margin-bottom: 0; }
.mtel-prov__std-label {
  display: block; font-size: var(--mtel-fs-xs); font-weight: 700; color: #5A6572;
  text-transform: uppercase; letter-spacing: .03em; margin-bottom: 4px;
}
.mtel-prov__srcnote {
  margin-top: 3px; font-size: var(--mtel-fs-xs); line-height: 1.4; color: #546E7A;
  font-style: italic;
}
.mtel-prov__hukum-label {
  display: inline-block; margin-right: 6px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em; font-size: var(--mtel-fs-2xs);
  color: var(--mtel-red, #C8102E);
}
.mtel-prov__tahun { color: var(--gray-500, #6B7280); font-size: var(--mtel-fs-xs); }
/* The archive link is deliberately quieter than the canonical URL: the remote
   source is the authority, the local copy is only the guarantee it stays
   readable. Styling them alike would invite mistaking one for the other. */
.mtel-prov__arsip {
  margin-left: 6px; padding: 0 5px; border: 1px solid var(--gray-300, #D1D5DB);
  border-radius: 3px; font-size: var(--mtel-fs-2xs); text-transform: uppercase;
  letter-spacing: .03em; color: var(--gray-600, #4B5563); text-decoration: none;
}
.mtel-prov__arsip:hover { background: var(--gray-100, #F3F4F6); }
.mtel-prov__sources { margin: 0; padding-left: 18px; font-size: var(--mtel-fs-sm); color: #37474F; }
.mtel-prov__sources a { color: var(--mtel-red, #E30613); }
.mtel-prov__akses { color: #5A6572; font-size: var(--mtel-fs-xs); }
.mtel-prov__parity { margin-top: 16px; padding: 8px 12px; border-radius: 6px;
  background: #E8F5E9; color: #1B5E20; font-size: var(--mtel-fs-sm); font-weight: 600; }
/* An unreadable parity report is not a pass. It must not look like one. */
.mtel-prov__parity--unknown { background: #FFF8E1; color: #7A5A00; }
.mtel-prov__note { margin-top: 10px; font-size: var(--mtel-fs-sm); color: #5A6572; font-style: italic; }
.mtel-prov__err { padding: 12px; background: #FFEBEE; color: #B71C1C;
  border-radius: 6px; font-size: var(--mtel-fs-base); }

/* ── Task C4: the Validasi view ────────────────────────────────────────────
   ORPHANED 2026-08-09 BY TASK 12. `validasi.html` — the only page that ever
   carried a `.val-*` class — was deleted under design decision D-9 ("no
   Validasi page, no dynamic parity"). Every rule from here to the end of the
   `.val-*` block now styles nothing. It is left in place rather than deleted
   because removing ~60 lines of shared stylesheet belongs in its own commit,
   not in the one that retires four pages — but DO NOT read it as live: no
   page applies these classes, and `verify/ui/layout-tokens.test.mjs` keeps
   `.val-page` in its CONTAINERS list only because the declaration itself
   still exists to assert against.

   The palette carried meaning here and the rule outlives the page: green is a
   gate that PASSED and can be substantiated, amber is "we cannot substantiate
   this", red is a real failure. An unreadable report is never styled as a pass
   — the same rule mtel-prov__parity--unknown already follows, and that one IS
   live (assets/mtel-provenance.js). */
.val-page { width: var(--mtel-page-max); margin: 0 auto; padding: 24px 0 64px; }
.val-head h1 {
  font-size: var(--mtel-fs-3xl); font-weight: 800; color: var(--mtel-navy, #1A2456); margin: 0 0 6px;
}
.val-lead { font-size: var(--mtel-fs-md); line-height: 1.7; color: #455A64; margin: 0 0 20px; max-width: 78ch; }

.val-gate {
  padding: 14px 18px; border-radius: 8px; font-size: var(--mtel-fs-lg); font-weight: 700;
  line-height: 1.5; border-left: 5px solid transparent;
}
.val-gate--pass    { background: #E8F5E9; color: #1B5E20; border-left-color: #2E7D32; }
.val-gate--fail    { background: #FFEBEE; color: #B71C1C; border-left-color: #C62828; }
.val-gate--unknown { background: #FFF8E1; color: #7A5A00; border-left-color: #F9A825; }

.val-threshold { margin: 14px 0 0; font-size: var(--mtel-fs-base); line-height: 1.7; color: #37474F; max-width: 88ch; }
.val-threshold--unknown { color: #7A5A00; }

.val-just {
  margin: 12px 0 0; padding: 10px 14px; border-left: 3px solid var(--mtel-navy, #1A2456);
  background: #F5F7FA; border-radius: 0 6px 6px 0;
}
.val-just__k, .val-callout__h {
  display: block; font-size: var(--mtel-fs-2xs); font-weight: 700; text-transform: uppercase;
  letter-spacing: .06em; color: var(--mtel-navy, #1A2456); margin: 0 0 4px;
}
.val-verbatim { display: block; font-size: var(--mtel-fs-sm); line-height: 1.75; color: #37474F; margin: 0; }
.val-src { display: block; margin-top: 6px; font-size: var(--mtel-fs-xs); color: #5A6572; }

.val-section { margin-top: 32px; }
.val-section h2 {
  font-size: var(--mtel-fs-lg); font-weight: 700; color: var(--mtel-navy, #1A2456);
  margin: 0 0 12px; padding-bottom: 6px; border-bottom: 1px solid #E0E4E8;
}

/* Layout restructure 2026-08-08 §3.2. validasi.html was the deepest scroller in
   the app (3.94 screens at 1366) and had ZERO grid columns — six callouts and
   three methodology sections stacked in a 996px column inside a 1673px area.
   auto-fit means the browser picks the count from the content minimum. */
.val-callout-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
  gap: var(--mtel-sp-4);
  align-items: start;
}
.val-method-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: var(--mtel-sp-4);
  align-items: start;
}
/* Prose inside a widened container still needs a measure. This is the whole
   point of the token: it goes on the TEXT, never on the page. */
.val-page > p, .val-section > p { max-width: var(--mtel-measure); }
.val-hint { font-size: var(--mtel-fs-sm); line-height: 1.7; color: #5A6572; margin: 0 0 14px; max-width: 88ch; }

.val-chart { height: 320px; position: relative; }
.val-chartmsg { font-size: var(--mtel-fs-base); color: #7A5A00; background: #FFF8E1; padding: 12px; border-radius: 6px; }

/* Wide tables scroll inside their own box; the page body never scrolls sideways. */
.val-tablewrap { overflow-x: auto; }
.val-table { width: 100%; border-collapse: collapse; font-size: var(--mtel-fs-base); min-width: 720px; }
.val-table th {
  text-align: left; padding: 8px 10px; background: var(--mtel-navy, #1A2456);
  color: #fff; font-weight: 600; font-size: var(--mtel-fs-sm); vertical-align: top;
}
/* F-V43: .mtel-prov__btn's default #5A6572 (fixed elsewhere in this file
   for its usual light-card contexts) is 2.47:1 on this navy header — a dark
   muted colour cannot also work on a dark background. Every `<th
   data-prov="...">` in this table gets one via Mtel.prov.tagAll(), so this
   is a real, reachable case, not a hypothetical. */
.val-table th .mtel-prov__btn { color: rgba(255,255,255,.8); }
.val-table td { padding: 8px 10px; border-bottom: 1px solid #ECEFF1; vertical-align: top; }
.val-table tbody tr:hover { background: #F8F9FA; }
.val-th-note {
  display: block; margin-top: 3px; font-weight: 400; font-size: var(--mtel-fs-2xs);
  color: rgba(255,255,255,.7); text-transform: none; letter-spacing: .01em;
}
.val-sub { color: #5A6572; font-size: var(--mtel-fs-xs); }
.val-table td.dev, .val-table td.pr { font-variant-numeric: tabular-nums; white-space: nowrap; }
.val-table tr.is-fail       { background: #FFF5F5; }
.val-table tr.is-incomplete { background: #FFF8E1; }
.val-flag {
  display: inline-block; margin-top: 3px; padding: 1px 6px; border-radius: 3px;
  background: #F9A825; color: #3E2C00; font-size: var(--mtel-fs-2xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em;
}
/* A missing value says so in words. It never borrows the look of a number. */
.val-na { color: #5A6572; font-style: italic; }

.val-crit {
  border: 1px solid #E0E4E8; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px;
  background: #fff;
}
.val-crit__head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.val-crit__name { font-size: var(--mtel-fs-base); font-weight: 700; color: var(--mtel-navy, #1A2456); }
.val-crit__gate {
  padding: 1px 8px; border-radius: 10px; font-size: var(--mtel-fs-2xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
}
.val-crit__gate--full    { background: #E8F5E9; color: #1B5E20; }
/* F-V43: #E65100 on #FFF3E0 was 3.46:1. */
.val-crit__gate--partial { background: #FFF3E0; color: #B84100; }
.val-crit__gate--none    { background: #ECEFF1; color: #455A64; }
.val-crit__tol  { margin-top: 5px; font-size: var(--mtel-fs-sm); color: #37474F; font-variant-numeric: tabular-nums; }
.val-crit__k    { font-size: var(--mtel-fs-2xs); font-weight: 700; text-transform: uppercase;
                  letter-spacing: .05em; color: #5A6572; margin-right: 4px; }
.val-crit__what { margin-top: 4px; font-size: var(--mtel-fs-sm); line-height: 1.65; color: #5A6572; }

.val-callout {
  padding: 12px 16px; border-radius: 6px; background: #F5F7FA;
  border-left: 4px solid var(--mtel-navy, #1A2456); margin-bottom: 10px;
}
.val-callout--warn { background: #FFF8E1; border-left-color: #F9A825; }
.val-callout ul { margin: 6px 0 0 0; padding-left: 20px; list-style-type: disc; list-style-position: outside; }
.val-callout li { margin: 4px 0; }
.val-kv { margin: 0 0 8px; padding: 0; list-style: none;
          display: flex; flex-wrap: wrap; gap: 4px 18px; }
.val-kv li { font-size: var(--mtel-fs-sm); color: #37474F; font-variant-numeric: tabular-nums; }
.val-kv__k { color: #5A6572; }

.val-note {
  margin-top: 28px; padding: 12px 16px; background: #F5F7FA; border-radius: 6px;
  font-size: var(--mtel-fs-sm); line-height: 1.75; color: #546E7A; max-width: 88ch;
}
.val-meta { margin-top: 12px; font-size: var(--mtel-fs-xs); color: #5A6572; }

/* ── F-V43: assets/refs.js contrast overrides (guide.html only since Task 12
   deleted validasi.html — refs.js is script-tagged on one page now) ─────────
   refs.js injects its own <style> tag at runtime and is not owned by this
   task (it is a separate script, not a mtel.css consumer or an HTML
   <style> block), so its failing pairs cannot be fixed at the source. All
   of them are overridden here instead, `!important` throughout because the
   two per-status colours (.mref-badge, .mref-t-status) are set inline by
   refs.js per formula row and inline styles otherwise beat any external
   rule regardless of source order or specificity.
     .mref-x / .mref-kat / .mref-arrow — plain classes, #9AA3B0 on white,
       2.55:1 (x/kat) or measured with the "×" at 24px, exactly the large-text
       cutover — still fails 3:1 there too.
     .mref-badge — a filled circular status dot, white text (#fff) on the
       inline per-status background. Green (ok, 3.13:1) and amber (warn,
       1.63:1) fail; red (none, 4.88:1) and purple (missing, 5.60:1) already
       pass and are left alone. Text goes dark instead of the background
       moving, so the brand ok/warn colours stay recognisable at full
       saturation — matched by inline background VALUE since the badge has
       no separate class per status.
     .mref-t-status — a light pill, inline `background:{color}1F` (~12%
       tint) with the SAME saturated colour as its own text. ok 2.76:1, warn
       1.53:1, none 3.96:1 all fail; here the background tint is left alone
       (it is not the failing side) and only the text darkens, matched by
       inline COLOR value.
     .mtel-refs__tally strong — plain class, #28A745 on white, 3.13:1 (the
       FAB panel's own "N dari M formula sesuai standar" tally line). */
.mref-x, .mref-kat, .mref-arrow { color: #5A6572 !important; }
.mref-badge[style*="background:#28A745"] { color: #0B1F3A !important; }
.mref-badge[style*="background:#FFC107"] { color: #0B1F3A !important; }
.mref-t-status[style*="color:#28A745"] { color: #1C7530 !important; }
.mref-t-status[style*="color:#FFC107"] { color: #935F07 !important; }
.mref-t-status[style*="color:#E30613"] { color: #CC0511 !important; }
.mtel-refs__tally strong { color: #1C7530 !important; }
/* .mref-sec h4 (#6B7280) was 4.83:1 on white but only 4.38:1 on .mref-poc's
   #EAF7EC tint — the same rule, two different real backgrounds, one of
   them under the floor. .mref-v1 h4 (#9AA3B0) combines with .mref-v1's own
   `opacity:.88` (compounding, per this file's own opacity note above) to
   2.14-2.19:1. Both move to the same darker shade already used for the old
   --mtel-gray-600/--gray-600 token, which clears every background these
   headings actually sit on with margin (5.5-7.9:1 measured). */
.mref-sec h4, .mref-v1 h4 { color: #48525E !important; }

/* ── Task 12 (R2-11): the PLAN dock's SEVENTH tab, and its `Perhitungan` pane
   ──────────────────────────────────────────────────────────────────────────
   These rules live here rather than in planning.html because that file is
   frozen for this task; they are scoped through `.ws-dock` so they reach only
   the planning dock and nothing else, and they are written at (0,3,0)
   specificity ON PURPOSE — planning.html's own `.ws-dock__tab` block is a
   (0,1,0) rule in a <style> that comes AFTER this stylesheet's <link>, so an
   equal-specificity override here would silently lose the cascade.

   ── THE MEASUREMENT THAT PRODUCED THIS (2026-08-11, Chromium, headless) ──

   The dock is 400px at >=1280px document width and 340px below. At 340px the
   seven tabs do NOT fit: measured, `Ringkasan` alone wants 61px of content in
   the 52px box `flex-shrink` gave it, and every one of the seven was in the
   same state. `.ws-dock__tab` is `overflow:hidden; text-overflow:ellipsis`,
   so that state renders as "Ringkasa…" / "Perhitunga…" — CONTENT SILENTLY
   MADE UNREADABLE RATHER THAN REACHABLE, which is this app's documented worst
   habit (audit §1), and it is invisible to a container-level fit check
   (`tests/planning_dock_tabs.spec.js` measures the last tab's right edge
   against the container's client width, and shrink-to-ellipsis satisfies that
   perfectly). tests/planning_perhitungan.spec.js measures each tab's own
   scrollWidth against its own clientWidth instead, which is the only check
   that can see it.

   THE FIX IS TO WRAP, NOT TO SHRINK AND NOT TO SCROLL. `min-width:max-content`
   withdraws permission to shrink below the label; `flex-wrap:wrap` then puts
   the overflow on a second row. Measured after: one row at 400px (the seven
   natural widths sum to just inside it), two rows at 340px, zero truncation at
   either. Scrolling was the alternative — the strip already carries
   `overflow-x:auto` with a deliberately STYLED scrollbar — and was rejected
   because a horizontally scrolled tab strip leaves the seventh tab off screen
   by default: reachable, but not shown, and a presenter who does not know it
   is there never opens it. The `overflow-x:auto` fallback is deliberately
   left in place underneath for any future label too wide for one whole row.
   DO NOT "fix" a future overflow by restoring `min-width: 0`, and do not chase
   an exact one-row fit by shaving padding — 400-32=368 and 400-12=388 land the
   dock's own layout on a measured boundary the next column would break. */
.ws-dock .ws-dock__tabs { flex-wrap: wrap; }
.ws-dock .ws-dock__tabs .ws-dock__tab { min-width: max-content; }

/* ── Round 3 Task 8 (item 15): THE PINNED STAT STRIP ──────────────────────
   #planStatStrip is a flex child of `.ws-dock`, ABOVE #planDockTabs, so the
   headline figures are readable from every PLAN tab rather than only from
   the one they used to live in. Its markup is in planning.html; these rules
   live here for the same cascade reason as the tab rules above — planning
   .html's own `.ws-stat*` block is a (0,1,0) rule in a <style> that comes
   AFTER this stylesheet's <link>, so every override below is written at
   (0,2,0) through `.ws-dock__strip` ON PURPOSE. An equal-specificity rule
   here would silently lose.

   THE VALUES ARE STEPPED DOWN 2xl -> lg, AND NOTHING ELSE IS. Four cells
   across a 340px dock is 85px each; at 26px a four-digit m² figure did not
   fit its own cell. What is NOT done: shaving `.ws-dock` padding to chase a
   fit (400-32=368 and 400-12=388 land the dock on a measured boundary the
   next column breaks at), and clipping — the labels WRAP (`white-space:
   normal; overflow-wrap: anywhere`) and the row wraps to a second line
   rather than truncating, which is this app's documented worst habit.

   `flex: none` is load-bearing, exactly as it is on `.ws-dock__legend`
   below: without it the strip shrinks under #planDockBody's `flex: 1` and
   its own numbers clip.

   `[hidden]` guards are written out because the native UA rule loses to any
   author `display` declaration on the same element — the gap
   tests/planning_gate.spec.js already records against `.ws-sidebar`. */
.ws-dock__strip {
  flex: none; background: #FBFCFD; border-bottom: 1px solid #E9ECEF;
  padding: 8px 12px 7px;
}
.ws-dock__strip[hidden] { display: none; }
.ws-dock__strip-row { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.ws-dock__strip .ws-stat { flex: 1 1 66px; min-width: 0; margin-bottom: 0; }
.ws-dock__strip .ws-stat__label {
  font-size: var(--mtel-fs-3xs); letter-spacing: .03em; margin-bottom: 0;
  white-space: normal; overflow-wrap: anywhere;
}
.ws-dock__strip .ws-stat__row { flex-wrap: wrap; gap: 0 3px; }
.ws-dock__strip .ws-stat__value { font-size: var(--mtel-fs-lg); line-height: 1.2; }
.ws-dock__strip .ws-stat__unit { font-size: var(--mtel-fs-2xs); }
.ws-dock__strip-notes:empty { display: none; }
.ws-dock__strip .ws-stat__note { margin-top: 4px; }
.ws-dock__strip .ws-stat__note[hidden] { display: none; }

/* The `Perhitungan` pane. `#planCalcPanel` is created once by
   assets/planning.js (planning.html carries no template for it) and is a LEAF
   host — no <canvas>, no render function of its own inside it — which is what
   makes it safe to innerHTML while the PANE above it is never rebuilt. */
.plan-calc { padding: 14px 16px; font-size: var(--mtel-fs-base); }
.plan-calc[hidden] { display: none; }
.plan-calc__title {
  font-size: var(--mtel-fs-sm); font-weight: 700; color: #1A2456;
  text-transform: uppercase; letter-spacing: .04em; margin: 0 0 6px;
}
.plan-calc__lede { font-size: var(--mtel-fs-xs); color: #495057; line-height: 1.55; margin: 0 0 10px; }
/* A data table is deliberately exempt from --mtel-measure: giving a table a
   readable prose measure is the defect, not the fix. */
.plan-calc__table { width: 100%; border-collapse: collapse; }
.plan-calc__table th,
.plan-calc__table td {
  text-align: left; padding: 5px 0; vertical-align: baseline;
  font-size: var(--mtel-fs-sm); font-weight: 400; color: #212529;
  border-bottom: 1px solid #F1F3F5;
}
.plan-calc__table td.plan-calc__val { text-align: right; white-space: nowrap; font-weight: 600; }
.plan-calc__tier {
  display: inline-block; margin-left: 5px; padding: 1px 5px; border-radius: 3px;
  background: #E7F1FF; color: #0B4EA2; font-size: var(--mtel-fs-3xs);
  font-weight: 700; letter-spacing: .02em; white-space: nowrap;
}
.plan-calc__noterow td { border-bottom: none; padding-top: 0; }
.plan-calc__note {
  font-size: var(--mtel-fs-3xs); color: #5A6572; line-height: 1.5; margin: 0 0 8px;
}
.plan-calc__sep td { border-bottom: 2px solid #DEE2E6; padding: 0; height: 6px; }
.plan-calc__assump { color: #7A5300; }

/* ── Task 4 (R2-16 items 8/9/10): the ONE legend, in the DOCK RAIL ────────
   Built by assets/planning.js (#mapLegend, created once, never innerHTML-
   rebuilt as a node) as the LAST flex child of `.ws-dock`, below
   #planDockBody — so it is visible in every mode and on every tab, beside
   the map rather than on top of it.

   IT WAS `position: absolute` OVER `.plan-main` UNTIL 2026-08-11, WHICH IS
   THE ONE THING IT MUST NOT BE. `tests/layout_rollout.spec.js` pins that
   nothing persistent floats over the map — D6/D7, whose own fix for the
   dashboard's two absolutely-positioned bands was to MOVE THEM INTO THIS
   RAIL. Three assertions caught it, two of them with no allow-list applied
   at all, so there was no exemption available that would not have meant
   deleting the checks that keep the allow-list honest. Re-homing costs the
   map zero area, which is the 44% -> 81% measurement the dock exists for.

   `flex: none` is load-bearing: without it this footer shrinks under
   #planDockBody's `flex: 1` and the swatch rows clip. */
.ws-dock__legend {
  flex: none; border-top: 1px solid #E9ECEF; background: #FBFCFD;
  padding: 7px 12px 8px; font-size: var(--mtel-fs-2xs); color: #212529;
}
.ws-dock__legend[hidden] { display: none; }
.ws-dock__legend-title {
  font-weight: 700; color: #1A2456; text-transform: uppercase;
  letter-spacing: .03em; font-size: var(--mtel-fs-3xs);
  margin: 6px 0 3px;
}
.ws-dock__legend-title:first-child { margin-top: 0; }
/* inline-flex, not flex: the rail is 340-400px and the rows are short, so
   they flow and WRAP onto as few lines as they need instead of spending one
   dock line each. That is what keeps a permanent footer cheap enough to be
   permanent. */
.ws-dock__legend-row {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 1px 0; margin-right: 12px;
}
.ws-dock__legend-swatch {
  display: inline-block; width: 10px; height: 10px; border-radius: 50%;
  flex: none; border: 1px solid rgba(0,0,0,.15);
}
/* The polygon row reuses the panel swatch's colour but as an outline, not a
   fill — the two share one hue in this app's palette (navy) and must still
   read as two different symbols on the same small card. */
.ws-dock__legend-swatch--outline { background: transparent !important; border-width: 2px; border-color: #1A2456; }
.ws-dock__legend-label { color: #495057; }
.ws-dock__legend-scale { height: 8px; border-radius: 4px; margin: 2px 0; }
.ws-dock__legend-scale-labels {
  display: flex; justify-content: space-between; color: #6C757D;
  font-size: var(--mtel-fs-3xs);
}

/* ── ROUND 5 (D5-1) — the plateau-centring notice on the Rekomendasi cards ──
   Rendered by assets/planning.js on BOTH `#planRecoCard` (PLAN -> Orientasi)
   and `#siteOverview` (SITE -> Rekomendasi) when the published orientation was
   moved off the edge of a run of numerically identical angles.

   WHY IT IS A CLASS OF ITS OWN RATHER THAN `.reco-card__flat`, WHICH IT LOOKS
   LIKE. The two sentences sit side by side and say different things — the flat
   note is about TILT tolerance ("praktis sama dalam rentang ini"), this one is
   about WHICH of several tied azimuths got published — and, decisively,
   `.reco-card__flat` is COUNT-ASSERTED: tests/planning_reco.spec.js reads it
   with toHaveCount(1) in one branch and toHaveCount(0) in another, so a second
   element wearing that class would break a passing spec while quietly changing
   what that spec proves. Sharing the class would also let a future
   `.reco-card__flat` text assertion match whichever of the two rendered first.

   It lives HERE rather than in planning.html's inline <style> (where the rest
   of the .reco-card__* family sits) only because that file is not this change's
   to edit. mtel.css is <link>ed ahead of the inline block and this selector is
   new, so nothing in the cascade collides. Colours come from the design tokens
   rather than repeating .reco-card__flat's raw hex. */
.reco-card__centred {
  display: block; margin-top: 5px; padding: 5px 8px;
  background: var(--mtel-gray-100); border-left: 3px solid var(--mtel-navy);
  border-radius: 0 6px 6px 0;
  font-size: var(--mtel-fs-xs); color: var(--mtel-navy);
}
