/* ===== EMT Quotation Portal — styles.css =====
   Theme: EMT Forest Teal (#1E3F3C). Arial throughout.
   v1.0 · 2026-05-17
   =============================================== */

:root {
  --primary: #1E3F3C;          /* Forest Teal */
  --primary-dark: #142B29;
  --primary-light: #2A5651;
  /* ⚠ THE BRAND GREEN, USABLE AS TEXT. Added 13 Aug 2026 on Shaf's instruction after
     the same defect surfaced a THIRD time ("all the reset green colors should be
     changed — the same issues in other pages as well").
     --primary is declared IDENTICALLY in both themes on purpose: it is the brand, and
     white-on-brand works anywhere. That is right for a FILL and wrong for TEXT, which
     needs a colour that flips with the surface behind it. Painted as text it measured
     1.00:1 on .btn-ghost, then the account button, then 1.42:1 on the login link.
     ⚠ THE LIGHT VALUE IS THE SAME HEX AS --primary, so light mode cannot move — that
     is proven by IDENTITY, not by measuring 33 sites.
     The dark value is derived, not picked: hue held to within 0.41 degrees of --primary
     (187.8 -> 188.3 in OKLCH) and HSL saturation within 0.4pp, with only lightness
     raised. That is this file's own house rule for a dark lift (--accent-grn 135->134,
     --accent-grn2-text 158->155): hold the hue, raise the lightness.
     ⚠ It is bound on --border #313A33 (4.92:1) — the LIGHTEST dark surface body text
     can land on — rather than on the surfaces in use today. A token that clears only
     today's enumerated backgrounds is exactly how --primary-as-text became a
     three-time scar. Measured: --panel 6.79, --bg 7.58, --row-band 6.08,
     --accent-grn2-bg 6.23, --accent-soft 5.32, --border 4.92. */
  --primary-text: #1E3F3C;
  --on-primary: #FFFFFF;      /* text that sits ON --primary / --toast-bg (C6b) */
  --accent-grn: #548235;
  --logo-grn: #88B068;

  /* ---- THE QUOTATION DOCUMENT'S OWN INK ---------------------------------
     These are `excel-export.js`'s C_DARK / C_MID / C_LIGHT / C_CREAM and the
     rules the sheet draws between them. They exist as tokens so the raw-hex
     ratchet stays honest (D17 — it may fall and never rise, and raising a
     baseline to pass is the thing that rule exists to stop), NOT because they
     are app chrome. They are the colours of a file a customer opens.
     ⚠ THEY ARE IDENTICAL IN BOTH THEMES, DELIBERATELY, like `--logo-grn`.
     Ink on a page does not flip when the reader turns a lamp off — and a
     theme change silently repainting a customer's quotation is the failure
     that would matter. The `--logo-grn` scar (white text left on a bar that
     was later deleted) says a non-flipping colour is safe only while its
     background never moves, so every one of these is used ONLY inside
     `.bq-sheet`, on a background that same block sets. Do not reach for them
     anywhere else. */
  --doc-ink: #2A4636;
  --doc-mid: #3D6A4D;
  --doc-light: #4F7359;
  --doc-cream: #EAE0CC;
  --doc-cream-soft: #F6F2E7;
  --doc-rule: #D8DCD4;
  --doc-rule-soft: #EDEFEA;
  --doc-text: #1A1A1A;
  /* 🛑 THE SAME VALUE IN BOTH THEMES, ON PURPOSE, LIKE EVERY TOKEN IN THIS FAMILY.
     The sheet paints its own paper (`--doc-paper` is #FFFFFF in light AND dark), so
     a token that FLIPS is wrong on it: `--muted` is #686F66 light (5.91:1 here) and
     #A6AEA1 dark, which on white paper is ~2.2:1 — the `--logo-grn` scar exactly,
     a colour that cannot adapt to a background that never moves. 5.91:1 on paper. */
  --doc-muted: #5F6660;
  --doc-paper: #FFFFFF;
  /* ⚠ THE SECOND GREEN, named on Shaf's decision 13 Aug 2026 after being shown it
     side by side with --primary. It is NOT --primary drifted: visibly lighter and
     sager, and it does a different job — panel rails, the ACTIVE mode toggle, the
     picker-door hover, the sub-assembly panel edge. He turned down folding it into
     --primary, which would have flattened the toggle and the rails into the brand
     colour. Naming it changes NOTHING visually in light; it exists so the colour
     can follow the theme, which a raw hex cannot.
     --accent-grn2 is the FILL/BORDER; --accent-grn2-text is the darker one used
     for text and hover text on a pale ground. */
  --accent-grn2: #3D6A4D;
  --accent-grn2-text: #2A4636;
  --accent-grn2-bg: #eef4f0;
  /* ⚠ THE DELETE GLYPH, named on his decision the same day and DELIBERATELY LEFT
     AS IT LOOKS. A softened --bad-text for a small × on a row — he turned down
     both the standard alert red (louder on rows carrying many deletes) and a
     quiet-until-hovered grey, on the grounds that a destructive control should
     not look harmless until you touch it. Named only so it can follow the theme. */
  --danger-soft: #aa5555;
  /* ⚠ A light BACKDROP for dark artwork, not a surface. Added 12 Aug 2026 with
     dark mode, which is what separated the two jobs `--panel` had been doing:
     "the raised surface" (must flip) and "something light to put a dark logo on"
     (must not). One token meaning two things is the D26 shape, and it stayed
     invisible for as long as there was only one theme. */
  --logo-chip: #FFFFFF;
  --bg: #FAFAF7;              /* Tier 1: cool grey → warm. See --text-3 note below. */
  --panel: #FFFFFF;
  /* --- PIPELINE STAGE COLOURS — from the Enviromet Design System (c98, 17 Aug 2026)
     Shaf pointed at the published EMT design system as the reference. These six are
     its own categorical chart hues, in its own stated order — forest · sky · amber ·
     dusk · lagoon · clay — so a one-or-two-series chart stays brand green, which is
     the reason that order exists.

     🛑 TWO DEPARTURES, BOTH MEASURED RATHER THAN PREFERRED, BOTH OWED BACK TO THE
     DESIGN SYSTEM AS FINDINGS:

     1. LEAF (its slot 2) IS NOT USED. Slots 1 and 2 are both greens, and in DARK
        mode the mark band is L 0.48–0.67 — too narrow to hold two greens apart. Every
        leaf step tried against forest measured ΔE 8.1–10.7 for NORMAL vision, under
        the floor of 15. Not a colour-blindness edge case: full-colour readers cannot
        tell them apart either. Skipping leaf keeps the order otherwise intact.

     2. THE STEPS ARE SNAPPED, NOT COPIED. The system's own values fail the validator
        as a series palette — forest #1E3B2B sits at L 0.324 against a 0.43–0.77 band
        (it is a brand ANCHOR, not a series slot), and several read as grey below the
        chroma floor. Each hue is kept and re-stepped to the nearest passing value,
        which is the documented way to onboard a system's ramps.

     ⚠ SEVEN OF THE EIGHT SOURCE HEXES WERE SAMPLED FROM THE SPECIMEN CARD, not read
     from tokens/colors.css — the share link renders its cards in sandboxed frames, so
     the token file is not machine-readable from here. Only forest #1E3B2B is verbatim.
     Replace these with the real values when the system is available as a file.

     ✅ VALIDATED IN BOTH THEMES, ALL CHECKS PASS. The one WARN (amber at 2.75:1 on
     the panel) is relieved by the direct label every bar carries — the documented
     remedy, and it stops being legal if those labels are ever removed.

     ✅ AND THE SYSTEM'S OWN RULE IS ALREADY HONOURED HERE: "never use the semantic
     status colors (ok/warn/alert) as series colors — reserve them for state." Won and
     lost take the status colours and never a slot. */
  --stage-1: #12784C;
  --stage-2: #2E7FB8;
  --stage-3: #C7901B;
  --stage-4: #7B5FC0;
  --stage-5: #00A29C;
  --stage-6: #BE3F28;

  /* --- PERSON AVATAR TINTS (c102, 17 Aug 2026) ------------------------------
     Shaf: a different colour per PIC on the initial avatar. A per-PERSON colour is
     the sanctioned use of a categorical palette — this file's own `--series-*`
     comment says the series palette is "for a part-to-whole where each part is a
     PERSON or a named thing" — so these are the SAME six design-system hues as the
     stage bars, at tint strength.

     ⚠ TINTS, NOT THE FULL HUES, AND THE REASON IS MEASURED. The initials sit ON the
     fill, so this is TEXT on a background. White initials on the amber slot
     (#C7901B) measure roughly 2.6:1 — under the 4.5 floor for small text, and the
     initials are 10px. A soft tint carrying `--text` clears it comfortably on all
     six, and keeps every avatar the same weight on the page instead of one shouting.
     ⚠ The colour is assigned from a HASH of the person's staff id, so the same
     person is the same colour on every screen and every session — colour follows
     the entity, never its position in a list. */
  --pav-1: #DCEDE5;
  --pav-2: #DDE8F4;
  --pav-3: #F5E9D2;
  --pav-4: #E7E1F5;
  --pav-5: #D6EFED;
  --pav-6: #F6DED9;

  --row-band: #F4F3EF;
  --border: #E4E4E0;
  --text: #1C2620;
  /* ⚠ DARKENED FROM #6B7268 ON 18 Aug 2026, ON SHAF'S APPROVAL OF A MEASURED PROPOSAL —
     and the reason is that it was fine on WHITE and failing everywhere else. It measured
     4.96:1 on `--panel` but only 4.47:1 on the row band and 4.45:1 on the pattern tint,
     which is EIGHT of the thirteen rules the contrast gate was holding, across 177 sites.
     One token, eight rules. Now 5.18 / 4.67 / 4.65 on those three grounds.
     ⚠ Chosen with headroom rather than landing on 4.50: the tightest passing value cleared
     by 0.01, which the next background tweak would have undone. */
  --muted: #686F66;
  /* The footer band is --primary-dark in BOTH themes (brand green, not theme-driven),
     so a grey sitting on it needs the DARK-appropriate grey regardless of theme.
     Value is identical to dark theme's own --muted (#A6AEA1) — not a new colour,
     the existing one applied to a context that is always dark. 6.53:1 on
     --primary-dark; the plain --muted it replaced there was 3.01:1. Shaf 15 Aug 2026. */
  --footer-muted: #A6AEA1;
  /* Tier 1 (9 Aug 2026) — the THIRD text level. The app had two, which is why a quiet
     label could not sit above a loud number without one of them shouting. Labels, units
     and em-dash placeholders. Check it against --panel AND --accent-soft: a tertiary
     grey on a cream fill is the pair most likely to fail contrast. */
  /* ⚠ DARKENED FROM #7E847A ON 18 Aug 2026, ON SHAF'S APPROVAL. At 3.84:1 on white it was
     the WORST entry on the contrast list — the deal reference codes (`DQ-2026-0005`) and the
     ad-hoc section titles. Now 4.62:1.
     ⚠ It is ALSO used as a border, where the floor is 3:1, so darkening is free there.
     Shaf 10 Aug 2026, still true: it clears 3:1 on panel AND cream; the grey before it failed
     both (2.59:1, 2.04:1). Do not lower a ratio to keep a colour. */
  --text-3: #71776E;
  /* The ONLY soft fill. Do not invent a second tint. */
  --accent-soft: #EAE4D4;
  /* Used ON --primary and nowhere else — it has no contrast story against white. */
  --brand-cream: #E7DFCC;
  --warn-bg: #FFF4E5;
  --alert-bg: #FDECE4;        /* stronger than warn: a copy priced differently from its original */
  --alert-text: #B5401D;
  --warn-text: #9E5819;        /* Shaf 15 Aug 2026: "deepen the amber" — same hue as the
     original #B5651D, darkened until it clears 4.5:1 on white (5.44) AND on --warn-bg
     (5.00). The old value was 4.34 / 3.99 — under floor on both, the widest-reach
     colour finding on the platform (13 rules). Dark theme's --warn-text was already
     fine and is untouched. */
  /* ⚠ DARKENED FROM #2F855A ON 18 Aug 2026, ON SHAF'S APPROVAL. It measured 4.07:1 where it
     paints the pattern library's "Do" headings on the pale-green tint. Now 4.63:1.
     ⚠ THE TOKEN MOVED, NOT JUST THAT ONE HEADING — `--ok` paints 8 sites, and a per-screen
     override would have been a second green that drifts from this one (D20). Every other use
     gets slightly MORE contrast, never less, which is why moving the token is safe here. */
  --ok: #2B7B53;
  --bad-text: #B00020;         /* invalid / not-in-catalog input state */
  --bad-bg: #FDE2E4;
  --ok-bg: #E0F5E9;            /* positive confirmation banner (approval refresh) */
  --staging-bar: #7A2E2E;      /* S0 (P3): staging banner — deliberately unlike any product colour */
  --toast-bg: #2A2A2A;         /* S0 (P3): transient bottom-centre notice */
  /* THE INVERSE SURFACE — the deal head's verdict panel (Shaf's decision 6, 21 Aug 2026: "the
     panel" stays). `--primary` is the BRAND and never moves between themes, which is right for
     a fill behind white text and wrong for a panel that must read in dark mode — measured at
     1.1–1.4:1 when the mock used it. So the panel has its own surface, dark in BOTH themes,
     with its own text and muted text; in dark it lifts off --panel rather than matching it. */
  --inverse-bg: #1E3F3C;
  --inverse-text: #F5F1E6;
  --inverse-muted: #A6AEA1;
  --inverse-line: rgba(245, 241, 230, 0.18);
  --ok-text: #1C5C3A;
  /* Categorical series — for a part-to-whole where each part is a PERSON or a named
     thing, not a magnitude. Four slots, deliberately: past four, fold to "Other".
     These are the first four slots of a validated palette, re-checked against THIS
     app's white panel: lightness band, chroma floor, colour-vision separation
     (worst adjacent ΔE 9.1) and normal-vision separation (22.9) all pass. Two of
     them sit under 3:1 against white, which is why every segment that uses them
     carries a visible label — that is the relief rule, not a nicety.
     Status colours above are RESERVED and never used as a series. Adding these
     raises the raw-hex baseline by four, deliberately, in this same change (D17). */
  --series-1: #2a78d6;
  --series-2: #eb6834;
  --series-3: #1baf7a;
  --series-4: #eda100;
  /* ⚠ THE WASH BEHIND AN OVERLAY — ONE IDEA THAT WAS WRITTEN THREE WAYS.
     Added 14 Aug 2026 with the detail-screen work. The modal, the catalogue
     drawer and the CRM sidebar each darkened the page behind them with their
     own hand-written value: rgba(12,26,24,0.52), rgba(15,23,42,0.45) and
     rgba(0,0,0,0.42). Nobody wrote that inconsistency — it emerged, one
     overlay at a time, which is the D26 shape on a colour instead of a column.
     ⚠ AND IT IS INVISIBLE TO THE HEX RATCHET BY CONSTRUCTION: rgba() is not
     #rrggbb, so all three sat outside the count that exists to stop exactly
     this. A ratchet on one NOTATION is not a census.
     The light value is the modal's — the most considered of the three, and the
     only one derived from the brand rather than from a stock blue-slate.
     ⚠ THE DARK VALUE IS NOT THE LIGHT ONE REUSED, and that is the whole reason
     this token has two values. A 52% dark wash over an already-dark page moves
     almost nothing: --bg is #121713 and --panel is #1B221D, so the panel edge
     would dissolve into the thing it is supposed to float above. Measured with
     the wash applied: panel-against-scrimmed-page is 1.25:1 in dark, better
     than the 1.117:1 separation panel-on-bg already carries in that theme. */
  --scrim: rgba(12, 26, 24, 0.52);
  /* ⚠ A LINK BLUE THAT CAN FLIP. #1355c0 was written raw in two rules
     (.ext-link, .drawer-sslink) and therefore could not respond to the theme —
     the same family as the --primary-as-text scar, one notation over.
     The dark value is DERIVED, not picked, by this file's house rule: hold the
     hue, raise the lightness. Light is HSL(217.1, 82%, 41%); dark is
     HSL(217, 75%, 70%) — hue held to 0.1 degrees.
     ⚠ Bound on --border #313A33, the LIGHTEST dark surface text can land on,
     not on the surfaces in use today. Measured dark: --bg 7.26, --panel 6.50,
     --border 4.71. Light on --panel: 6.80. */
  --link: #1355c0;
  --shadow-sm: 0 1px 2px rgba(30,63,60,0.06), 0 2px 6px rgba(30,63,60,0.04);
  --shadow-md: 0 4px 12px rgba(30,63,60,0.08), 0 8px 24px rgba(30,63,60,0.06);
  --radius: 6px;
  --radius-lg: 10px;
  /* ⚠ THE HEIGHT OF THE STAGING BANNER, AND IT IS A TOKEN BECAUSE TWO RULES NEED
     THE SAME NUMBER. Added 14 Aug 2026 after a D14 look at the peek found the
     drawer's header sitting UNDERNEATH the banner: `.staging-banner` is fixed at
     top 0 with z-index 300, and `.drawer-overlay` is fixed at `inset: 0` with
     z-index 60 — so on staging the panel's title and its CLOSE BUTTON were both
     cut in half. Nobody had hit it because the modal is the other full-screen
     overlay and it centres itself with padding; the drawer is the only one that
     starts at the very top. It was live on the portal's catalogue drawer too.
     ⚠ The number was already written once, in `body.is-staging`'s padding-top.
     Writing it a second time by hand is how the two come to disagree the day the
     banner's font size changes (D26/D35) — so it is declared here and both read
     it. The banner exists only on staging, so both sites are guarded by the same
     `body.is-staging` class rather than by a number one of them assumes. */
  --staging-h: 30px;
  /* The customer sheet's first column. TWO rules need this number — the grid track
     and the sticky offset that freezes Description beside it — so it is declared
     once rather than hand-copied (D35). ⚠ In `:root` and not on `.bq-sheet`, because
     `uicheck` reads token declarations from `:root` only and a component-scoped one
     reddens as UNDEFINED-TOKEN; `--staging-h` set the precedent. */
  --bq-qid-w: 54px;
  /* Tier 1 — the two faces. --font is everything; --mono is NUMBERS, and the codes that
     were already monospaced under five different spellings before this change.
     ⚠ `body` also declares font-family and WINS over :root, so both must read --font.
     Changing only this one moves nothing on screen and every gate still passes — which
     is exactly how a theme gets reported as shipped while the app is still Arial. */
  /* ── The type scale (THEME_TIER1_PLAN §1a) ─────────────────────────────────
     Declared at TODAY's desktop values so defining them moves nothing; the phone
     block at the foot of this file is the only place they change. */
  --fs-base: 14px;
  --fs-table: 13px;
  --fs-small: 11px;
  --fs-label: 11px;
  /* ⚠ 16px ON PHONE IS A NUMBER, NOT A PREFERENCE. Below 16px, Safari on iOS
     ZOOMS THE PAGE BY ITSELF the moment a finger touches an input — the layout
     jumps and the reader has to pinch back. Measured today: 25 selectors set a
     font-size on an input, select or textarea and EVERY ONE is under 16px, from
     11.5px to 14px. So every form on both pages does this today. */
  --fs-input: 14px;
  --tap: 32px;
  --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
  --mono: ui-monospace, 'SF Mono', 'Roboto Mono', Menlo, Consolas, monospace;
  font-family: var(--font);
  color-scheme: light;
}

/* ============================================================================
   DARK MODE — Shaf's ask, 12 Aug 2026: "Create 2 types of screen mode."

   ⚠ EVERY TOKEN USED IN A COLOUR RULE IS RESTATED HERE, INCLUDING THE ONES THAT
   DO NOT CHANGE. An absent token silently inherits its light value and looks
   deliberate; a restated one is a decision somebody can read. `themecheck` fails
   on absence, not on sameness, for exactly that reason.

   ⚠ AND THIS BLOCK IS NOT THE WHOLE STORY. 302 raw hex values still sit in rules
   across these stylesheets, and a raw hex CANNOT flip — no attribute reaches it.
   So roughly a third of the app's colour decisions stay light-themed until they
   are tokenised. `themecheck` prints that number every run and ratchets it
   downward. Do not read a working dark shell as a finished dark theme.

   ── How the values were chosen ──────────────────────────────────────────────
   Measured, not eyeballed, because this app already hit a contrast wall once:
   three text levels do not fit above 4.5:1 on the light cream, and `--text-3`
   had to be darkened after the ratios were computed rather than before.
   Text pairs here clear their floors with room — text 13.7:1 on panel, muted
   7.1:1, `--text-3` 5.1:1 on panel and 4.0:1 on the soft fill.

   ⚠ Surface-on-surface pairs are DELIBERATELY subtle and are measured against the
   LIGHT THEME rather than against an absolute floor, because the light theme
   never met one either: panel-on-bg is 1.05:1 in light and 1.12:1 here,
   border-on-panel 1.27:1 light and 1.38:1 here. Dark separates BETTER than light
   on all three. Forcing 3:1 on a hairline would give a garish theme and would be
   answering a question about component boundaries with a rule about decoration.

   ── The brand ───────────────────────────────────────────────────────────────
   `--primary` DOES NOT MOVE. It is the brand, it is used as a FILL (the sidebar,
   primary buttons), and on this darker ground it reads as raised rather than
   receding — the page is darker than the sidebar, which is the separation light
   mode gets the other way round. `--brand-cream` sits ON `--primary` and is
   unchanged for the same reason. White on primary measures 11.5:1.
   ⚠ What DOES need care is `--primary` used as TEXT on a pale surface. Those call
   sites are raw hex today, so they are part of the tokenising sweep, not this
   block — flagging it here so the next session does not discover it as a surprise.

   ── The series palette ──────────────────────────────────────────────────────
   Lightened. The light values were tuned against a white panel and two of them
   sit under 3:1 there — which the token comment above already admits, and why
   every segment carries a visible label. On this dark panel the lightened set
   measures 5.6 / 6.4 / 7.9 / 9.4:1, so it is better off here than in light.

   ── Shadows ─────────────────────────────────────────────────────────────────
   A tinted shadow does not read on a dark ground. These go to black at a higher
   alpha, which is depth rather than a smudge.
   ============================================================================ */
:root[data-theme="dark"] {
  /* the brand — unchanged on purpose, see above */
  --primary: #1E3F3C;
  --on-primary: #FFFFFF;
  --brand-cream: #E7DFCC;
  --primary-dark: #16302E;
  --primary-light: #35635D;
  --primary-text: #65B5AE;
  --accent-grn: #7FB05C;
  --logo-grn: #88B068;

  /* ---- THE QUOTATION DOCUMENT'S OWN INK ---------------------------------
     These are `excel-export.js`'s C_DARK / C_MID / C_LIGHT / C_CREAM and the
     rules the sheet draws between them. They exist as tokens so the raw-hex
     ratchet stays honest (D17 — it may fall and never rise, and raising a
     baseline to pass is the thing that rule exists to stop), NOT because they
     are app chrome. They are the colours of a file a customer opens.
     ⚠ THEY ARE IDENTICAL IN BOTH THEMES, DELIBERATELY, like `--logo-grn`.
     Ink on a page does not flip when the reader turns a lamp off — and a
     theme change silently repainting a customer's quotation is the failure
     that would matter. The `--logo-grn` scar (white text left on a bar that
     was later deleted) says a non-flipping colour is safe only while its
     background never moves, so every one of these is used ONLY inside
     `.bq-sheet`, on a background that same block sets. Do not reach for them
     anywhere else. */
  --doc-ink: #2A4636;
  --doc-mid: #3D6A4D;
  --doc-light: #4F7359;
  --doc-cream: #EAE0CC;
  --doc-cream-soft: #F6F2E7;
  --doc-rule: #D8DCD4;
  --doc-rule-soft: #EDEFEA;
  --doc-text: #1A1A1A;
  /* 🛑 THE SAME VALUE IN BOTH THEMES, ON PURPOSE, LIKE EVERY TOKEN IN THIS FAMILY.
     The sheet paints its own paper (`--doc-paper` is #FFFFFF in light AND dark), so
     a token that FLIPS is wrong on it: `--muted` is #686F66 light (5.91:1 here) and
     #A6AEA1 dark, which on white paper is ~2.2:1 — the `--logo-grn` scar exactly,
     a colour that cannot adapt to a background that never moves. 5.91:1 on paper. */
  --doc-muted: #5F6660;
  --doc-paper: #FFFFFF;
  /* The second green, lifted for dark exactly as --accent-grn is: the light value
     is a mid green that goes muddy on a dark ground, so it rises rather than being
     reused. The text variant becomes the LIGHTER of the pair in dark, because it
     now sits on dark rather than on a pale chip. */
  --accent-grn2: #5E9370;
  --accent-grn2-text: #9CC7AA;
  --accent-grn2-bg: #1E2A22;
  /* Lifted to stay legible on a dark row while keeping the softened character
     Shaf chose — it is still quieter than --bad-text (#F2909C) in dark. */
  --danger-soft: #D98A8A;
  --logo-chip: #FFFFFF;

  /* surfaces, warm-dark rather than neutral grey — the identity is a warm one */
  --bg: #121713;
  --panel: #1B221D;
  /* Dark steps are SELECTED against the dark surface, never an automatic flip: the
     dark mark band is L 0.48–0.67, DARKER than light's 0.43–0.77, because a dark theme
     wants marks that sit on the surface rather than glare off it. Same six hues, same
     order, validated separately — all checks pass with no warnings at all. */
  --stage-1: #0F9A5E;
  --stage-2: #4A93D6;
  --stage-3: #B5871B;
  --stage-4: #9573E0;
  --stage-5: #0E9B95;
  --stage-6: #D9553A;

  /* Dark tints are SELECTED, not darkened copies: they carry `--text` (light in this
     theme) and must sit quietly on `--panel` without glowing. */
  --pav-1: #24382E;
  --pav-2: #23303F;
  --pav-3: #3A3221;
  --pav-4: #302A3F;
  --pav-5: #1F3937;
  --pav-6: #3D2A26;

  --row-band: #232B25;
  --border: #313A33;

  /* the three text levels, all measured on --panel */
  --text: #E9EDE6;
  --muted: #A6AEA1;
  --text-3: #8A9286;
  /* Same value as light — the footer band is --primary-dark in BOTH themes, so the
     grey sitting on it does not move with the theme either. See :root. */
  --footer-muted: #A6AEA1;

  /* the ONE soft fill, still the only one */
  --accent-soft: #2C3529;

  /* status: fills darkened, text lightened, each pair measured above 6.7:1 */
  --warn-bg: #3A2E19;
  --warn-text: #EFC08A;
  --alert-bg: #3A211A;
  --alert-text: #F0A288;
  --ok-bg: #16301F;
  --ok-text: #86D6A6;
  --ok: #48A97A;
  --bad-bg: #3A1D22;
  --bad-text: #F2909C;

  /* the staging banner keeps its colour — it is deliberately unlike any product
     colour, and that job does not change with the theme. White on it: 9.3:1. */
  --staging-bar: #7A2E2E;
  /* the toast DOES move: a near-black notice on a dark page has no edge at all */
  --toast-bg: #3A423B;
  --inverse-bg: #2C3F36;
  --inverse-text: #F5F1E6;
  --inverse-muted: #B9C2BA;
  --inverse-line: rgba(245, 241, 230, 0.16);

  --series-1: #5B9BE8;
  --series-2: #F0855A;
  --series-3: #3FCB99;
  --series-4: #F5BC3E;

  /* Heavier than light, and deliberately neutral rather than brand-tinted: on a
     dark page the job is no longer "tint the page back" but "push it far enough
     down that the panel still reads as raised". See the note in :root. */
  --scrim: rgba(0, 0, 0, 0.65);
  --link: #79A5EC;

  --shadow-sm: 0 1px 2px rgba(0,0,0,0.40), 0 2px 6px rgba(0,0,0,0.28);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.48), 0 8px 24px rgba(0,0,0,0.34);

  /* Tells the browser to render form controls, scrollbars and the caret dark. A
     dark page with a bright native <select> is the tell that a theme was painted
     rather than declared. */
  color-scheme: dark;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  font-family: var(--font);
  font-size: 14px;
  color: var(--text);
  background: var(--bg);
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* ============== TOPBAR ============== */
.topbar {
  background: var(--primary);
  color: var(--on-primary);
  padding: 14px 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-shadow: var(--shadow-sm);
}
.brand {
  display: flex;
  align-items: center;
  gap: 14px;
  font-weight: bold;
  font-size: 16px;
  letter-spacing: 0.5px;
}
.topbar-meta {
  display: flex;
  align-items: center;
  gap: 16px;
  font-size: 13px;
}
/* ---- The master-data status line, on a LIGHT ground (23 Aug 2026) ----------
   All three of these were written for the `.topbar`, whose background was
   `var(--primary)` — a dark green bar. `c169`-`c171` removed that bar and left
   `#loadStatus` in `.topbar-meta`, which now sits on `--panel`. So all three
   states were painting light-on-light, MEASURED not assumed: neutral 1.00:1
   (white on white, invisible), `.ok` 2.48:1 and `.err` 1.36:1, against a 4.5
   floor. Dark theme was fine throughout, which is why it survived: the defect
   only exists in one theme.
   ⚠ `contrastcheck` could only ever see ONE of the three. `.ok` and `.err` are
   transient — no walk has ever painted them — so they sat in the 149 classes
   the gate reports as NEVER PAINTED. **Fixing only the flagged one would have
   left two live defects no gate can see** (D28, and the "no fixture had ever
   put a screen into a failed state" scar met from the other side).
   🛑 THE ROOT CAUSE IS A TOKEN THAT CANNOT FLIP, and that is the transferable
   part: `--logo-grn` is the SAME hex in both themes by design, so it can never
   adapt to the surface behind it. A colour that does not flip is safe only for
   as long as its background never moves — and this one's background moved out
   from under it in a shell restructure that touched no colour at all. Every
   value here is now a token carrying BOTH a light and a dark value, so the two
   themes cannot drift apart again (D20/D26).
   Shaf's decision, 23 Aug 2026: the loaded state stays GREEN rather than
   becoming body text, so the tick still reads as success — `--accent-grn2-text`
   is the app's existing green-text-on-a-pale-ground token, not a new colour. */
.status {
  color: var(--muted);
  font-style: italic;
}
.status.ok { color: var(--accent-grn2-text); font-style: normal; }
.status.err { color: var(--bad-text); font-style: normal; }

/* Refresh, Password and Log out are `.btn-ghost`, which is dark-on-light BY
   DESIGN — `color: var(--primary)` with a matching border. The top bar's
   background is also `var(--primary)`. Identical value, so all three buttons were
   drawn in exactly the colour they sat on: present, sized, clickable and
   INVISIBLE. The reason it survived this long is that the hover rule flips the
   text to white, so anybody who reached for one saw it appear under the pointer
   and never reported it. Measured 7 Aug: rgb(30,63,60) on rgb(30,63,60).
   Same scar as C15's "a filled button, not white text on nothing" — the other way
   round. On a dark bar the ghost is light-on-dark. */
.topbar .btn-ghost {
  color: var(--on-primary);
  border-color: rgba(255, 255, 255, 0.42);
  /* "Log out" was already breaking across two lines here; nobody could see it
     while the text matched the background. Making them visible made the wrap
     visible too, so it is fixed in the same change rather than left as a new
     defect introduced by a fix. */
  white-space: nowrap;
}
.topbar .btn-ghost:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.14);
  color: var(--on-primary);
}

/* ============== LAYOUT ============== */
/* THE QUOTE RAIL IS RETIRED — slice 3a (Shaf's Configuration-tab design, 25 Aug 2026).
   The collapsible left rail and all of its machinery (`.qrail`, `.qrail-toggle`, the
   vertical stub, the `data-qrail` state) are GONE, not hidden: its last resident, the
   client-and-deal block, lives under Configuration → Client & deal, and the priced
   table gets the width the rail was starving it of (measured 23 Aug 2026: the table
   needed 1,074px and was given 922px). `.layout` is single-column now; the 270px
   override at 1280px and the long-dead 980px single-column rule (the
   `quote-layout-980-dead-rule` item, answered DELETE) went with it. */
.layout {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
  padding: 20px;
  max-width: 1640px;
  width: 100%;
  margin: 0 auto;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 22px;
  box-shadow: var(--shadow-sm);
}
.panel-title {
  margin: 0 0 4px;
  color: var(--primary-text);
  font-size: 18px;
  font-weight: bold;
}
.panel-subtitle {
  margin: 0 0 18px;
  color: var(--muted);
  font-size: 13px;
}

/* ============== FORM ============== */
.field-group {
  border: none;
  padding: 0;
  margin: 0 0 18px;
  /* `min-width: 0` is load-bearing here, not tidying. A <fieldset> carries a
     UA-level `min-inline-size: min-content` that `width` cannot override — it is
     the one element that flatly refuses to shrink below its own contents.
     Below 1280px the left rail narrows from 300px to 270px; the cluster card's
     min-content is about 305px, so the fieldset held its ground and its cards
     spilled ~15px past the rail and under the quote panel's dark background.
     The × and "+ site" controls landed in that strip — drawn, but unclickable.

     Shaf caught it on 31 Jul at a medium window width while layoutcheck reported
     0 errors, because that gate asks whether a container SCROLLS and here nothing
     scrolled: the child simply escaped its parent. The rule goes on the class —
     every fieldset in this app is one of these panels (D-fix-the-class). */
  min-width: 0;
}
.field-group legend {
  font-size: 12px;
  font-weight: bold;
  color: var(--primary-text);
  letter-spacing: 0.7px;
  text-transform: uppercase;
  padding: 0 0 8px;
  border-bottom: 1px solid var(--border);
  width: 100%;
  margin-bottom: 12px;
}

/* Collapsible field group (p25-ui1). Clusters and Indirect costs fold to one
   header line because most quotes never touch them (Shaf, 1 Aug). They
   COLLAPSE, never hide: the legend keeps a live summary (trips · stations,
   pools · RM) written by the same renders that draw the cards, so money and
   structure stay visible even folded — a pool gone from view without saying so
   is the exact failure S3c fought. Collapsed is the fresh-quote default; the
   panel opens itself the moment the quote actually carries content, and a
   manual toggle wins after that. */
.fg-head { cursor: pointer; user-select: none; }
.fg-chevron { display: inline-block; margin-right: 6px; font-size: 10px; transition: transform 0.15s ease; }
.fg-collapsed .fg-chevron { transform: rotate(-90deg); }
.fg-collapse.fg-collapsed > *:not(legend) { display: none; }
.fg-collapse.fg-collapsed > legend { margin-bottom: 0; }

/* A folded group inside a PANEL is a different shape from one in the left rail:
   the rail has no card around it, the panel does. Without these two rules the
   card keeps its 22px floor and the group its 18px margin, so a folded form sits
   above ~40px of empty white and reads as a panel that failed to load — which is
   what it looked like on the Leads screen (D14: a gate judges boxes, eyes judge
   whether it looks broken). Folded, the panel is one line tall. */
.fg-collapse.fg-collapsed { margin-bottom: 0; }
.panel-form:has(> .fg-collapse.fg-collapsed) { padding-bottom: 14px; }
.fg-sum {
  font-weight: normal;
  text-transform: none;
  letter-spacing: 0;
  color: var(--muted);
  font-size: 12px;
  margin-left: 8px;
}

/* ---- the deal screen's three groups (slice 11) -----------------------------
   Eleven flat bands became three. Only the CONTAINER is new: the fold state
   (`fg-collapsed`), the pressable header (`fg-head`), the chevron and the live
   summary are the classes the quote portal's rail panels and the lead form
   already use, so the three folds in this app cannot drift apart the way their
   three background washes once did (D20).
   ⚠ The body is a wrapper rather than `> *:not(header)` because these groups
   hold BANDS, each with its own heading — reusing `.fg-collapse`'s selector
   would have meant editing a rule the quote portal depends on to teach it about
   a shape it will never contain. */
.crm-group { margin: 0 0 18px; }
.crm-group-head {
  display: flex; align-items: baseline; flex-wrap: wrap;
  margin: 0 0 10px;
  padding: 0 0 6px;
  border-bottom: 1px solid var(--border);
  font-size: 12px; font-weight: bold; letter-spacing: 0.7px;
  text-transform: uppercase; color: var(--primary-text);
}
/* 44px is the floor for anything a finger has to find, and this header is the
   only way back into a folded group. The chevron and the label are small type,
   so the tap area is bought with padding rather than with size — measured at
   390px, not assumed from the sum of the parts, which is the mistake slice 10
   made three times in one afternoon. */
.crm-group-head { min-height: 44px; align-content: center; }
.crm-group-head:focus-visible { outline: 2px solid var(--primary-text); outline-offset: 2px; }
.crm-group.fg-collapsed > .crm-group-body { display: none; }
.crm-group.fg-collapsed > .crm-group-head { margin-bottom: 0; }

/* ---- Station types (S3e-a) -------------------------------------------------
   The rail card mirrors .pool-card's proportions; the delta editor rows use ONE
   grid shared by the heading row (D12) — `.sd-cols` and `.sd-row` must stay in
   step or the columns lie. `min-width: 0` on the grid children for the same
   reason it is on .field-group: an <input type=number> floors at min-content
   otherwise (the S3b scar). */
.stype-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 8px 10px;
  margin-bottom: 8px;
  background: var(--panel);
}
.stype-top { display: flex; align-items: center; gap: 6px; }
.stype-top .sty-name { flex: 1 1 0; min-width: 0; }
.sty-count { color: var(--muted); font-size: 12px; white-space: nowrap; }
.stype-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 6px; margin-top: 6px;
}
.sty-delta { color: var(--muted); font-size: 12px; min-width: 0; }

.sd-cols, .sd-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 64px 90px auto;
  gap: 8px;
  align-items: center;
}
.sd-cols {
  font-size: 11px; font-weight: bold; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.05em;
  padding: 0 0 6px; border-bottom: 1px solid var(--border);
}
.sd-row { padding: 8px 0; border-bottom: 1px solid var(--border); }
.sd-item { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sd-iname { font-weight: 600; }
.sd-meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-swapnote { color: var(--accent-grn); font-size: 12px; }
.sd-base { color: var(--muted); text-align: center; }
.sd-tqty { width: 100%; min-width: 0; }
.sd-touched { border-color: var(--accent-grn); }
.sd-actions { display: flex; gap: 6px; white-space: nowrap; }
.sd-removed .sd-item, .sd-removed .sd-base { opacity: 0.45; text-decoration: line-through; }
.sd-added .sd-iname { color: var(--accent-grn); }

/* The site-row type selector takes a full wrapped line of its own — squeezing a
   fifth control into the 300px rail row is how S3b's overflow happened. */
.site-row { flex-wrap: wrap; }
.site-row .st-type { flex: 1 1 100%; min-width: 0; }
/* One-line how-to for a field (p16 → p16.1). Non-italic, muted, never bold — a
   guide, not a warning. It lives as a FULL-WIDTH line under the field's row, never
   inside the field cell: a `.field` bottom-aligns its control (justify-content:
   flex-end) so a guide inside it shoves the control up and out of line with the
   fields beside it. As its own grid row it cannot do that, in any form. */
/* ⚠ AND IT HAS A READING MEASURE, ADDED 18 Aug 2026 — WITHOUT ONE IT WAS THE WIDEST
   TEXT IN THE APP, AT THE SMALLEST SIZE. Measured on the real elements at 1512px
   (D33): ELEVEN guides on the deal screen, every one **1194px wide**, carrying 79 to
   337 characters — so single lines of roughly 200+ characters at 11px. The eye loses
   its place returning to the start of a line that long, and this is the type that can
   least afford it.
   ⚠ `72ch`, the SAME measure `.hint` already uses, rather than a second number chosen
   here (D20). `ch` is the unit that makes that a shared decision rather than a
   coincidence: both rules cap at seventy-two CHARACTERS, so they agree even though one
   is 11px and the other 12.5px. `#crmBrief .brief-body` set the precedent at 78ch, and
   its comment says the reason plainly — the full width of a 1512px window is a
   paragraph nobody finishes.
   ⚠ Safe inside `.if-grid`, where this element is `grid-column: 1 / -1`: `max-width`
   caps the CONTENT box and does not change how many tracks the item spans, so the
   full-width-row rule below still holds. It can only ever narrow, never overflow. */
.field-guide {
  display: block;
  font-size: 11px;
  font-weight: 400;
  font-style: normal;
  color: var(--muted);
  line-height: 1.45;
  letter-spacing: 0;
  text-transform: none;
  max-width: 72ch;
}
.if-grid .field-guide { grid-column: 1 / -1; margin: -8px 0 2px; }
.if-grid .field-guide[hidden] { display: none; }

.field {
  display: flex;
  flex-direction: column;
  margin-bottom: 12px;
  font-size: 13px;
}
.field > span {
  color: var(--muted);
  margin-bottom: 4px;
  font-weight: 600;
}
.field input,
.field select {
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--panel);
  font-family: inherit;
  font-size: 13px;
  color: var(--text);
  width: 100%;
}
.field input:focus,
.field select:focus {
  outline: none;
  border-color: var(--primary-text);
  box-shadow: 0 0 0 2px rgba(30,63,60,0.12);
}
.field-inline {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
  font-size: 13px;
  color: var(--text);
}
.field-inline input[type="checkbox"] {
  accent-color: var(--primary-text);
  width: 16px; height: 16px;
}

/* ============== BUTTONS ============== */
.btn {
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  padding: 10px 16px;
  border-radius: var(--radius);
  border: 1px solid transparent;
  cursor: pointer;
  transition: all 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
  background: var(--primary);
  color: var(--on-primary);
}
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
.btn-ghost {
  background: transparent;
  color: var(--primary-text);
  border-color: var(--primary-text);
}
.btn-ghost:hover:not(:disabled) { background: var(--primary); color: var(--on-primary); }

/* ============== QUOTE PANEL ============== */
.panel-quote { min-height: 600px; padding: 0; overflow: hidden; }

.match-status {
  padding: 60px 40px;
  text-align: center;
}
.match-status h2 {
  color: var(--primary-text);
  margin: 0 0 12px;
  font-size: 22px;
}
.match-status p {
  color: var(--muted);
  max-width: 480px;
  margin: 0 auto 10px;
  line-height: 1.5;
}

.quote-view { padding: 0; }
.quote-header {
  background: var(--primary);
  color: var(--on-primary);
  padding: 22px 28px;
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: flex-start;
  gap: 20px;
}
.quote-id h2 { margin: 0 0 4px; font-size: 18px; color: var(--on-primary); }
.quote-id .muted { color: rgba(255,255,255,0.75); }
.quote-id code {
  background: rgba(255,255,255,0.1);
  padding: 3px 8px;
  border-radius: 4px;
  font-family: var(--mono);
  font-size: 12px;
}
.quote-totals {
  display: flex;
  gap: 14px;
}
.total-card {
  background: rgba(255,255,255,0.1);
  padding: 12px 18px;
  border-radius: var(--radius);
  border: 1px solid rgba(255,255,255,0.2);
  text-align: right;
  min-width: 140px;
}
.total-card.subtle { background: rgba(255,255,255,0.05); }
.total-card.below-floor { background: var(--warn-bg); border-color: var(--warn-text); color: var(--warn-text); }
.total-label {
  display: block;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  opacity: 0.9;
  margin-bottom: 4px;
}
.total-value { display: block; font-size: 18px; font-weight: bold; font-family: var(--mono); font-variant-numeric: tabular-nums; }

/* ============== BOM TABLE ============== */
.bom-wrap { padding: 24px 20px 0; overflow-x: auto; }
.bom {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}
.bom thead th {
  background: var(--primary);
  color: var(--on-primary);
  text-align: left;
  white-space: nowrap;
  padding: 10px 10px;
  font-weight: bold;
  font-size: 12px;
  letter-spacing: 0.3px;
}
.bom thead th.num { text-align: right; }
.bom tbody td {
  padding: 9px 12px;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}
.bom tbody td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.bom tbody tr:nth-child(even) td { background: var(--row-band); }
.bom tbody tr.section-row td {
  background: var(--primary-light);
  color: var(--on-primary);
  font-weight: bold;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  padding: 6px 12px;
}
.bom tbody tr.section-row td { background: var(--primary); color: var(--on-primary); padding: 8px 14px; }
.bom tbody tr.section-row .section-head {
  display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
}
/* ---- R1e: folding a band, and dragging one into a new position -------------
   The chevron, the grip and the band summary all sit inside bands that already
   have their own colour (dark for a section/place, pale for a sub-section), so
   each takes its colour from `currentColor` rather than naming one. That is
   what lets ONE rule serve both a white-on-dark band and a dark-on-pale one
   without a second copy of itself (D20). */
.band-toggle {
  background: none; border: 0; padding: 0 6px 0 0; margin: 0;
  color: inherit; font: inherit; line-height: 1; cursor: pointer;
}
.band-toggle:hover { opacity: 0.7; }
.band-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
/* The band's own name, separated from the chevron beside it so the text a
   person reads is also the text a gate can read — the first version put the
   chevron glyph inside the label and the place-band check started comparing
   "▾Pahang" against "Pahang". */
.band-name { display: inline; }
.band-sum {
  margin-left: auto; padding-left: 14px;
  font-weight: normal; font-size: 11px; opacity: 0.8;
  text-transform: none; letter-spacing: 0; white-space: nowrap;
}
/* A sub-section band is a plain <td>, not a flex row, so its summary floats to
   the right edge instead of being pushed there by `margin-left:auto`. */
.bom tbody tr.sub-section-row .band-sum { float: right; }
.drag-grip {
  display: inline-block; padding-right: 6px; cursor: grab;
  opacity: 0.45; letter-spacing: -2px; user-select: none;
}
tr[draggable="true"]:hover .drag-grip { opacity: 0.9; }
.bom tbody tr.drag-lift { opacity: 0.4; }
/* The drop indicator is a line ABOVE the row being pointed at, not a fill:
   a filled row reads as "this row is selected", which is the opposite of what
   is about to happen to it. */
.bom tbody tr.drag-over td { box-shadow: inset 0 2px 0 0 var(--accent-grn); }
/* A shut band still shows its own figures — the summary is what stops a fold
   from hiding money with no sign that it has (D5/D10). */
.bom tbody tr.band-shut .band-toggle { opacity: 0.9; }

/* The arrangement switch above the table. */
.arrange-bar {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 0 0 8px;
}
.arrange-label {
  font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px;
  color: var(--muted);
}
.arrange-switch { display: inline-flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.arrange-btn {
  background: var(--panel); color: var(--muted); border: 0; padding: 5px 14px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: bold;
}
.arrange-btn.active { background: var(--primary); color: var(--on-primary); }
.arrange-btn:not(.active):hover { background: var(--row-band); color: var(--primary-text); }
.arrange-note { font-size: 11px; color: var(--muted); }
.bom tbody tr.section-row .sec-label { font-weight: bold; font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; white-space: nowrap; }
.bom tbody tr.section-row .pkg-picker { display: flex; align-items: center; gap: 6px; font-weight: normal; font-size: 11px; text-transform: none; letter-spacing: normal; }
.bom tbody tr.section-row .pkg-picker span { color: rgba(255,255,255,0.85); }
.bom tbody tr.section-row .pkg-picker { min-width: 0; }
.bom tbody tr.section-row .pkg-picker select {
  background: var(--panel); color: var(--text); border: 1px solid var(--primary-dark); border-radius: 4px;
  padding: 3px 8px; font-family: inherit; font-size: 12px; min-width: 140px; max-width: 300px;
}
.contract-years-field,
.rental-duration-field {
  display: inline-flex; align-items: center; gap: 8px;
  margin-top: 6px; margin-right: 14px; font-size: 12px; color: var(--muted);
}
.contract-years-field input,
.rental-duration-field input,
.rental-duration-field select {
  padding: 3px 6px; border: 1px solid var(--border); border-radius: 4px;
  font-family: inherit; font-size: 13px;
}
.contract-years-field input,
.rental-duration-field input { width: 60px; text-align: right; }

/* Mode switch (Buy / Rental) */
.mode-switch {
  display: inline-flex; border: 1px solid rgba(255,255,255,0.35); border-radius: 6px; overflow: hidden;
  margin: 0 20px;
}
.mode-switch-btn {
  background: transparent; color: rgba(255,255,255,0.75); border: 0; padding: 6px 14px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.6px;
}
.mode-switch-btn.active { background: var(--panel); color: var(--primary-text); }
.mode-switch-btn:not(.active):hover { background: rgba(255,255,255,0.12); color: var(--on-primary); }

/* Rental summary row */
.bom tbody tr[data-rental="1"] td { background: #FDF6E7; }
.bom tbody tr[data-rental="1"] .code-cell { color: var(--warn-text); font-weight: bold; }
.bom tbody tr.sub-section-row td {
  background: var(--accent-grn2-bg); color: var(--primary-text); font-weight: bold; font-size: 11px;
  text-transform: uppercase; letter-spacing: 0.4px; padding: 5px 24px;
}
/* ---- R1d/S3e-b: the reading arrangements' own bands ------------------------
   These MUST sit after the .section-row and .sub-section-row rules above, not
   beside them. A `.place-row` selector carries exactly the same specificity as
   the `.section-row` one it overrides, so the winner is decided by ORDER — the
   first draft put these earlier in the file and the place band rendered
   SHOUTING IN CAPS anyway, which no gate would have caught and which reading
   the screen did (D14, and the cascade half of D11/D20).
   ⚠ S3e-b's `.stype-row` band is presented IDENTICALLY, so it JOINS these
   selector lists rather than copying them — one class, one place (D20); a
   second block would drift within a week (D12). It inherits the ordering trap
   above with them, which is the other half of why joining beats copying. */
.bom tbody tr.place-row td,
.bom tbody tr.stype-row td { text-transform: none; letter-spacing: 0; }
.bom tbody tr.place-row .sec-label,
.bom tbody tr.stype-row .sec-label {
  text-transform: none; letter-spacing: 0; font-size: 13px; white-space: normal;
}
.bom tbody tr.place-row .section-head,
.bom tbody tr.stype-row .section-head { justify-content: space-between; gap: 12px; }
/* Inside a place, Section is the stronger of the two remaining bands and
   Sub-section is indented under it, so three levels read as three. */
.bom tbody tr.sub-section-sec td { background: var(--border); padding-left: 14px; }
.bom tbody tr.sub-section-deep td { font-weight: normal; padding-left: 38px; }
.bom .code-cell { font-family: var(--mono); font-size: 12px; color: var(--primary-text); white-space: nowrap; }
/* p16.1: the code is a shortcut to the item/package detail drawer. */
.bom .code-cell .code-link { cursor: pointer; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--primary-text) 35%, transparent); text-underline-offset: 2px; }
.bom .code-cell .code-link:hover { text-decoration-color: var(--primary-text); color: var(--primary-text); }
.bom tbody tr[data-rental="1"] .code-cell .code-link { color: inherit; }
/* p16.2: the solution code in the quote header is a link too (same affordance as
   the quote-line codes, but it lives outside .bom so it carries its own rule). */
.quote-id .code-link { cursor: pointer; text-decoration: underline; text-decoration-color: rgba(255,255,255,0.5); text-underline-offset: 2px; }
.quote-id .code-link:hover { background: rgba(255,255,255,0.2); text-decoration-color: rgba(255,255,255,0.95); color: var(--on-primary); }
/* Package detail shortcut beside each section picker. */
.bom tbody tr.section-row .pkg-detail {
  background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.95); border: 1px solid rgba(255,255,255,0.35);
  border-radius: 999px; width: 20px; height: 20px; line-height: 1; font-size: 12px;
  cursor: pointer; padding: 0; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
}
.bom tbody tr.section-row .pkg-detail:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
.bom tbody tr.section-row .pkg-detail:disabled { opacity: 0.35; cursor: default; }

/* ============== DISCOUNT BAR ============== */
.discount-bar {
  margin: 18px 28px 0;
  padding: 14px 18px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 16px;
}
.discount-bar .field-inline { margin: 0; }
.discount-bar input[type="range"] { width: 180px; }
.discount-bar input[type="number"] {
  width: 70px;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: 4px;
  text-align: right;
}
.discount-meta {
  font-size: 13px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
}
.discount-meta strong { color: var(--primary-text); font-size: 15px; }
.warn { color: var(--warn-text); font-weight: 600; font-size: 12px; }

/* ============== ACTIONS ============== */
.actions {
  padding: 18px 28px 28px;
  display: flex;
  /* 🛑 ADDED 19 Aug 2026 — Shaf, on a phone: the four lead-decision buttons ran off the
     right edge and "Reject" was cut in half. A flex row with no wrap CANNOT reflow, so on
     any width narrower than its content the last child is simply unreachable — and the page
     does not scroll to reveal it, so it reads as a button that is not there.
     ⚠ Purely additive: a row that already fits is untouched, and one that did not now wraps
     instead of clipping. */
  flex-wrap: wrap;
  gap: 12px;
}

/* 🛑 AND THE ROW DID NOT LINE UP WITH THE FIELDS ABOVE IT — same report, both phone and
   desktop. `.actions` carries 28px of its own horizontal padding because it began life as a
   page-level footer bar. Inside a `.panel`, which already pads 22px, that padding is added a
   SECOND time: the inputs sit at the panel's edge and the buttons under them sit ~28px
   further in, on every panel that ends in an action row.
   ⚠ A CONTEXT, NOT A SECOND COPY (D20): the bare `.actions` rule above still owns the
   property, and this says what it becomes inside a container that has already paid for the
   inset. Vertical padding is deliberately untouched — the gap above the row is correct.
   ⚠ Direct children only. A row nested deeper is inside something with its own reasons. */
.panel > .actions,
.panel-form > .actions {
  padding-left: 0;
  padding-right: 0;
}

/* ============== FOOTER ============== */
.footer {
  padding: 14px 24px;
  background: var(--primary-dark);
  color: rgba(255,255,255,0.75);
  font-size: 12px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.footer a { color: rgba(255,255,255,0.9); text-decoration: none; }
.footer a:hover { color: var(--logo-grn); }
.muted { color: var(--muted); }
.footer .muted { color: var(--footer-muted); } /* the band is always dark — see --footer-muted */

/* ============== SITE LOCATIONS GRID ============== */
.loc-total {
  font-size: 12px;
  color: var(--text);
  padding: 8px 10px;
  background: var(--row-band);
  border-radius: var(--radius);
  text-align: right;
}
.loc-total strong {
  color: var(--primary-text);
  font-size: 14px;
}

/* ============== DEAL SUMMARY (read-only dl) ============== */
.summary-list {
  display: grid;
  grid-template-columns: 110px 1fr;
  gap: 4px 10px;
  margin: 0;
  font-size: 12px;
}
.summary-list dt {
  color: var(--muted);
  font-weight: 600;
}
.summary-list dd {
  margin: 0;
  color: var(--text);
}

/* ============== Optional row highlight in BOM ============== */
.bom tbody tr.row-optional td {
  background: #F0F6F4;
  font-style: italic;
}
.bom tbody tr.row-optional:nth-child(even) td {
  background: #E8F0EE;
}
.badge-opt {
  display: inline-block;
  background: var(--primary);
  color: var(--on-primary);
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 0.5px;
  padding: 1px 5px;
  border-radius: 2px;
  margin-left: 4px;
  vertical-align: middle;
  text-transform: uppercase;
}

/* ============== BOM EDIT CONTROLS ============== */
.actions-col { width: 90px; text-align: center; }
.bom tbody td.bom-actions {
  text-align: center;
  white-space: nowrap;
  padding: 4px 6px;
}
.bom .qty-input,
.bom .disc-input,
.bom .sst-input,
.bom .price-input {
  padding: 3px 6px;
  border: 1px solid var(--border);
  border-radius: 4px;
  text-align: right;
  font-family: inherit;
  font-size: 12px;
  background: var(--panel);
}
.bom .qty-input  { width: 46px; }
.bom .disc-input { width: 46px; }
.bom .sst-input  { width: 46px; }
.bom .price-input { width: 78px; }
.bom .disc-cell-wrap { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.bom .disc-cell-wrap .pct-suffix { color: var(--muted); font-size: 12px; }
.bom .qty-input:focus,
.bom .disc-input:focus,
.bom .sst-input:focus,
.bom .price-input:focus {
  outline: none;
  border-color: var(--primary-text);
}
.desc-cell {
  display: block;
  cursor: text;
  padding: 2px 4px;
  border-radius: 3px;
  border: 1px solid transparent;
  min-height: 18px;
}
.desc-cell:hover {
  background: rgba(30, 63, 60, 0.04);
  border-color: var(--border);
}
.desc-cell:focus {
  outline: none;
  background: var(--panel);
  border-color: var(--primary-text);
  box-shadow: 0 0 0 1px var(--primary);
}
.margin-cell {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  color: var(--ok);
  font-weight: 600;
}
.margin-cell.margin-floor {
  color: #B91C1C;
  background: #FEF2F2;
}
.pr-desc {
  font-size: 11px;
  color: var(--muted);
  line-height: 1.4;
  margin-top: 2px;
}
.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--muted);
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  padding: 0;
  margin: 0 1px;
}
.btn-icon:hover { color: var(--primary-text); border-color: var(--primary-text); background: var(--accent-grn2-bg); }
.btn-icon.btn-icon-danger:hover { color: #B91C1C; border-color: #B91C1C; background: #FEF2F2; }
.add-line-wrap {
  padding: 14px 28px 0;
  display: flex;
  align-items: center;
}

/* Customisation indicators — accent on the row edge only, not every cell */
/* ⚠ THE ROW TINT, not just the marker. Found 13 Aug 2026 by LOOKING at the result
   of Shaf's "one amber" decision in dark mode: the marker and badge had been
   tokenised and the ROW BEHIND them had not, so the row rendered as a bright cream
   band with light text on it — the text was very nearly invisible. Neither my grep
   nor the review's own list of this cluster named these two lines, because both
   searched for the amber values and this is a different, paler hex again. */
.bom tbody tr.row-modified td { background: var(--warn-bg) !important; }
.bom tbody tr.row-modified td:first-child { box-shadow: inset 3px 0 0 var(--warn-text); }
.bom tbody tr.row-custom td { background: #F0F8EC !important; }
.bom tbody tr.row-custom td:first-child { box-shadow: inset 3px 0 0 var(--accent-grn); }
.badge-mod, .badge-custom {
  display: inline-block;
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 0.5px;
  padding: 1px 5px;
  border-radius: 2px;
  margin-left: 4px;
  vertical-align: middle;
  text-transform: uppercase;
}
.badge-mod { background: var(--warn-bg); color: var(--warn-text); }
.badge-custom { background: var(--accent-grn); color: var(--on-primary); }
.badge-billing {
  display: inline-block;
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 0.5px;
  padding: 1px 5px;
  border-radius: 2px;
  margin-left: 4px;
  vertical-align: middle;
  text-transform: uppercase;
  border: none;
  cursor: pointer;
  font-family: inherit;
}
.badge-billing.monthly { background: #2563EB; color: var(--on-primary); }
.badge-billing.onetime { background: #6B21A8; color: var(--on-primary); }
/* R1b slice 4: the place select carries a NAME (a trip, "Shared across the
   quote", "Project-wide") rather than a one-word state, so it is styled as a
   compact bordered control, not an uppercase colour pill like the badges
   above — uppercase would shout a site name someone typed. Tokens only. */
/* R1c: names the trip a split package line belongs to. Shares .badge-place's
   register deliberately — bordered and sentence-case, not an uppercase colour
   pill — because both carry a place name a person typed. Kept as its own class
   rather than a second copy of .badge-place's values (D20): one is a control
   the user changes, this one is a fact they read. */
.badge-trip {
  display: inline-block;
  font: inherit;
  font-size: 10px;
  color: var(--muted);
  background: var(--panel);
  border: 1px dashed var(--border);
  border-radius: 3px;
  padding: 1px 4px;
  margin-left: 4px;
  vertical-align: middle;
  max-width: 150px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.badge-place {
  display: inline-block;
  font: inherit;
  font-size: 10px;
  color: var(--primary-text);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 1px 3px;
  margin-left: 4px;
  vertical-align: middle;
  max-width: 150px;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}
.badge-place:hover { border-color: var(--primary-light); }

/* ============== ITEM PICKER MODAL ============== */
.modal-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}
.modal-overlay[hidden] { display: none; }
/* Shaf, 27 Aug 2026: "we don't do quotation on phone." The deal record's New quotation link
   goes to the portal's composer, which is desktop-shaped by design (N7) and which the portal's
   boot router would not even open below 620px. 620px is `browse.js`'s own phone-default
   breakpoint, so the swap happens exactly where the old bounce did. */
.dh-newquote-note { display: none; }
@media (max-width: 620px) {
  /* ⚠ `!important` IS LOAD-BEARING HERE AND IT WAS MEASURED, NOT ASSUMED. The first version
     was a bare `.dh-newquote { display: none }` and the button stayed on screen at 390px: the
     link is an `<a class="btn">`, and `a.btn { display: inline-flex }` (styles.css ~:3500) is
     specificity (0,1,1) against this rule's (0,1,0) AND sits after every phone media block in
     the file — so it wins on specificity and on order, and a media query adds neither. The
     note beside it switched correctly the whole time, which is what made the failure legible.
     Same reason and same shape as `main[hidden]`, `.layout[hidden]` and `.qtab-panel[hidden]`
     above. Any future attempt to hide a LINK-button on a phone meets this. */
  .dh-newquote { display: none !important; }
  .dh-newquote-note { display: inline; }
}

/* Shell only. Size, radius and shadow are owned by the ui1 modal block below —
   they used to be declared here as well, which is how `.modal-wide` ended up
   taking width from one rule and max-width from another (D20). */
.modal {
  background: var(--panel);
  display: flex;
  flex-direction: column;
}
.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.modal-header h3 { margin: 0; color: var(--primary-text); font-size: 16px; }
.modal-footer { display: flex; }
.picker-results {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  max-height: 280px;
  overflow-y: auto;
  margin: 8px 0 14px;
  background: var(--panel);
}
.picker-result {
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: 12px;
}
.picker-result:last-child { border-bottom: none; }
.picker-result:hover { background: var(--row-band); }
.picker-result.selected {
  background: var(--accent-grn2-bg);
  border-left: 3px solid var(--primary);
  padding-left: 9px;
}
.picker-result .pr-head { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.picker-result .pr-code {
  font-family: var(--mono);
  color: var(--primary-text);
  font-weight: 600;
}
.picker-result .pr-source {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--muted);
}
.picker-result .pr-name { color: var(--text); }
.picker-result .pr-price { color: var(--muted); font-size: 11px; }
.picker-empty { padding: 30px; text-align: center; color: var(--muted); font-size: 13px; }

/* ============== RESPONSIVE ============== */
@media (max-width: 980px) {
  /* The rail rules that lived here died with the rail (slice 3a) — `.layout` is
     single-column at every width now, so this block keeps only the totals row. */
  .quote-totals { width: 100%; }
  .total-card { flex: 1; min-width: 0; }
}

/* Save-as-solution modal */
.save-radio-row { display: flex; gap: 18px; margin: 10px 0 14px; font-size: 14px; }
.save-radio-row label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.save-target { font-weight: 600; padding: 6px 0; }
.save-note { font-size: 12px; color: #667; margin: 8px 0 0; }

/* ===== Phase 1: browse views ===== */
.main-nav { display: flex; gap: 4px; margin-left: 18px; }
.nav-btn { background: none; border: none; color: #cfd8e3; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; }
.nav-btn:hover { background: rgba(255,255,255,0.08); color: var(--on-primary); }
.nav-btn.active { background: rgba(255,255,255,0.14); color: var(--on-primary); font-weight: 600; }
.health-strip { font-size: 12px; padding: 4px 10px; border-radius: 999px; font-weight: 600; border: 0; font-family: inherit; }
.health-strip.ok { background: #143d2b; color: #7be3ab; }
.health-strip.bad { background: #4a1420; color: #ff9db0; }
.health-strip.warn { background: #3a3416; color: #ffd479; }
/* p16.1: the strip is now a button — a compact count in the header, the detail
   behind a click. Only interactive when there is something to open. */
.health-strip.clickable { cursor: pointer; }
.health-strip.clickable:hover { filter: brightness(1.15); }
.health-strip.clickable:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.health-strip .hs-caret { opacity: 0.7; margin-left: 3px; font-weight: 700; }

/* Data Issues modal — the list the header count links to. */
.issue-groups { display: flex; flex-direction: column; gap: 18px; }
.issue-group { display: block; }
.issue-group-title {
  font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--muted); margin: 0 0 8px; padding-bottom: 5px; border-bottom: 1px solid var(--border);
}
.issue-row {
  display: grid;
  grid-template-columns: 20px minmax(200px, 1.3fr) minmax(200px, 1.6fr) auto;
  gap: 10px; align-items: baseline;
  padding: 9px 2px; border-bottom: 1px solid var(--border);
}
.issue-row:last-child { border-bottom: 0; }
.issue-sev { font-size: 13px; line-height: 1.2; }
.issue-ident .issue-code { font-family: var(--mono); font-size: 12.5px; color: var(--primary-text); font-weight: 600; }
.issue-ident .issue-name { display: block; font-size: 12.5px; color: var(--text); margin-top: 1px; }
.issue-what { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
.issue-row .issue-open { white-space: nowrap; align-self: center; }
.issue-empty { padding: 20px 4px; text-align: center; color: var(--muted); font-size: 13px; }
.issue-sev.block { color: var(--bad-text); }
.issue-sev.warn  { color: var(--warn-text); }
@media (max-width: 700px) {
  .issue-row { grid-template-columns: 20px 1fr; }
  .issue-what, .issue-row .issue-open { grid-column: 2; }
}
.browse-layout { padding: 18px 22px; width: 100%; max-width: 1280px; margin: 0 auto; }
/* ^ width: 100% is load-bearing (theme B1 fix). The body is a flex container, so a
   browse view is sized by fit-content and FLOORED at its own min-content — a floor
   min-width: 0 cannot lower, because min-width never enters the fit-content formula.
   A wide table inside .browse-tablewrap (overflow: auto) still PROPAGATES its
   min-content upward through block layout, so without an explicit width the page
   itself grows sideways (the catalogue table hit 998px min under the new font and
   pushed the page to 1044px in a 1024px viewport). With width: 100% the view can
   never exceed the viewport and the table scrolls inside its wrap — the N7 rule:
   wide tables scroll in their own container; the page body never sideways. */
.browse-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.browse-controls input[type="search"] { flex: 1; min-width: 220px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; }
.browse-controls select { padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; background: var(--panel); }
.browse-controls input[type="date"], .browse-controls input[type="number"] {
  padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px;
  font-size: 13px; background: var(--panel); color: var(--text);
}
.browse-controls input[type="number"] { width: 130px; }
.check-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); }
.ctl-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.ctl-label { font-size: 12px; color: var(--muted); }
.browse-tablewrap { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; overflow: auto; max-height: calc(100vh - 170px); }
.browse-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
/* The header band. ⚠ Both colours here were raw hex until 13 Aug 2026, and the
   background one was a LIVE dark-mode defect on every browse table except the
   deal list: #f6f8fb never flips, so a dark page drew a near-WHITE band with
   --text-3 on top of it — measured 2.1:1, against a 4.5:1 floor for label text.
   The same shape as the .btn-ghost and .umenu-btn scars: a colour that renders
   perfectly and cannot be READ, invisible to uicheck (which reads files),
   layoutcheck (geometry) and the suites (values). Only measuring the two
   colours in the rendered page finds it (D33).
   The type treatment is slice 8's, which Shaf reviewed in the prototype and
   which shipped for the deal list — micro-caps on a band, quieter than the
   figures underneath, which are what anyone opened the screen for.
   ⚠ THE BAND IS --bg, NOT --row-band, AND THAT IS A CONTRAST DECISION — CORRECTED
   13 Aug 2026 AFTER SHIPPING IT WRONG ONCE. The first version of this rule used
   --row-band and claimed in writing that light mode "did not regress". It did.
   Measured properly afterwards: --muted on --row-band is 4.469:1 against a 4.5:1
   AA floor for small text, where the OLD raw-hex rule had been 4.664:1 — so the
   change fixed dark mode and quietly pushed light BELOW the floor, at 11px
   uppercase, the size that needs more contrast rather than less.
   ⚠ The wrong number is how it happened: the old ratio was computed by hand as
   "4.54" and the new one measured at 4.47, which read as a rounding-level move
   rather than a regression across a threshold. **Compute BOTH sides the same
   way, from the tokens, before calling a contrast change neutral.**
   --bg fixes it with NO new colour: 4.745:1 light (better than the 4.664 it
   replaces) and 7.939:1 dark. It is still a BAND — separation from --panel is
   1.046 light / 1.117 dark, which is the same separation panel-on-bg already has
   and that this theme was measured against when it was designed.
   ⚠ --text-3 was slice 8's choice here and is worse still (3.458:1). Hierarchy
   stays intact either way: body text on this band is ~14:1, so the heading is
   plainly the quiet one. The deal list inherits this, so all eight headings match.
   ⚠ Density deliberately UNCHANGED (padding, font-size of the body cells):
   the airier variant was built and rejected, and this is a colour and type
   job, not a density one. */
.browse-table thead th { position: sticky; top: 0; background: var(--bg); text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); z-index: 1; }
.browse-table tbody td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
.browse-table tbody tr[data-code] { cursor: pointer; }
.browse-table tbody tr[data-code]:hover { background: var(--row-band); }
.browse-table .num, .browse-table th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.browse-table.sub { font-size: 12.5px; margin: 6px 0 14px; }
.browse-table.sub thead th { position: static; }
.mono { font-family: var(--mono); font-size: 12.5px; }
/* Right-align a number cell. This lived as `.comp-cols .ta-r` — scoped to a container
   only index.html and patterns.html ever emit — while `crm.js` used the class 18 times
   and `crm.html` 12 more, none of them inside that container. So every money column in
   the CRM, Value and Weighted and Idle included, has been left-aligned since it was
   built, and uicheck could not see it: it asks whether a used class is DEFINED, and this
   one was. Nobody asked whether the rule defining it could ever MATCH. A class can be
   defined, used, and still inert. Unscoped here so there is one rule in one place (D20);
   the three portal uses all sit inside `.comp-cols` and are unaffected. */
.ta-r { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; } /* mono+tabular: theme B1, the half sesi 36e deliberately left for this slice */

/* 🛑 AND THE SAME SCAR HAD A SECOND HALF NOBODY HAD LOOKED FOR, FOUND 19 Aug 2026.
   The comment above fixed the SCOPE half — `.ta-r` was defined under a container the CRM
   never emits. D33 names TWO ways a declaration can fail to win, and this is the other:
   *"or because something more specific re-declares it."*

   `.browse-table thead th` (line ~1570) sets `text-align: left` at specificity (0,1,2).
   `.ta-r` is (0,1,0). So EVERY `<th class="ta-r">` lost, always — and the markup asks for
   right on EIGHT numeric column headings: Deals, Value, Weighted, Stalled, Idle, Value in
   play, Contacts, Divisions. **Every one of them has sat left-aligned over a right-aligned
   column of numbers since the day it was written**, on the dashboard, the deal list, the
   accounts list, leads and wins.

   ⚠ HOW IT WAS FOUND, because the method is the transferable part: not by reading the CSS
   and not from a screenshot — a heading one word wide looks the same either way. It came
   from walking every screen and reading COMPUTED text-align on every `.ta-r`, then asking
   why some said `left`. Every one of them was a `<th>` inside a `<thead>` (D33: only
   computed style can see this).
   ⚠ Fixed by RAISING this selector rather than lowering the heading rule: headings are
   left-aligned by default on purpose, and that default is correct for the other columns. */
.browse-table thead th.ta-r { text-align: right; }
.muted { color: var(--muted); }
.small { font-size: 11.5px; }
.row-bad { background: var(--bad-bg); }
.row-warn { background: var(--warn-bg); }
.badge { display: inline-block; font-size: 10.5px; font-weight: 700; padding: 2px 8px; border-radius: 999px; background: var(--row-band); color: var(--muted); letter-spacing: 0.02em; }
.badge.ok { background: var(--ok-bg); color: var(--ok-text); }
.badge.warn { background: var(--warn-bg); color: var(--warn-text); }
.badge.bad { background: var(--bad-bg); color: var(--bad-text); }

/* ⚠ THE OTHER TWO BADGE SHAPES — added 14 Aug 2026 (detail screens, slice 5).
   Shaf's badge contract of 9 Aug 2026 says a STATE and a LABEL must not share a
   shape: a state is a filled pill (`.badge` above), a label is an outline chip.
   On 14 Aug he approved a THIRD shape — the dashed chip, meaning a HOLE. The
   separation is by SHAPE rather than hue deliberately: it survives greyscale and
   colour-vision deficiency, and it spends zero new colours, so the hex ratchet
   does not move.

     filled pill  = a state we know          (.badge)
     outline chip = a label someone named    (.chip)
     dashed chip  = a hole                   (.chip.chip-hole)

   ⚠ THE CONTRACT'S OWN BORDER COLOUR WAS UNUSABLE AND THE MEASUREMENT IS WHY IT
   IS NOT USED HERE. It prescribed `--accent-soft` for the outline, which measures
   1.27:1 against `--panel` in BOTH themes — and `--border` is 1.27 / 1.38. A shape
   that carries the whole meaning cannot be drawn in a colour you cannot see: at
   1.27:1 the outline chip and the dashed chip are the same object. `--text-3` is
   the QUIETEST token that clears the 3:1 floor WCAG sets for a non-text component
   that carries meaning — 3.84:1 light, 5.05:1 dark — so it is the border, and it
   is the token whose stated job is labels, units and placeholders.

   ⚠ AND THE HOLE'S LABEL IS `--muted`, NOT `--text-3`, although --text-3 is the
   placeholder token: as TEXT on `--panel` it measures 3.84:1, below the 4.5 floor,
   and a new rule painting text under the floor grows the contrast ratchet. --muted
   is 4.96 light / 7.11 dark. The border may be --text-3; the words may not.

   ⚠ ON A PEEK THE DASHED CHIP IS A STATEMENT, NOT A CONTROL. The design says the
   chip IS the control that fills the hole — true where editing exists, and the
   peek is the one surface where it deliberately does not (no classification
   writes). A control that offers a change it cannot deliver is what D5 forbids,
   so nothing here is focusable or clickable. It becomes the control on the
   account screen, in the slice that builds editing. */
.chip {
  display: inline-block;
  font-size: 10.5px;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 999px;
  background: transparent;
  border: 1px solid var(--text-3);
  color: var(--text);
  letter-spacing: 0.02em;
}
.chip.chip-hole { border-style: dashed; color: var(--muted); font-weight: 400; }

/* ⚠ SENTIMENT — the fourth badge job, added 14 Aug 2026 (slice 6, the human).
   A closed ordered vocabulary of five, from `vocab` list_key='sentiment':
   champion · supportive · neutral · skeptical · blocker.

   🛑 THE DEFECT THIS EXISTS TO AVOID, named in the design before a line was written:
   under the plain badge contract the good end is a green FILL, the bad end a red
   FILL and everything between shares ONE neutral fill — so **supportive, neutral and
   skeptical would render identically**, and the day somebody first records a
   Skeptical the screen would show no change at all. A vocabulary growing into use
   would produce silence. 36 of the 66 people recorded are neutral and 19 supportive:
   collapsing those two alone is 55 of 66 people wearing one badge.

   🛑 AND IT IS NOT A FILLED PILL, WHICH IS THE OTHER HALF. The one-pill rule (Shaf,
   14 Aug) says a record wears exactly ONE solid pill and it is the LIFECYCLE one —
   on a deal that is the stage. Sentiment belongs to the PERSON nested inside the
   record, so it takes the chip shape and the stage keeps the only fill. Giving it
   `.badge` would have put two filled pills on one panel, and the assertion guarding
   that rule counts `.dl-pill` only, so it would have sailed straight past.

   HOW THE FIVE SEPARATE, AND EVERY AXIS SURVIVES GREYSCALE — no hue is load-bearing:
     · border WEIGHT  2px for the two ends, 1px for the three in between
     · TEXTURE        a diagonal hatch on the two negative values, nothing on the rest
     · text WEIGHT    700 at the ends, 600 / 400 between
   So champion and supportive differ by border weight and text weight; skeptical and
   neutral differ by texture; blocker and skeptical differ by border weight and text
   weight. A colour-blind reader, a greyscale printout and a screenshot all keep the
   five apart. ZERO new colours — every value below is an existing token, so the raw
   hex ratchet does not move.

   Measured, both themes, with the WCAG formula from the token hexes (text on
   `--panel`): champion/supportive `--ok-text` 7.95 light / 9.42 dark · neutral
   `--muted` 4.96 / 7.11 · skeptical `--text` 15.58 / 13.70 · blocker `--bad-text`
   7.33 / 7.15. Borders carry meaning here, so they clear the 3:1 non-text floor
   rather than the 4.5 text one: `--ok-text` and `--bad-text` are far above it and
   `--text-3` is 3.84 / 5.05.
   ⚠ THE `--ok-text` FIGURE READ 6.97 / 8.25 IN THE FIRST VERSION OF THIS COMMENT, AND
   THAT IS THE WRONG PAIR: it is `--ok-text` on `--ok-bg` — the FILLED-badge contract
   this chip spends a paragraph explaining it deliberately does NOT use. The number was
   carried across from the won pill instead of being re-derived for the chip, inside a
   comment block whose entire subject is measuring. Found by a review; **no gate reads a
   comment.** The other five figures checked out exactly, which is what made the wrong
   one look right.
   ⚠ THE HATCH IS THE ONE THING `contrastcheck` WILL NOT JUDGE — it reports text over a
   background-image as UNMEASURABLE rather than as a pass, which is the honest answer and
   is why these two are measured by hand. At the WORST pixel, under a stripe, where the
   ground is the panel plus a 12% wash of the chip's own colour: skeptical 12.34 light /
   9.83 dark, blocker 5.83 / 5.80. All four clear the 4.5 floor.

   ⚠ FOUR OF THESE FIVE ARE DRAWN BY NO LIVE ROW. Staging holds champion 7,
   supportive 19, neutral 36 and **zero skeptical, zero blocker** — while `vocab`
   defines all five as active. The harness fixture manufactures both missing values
   and `patterns.html` renders all five, because a style no fixture draws is a style
   every gate reports clean (D22/D32). */
.sent { display: inline-block; }
.sent-champion  { border-width: 2px; border-color: var(--ok-text);  color: var(--ok-text);  font-weight: 700; }
.sent-supportive{ border-color: var(--ok-text);  color: var(--ok-text);  font-weight: 600; }
.sent-neutral   { border-color: var(--text-3);   color: var(--muted);    font-weight: 400; }
.sent-skeptical { border-color: var(--text-3);   color: var(--text);     font-weight: 600; }
.sent-blocker   { border-width: 2px; border-color: var(--bad-text); color: var(--bad-text); font-weight: 700; }
/* The hatch is drawn from the chip's OWN text colour via currentColor, so it follows
   the theme with no second declaration and costs no token. 12% keeps it a texture
   rather than a fill — the words on top stay at the ratios measured above. */
/* ---- the mood line (slice 12) ----------------------------------------------
   One line above the people band. A SENTENCE below five contacts, a stacked bar
   at five or more — see the renderer in crm.js for why the threshold is there.
   ⚠ The segments carry the sentiment chips' OWN colour families and nothing new.
   Champion and supportive share `--ok-text` and are separated by a hairline in
   `--panel` plus their labels; the five values deliberately do NOT separate on
   colour alone, which is why every segment is labelled underneath rather than
   relying on a legend the eye has to carry across the screen.
   ⚠ Non-text contrast floor is 3:1 and all three families are TEXT tokens that
   already clear 4.5:1 on `--panel`, so the segments clear the lower floor by
   construction rather than by measurement. */
.mood { margin: 0 0 14px; }
.mood-said { margin: 0 0 6px; font-size: 13px; color: var(--muted); }
.mood-bar {
  display: flex; height: 10px; border-radius: 999px; overflow: hidden;
  border: 1px solid var(--border); background: var(--panel);
}
.mood-seg { min-width: 4px; }
.mood-seg + .mood-seg { border-left: 1px solid var(--panel); }
.mood-keys {
  display: flex; flex-wrap: wrap; gap: 4px 14px;
  margin: 6px 0 0; font-size: 12px; color: var(--muted);
}
.mood-key { display: inline-flex; align-items: center; gap: 5px; }
.mood-dot { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
.mood-champion, .mood-supportive { background-color: var(--ok-text); }
.mood-neutral, .mood-skeptical { background-color: var(--text-3); }
.mood-blocker { background-color: var(--bad-text); }
.mood-none { background-color: var(--border); }
/* The same texture the skeptical and blocker CHIPS wear, so the negative end
   reads as a hole in your support in greyscale here too. Drawn from the panel
   colour rather than currentColor: a segment has no text, so currentColor would
   be whatever it inherited. */
.mood-skeptical, .mood-blocker {
  background-image: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--panel) 55%, transparent) 0 2px,
    transparent 2px 5px);
}

.sent-skeptical, .sent-blocker {
  background-image: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, currentColor 12%, transparent) 0 2px,
    transparent 2px 5px);
}

/* ⚠ INFLUENCE PIPS — five blocks filled up to a 1-5 rating, with the figure beside
   them in the fixed-width face. Shaf ruled 14 Aug 2026 that **5 means the MOST**,
   and the database says only that the column is an integer 1..5 with a CHECK
   constraint and no vocabulary binding — so nothing in the data says which end is
   high, and NO GATE CAN CATCH THIS BEING BACKWARDS. It is a recorded decision, and
   the harness asserts the FILLED count against the rating on a 1 and on a 5, because
   five pip ELEMENTS are drawn either way round.

   🛑 THESE ARE INVISIBLE TO EVERY GATE WE OWN AND THAT IS WHY THE NUMBER IS PRINTED
   BESIDE THEM. `contrastcheck` says in its own header that it does not judge non-text
   contrast, and its element filter requires a direct text node — so a pip carries no
   text and is skipped entirely; `layoutcheck` reads only overflow; `uicheck` reads
   files. A filled pip indistinguishable from an empty one would pass the whole suite
   in both themes. The figure beside the rail is not decoration: it is the part a gate
   can read, and the part a screen reader gets.

   ⚠ Empty is `--text-3`, NOT `--border`. `--border` against `--panel` is 1.27:1 in
   both themes — a rail of five invisible boxes, which is the identical mistake the
   badge contract made when it prescribed `--accent-soft` for the outline chip. The
   design said "empty = border colour"; the measurement says otherwise. */
.pips { display: inline-flex; gap: 2px; vertical-align: middle; }
.pip { width: 6px; height: 12px; border-radius: 2px; background: var(--text-3); }
.pip.pip-on { background: var(--text); }
/* The figure the gates and the screen reader can both read. `--muted` on `--panel`
   is 4.96 light / 7.11 dark; `--text-3`, whose job this nominally is, would be
   3.84 in light and would grow the contrast ratchet. */
.pip-n { font-size: 11px; color: var(--muted); margin-left: 6px; }

.issue-line { color: var(--bad-text); font-size: 12.5px; }
.drawer-overlay { position: fixed; inset: 0; background: var(--scrim); z-index: 60; display: flex; justify-content: flex-end; }
.drawer-overlay[hidden] { display: none; }

/* ── The slide, added 14 Aug 2026 (slice 2). ──────────────────────────────────
   ⚠ The DURATION LIVES ON THE STATE BEING ENTERED, which is why the two numbers
   look like they are on the wrong rules. A transition is read off the element in
   its NEW state, so `.is-open` carries the 180ms opening and the base rule
   carries the 140ms closing. Putting both on the base rule gives you one speed
   in both directions and an exit that feels slower than the entrance.
   ⚠ The panel moves with `transform`, never `width` or `right` — animating
   either of those re-lays-out every line of text inside the panel on every
   frame, which stutters on exactly the phone this is meant to serve.
   ⚠ Opening without the class is still a valid state: `overlay.js` un-hides
   FIRST and adds `.is-open` a beat later, so the un-hidden-but-not-open frame
   below is what the animation starts from. It is deliberate, not a gap. */
.drawer-overlay { opacity: 0; transition: opacity 140ms ease-in; }
.drawer-overlay .drawer { transform: translateX(100%); transition: transform 140ms ease-in; }
.drawer-overlay.is-open { opacity: 1; transition: opacity 180ms ease-out; }
.drawer-overlay.is-open .drawer { transform: translateX(0); transition: transform 180ms ease-out; }

/* ⚠ THE PLATFORM'S FIRST prefers-reduced-motion RULE, and it is overdue: the CRM
   sidebar has been sliding at people who asked their operating system for less
   motion since the day it shipped.
   ⚠ It is not only a courtesy — `overlay.js` reads this same preference and
   closes SYNCHRONOUSLY when it is set, because with no transition there is no
   `transitionend` to wait for and the close would sit on its safety deadline.
   The two must agree; if this media query is ever changed, change that check. */
@media (prefers-reduced-motion: reduce) {
  .drawer-overlay,
  .drawer-overlay .drawer,
  .drawer-overlay.is-open,
  .drawer-overlay.is-open .drawer { transition: none; }
}
/* ⚠ --drawer-w IS A DIAL, NOT A SECOND DEFINITION. The CRM's peek needs 620px
   (a tile row carrying "RM 1,240,000" does not fit its column at 560), and the
   quote portal's catalogue drawer needs the 560 it has always had. Writing that
   as two `.drawer` rules is precisely what D20 forbids and how .modal-wide came
   to be defined three times. The fallback IS the portal's current value, so the
   portal is unchanged by identity rather than by measurement.
   The mechanism is not invented here: [data-shell="2"] #crmLeads already sets
   --card-label-w this way (crm-shell2.css:182) against the same default shape. */
.drawer { width: min(var(--drawer-w, 560px), 94vw); height: 100%; background: var(--panel); box-shadow: -12px 0 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; }
.drawer-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.drawer-header h3 { margin: 0; font-size: 16px; }
.drawer-body { padding: 16px 18px; overflow: auto; }
.drawer-body h4 { margin: 16px 0 6px; font-size: 13.5px; }
/* Doctrine D21: a table is sized by its declared columns, never by its data.
   `.kv` carries approval details and drawer facts, and a single long value —
   an item description pasted into an approval request — used to widen the
   table past its card and push the whole page sideways. Shaf found this on a
   phone, 29 Jul, on the same day the identical bug was fixed in the BOM table:
   the fix had been applied to one instance instead of to the rule. */
.kv { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.kv th { text-align: left; color: var(--muted); font-weight: 500; padding: 5px 10px 5px 0; width: 42%; vertical-align: top; word-break: break-word; }
.kv td { padding: 5px 0; word-break: break-word; overflow-wrap: anywhere; }
ul.plain { list-style: none; padding: 0; margin: 4px 0; font-size: 13px; }
ul.plain li { padding: 3px 0; }
.drawer-sslink { color: var(--link); text-decoration: none; }
.drawer-sslink:hover { text-decoration: underline; }

/* View switching: the hidden attribute must always win over layout display rules */
main[hidden], .layout[hidden], .browse-layout[hidden] { display: none !important; }

/* Price freshness */
.age { font-size: 11.5px; padding: 2px 7px; border-radius: 999px; white-space: nowrap; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.age-ok { background: var(--ok-bg); color: var(--ok-text); }
.age-amber { background: var(--warn-bg); color: var(--warn-text); }
.age-red { background: var(--bad-bg); color: var(--bad-text); }
.age-none { color: var(--text-3); }
.ext-link { color: var(--link); text-decoration: none; }
.ext-link:hover { text-decoration: underline; }

/* Phase 2: item form + approvals */
/* ⚠ DARKENED FROM #e5484d ON 18 Aug 2026. White on the old red was 3.91:1 — below the floor
   in BOTH themes, because the badge paints its own background and the theme never touches it.
   Now 4.65:1. ⚠ Shaf had KEPT this colour on 15 Aug and changed his mind on 18 Aug after
   seeing the two rendered side by side: the readability question and the "it should shout"
   question are different questions, and only the second one is a matter of taste.
   ⚠ Still a raw hex rather than a token, deliberately: it is one specific alarm colour used
   in one place, and the ratchet counts it either way. */
.nav-badge { background: #cf4146; color: var(--on-primary); font-size: 10.5px; font-weight: 700; border-radius: 999px; padding: 1px 7px; margin-left: 4px; }
/* .modal-wide is defined ONCE, in the modal block below. Three competing
   definitions (760 / 880 / 860) lived here, in the modal block and in the SA-2
   block; the cascade silently picked width from one and max-width from another,
   so "wide" modals were 860px while the Edit BOM modal was 880px. Shaf's
   report, 27 Jul. uicheck now fails the build on a repeat. */
/* The column count and gaps are owned by the ui1 12-column grid below. */
.if-grid { display: grid; }
.if-span2 { grid-column: span 2; }
.if-preview { background: #f4f7fb; border: 1px solid #e0e7f0; border-radius: 8px; padding: 10px 12px; font-size: 13px; margin: 12px 0 4px; }
.drawer-actions { display: flex; gap: 10px; margin: 12px 0 4px; }
.ap-card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
.ap-head { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.ap-right { margin-left: auto; }
.ap-summary { margin: 8px 0 4px; font-size: 13.5px; }
.ap-actions { display: flex; gap: 10px; margin-top: 10px; }
.small-kv th { width: 30%; }
.old-val { color: var(--text-3); text-decoration: line-through; }

/* Package builder lines */
.pf-line { display: grid; grid-template-columns: 170px 1fr 140px 70px 64px 90px 36px; gap: 8px; align-items: center; margin-bottom: 6px; }
.pf-line input, .pf-line select { padding: 7px 9px; border: 1px solid var(--border); border-radius: 7px; font-size: 13px; }
@media (max-width: 900px) { .pf-line { grid-template-columns: 1fr 1fr; } }

/* ===== FX-used note + price-check flag (Build Standard v1.2, 2026-07-16) ===== */
.fx-note { margin: 6px 2px 0; font-size: 12.5px; color: var(--muted); }
.fx-note .muted { color: var(--muted); }
.pricecheck-banner { margin: 10px 2px 0; padding: 9px 12px; border-radius: 8px; background: #fff4e0; border: 1px solid #e8b84b; color: #7a5a00; font-size: 13px; line-height: 1.45; }
.pricecheck-banner .mono { font-family: var(--mono); font-size: 12px; }
.badge-pricecheck { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; background: #fff0d2; border: 1px solid #e8b84b; color: #8a6400; font-size: 10.5px; font-weight: 600; white-space: nowrap; vertical-align: middle; }
tr.row-pricecheck td { background: var(--warn-bg); }

/* ===== Rental & floor polish (D10, 2026-07-16) ===== */
.price-input.price-floor { border-color: var(--warn-text) !important; background: var(--warn-bg); color: var(--warn-text); font-weight: 600; }

/* The quote record's tab panels. `hidden` is the app's idiom for "not on screen"
   (#quoteView uses it), and the !important is the same guard the fx4 bugfix below
   needed: any later rule that gives one of these a display would silently defeat
   the tab. No colour here on purpose — the tab CONTROL reuses .mode-switch-panel,
   so this change declares no new colour and cannot move the light theme. */
.qtab-panel[hidden] { display: none !important; }

/* The tab strip sits directly under the quote header, and the ACTIVE tab is
   `--primary` — the same colour as that header. Flush against it the selected tab
   merges into the header and stops reading as "selected" (D14: read the screen, a
   gate judges boxes). A gap is all it needs; no colour is declared here, so the
   light theme cannot move. */
#qtabBar { margin-top: 14px; }

/* 🛑 FOUR TABS DO NOT FIT IN 390px, AND `.mode-switch-panel` IS `overflow: hidden`,
   so the fourth one was CLIPPED — 211px of it unreachable, measured by layoutcheck
   on its first run with the tabs in. A control that has run off the edge looks
   identical to one that was never drawn: that is the scar Shaf hit himself when he
   reported a MISSING Reject button that was really a clipped one. Wrapping costs
   nothing on a wide screen (four tabs fit on one row) and keeps every tab reachable
   on a narrow one. */
#qtabBar { flex-wrap: wrap; }
/* 🛑 `min-width: 40%` ON FOUR BUTTONS IS 160% OF THE ROW, SO THEY WRAPPED AT EVERY
   WIDTH — including the desktop, where all four fit comfortably on one line. Shipped
   in c179 and Shaf saw it immediately: "make the tabs 1 horizontal line instead of 2".
   The screenshot that would have caught it was taken BEFORE this rule was added and
   never retaken (D14 — look at the screen AFTER the last change, not before it).
   The floor belongs only where it is needed: a phone, where four tabs genuinely do
   not fit and two rows of two is the readable answer. Above that, `.mode-switch-panel`
   gives every button `flex: 1` and they share the row in equal quarters. */
@media (max-width: 560px) {
  #qtabBar .mode-switch-btn { min-width: 40%; }
}

/* hidden attribute must win over .mode-switch display rule (fx4 bugfix) */
.mode-switch[hidden] { display: none !important; }

/* Mode switch moved into the left panel (fx5): light-context segmented control */
.mode-switch-panel {
  display: flex; width: 100%; margin: 0 0 14px; border: 1px solid var(--primary);
  border-radius: 8px; overflow: hidden; background: var(--panel);
}
.mode-switch-panel .mode-switch-btn {
  flex: 1; padding: 9px 0; font-size: 13.5px; font-weight: 600;
  background: var(--panel); color: var(--primary-text); border: none; cursor: pointer;
}
.mode-switch-panel .mode-switch-btn.active { background: var(--primary); color: var(--on-primary); }
.mode-switch-panel .mode-switch-btn:not(.active):hover { background: var(--accent-grn2-bg); color: var(--primary-text); }

/* Slice 2b: the EMPTY composer shows the tab shell — Configuration → Solution is
   the door into a fresh quote — with the money chrome held back, because there is
   no quote yet to total, save or download. The record tabs are real disabled
   buttons in that state; the #qtabBar id outweighs the hover rule above, so a
   disabled tab cannot light up under the pointer. WCAG exempts disabled controls
   from the text floor; --text-3 keeps it legibly quieter than the live tabs. */
#quoteView.q-empty .quote-header, #quoteView.q-empty .actions { display: none; }
#qtabBar .mode-switch-btn:disabled { color: var(--text-3); background: transparent; cursor: default; }

/* ===== Set Price note (fx6) ===== */
.setprice-note { margin: 8px 2px 0; padding: 9px 12px; border-radius: 8px; background: var(--ok-bg); border: 1px solid var(--ok-text); color: var(--ok-text); font-size: 13px; }
.setprice-note.warn { background: var(--warn-bg); border-color: var(--warn-text); color: var(--warn-text); }

.btn-sm { padding: 3px 10px; font-size: 12px; margin-left: 8px; vertical-align: middle; border: 1px solid currentColor; border-radius: 6px; background: transparent; cursor: pointer; }

/* fx9: sortable headers + rental editor */
th.sortable { cursor: pointer; user-select: none; }
th.sortable .sort-ind { font-size: 10px; color: var(--primary-text); }
tr.rrc-row { cursor: pointer; }
tr.rrc-row:hover td { background: var(--row-band); }

tr.row-included td { background: #f5f8f7; color: var(--muted); font-style: italic; }

/* fx14: full-width fields inside modal grids */
.if-spanall { grid-column: 1 / -1 !important; }
.if-spanall textarea { width: 100%; resize: vertical; font: inherit; padding: 8px 10px; border: 1px solid #cfd8d6; border-radius: 6px; box-sizing: border-box; }

/* fx24: hidden must win over .rental-duration-field display rule (same class of bug as fx4/fx11) */
.rental-duration-field[hidden] { display: none !important; }

/* fx24 GLOBAL GUARD: the hidden attribute always wins, everywhere. Kills the whole
   CSS-display-beats-hidden bug class (mode switch fx4, rental form fx11, duration field fx24). */
[hidden] { display: none !important; }

/* fx25: header polish — notes readable on the dark header, logo chip, tidy summary */
.fx-note { color: #b9cdc7; }
.fx-note .muted { color: #8fa8a1; }
.fx-note strong { color: var(--on-primary); }
#rentalNote .rn-label { display: inline-block; background: rgba(255,255,255,0.14); color: var(--on-primary); padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; margin-right: 6px; letter-spacing: 0.4px; }
.desc-cell br + br { display: none; }

/* fx26: quote table — description luas, kod kecil (ikut kekemasan cell sheet) */
.bom th:first-child, .bom td.code-cell { width: 118px; max-width: 118px; word-break: break-word; font-size: 11px; line-height: 1.35; }
.bom td:nth-child(2) { min-width: 360px; }
.desc-cell { display: block; line-height: 1.55; max-width: 560px; }
.bom td.num, .bom th.num { white-space: nowrap; }

/* fx27: code wraps inside its column; description paragraphs + bullets */
.bom td.code-cell { white-space: normal !important; overflow-wrap: anywhere; word-break: break-word; }
.desc-cell .desc-p { margin: 0 0 7px; }
.desc-cell .desc-p:last-child { margin-bottom: 0; }
.desc-cell .desc-list { margin: 2px 0 7px; padding-left: 18px; list-style: disc; }
.desc-cell .desc-list li { margin: 2px 0; }

/* fx28 — item drawer: admin status row */
.drawer-status-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; }
.drawer-status-row select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; font: inherit; }

/* fx33 — optional qty-0 lines: soft amber row, edge accent, chip strip above the description */
/* Same tint as row-modified, per his "one amber for both". The TEXT stays quieter
   than the modified row's: an optional line at zero quantity is de-emphasised, and
   --muted is the token that already means exactly that. */
.bom tbody tr.row-optzero td { background: var(--warn-bg); color: var(--muted); }
.bom tbody tr.row-optzero td:first-child { box-shadow: inset 3px 0 0 var(--warn-text); }
.optzero-strip { display: flex; align-items: baseline; gap: 8px; margin: 0 0 4px; }
.badge-optzero {
  display: inline-block; background: var(--warn-bg); color: var(--warn-text);
  font-size: 9px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase;
  padding: 1px 6px; border-radius: 2px; white-space: nowrap;
}
.optzero-hint { font-size: 11px; font-style: italic; color: #a08c50; }

/* ===== qlog3: mandatory quote info before download ===== */
.field input.field-missing { border-color: var(--bad-text); background: var(--bad-bg); }
/* The download warning sat flush against the panel edge while every control
   around it is inset, so it read as escaping the block (Shaf, 31 Jul). It now
   shares the inset and carries the warn background, which also makes it look
   like a warning rather than loose red text. */
.dl-warn {
  margin: 0 0 10px; padding: 8px 12px;
  background: var(--warn-bg); border-radius: var(--radius);
  border: 1px solid var(--warn-text);
}

/* ===== qlog4: BDM dropdown missing-state ===== */
.field select.field-missing { border-color: var(--bad-text); background: var(--bad-bg); }

/* ===== qlog5: access gate ===== */
.gate-overlay {
  position: fixed; inset: 0; z-index: 9999;
  /* ⚠ DELIBERATE light-theme change, 12 Aug 2026: this was a raw #f2f5f8, a COOL
     blue-grey left over from before Tier 1 warmed the palette. It is the page
     ground behind the login box, so it is --bg and always was. In light that is
     #FAFAF7 against #f2f5f8 — imperceptible, and it kills a stray that no longer
     matched anything around it. In dark it is the whole point: the box was going
     dark while the page it sat on stayed pale. */
  background: var(--bg);
  display: flex; align-items: center; justify-content: center;
}
.gate-box {
  background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
  padding: 36px 40px; width: 340px; max-width: 90vw;
  text-align: center; box-shadow: 0 8px 30px rgba(20, 40, 60, 0.08);
}
/* ⚠ THE SAME TRAP, A THIRD ELEMENT. The logo is dark artwork on transparency, so
   on a dark surface it disappears — the CRM sidebar taught this, --logo-chip was
   created for it, and .brand-logo already had a chip. This one never did, because
   in light mode a transparent logo on a white box looks perfectly fine. Dark mode
   is what made the missing chip visible. */
/* ⚠ AND THE CHIP THEN OVERFLOWED THE CARD — reported by Shaf from the live login
   screen, 13 Aug 2026, measured at 53px. The logo is a 2000x300 lockup (6.67:1),
   so `height: 44px` alone makes it 293px WIDE, and `box-sizing: content-box` added
   the 20px of chip padding ON TOP of that — 313px inside a card whose content area
   is 260px. It spilled ~26px past each edge.
   ⚠ IT WAS ALWAYS OVERFLOWING; only dark mode made it VISIBLE. In light the chip is
   white on a white card, so 53px of white sticking out of white showed nothing. The
   chip was added on 12 Aug precisely because dark revealed the missing backdrop —
   and it arrived carrying this, unseen, for the same reason.
   The fix constrains WIDTH rather than height: the lockup is wide, so width is what
   the card can actually afford. `height: auto` lets the aspect ratio decide the
   rest, and border-box keeps the padding inside the limit instead of adding to it. */
.gate-logo {
  height: auto; max-width: 100%; margin-bottom: 10px;
  background: var(--logo-chip); padding: 4px 10px; border-radius: 6px;
  box-sizing: border-box;
}
.gate-box h2 { margin: 0 0 4px; font-size: 18px; }
.gate-sub { margin: 0 0 16px; font-size: 13px; color: var(--muted); }  /* was raw #667 */
.gate-box input {
  width: 100%; padding: 10px 12px; margin-bottom: 10px;
  border: 1px solid var(--border); border-radius: var(--radius);
  font-family: inherit; font-size: 14px; text-align: center;
}
.gate-box .btn { width: 100%; }
/* Google sign-in (item 13, 27 Aug 2026) — the primary, first-offered way in for everyone
   except the break-glass account. Google's own guidance for this button is a light
   panel with dark text, never a brand-colour fill behind their mark, so this reuses
   --panel/--text/--border rather than --primary — which happens to solve theming for
   free: --panel is white in light and a dark panel in dark mode, exactly the two
   official Google button styles, so no separate dark-mode rule is needed (D17). The
   G mark's four colours are Google's own fixed brand colours, not app theming — they
   are SVG fill attributes in the markup, not raw hex in this stylesheet, so uicheck's
   raw-hex ratchet (which reads stylesheets only) never sees them and no baseline
   change is needed here. */
.gate-btn-google {
  display: inline-flex; align-items: center; justify-content: center; gap: 10px;
  background: var(--panel); color: var(--text); border-color: var(--border);
}
.gate-btn-google:hover:not(:disabled) { background: var(--bg); }
.gate-btn-google svg { flex: none; }
/* The email/password box, once Google is offered alongside it: no longer the only way
   in, so it steps back to .btn-ghost (D13 — reuse the existing secondary style rather
   than inventing one). Swapped back to .btn-primary by app-quote.js/crm-core.js on the
   one environment where Google is not offered at all (production), so the sole option
   there still reads as the primary action rather than a demoted one. */
.gate-divider {
  display: flex; align-items: center; gap: 10px; margin: 14px 0;
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
}
.gate-divider::before, .gate-divider::after {
  content: ''; flex: 1; height: 1px; background: var(--border);
}
.gate-box .warn { margin: 10px 0 0; }
/* The quiet way out of a locked account (R0a2): an invite proves the address
   but sets no password, so this link has to exist on the gate itself — the
   person who needs it is by definition unable to get past the gate. */
/* ⚠ THIS IS THE `.btn-ghost` SCAR FOR THE THIRD TIME, reported by Shaf from the
   live login screen the same minute as the logo above: "reset password font also
   not clear". `--primary` is the BRAND colour and deliberately does not move
   between themes — so any text painted with it is fine on a light surface and
   invisible on a dark one. Measured on the gate card: 11.47:1 light, **1.42:1
   dark**, against a 4.5:1 floor. The same shape took `.btn-ghost` (1.00:1 on its
   own bar) and `.umenu-btn` before it.
   ⚠ The lesson each time: --primary is a FILL, not a text colour. Text needs a
   token that flips. --accent-grn2-text does (10.36:1 light / 8.63:1 dark) and it
   keeps the green character the link had — it exists because Shaf chose to keep
   and name the second green earlier today, so this reuses his decision rather
   than inventing a colour. The other user is the CRM's proof-file links, which
   sit on a panel and had the same problem. */
.btn-link {
  background: none; border: none; padding: 0; margin: 12px 0 0;
  color: var(--accent-grn2-text); font-family: inherit; font-size: 12.5px;
  text-decoration: underline; cursor: pointer;
}
.gate-box .hint { margin: 8px 0 0; font-size: 12px; line-height: 1.5; }

/* ---- R0b legacy import (sheet → database) ----
   One grid shared by the heading and the rows (D12). The three money columns
   sit side by side on purpose: the whole point of the screen is that a reader
   can SEE the two computed totals agree, not be told they do. */
.imp-actions { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.imp-cols, .imp-row {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) 120px 120px 130px minmax(0, 1.2fr);
  gap: 8px; align-items: baseline;
}
.imp-cols {
  font-size: 11px; font-weight: bold; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.05em;
  padding: 0 0 6px; border-bottom: 1px solid var(--border);
}
.imp-row { padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.imp-qid { font-family: var(--mono); font-size: 12px; min-width: 0; }
.imp-detail { display: block; font-family: inherit; color: var(--muted); font-size: 11.5px; }
.imp-verdict { font-weight: 600; }
.imp-ok { color: var(--ok); }
.imp-muted { color: var(--muted); font-weight: normal; }
.imp-bad { color: var(--bad-text); }

/* The deal's stage, beside its name in the Saved list. Muted on purpose: it is
   context for the quote, not the subject of the screen. */
.ql-stage {
  display: inline-block; font-size: 10.5px; text-transform: uppercase;
  letter-spacing: 0.04em; color: var(--muted);
  border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px;
  vertical-align: middle;
}

/* ===== cl1: cluster rows (Phase B) ===== */
#addClusterBtn { margin: 2px 0 8px; }

/* ===== sst1: SST column + milestones ===== */
.ms-footer { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
/* ⚠ THE MONEY BALANCE LINE — app.js writes "✓ balanced" or "out by RM x" here.
   Missed by the first pass because the hex is written LOWERCASE and the edit was
   case-sensitive; CSS hex is not. Measured 1.57:1 on --panel in dark, i.e. the one
   line on the schedule that says whether the money adds up was unreadable. */
.ms-summary { font-size: 12.5px; color: var(--accent-grn2-text); }
.ms-summary .ms-ok { color: var(--ok-text); font-weight: 700; }
#milestoneGroup { background: #fbfaf7; }

/* ===== sst3: overflow guards ===== */
.bom tbody td { word-break: normal; }
@media (max-width: 1280px) {
  .bom { font-size: 12.5px; }
}

/* ===== sst4: milestone box alignment + cluster cards ===== */
#milestoneGroup {
  margin: 16px 28px 0;
  padding: 14px 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
#milestoneGroup legend {
  float: left;
  width: 100%;
  border-bottom: 1px solid var(--border);
  padding: 0 0 7px;
  margin-bottom: 10px;
}
#milestoneGroup > p, #milestoneGroup > div { clear: both; }

.cluster-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--panel);
  padding: 8px 10px;
  margin-bottom: 8px;
}
.cluster-top { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.cluster-top .cluster-no {
  font-weight: 700; font-size: 11px; color: var(--on-primary); background: var(--accent-grn2);
  border-radius: 50%; min-width: 22px; height: 22px; line-height: 22px; text-align: center;
}
.cluster-top .cl-region {
  flex: 1; min-width: 0;
  padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--radius);
  font-family: inherit; font-size: 12.5px; background: var(--panel);
}
.cluster-top .cl-del { border: none; background: none; cursor: pointer; color: var(--danger-soft); font-size: 16px; padding: 0 2px; }
.cluster-top .cl-del:disabled { opacity: 0.25; cursor: default; }
.cluster-body { display: flex; gap: 10px; }
.cl-mini { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.cl-mini > span {
  font-size: 10px; font-weight: 700; letter-spacing: 0.4px;
  text-transform: uppercase; color: #778;
}
.cl-mini input {
  padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius);
  font-family: inherit; font-size: 12.5px; background: var(--panel); width: 100%;
}
.cl-mini .cl-sites { width: 64px; text-align: center; }
.cl-grow { flex: 1; }

/* ============ Rental payment schedule (sol2) ============ */
#rentalSchedule { margin: 10px 28px 0; border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; background: #fbfdf9; font-size: 13px; color: #243830; }
#rentalSchedule strong { color: #16281f; }

/* ============================================================
   ui1 (2026-07-24): MODAL DESIGN SYSTEM — one look for every popup.
   Covers: header / close button / footer, 12-column form grid with
   aligned fields, textareas, auto-code field, BOM line builder,
   item search combo, key modal. Overrides the legacy rules above.
   ============================================================ */

/* ---- shell ---- */
.modal-overlay { padding: 28px 20px; background: var(--scrim); }
.modal {
  width: min(600px, 100%);
  max-width: none;
  max-height: 90vh;
  border-radius: 14px;
  box-shadow: 0 18px 50px rgba(10, 24, 22, 0.32);
  overflow: hidden;
}
.modal-wide { width: min(880px, 100%); max-width: 880px; }
/* A table inside a modal is laid out from its DECLARED column widths, never
   from its content. With the default auto layout a single long cell — the
   `SEN-030 · Biral · VPF710 · RM 66,413.66 · cost RM 46,489.56` meta line under
   an item name — sets the column's minimum width, so the table grows past the
   modal and the right-hand columns fall off the edge. The width then depends on
   WHICH package you opened, which is why it looked fixed on one and broken on
   the next (Shaf, 27 Jul). Fixed layout also makes the meta line's own
   text-overflow:ellipsis actually engage. */
.modal table.browse-table { table-layout: fixed; width: 100%; }
.modal-header {
  padding: 18px 26px;
  gap: 16px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.modal-header h2, .modal-header h3 {
  margin: 0;
  color: var(--primary-text);
  font-size: 17px;
  font-weight: bold;
  line-height: 1.35;
  letter-spacing: 0.2px;
}
.modal-close {
  flex: 0 0 32px;
  width: 32px; height: 32px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel);
  color: var(--muted);
  font-size: 17px;
  line-height: 1;
  padding: 0;
  cursor: pointer;
  transition: all 0.12s;
}
.modal-close:hover { color: var(--primary-text); border-color: var(--primary-text); background: var(--accent-grn2-bg); }
.modal-body { padding: 22px 26px 20px; overflow-y: auto; }
.modal-footer {
  padding: 14px 26px;
  border-top: 1px solid var(--border);
  background: var(--bg);
  justify-content: flex-end;
  gap: 10px;
  flex-shrink: 0;
}
.modal-footer .btn { min-width: 112px; }

/* ---- form grid: 12 columns; legacy spans keep their proportions ---- */
.if-grid { grid-template-columns: repeat(12, 1fr); gap: 14px 16px; }
/* Moved here 27 Jul: this fallback used to sit ~330 lines EARLIER than the rule
   it overrides, so the later declaration won and narrow windows never got the
   simplified grid. A media query has no extra weight — only its position. */
@media (max-width: 900px) { .if-grid { grid-template-columns: repeat(6, 1fr); } }
.if-grid .field { margin: 0; grid-column: span 3; justify-content: flex-end; min-width: 0; }
.if-grid .if-span2 { grid-column: span 6; }
.if-grid .if-spanall { grid-column: 1 / -1; }
.if-grid .col-4 { grid-column: span 4; }
.if-grid .col-6 { grid-column: span 6; }
.if-grid .col-8 { grid-column: span 8; }
.if-grid .col-12 { grid-column: 1 / -1; }
.field > span { line-height: 1.3; margin-bottom: 5px; }
.lbl-hint { font-style: normal; font-weight: normal; color: var(--muted); }
.field input, .field select {
  padding: 9px 11px;
  border-radius: 7px;
  font-size: 13.5px;
}
.field textarea {
  width: 100%;
  resize: vertical;
  font: inherit;
  font-size: 13.5px;
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 7px;
  padding: 9px 11px;
  min-height: 76px;
  background: var(--panel);
}
.field textarea:focus {
  outline: none;
  border-color: var(--primary-text);
  box-shadow: 0 0 0 2px rgba(30,63,60,0.12);
}
.form-msg { font-weight: 600; font-size: 12.5px; margin: 8px 0 0; min-height: 16px; color: var(--primary-text); }

/* ⚠ A FAILURE MUST NOT LOOK LIKE A STATUS NOTE. 19 Aug 2026: approving a lead died on a
   check constraint and the raw Postgres sentence was written into this very span — same
   weight, same size, SAME COLOUR as the "Approving and creating the deal…" that preceded
   it. Shaf read past it for some time, on a screen that had simply stopped working.
   `.form-msg` is a span, so the box needs `display:block` to exist at all; the padding
   and rule are what carry it at a glance on a phone, where the text sits under the
   buttons and off the first screenful. The token pair is the one `.badge.bad` already
   uses, so both themes are inside the contrast baseline rather than new colour (D13). */
/* ⚠ `#crmGateMsg` and `#ndMsg` are <p class="warn">, not `.form-msg`, so `msgBad` was
   adding a class WITH NO RULE — a state that could not win, on the login gate of all
   places (D33). Amber was still distinct, so nothing looked broken; it simply never
   became the failure colour it asked for. Named in the selector rather than given a bare
   `.msg-bad` rule, so the state stays something a node opts into. */
.warn.msg-bad,
.form-msg.msg-bad {
  display: block;
  color: var(--bad-text);
  background: var(--bad-bg);
  border-left: 3px solid var(--bad-text);
  padding: 8px 10px;
  border-radius: 4px;
  margin-top: 10px;
}
/* The class is removed with the text, but an empty red box is the one way this rule could
   make a working screen look broken, so it is refused here as well as in the helper (D5). */
.warn.msg-bad:empty,
.form-msg.msg-bad:empty { display: none; }

/* ---- auto-generated code field ---- */
.code-wrap { display: flex; gap: 6px; align-items: stretch; }
.code-wrap input {
  flex: 1;
  min-width: 0;
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0;
  padding-left: 8px;
  padding-right: 8px;
}
.code-wrap input[readonly] { background: var(--accent-grn2-bg); color: var(--primary-text); border-style: dashed; }
.code-edit {
  flex: 0 0 36px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--border);
  border-radius: 7px;
  background: var(--panel);
  color: var(--muted);
  font-size: 14px;
  cursor: pointer;
  padding: 0;
}
.code-edit:hover { color: var(--primary-text); border-color: var(--primary-text); background: var(--accent-grn2-bg); }
.code-edit.active { color: var(--warn-text); border-color: var(--warn-text); background: var(--warn-bg); }

/* ---- BOM line builder ---- */
.bl-block {
  margin-top: 18px;
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 14px 16px 12px;
  background: var(--bg);
}
.bl-title {
  font-size: 12px; font-weight: bold; color: var(--primary-text);
  text-transform: uppercase; letter-spacing: 0.7px;
  margin-bottom: 10px;
}
.bl-title .bl-sub { text-transform: none; letter-spacing: normal; font-weight: normal; color: var(--muted); }
.bl-head, .pf-line {
  display: grid;
  grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) 62px 72px 92px 32px;
  gap: 8px;
  align-items: start;
}
.bl-head { margin-bottom: 6px; padding: 0 2px; }
.bl-head span {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
  text-transform: uppercase; color: var(--muted); cursor: default;
}
.pf-line { margin-bottom: 8px; }
.pf-line input, .pf-line select {
  padding: 8px 9px;
  border: 1px solid var(--border);
  border-radius: 7px;
  font-size: 13px;
  width: 100%;
  min-width: 0;
  background: var(--panel);
}
.pf-line .pfl-del { margin-top: 5px; }

/* ---- item search combo (name + brand · model) ---- */
.combo { position: relative; min-width: 0; }
.combo-anchor { position: relative; }
.combo .combo-input { width: 100%; }
.combo-meta {
  margin-top: 3px;
  font-size: 11px;
  color: var(--muted);
  line-height: 1.35;
  min-height: 15px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* p16.1: an empty meta reserved 15px under the input, so the Component combo sat
   taller than QTY/NOTE and looked out of line. Collapse it when there is nothing
   to show — this fixes every combo (composition rows, SA-2 builder, package BOM). */
.combo-meta:empty { display: none; min-height: 0; margin-top: 0; }
.combo-meta .combo-meta-code { font-family: var(--mono); color: var(--primary-text); font-weight: 600; }
.combo-panel {
  position: absolute;
  z-index: 60;
  top: calc(100% + 3px);
  left: 0;
  width: max(100%, 360px);
  max-width: 480px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 9px;
  box-shadow: 0 10px 28px rgba(15, 30, 30, 0.18);
  max-height: 250px;
  overflow-y: auto;
}
.combo-row {
  display: flex; justify-content: space-between; gap: 10px;
  padding: 7px 11px;
  cursor: pointer;
  border-bottom: 1px solid #EFF2F1;
}
.combo-row:last-child { border-bottom: none; }
.combo-row:hover, .combo-row.active { background: var(--accent-grn2-bg); }
.combo-main { min-width: 0; }
.combo-name { font-weight: 600; font-size: 12.5px; color: var(--text); display: block; }
.combo-bm { font-size: 11px; color: var(--muted); display: block; margin-top: 1px; }
.combo-side { text-align: right; flex-shrink: 0; }
.combo-code { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--primary-text); }
.combo-price { display: block; font-size: 10.5px; color: var(--muted); margin-top: 1px; }
.combo-empty { padding: 12px; font-size: 12px; color: var(--muted); text-align: center; }

/* ---- Add Line / Swap picker: name first, brand · model under it ---- */
.picker-result .pr-name { font-weight: 600; font-size: 13px; color: var(--text); }
.picker-result .pr-bm { font-size: 11.5px; color: var(--muted); }
.picker-result .pr-code {
  background: rgba(30, 63, 60, 0.07);
  padding: 1px 6px;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 600;
}

/* ---- key modal ---- */
.modal-key { width: min(400px, 100%); }
.modal-key .modal-body { text-align: center; }
.key-sub { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
.modal-key input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 7px;
  font-family: inherit;
  font-size: 14px;
  text-align: center;
}
.modal-key input:focus { outline: none; border-color: var(--primary-text); box-shadow: 0 0 0 2px rgba(30,63,60,0.12); }
.modal-key .save-note { text-align: center; }

/* ---- responsive ---- */
@media (max-width: 900px) {
  .if-grid .field, .if-grid .if-span2,
  .if-grid .col-4,
  .if-grid .col-6, .if-grid .col-8 { grid-column: span 6; }
  .if-grid .if-spanall, .if-grid .col-12 { grid-column: 1 / -1; }
  .bl-head, .pf-line { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) 56px 66px 86px 30px; }
}
@media (max-width: 760px) {
  .bl-head { display: none; }
  .pf-line { grid-template-columns: 1fr 1fr; }
  .pf-line .pfl-itemcell { grid-column: 1 / -1; }
}
@media (max-width: 620px) {
  .modal-overlay { padding: 12px; }
  .modal { max-height: 94vh; }
  .modal-header, .modal-body, .modal-footer { padding-left: 16px; padding-right: 16px; }
  .if-grid .field, .if-grid .if-span2,
  .if-grid .col-4,
  .if-grid .col-6, .if-grid .col-8 { grid-column: 1 / -1; }
}

/* ============ q1 (2026-07-24): Quotes list + save/restore ============ */
/* ⚠ `.ql-actions` and its button sizing DELETED 25 Aug 2026 — the All Quotes register
   carries NO row buttons (Shaf's Option B): the row opens the record and the record's
   own action band (`.qa-band`, below) holds every control. */
#qlTable td { vertical-align: middle; }
#qlTable tbody tr { cursor: pointer; }
#qlTable .ql-total, #qlTable .ql-gp { font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 600; }
#qlTable .ql-gp { font-weight: 400; }
/* ═══ The status pill — restyled 25 Aug 2026 to the approved All Quotes design ═══
   One pill, one fact (D26): a CLOSED ORDERED SET wears one neutral fill for every
   open state, semantic at the good end, quiet when dead — the same contract the
   CRM's stage pill follows (emt-ui badge contract). The OLD scheme painted DRAFT
   amber (a warning about nothing) and DOWNLOADED green (success it had not earned);
   both now take `.badge`'s own neutral base. Approvals are NOT statuses — they are
   `.qa-chip` outline chips beside the pill, never merged into it. */
.badge.st-draft, .badge.st-downloaded { background: var(--row-band); color: var(--muted); }
.badge.st-final { background: var(--ok-bg); color: var(--ok-text); }
.badge.st-archived { background: transparent; color: var(--muted); border: 1px solid var(--text-3); font-weight: 400; }
/* The approval chip: outline, the ok TEXT token as its edge (the quietest token
   clearing the 3:1 non-text floor on this hue — the emt-ui chip lesson). */
.qa-chip { display: inline-block; font-size: 10.5px; font-weight: 600; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ok-text); color: var(--ok-text); background: transparent; white-space: nowrap; }
tr.ql-archived td { opacity: 0.55; }

/* ═══ The record's ACTION BAND (Option B, 25 Aug 2026) ═══ Every action on a SAVED
   quote lives here, beside the document it judges. Spans the composer's grid. */
.qa-band { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; }
.qa-qid { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 12.5px; font-weight: 600; }
.qa-note { font-size: 12px; }
.qa-spacer { flex: 1; }
.qa-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.qa-actions .btn { padding: 6px 12px; font-size: 12.5px; margin: 0; }

/* ═══ The initial avatar + short name (`person-badge.js`) ═══ MOVED here from
   `crm-shell2.css` 25 Aug 2026, un-scoped from `#crmList`: the All Quotes register
   wears the same avatar (Shaf's ask), and only crm.html loads that stylesheet.
   One definition, both pages (D20). Values unchanged byte-for-byte. */
.pcell { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.pav {
  flex: 0 0 auto;
  width: 22px; height: 22px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--accent-soft); color: var(--text);
  font-size: 10px; font-weight: 700; letter-spacing: 0.02em;
}
/* One tint per person, chosen by a hash of the name so it never moves. */
.pav-1 { background: var(--pav-1); }
.pav-2 { background: var(--pav-2); }
.pav-3 { background: var(--pav-3); }
.pav-4 { background: var(--pav-4); }
.pav-5 { background: var(--pav-5); }
.pav-6 { background: var(--pav-6); }
.pnm { min-width: 0; }
#clientAddress { min-height: 52px; }
#restoreNote { background: #e9f1f7; border-color: #4a7fa5; color: #1f4e6e; }
#restoreNote .mono { font-family: var(--mono); font-size: 12px; }

/* ============ p10 (2026-07-25): ad-hoc items + deal link ============ */

/* Two doors in the picker: catalog search, or create an item that isn't there. */
.picker-doors {
  display: flex;
  gap: 6px;
  margin: 0 0 14px;
  padding: 4px;
  background: var(--row-band);
  border-radius: 10px;
}
.picker-door {
  flex: 1;
  padding: 8px 12px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.picker-door:hover { color: var(--accent-grn2-text); }
.picker-door.active {
  background: var(--panel);
  color: var(--accent-grn2-text);
  box-shadow: 0 1px 3px rgba(20, 30, 40, 0.12);
}

.adhoc-form { display: block; }
.adhoc-intro {
  margin: 0 0 14px;
  padding: 10px 12px;
  background: #f3f7f4;
  border-left: 3px solid var(--accent-grn2);
  border-radius: 0 6px 6px 0;
  font-size: 12.5px;
  line-height: 1.55;
  color: #3f4b56;
}
.adhoc-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 14px;
}
.adhoc-section-title {
  margin: 18px 0 8px;
  padding-bottom: 5px;
  border-bottom: 1px solid var(--border);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-3);
}
.adhoc-modes { display: flex; gap: 6px; margin-bottom: 12px; }
.adhoc-mode {
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel);
  color: var(--muted);
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}
.adhoc-mode.active {
  border-color: var(--accent-grn2);
  background: var(--accent-grn2);
  color: var(--on-primary);
}
.adhoc-form .field .hint,
#dealHint {
  display: block;
  margin-top: 3px;
  font-size: 11.5px;
  font-weight: 400;
  color: var(--muted);
}
.adhoc-form .field .req {
  margin-left: 4px;
  font-size: 11px;
  font-style: normal;
  font-weight: 600;
  color: var(--bad-text);
}
.adhoc-form textarea { resize: vertical; }

/* Live costing preview — the BDM sees the arithmetic, not just the answer. */
.adhoc-preview {
  margin-top: 16px;
  padding: 12px 14px;
  background: var(--row-band);
  border: 1px solid #e0e6ed;
  border-radius: 8px;
  font-size: 13px;
}
.adp-formula {
  margin-bottom: 8px;
  padding-bottom: 8px;
  border-bottom: 1px dashed var(--border);
  font-size: 11.5px;
  color: var(--text-3);
}
.adp-row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; }
.adp-row span { color: var(--muted); }
.adp-row strong { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.adp-total { margin-top: 6px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 14px; }
.adp-good { color: var(--ok-text); }
.adp-bad { color: var(--bad-text); }
.adp-warn {
  margin-top: 10px;
  padding: 8px 10px;
  background: var(--warn-bg);
  border-radius: 6px;
  font-size: 12px;
  color: var(--warn-text);
}

/* Row badges */
.badge-adhoc {
  display: inline-block;
  padding: 1px 6px;
  border-radius: 4px;
  background: #6B21A8;
  color: var(--on-primary);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  vertical-align: middle;
}
.badge-src {
  display: inline-block;
  padding: 1px 6px;
  border-radius: 4px;
  font-size: 10px;
  font-weight: 600;
  vertical-align: middle;
  text-decoration: none;
}
.badge-src.ok { background: var(--ok-bg); color: var(--ok-text); }
.badge-src.ok:hover { background: #c9ecd8; }
.badge-src.missing { background: var(--bad-bg); color: var(--bad-text); }
.badge-edit, .badge-propose {
  padding: 1px 6px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel);
  color: var(--muted);
  font: inherit;
  font-size: 10px;
  font-weight: 600;
  cursor: pointer;
  vertical-align: middle;
}
.badge-edit:hover, .badge-propose:hover { border-color: var(--accent-grn2); color: var(--accent-grn2-text); }

tr.row-custom .badge-adhoc + .badge-src { margin-left: 2px; }
#adhocWarn { margin-top: 6px; }

@media (max-width: 620px) {
  .adhoc-grid { grid-template-columns: 1fr; }
  .picker-doors { flex-direction: column; }
}

/* The R1b working-view panel's CSS RETIRED with the panel itself (config-tab, last
   slice). Its money-by-place reading now sits on the bands in every arrangement,
   which reuse the existing band styling — no replacement rules were needed. */

/* ============ P2 (2026-07-25): unified payment schedule ============ */
/* The schedule shares the quote panel's 28px inset with .discount-bar and .actions.
   e2e_p16 asserts the left/right edges line up — if this drifts, the build goes red. */
.sched-block { margin: 12px 28px 18px; }
/* The Configuration tab's sub-strip shares .sched-block's 28px inset, or its
   "Set up" label hangs left of every panel beneath it — which is exactly how it
   shipped in c196 and how Shaf reported it ("over flown to the left", 25 Aug
   2026, from the live screen). One number, stated where its sibling lives. */
#qsubBar { margin: 6px 28px 0; }

.sched-mode {
  margin-left: 8px;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  vertical-align: middle;
}
.sched-mode.auto   { background: var(--ok-bg); color: var(--ok-text); }
.sched-mode.manual { background: var(--warn-bg); color: var(--warn-text); }

.sched-empty {
  padding: 14px 12px;
  background: var(--row-band);
  border: 1px dashed var(--border);
  border-radius: 8px;
  font-size: 12.5px;
  color: var(--text-3);
}

/* Lifecycle group heading — the client reads the schedule in this order. */
.sch-group-head {
  margin: 12px 0 4px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--border);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-grn2);
}
.sch-group-head:first-child { margin-top: 0; }

.sch-row {
  display: grid;
  grid-template-columns: minmax(120px, 1.6fr) minmax(110px, 1fr) minmax(110px, 1fr) minmax(90px, 1fr) auto auto;
  gap: 6px;
  align-items: center;
  padding: 4px 0;
}
.sch-row input,
.sch-row select { padding: 5px 7px; font-size: 12.5px; }
.sch-row.sch-auto .sch-label { color: var(--muted); font-style: italic; }
.sch-amt {
  min-width: 108px;
  text-align: right;
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-weight: 600;
  font-size: 12.5px;
}
.sch-pct { display: inline-flex; align-items: center; gap: 2px; }
.sch-pct input { width: 100%; }
.sch-group { display: flex; gap: 4px; }
.sch-group select { min-width: 0; flex: 1; }
.sch-streamnote { font-size: 12px; color: var(--text-3); text-align: center; }

.sch-sub { padding: 2px 0 6px 4px; }
.sch-sub input[type="text"] { width: 100%; padding: 5px 7px; font-size: 12.5px; }
.sch-note {
  font-size: 11.5px;
  color: var(--warn-text);
  background: var(--warn-bg);
  border-radius: 5px;
  padding: 5px 8px;
}

/* Segmented rental rates — the Tide Gauge 3+1+1 shape. */
.sch-segments {
  margin: 2px 0 8px;
  padding: 8px 10px;
  background: var(--row-band);
  border-left: 3px solid var(--accent-grn2);
  border-radius: 0 6px 6px 0;
}
.sch-seg-head {
  margin-bottom: 6px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}
.sch-seg-head .muted { font-weight: 400; text-transform: none; letter-spacing: 0; }
.sch-seg {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 3px 0;
  font-size: 12.5px;
}
.sch-seg input { width: 84px; padding: 4px 6px; font-size: 12.5px; }
.sch-seg-lbl { color: var(--text-3); font-size: 12px; }
.sch-seg-sub {
  margin-left: auto;
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-weight: 600;
}
.sch-seg-add { margin-top: 6px; }

.sched-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sched-preset { padding: 5px 8px; font-size: 12.5px; }

.badge-billing.uninstall { background: #6B7280; color: var(--on-primary); }
/* ⚠ The SAME green as --accent-grn2, written as rgba so no hex search could ever
   find it — the ratchet counts #rrggbb and this was never in its scope. Found by a
   review that read the rules rather than grepping them. color-mix keeps the 35%. */
.badge-billing.overridden { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-grn2) 35%, transparent); }

@media (max-width: 900px) {
  .sch-row { grid-template-columns: 1fr 1fr; }
  .sch-amt { text-align: left; }
}

/* ============ SA-1 (2026-07-25): sub-assembly composition ============ */
.badge-sa {
  padding: 1px 6px;
  border: 0;
  border-radius: 4px;
  font: inherit;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.03em;
  cursor: pointer;
  vertical-align: middle;
}
.badge-sa.ok   { background: var(--ok-bg); color: var(--ok-text); }
.badge-sa.warn { background: var(--warn-bg); color: var(--warn-text); }
.badge-sa.bad  { background: var(--bad-bg); color: var(--bad-text); }
.badge-sa:hover { filter: brightness(0.95); }

tr.sa-row > td { padding: 0 !important; background: var(--row-band); }
.sa-panel { padding: 12px 16px 14px; border-left: 3px solid var(--accent-grn2); }
.sa-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.sa-table th {
  padding: 4px 8px;
  border-bottom: 1px solid var(--border);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-3);
  text-align: left;
}
.sa-table th.num, .sa-table td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.sa-table td { padding: 4px 8px; border-bottom: 1px solid #eef2f6; }
.sa-table tfoot td {
  padding-top: 6px;
  border-bottom: 0;
  border-top: 1px solid var(--border);
  font-weight: 600;
}
.sa-table tr.sa-missing td { background: var(--bad-bg); color: var(--bad-text); }
.sa-verdict {
  margin-top: 10px;
  padding: 7px 10px;
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.5;
}
.sa-verdict.ok   { background: var(--ok-bg); color: var(--ok-text); }
.sa-verdict.warn { background: var(--warn-bg); color: var(--warn-text); }
.sa-verdict.bad  { background: var(--bad-bg); color: var(--bad-text); }
.sa-empty {
  padding: 12px 16px;
  border-left: 3px solid var(--border);
  font-size: 12.5px;
  color: var(--text-3);
}
.sa-chip {
  margin-top: 2px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.02em;
}
.sa-chip.ok   { color: var(--ok-text); }
.sa-chip.warn { color: var(--warn-text); }
.sa-chip.bad  { color: var(--bad-text); }

/* Kit vs supplier unit (SA-1, revised 25 Jul) */
.sa-typeline {
  margin-bottom: 10px;
  padding: 7px 10px;
  border-radius: 6px;
  font-size: 11.5px;
  line-height: 1.5;
  background: var(--row-band);   /* neutral until a type is chosen */
  color: var(--muted);
}
.sa-typeline.kit  { background: var(--accent-grn2-bg); color: var(--accent-grn2-text); }
.sa-typeline.unit { background: #eef1f7; color: #33406b; }
.sa-table tr.sa-desc td { color: var(--text-3); font-style: italic; }

/* SA-2 composition builder */
.sa-build-row {
  display: grid;
  grid-template-columns: 150px 58px 72px 1fr 110px 30px;
  gap: 6px;
  align-items: center;
  padding: 4px 0;
}
.sa-build-row input { padding: 5px 7px; font-size: 12.5px; }
.sa-build-cost { text-align: right; font-size: 12.5px; font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 600; }
@media (max-width: 700px) {
  .sa-build-row { grid-template-columns: 1fr 58px 70px 30px; }
  .sa-build-cost { grid-column: 1 / -1; text-align: left; }
}

/* Target-price verdict in the item form (p12.1). A derived price that misses the
   number the user asked for must say so — the miss is small enough to be invisible
   otherwise, which is exactly how RM3,999.66 passed for RM4,000. */
/* Shaf 15 Aug 2026: "brighten both for dark mode, same hues" — raw hex never adjusted
   for dark, so it read fine in light (5.35 / 6.14) and failed dark (3.04 / 2.65,
   against --panel #1B221D). Rather than mint two new tokens for one rarely-seen pair,
   reuse the already dark-adjusted tokens nearest their hue: --ok-text (green, "on
   target") and --alert-text (red-orange, more severe than --warn-text, "off target").
   Both clear the floor with margin in both themes: 7.95/9.42 and 5.65/7.91. */
.if-target-ok  { margin-top: 6px; color: var(--ok-text); font-weight: 600; }
.if-target-off { margin-top: 6px; color: var(--alert-text); font-weight: 600; }

/* ================= p13 layout ==================================
   Two things were wrong in the first pass and both were the same mistake:
   markup written to class names this stylesheet does not define, and a band
   laid out as if it were a field. Fixed here rather than patched around. */

/* --- A computed field must not look like an empty one you forgot to fill. --- */
input.is-computed, .if-grid select:disabled {
  background: #eef2f0; color: #33413c; cursor: not-allowed; border-style: dashed;
}

/* --- Sub-assembly composition inside the item form. It is a full-width BAND in
       the 4-column field grid, not a 2-column field: spanning two columns left a
       hole beside it and pushed the cost row out of line. --- */
/* Same visual language as the package form's BOM band (.bl-block): plain 1px
   border, no left accent. The 3px primary edge p13 shipped read as a rendering
   glitch against the 10px radius — Shaf, 26 Jul. */
.if-sa-block {
  grid-column: 1 / -1;
  border: 1px solid var(--border);
  border-radius: 10px; padding: 14px 16px 12px; margin: 4px 0 2px;
  background: var(--bg);
}
.if-sa-head { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.if-sa-type { display: flex; flex-direction: column; gap: 4px; flex: 0 1 420px; min-width: 260px; }
.if-sa-type > span { font-size: 12px; font-weight: 600; color: var(--text); }
.if-sa-type select { padding: 7px 9px; font-size: 13px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); }
.if-sa-block .sa-typeline { margin: 8px 0 10px; }
.if-sa-note { margin-top: 8px; font-size: 11.5px; color: var(--muted); line-height: 1.5; }

/* ONE grid for every composition surface — the standalone builder and the block
   inside Add Item. Headings and rows share it, so they cannot fall out of
   alignment, and the two surfaces cannot drift apart visually (doctrine D1). */
.comp-cols,
.comp-row {
  display: grid;
  grid-template-columns: minmax(200px, 2fr) 64px minmax(140px, 1.4fr) 104px 28px;
  gap: 8px;
}
/* p16.1: rows top-align so the Component combo's first line sits on the same line
   as QTY and NOTE; when an item is picked the meta hangs BELOW without moving them.
   The Find button is gone — the combo is the finder now (matches the package BOM). */
.comp-cols { align-items: end; }
.comp-row  { align-items: start; }
.comp-row .sa-build-cost, .comp-row .btn-icon { align-self: start; margin-top: 5px; }
.comp-cols {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--muted); padding: 0 2px 5px;
  border-bottom: 1px solid var(--border); margin-bottom: 7px;
}
.comp-row { padding: 3px 0; }
.comp-row input { padding: 6px 8px; font-size: 12.5px; min-width: 0; }
.comp-row .sa-build-cost { text-align: right; font-size: 12.5px; font-weight: 600; font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* The shared item-search combo used INSIDE a composition row (p16). Same control as
   the package-BOM rows; a bare code box is never acceptable for item selection (D1). */
.comp-combo, .ifsa-combo, .sa-combo { min-width: 0; align-self: start; }
.comp-row .combo-meta { font-size: 10.5px; }
.combo-cost { display: block; font-size: 10.5px; color: var(--muted); margin-top: 1px; }
.comp-row.sa-missing .combo-input { border-color: var(--bad-text); background: var(--bad-bg); }

.sa-build-foot { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; }

@media (max-width: 900px) {
  .comp-cols { display: none; }
  .comp-row { grid-template-columns: 1fr 64px 28px; }
  .comp-row .ifsa-note, .comp-row .sa-note { grid-column: 1 / -1; }
  .comp-row .sa-build-cost { grid-column: 1 / -1; text-align: left; margin-top: 0; }
  .if-sa-type { min-width: 0; }
}

/* Modifier classes that previously carried no rule at all. Each now states the
   small thing it was always meant to say, so the check stays honest. */
.panel-form { display: flex; flex-direction: column; }
.match-status.match-empty { color: var(--muted); font-style: italic; }
.section-row.section-row-multi td { padding-top: 12px; }
.sa-build-list { display: block; }

/* p16.2: "updated after approval" note — a positive confirmation that the open
   quote redrew to freshly approved data, listing any line whose price moved (D10).
   Green/ok tone sets it apart from the amber drift warning above it. */
.refresh-note {
  margin: 8px 0 4px; padding: 9px 12px; border-radius: var(--radius);
  background: var(--ok-bg); border-left: 3px solid var(--ok);
  font-size: 12.5px; line-height: 1.55; color: var(--ok-text);
}
.refresh-note .btn { margin-left: 8px; }

/* As-quoted price banner (N3). Informational, never alarming — a restored quote
   showing its original prices is correct behaviour, not a problem. */
.pricedrift-banner {
  margin: 8px 0 4px; padding: 9px 12px; border-radius: var(--radius);
  background: var(--warn-bg); border-left: 3px solid var(--warn-text);
  color: var(--text); font-size: 12.5px; line-height: 1.65;
}
.pricedrift-banner .pd-item { white-space: nowrap; font-family: var(--mono); font-size: 11.5px; }
.pricedrift-banner .pd-delta { font-family: inherit; font-weight: 600; }
.pricedrift-banner .pd-delta.up { color: var(--warn-text); }
.pricedrift-banner .pd-delta.down { color: var(--ok); }
.pricedrift-banner .btn { margin-left: 8px; vertical-align: baseline; }
.pricedrift-banner.pd-alert { background: var(--alert-bg); border-left-color: var(--alert-text); }

/* ============================================================
   PHONE — Approvals is the screen that has to work here.
   nfr.md N7 commits the portal to being READABLE on a phone. It was not:
   every screen needed ~630px of sideways scrolling and the top bar pushed
   the page off to the right before anything was even tapped.

   Shaf's call, 28 Jul: the one job he genuinely does from a phone is
   approving pricing changes. So this block makes APPROVALS work at 390px
   and leaves the rest desktop-shaped — they still open, they just expect
   a bigger screen. Building a quote stays desktop work by design.

   Deliberately last in the file: a media query carries no extra weight,
   only its position, and an earlier copy of this once sat 330 lines above
   the rule it meant to override and never applied at all.
   ============================================================ */
/* --- NARROW WINDOWS (not just phones) -----------------------------------
   The top bar needs ONE ROW's worth of width or it pushes the WHOLE PAGE
   sideways, so every screen scrolls horizontally in an ordinary laptop window.
   Shaf hit this at ~980px on 29 Jul, one day after the phone pass — the phone
   was fixed and the far more common case was not.
   Wrapping the bar fixes every screen at once, because it is the shell.

   THE NUMBER MOVED, and that is the lesson. It was 1033px on 29 Jul and the
   breakpoint was set at 1100 to suit. Measured again 7 Aug: **1326px** — brand
   265 + nav 588 + meta 431. The bar had quietly outgrown its own breakpoint, so
   everything between 1101px and 1326px overflowed, including the 1280 gate width
   and any ordinary laptop window. Nothing reported it because layoutcheck could
   not run outside the container until the harness was fixed.
   1400 is deliberately generous rather than 1340: this bar has grown twice now,
   and a breakpoint tuned to the current pixel is a breakpoint that rots again the
   next time a nav item or a status word is added.
   MEASURE IT AGAIN — do not adjust it from memory — whenever the bar gains an
   item. `tests/e2e_entrances.js` now fails if the page scrolls sideways. */
@media (max-width: 1400px) {
  .topbar {
    flex-wrap: wrap;
    align-content: flex-start;   /* wrapped rows sit together, no dead band */
    row-gap: 8px;
    column-gap: 10px;
  }
  /* the nav drops to its own row and scrolls inside itself rather than
     widening the page */
  .main-nav {
    order: 3;
    width: 100%;
    margin-left: 0;
    overflow-x: auto;
    flex-wrap: nowrap;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .main-nav::-webkit-scrollbar { display: none; }
  .nav-btn { flex: 0 0 auto; }
  .topbar-meta {
    order: 2;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin-left: auto;
  }
}

@media (max-width: 620px) {
  /* --- phone-only tightening, on top of the narrow-window rules above --- */
  .topbar { padding: 10px 14px 6px; }
  .brand { gap: 8px; }
  .nav-btn { padding: 8px 12px; font-size: 13.5px; }
  /* The status line duplicates what the health strip already says, and on a
     390px screen it pushed Refresh onto a row of its own that read as a blank
     dark band. Health strip + Refresh share one line instead. */
  .topbar-meta .status { display: none; }
  .topbar-meta .btn { padding: 5px 10px; font-size: 12.5px; }
  .health-strip { font-size: 11.5px; }

  /* --- the Approvals screen itself --- */
  .browse-layout { padding: 12px 12px 32px; }
  .browse-controls { gap: 8px; }
  .browse-controls select,
  .browse-controls .btn { flex: 1 1 auto; min-height: 40px; }
  .browse-controls .muted.small { flex: 1 1 100%; }

  .ap-card { padding: 12px 13px; }
  /* a 30/70 label-value split leaves ~90px for the value on a phone; stacking
     gives the value the full width instead */
  .ap-card .kv, .ap-card .kv tbody, .ap-card .kv tr,
  .ap-card .kv th, .ap-card .kv td { display: block; width: auto; }
  .ap-card .kv th { padding: 6px 0 0; font-size: 12px; }
  .ap-card .kv td { padding: 0 0 4px; }
  .ap-card .kv tr + tr th { border-top: 1px solid var(--border); margin-top: 4px; }
  .ap-head { gap: 8px; }
  .ap-right { margin-left: 0; flex: 1 1 100%; }
  .ap-summary { font-size: 13px; word-break: break-word; }

  /* finger targets, not mouse targets */
  .ap-actions { flex-wrap: wrap; gap: 8px; }
  .ap-actions .btn { flex: 1 1 calc(50% - 4px); min-height: 44px; }
}

/* --- S0 (P3): staging environment. Loud on purpose — the cost of mistaking
       staging for production is a quote sent to a client from the wrong place. --- */
.staging-banner {
  position: fixed; top: 0; left: 0; right: 0; z-index: 300;
  background: var(--staging-bar); color: var(--on-primary);
  font-size: 12px; line-height: 1.4; font-weight: 600;
  padding: 7px 14px; text-align: center;
  letter-spacing: 0.01em;
}
body.is-staging { padding-top: var(--staging-h); }

/* ⚠ AND THE ONE OVERLAY THAT STARTS AT THE VERY TOP HAS TO CLEAR IT. A drawer is
   `position: fixed; inset: 0` at z-index 60; the banner is fixed at top 0 at
   z-index 300. Everything else on the page is pushed down by the body padding
   above, but a fixed element is not in that flow, so the drawer's header — its
   title AND its close button — was drawn behind the banner and cut in half.
   Found by looking at a screenshot (D14), not by any gate: every assertion about
   the drawer reads text content and geometry, and both were correct.
   ⚠ Raising the drawer's z-index above the banner would be the WRONG fix — the
   banner exists so nobody mistakes staging for production, and a panel that
   covers it defeats the one job it has. The drawer moves; the warning stays. */
body.is-staging .drawer-overlay { top: var(--staging-h); }
.staging-block-note {
  position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); z-index: 320;
  background: var(--toast-bg); color: var(--on-primary); font-size: 13px; line-height: 1.45;
  padding: 10px 16px; border-radius: var(--radius-lg); max-width: min(560px, 92vw);
  box-shadow: var(--shadow-md);
}

/* --- S3b: sites inside a cluster. The list only appears once a BDM splits a
       trip into named sites; an unsplit cluster keeps the single Stations box
       it always had, so the common one-station quote is untouched. --- */
/* The site list is a SIBLING of `.cluster-body`, not a child of it. `.cluster-body`
   is a row flex, and putting the list inside it made every row refuse to shrink
   below its content width: on first build the name inputs pushed the qty box and
   both buttons past the panel edge and under the quote table, where they were
   visible but unclickable. Layoutcheck's screenshot is what showed it. Sitting
   outside that flex row, the list simply takes the card's full width. */
.site-list { display: block; margin: 8px 0 2px; min-width: 0; max-width: 100%; }
.site-row + .site-row { margin-top: 6px; }
.site-row { display: flex; align-items: center; gap: 6px; }
/* `min-width: 0` on BOTH inputs, not just the name. A flex item defaults to
   `min-width: auto`, which floors it at min-content — and an <input type=number>
   reports a min-content width of ~188px because of its default size and spinner.
   The qty box therefore ignored its 58px basis, the row could not shrink, and the
   card overflowed the panel with the buttons landing under the quote table. */
.site-row .st-name {
  /* `flex: 1 1 0`, not `1 1 auto`. With an `auto` basis the input's preferred
     width (~192px, the browser default for a text input) counts toward the
     container's max-content size, and the cluster panel sizes to max-content —
     so the card grew from 272px to 348px and pushed its buttons out past the
     300px panel. A zero basis contributes nothing and lets the row fill instead. */
  flex: 1 1 0; min-width: 0; width: 0; padding: 5px 8px; font-size: 13px;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.site-row .st-qty {
  flex: 0 0 58px; min-width: 0; width: 58px; padding: 5px 4px; font-size: 13px; text-align: center;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.site-row .st-name:focus, .site-row .st-qty:focus {
  outline: none; border-color: var(--primary-text);
}
.cluster-foot {
  display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap;
  margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border);
}
.cl-sitecount { font-size: 12px; color: var(--muted); }
.cl-addwrap { display: flex; align-items: center; gap: 6px; }
.cl-addn {
  width: 52px; min-width: 0; padding: 4px 6px; font-size: 12px; text-align: center;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.cluster-foot .cl-addsite { padding: 4px 10px; font-size: 12px; }

/* --- S3c: indirect cost pools. Same card grammar as the cluster cards so the
       left rail reads as one column, not two systems bolted together (D13). --- */
.pool-card {
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--panel); padding: 8px 10px; margin-bottom: 8px;
}
.pool-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.pool-top .pl-label {
  flex: 1 1 0; min-width: 0; width: 0; padding: 5px 8px; font-size: 13px;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.pool-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.pool-grid .cl-mini { flex: 1 1 96px; min-width: 0; }
.pool-grid select, .pool-grid input {
  width: 100%; min-width: 0; padding: 5px 6px; font-size: 12px;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.pool-foot {
  display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px;
  margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--border);
  font-size: 11px; color: var(--muted);
}
.pool-share { font-weight: 600; }

.pool-vis { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11px; color: var(--muted); }
.pool-vis .pl-visible { flex: 0 0 auto; width: auto; min-width: 0; margin: 0; }
.pool-dest { flex: 1 1 100%; }

/* --- S3c: per-item assignment of an indirect cost. Same zero-basis rule as the
       site rows — an <input> or <select> with an `auto` basis reports a wide
       preferred size and drags the whole 300px rail out with it. --- */
.pool-items { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); }
.pool-item-row { display: flex; align-items: center; gap: 5px; margin-bottom: 5px; }
.pool-item-row .pi-item {
  flex: 1 1 0; min-width: 0; width: 0; padding: 4px 6px; font-size: 12px;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.pool-item-row .pi-pct {
  flex: 0 0 52px; min-width: 0; width: 52px; padding: 4px 4px; font-size: 12px; text-align: center;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
}
.pi-sign { flex: 0 0 auto; font-size: 11px; color: var(--muted); }
.pool-item-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 11px; }
.pool-item-foot .pi-add { padding: 3px 9px; font-size: 11px; }
.pi-ok { color: var(--muted); }
.pi-warn { color: var(--warn-text); font-weight: 600; flex: 1 1 100%; }

/* ===========================================================================
   CRM (C6b) — the deal record's own surfaces.
   Every colour is a token. Green means confirmed, amber means look at this, and
   neither carries meaning without words beside it (doctrine D17, emt-ui).
   =========================================================================== */

/* The quote portal's .layout is a 300px sidebar plus a content column. The CRM has
   no sidebar, and reusing .layout squeezed every panel into the 300px track — the
   money tiles wrapped mid-figure and the exception detail rendered one word wide.
   Caught by the first screenshot (D14), not by any assertion. */
/* Nine nav items instead of six. The label and its badge wrapped onto two lines at
   1512px, which reads as a broken control rather than a full menu. Scoped to the CRM
   page so the quotation portal's own six-item bar is untouched. */
.crm-page .main-nav { flex-wrap: wrap; }
.crm-page .main-nav .nav-btn { padding: 6px 11px; font-size: 13px; white-space: nowrap; }
.crm-page .topbar-meta { white-space: nowrap; }

.crm-layout {
  flex: 1; padding: 20px; max-width: 1640px; width: 100%; margin: 0 auto;
}

.crm-view { display: none; }
.crm-view.on { display: block; }

.crm-band { margin-bottom: 22px; }
.crm-band-title {
  font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); margin: 0 0 8px; font-weight: 700;
}

/* --- stat tiles ----------------------------------------------------------- */
/* minmax(0, 1fr), not 1fr. A grid track's default minimum is min-content, so a
   tile holding "RM 5,610,845.75" refuses to shrink and pushes the whole row wider
   than the screen — the same trap as `min-width: auto` on a flex item, which cost
   us the S3b cluster-card overflow. */
.tile-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.tile {
  background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 14px 16px; box-shadow: var(--shadow-sm);
}
.tile-warn { border-color: var(--warn-text); background: var(--warn-bg); }
.tile-label { font-size: 12px; color: var(--muted); }
.tile-value { font-size: 24px; font-weight: 700; color: var(--text); margin: 4px 0 2px; }
.tile-note { font-size: 12px; color: var(--muted); }

.crm-table td, .crm-table th { vertical-align: middle; }

/* --- the flow board ------------------------------------------------------- */
.group-bar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-bottom: 12px;
}
.group-btn {
  border: 1px solid var(--border); background: var(--panel); color: var(--text);
  border-radius: 999px; padding: 4px 12px; font-size: 12px; cursor: pointer;
}
.group-btn.active { background: var(--primary); border-color: var(--primary-text); color: var(--on-primary); }
.flow-hint { font-size: 12px; margin-left: auto; }

/* A flex row that scrolls, never a grid that collapses: a stage that folds under
   another stage stops being a flow. */
.flow {
  display: flex; align-items: flex-start; gap: 0;
  overflow-x: auto; padding-bottom: 10px;
  scroll-snap-type: x proximity;
}
.flow-col {
  flex: 0 0 250px; scroll-snap-align: start;
  background: var(--row-band); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 8px;
}
.flow-head {
  display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
  padding: 2px 4px 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px;
}
.flow-name { font-weight: 700; font-size: 13px; color: var(--text); }
.flow-count {
  background: var(--primary); color: var(--on-primary); border-radius: 999px;
  padding: 0 7px; font-size: 11px; line-height: 17px;
}
.flow-sum { margin-left: auto; font-size: 11px; color: var(--muted); }
.flow-cards { min-height: 60px; display: flex; flex-direction: column; gap: 8px; }
.flow-empty { font-size: 12px; text-align: center; padding: 12px 0; }
/* The ended-column remainder ("and 12 older — in All deals"). --text rather than
   --muted deliberately: this sits on the column's --row-band fill, where --muted
   measures 4.47:1 — under the 4.5 text floor (the peek-tile scar). */
.flow-more { font-size: 11px; text-align: center; padding: 6px 0 2px; color: var(--text); }
.flow-sep {
  flex: 0 0 22px; text-align: center; color: var(--muted);
  font-size: 20px; line-height: 1; padding-top: 26px; user-select: none;
}
.flow-group { margin-bottom: 6px; }
.flow-group-head {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--muted); padding: 4px 2px;
}

.deal-card {
  background: var(--panel); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 8px 10px; cursor: pointer;
  box-shadow: var(--shadow-sm);
}
.dc-stalled { border-left: 3px solid var(--warn-text); }
.dc-code { font-size: 11px; color: var(--muted); }
.dc-name { font-size: 13px; font-weight: 600; color: var(--text); margin: 1px 0; }
.dc-meta { font-size: 11px; color: var(--muted); }
.dc-step { font-size: 11px; color: var(--muted); margin-top: 1px; }
.dc-value { font-size: 12px; font-weight: 700; margin-top: 4px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.dc-wt { display: block; font-size: 11px; font-weight: 400; color: var(--muted); font-family: var(--mono); font-variant-numeric: tabular-nums; }
.dc-flags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
.dc-stall {
  background: var(--warn-bg); color: var(--warn-text);
  border-radius: 3px; padding: 0 5px; font-size: 11px;
}
.dc-pic { font-size: 11px; color: var(--muted); margin-top: 4px; }

/* --- the exception panel -------------------------------------------------- */
.att-row {
  display: grid; grid-template-columns: 120px minmax(220px, 1fr) 2fr;
  gap: 10px; align-items: baseline;
  padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.att-kind { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.att-what { color: var(--text); }
.att-detail { color: var(--muted); font-size: 12px; }
.att-sev1 .att-kind { color: var(--alert-text); }
.att-sev2 { color: var(--warn-text); }
.att-sev3 .att-kind { color: var(--muted); }

/* B2 — the band had TWO text levels where the app has three, and every element in
   it was font-weight 400. Measured on staging at 390px, 12 Aug 2026: kind 11px/400,
   headline 13px/400, detail 12px/400, and `--text-3` not used anywhere. Nothing was
   emphasised, so nothing receded — which is exactly what Shaf meant by "pale".
   The reference is a LABEL and drops to the third level; the name is the thing the
   eye is hunting and is the only thing that gains weight. No size changes, no
   padding changes — density was settled when the airier variant was measured at
   +29% row height and rejected. */
.umenu-note { display: block; padding: 2px 12px 8px; font-size: 11.5px; color: var(--text-3); }
.att-ref { color: var(--text-3); }
.att-name { font-weight: 600; }

/* The group heading, borrowing the "Wins with nothing to prove them" head that sits
   directly above it on the same screen. `.att-why` is the sentence the whole group
   shares, said once here instead of on every row. */
.att-head { margin-top: 20px; }
.att-head:first-child { margin-top: 0; }
.att-head-urgent .work-title { color: var(--alert-text); }
.att-head-warn .work-title { color: var(--warn-text); }
.att-head-calm .work-title { color: var(--text); }
.att-why { margin: -4px 0 9px; font-size: 12px; color: var(--muted); max-width: 68ch; }

/* When the heading names the kind, the row drops the 120px chip column; when the
   heading also carries the sentence, the row drops the detail column too and the
   name gets the whole width. Both are multi-class on purpose — they must beat the
   bare `.att-row` above, and a media query adds no specificity of its own, so the
   phone override further down matches these selector shapes rather than relying on
   `@media` to win. */
.att-row.att-grouped { grid-template-columns: minmax(0, 1fr) 2fr; }
.att-row.att-grouped.att-solo { grid-template-columns: minmax(0, 1fr); }

/* --- the activity feed ---------------------------------------------------- */
.act-row {
  display: grid; grid-template-columns: 110px 150px 1fr 150px; gap: 10px;
  padding: 6px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.act-when, .act-who { color: var(--muted); font-size: 12px; }
.act-type { font-size: 12px; }
.act-body { color: var(--text); }

/* --- the scorecard -------------------------------------------------------- */
.kpi-row {
  display: grid; grid-template-columns: minmax(180px, 1.4fr) 110px 100px 90px 2fr;
  gap: 10px; align-items: baseline;
  padding: 8px 4px; border-bottom: 1px solid var(--border);
}
.kpi-label { font-size: 13px; font-weight: 600; color: var(--text); }
.kpi-cadence {
  display: block; font-size: 11px; font-weight: 400; color: var(--muted);
  text-transform: capitalize;
}
.kpi-value { font-size: 16px; font-weight: 700; text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.kpi-none { color: var(--muted); font-weight: 400; }
.kpi-target { font-size: 12px; color: var(--muted); }
.kpi-basis { font-size: 12px; color: var(--muted); }
.kpi-status {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
  border-radius: 3px; padding: 1px 6px; text-align: center;
}
.st-on_track { background: var(--ok-bg); color: var(--ok-text); }
.st-at_risk { background: var(--warn-bg); color: var(--warn-text); }
.st-behind { background: var(--alert-bg); color: var(--alert-text); }
.st-baseline { background: var(--row-band); color: var(--muted); }

/* A link that acts as a button, and a size modifier that was quietly also a colour
   modifier. `.btn-sm` (line ~1054) sets `background: transparent` and is declared
   AFTER `.btn-primary`, so `btn btn-primary btn-sm` rendered white text on nothing —
   invisible inside a white panel. C7's deal record was the first place in the app to
   pair them, and the screenshot is what caught it; the E2E check found the anchor and
   read its href quite happily. Two classes beat one, so the intent is restored without
   disturbing the standalone `.btn-sm` buttons that rely on being transparent. */
.btn.btn-primary { background: var(--primary); color: var(--on-primary); }
a.btn { display: inline-flex; align-items: center; text-decoration: none; }

/* --- the deal record ------------------------------------------------------ */
.row-open { cursor: pointer; }
.row-open:hover { background: var(--row-band); }

/* THE ROW YOU ARE ON — and it is read AFTER the peek closes, not through it.
   🛑 MEASURED BEFORE IT WAS BUILT, AND THE MEASUREMENT MOVED THE DESIGN. The plan
   said "mark the row behind the wash". Behind the DARK wash NOTHING can be marked:
   `--scrim` is 65% black over an already-dark panel, which crushes the entire range
   into the bottom of the scale where ratios collapse. `--primary-text` reaches
   1.73:1 against the row beside it, and **PURE WHITE only reaches 2.81:1** — against
   a 3:1 non-text floor. There is no colour that passes, so none was picked and the
   claim was dropped instead of being quietly shipped as if it worked.
   ✅ What the mark is FOR is the moment the peek CLOSES — twenty rows, decide, move
   on, without losing your place. Read there, with no scrim over it, the bar is
   **10.33:1 light / 6.08:1 dark**. So it deliberately SURVIVES the close.
   ⚠ Both numbers are hand-computed from the tokens, because NO GATE WE OWN CAN JUDGE
   EITHER — `contrastcheck` reads text and says so in its own header. Re-derive them;
   do not trust this comment. The during-stepping feedback is the peek's own title
   changing, which IS text, sits in FRONT of the wash, and is gated by the suite.
   ⚠ Zero new colours. The band is the hover band; the bar is `--primary-text`, the
   token that exists precisely because the brand green is a FILL and cannot be read on
   a dark surface. Using a text token as a BORDER is safe in this direction — it
   already clears the higher 4.5:1 text floor — which is the inverse of the `--text-3`
   trap from slice 5, where a border colour was not safe as a label. */
/* 🛑 THE BAND IS `--bg`, NOT `--row-band` — CORRECTED 14 Aug 2026 (slice 12), and
   it is the SECOND time this exact pair has had to be corrected. `--muted` on
   `--row-band` is 4.469:1 against a 4.5:1 floor; on `--bg` it is 4.589:1. Both
   computed from the tokens the same way, which is the lesson the first
   correction left behind (13 Aug: one side measured by hand at "4.54" and the
   other in a browser at 4.47, so a regression across a threshold read as a
   rounding-level move).
   ⚠ WHAT MADE IT LIVE: this rule was harmless while only the DEAL list could be
   peeked — its cells carry no muted sub-text. Slice 12 let the ACCOUNTS register
   be peeked too, and that register's rows carry `.adm-sub` ("2 in play",
   "1 unplaced") in `--muted`. Marking a row therefore pushed six text sites under
   the floor. Caught by `contrastcheck` reporting a NEW rule, on a surface it
   could only see because the same slice registered the peek as measured.
   ⚠ Still a real band: separation from `--panel` is 1.081 light, better than the
   1.046 the 13 Aug correction accepted. Zero new colours. */
tr.row-peeked > td { background: var(--bg); }
tr.row-peeked > td:first-child { box-shadow: inset 3px 0 0 var(--primary-text); }
.deal-kv {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px 18px; margin: 12px 0 4px;
}
/* One fact inside `.deal-kv`. It wore `.kv` until slice 11 — the APPROVALS TABLE
   rule 1,600 lines up — and took `width: 100%` and `font-size: 13px` from it by
   accident; the other two properties are no-ops on a div, which is why nothing
   ever showed. Nothing moves by renaming it: a grid item already fills its track,
   and both children set their own size, so the 13px landed on no text at all.
   That is an identity argument, not a measurement, and it is the stronger one.
   ⚠ It lives HERE, beside `.deal-kv` / `.kv-k` / `.kv-v`, rather than in the CRM
   stylesheet as the design sketched: its three siblings are already in this file,
   and putting one quarter of a component somewhere else is precisely the split
   D20 exists to prevent. `min-width: 0` because a grid item's default minimum is
   min-content (D21) and these hold client and division names nobody chose. */
.dkv { min-width: 0; }
.kv-k { display: block; font-size: 11px; color: var(--muted); }
.kv-v { display: block; font-size: 14px; font-weight: 600; color: var(--text); }
/* A fact whose value is a SENTENCE rather than a word — the lead's "What do they need?",
   which is the submitter's own answer and the longest thing on that panel.
   ⚠ ADDED 19 Aug 2026, and it replaces a real defect rather than decorating one: that
   answer was rendered as a bare `.field-guide` paragraph — the MUTED HELPER-TEXT style,
   with no label — so the most important thing a person wrote read like instructions from
   the app. Shaf spotted it on his own test lead.
   ⚠ `1 / -1` because it spans the grid (D13), and `min-width: 0` for the same reason
   `.dkv` has it: a long sentence must be allowed to wrap rather than set the track's
   minimum (D21). `.kv-p` is NOT `.kv-v` — 600 weight is right for a name and wrong for
   prose, and a paragraph needs line-height a one-word value does not. */
.dkv-wide { grid-column: 1 / -1; min-width: 0; }
.kv-p { display: block; font-size: 14px; color: var(--text); line-height: 1.5; }

/* --- the screens that write (C8) ------------------------------------------ */

.cost-row {
  display: grid;
  grid-template-columns: 110px minmax(150px, 1.3fr) repeat(4, 120px) 90px minmax(200px, 1.4fr);
  gap: 10px; align-items: center;
  padding: 9px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.cost-code { font-size: 12px; }
.cost-name { font-weight: 600; }
.cost-client { display: block; font-size: 11px; font-weight: 400; color: var(--muted); }
.cost-fig { font-size: 13px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.cost-lbl { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.cost-status { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.cost-disputed { background: var(--warn-bg); }
.cost-confirmed .cost-status, .cost-decided .cost-status { color: var(--ok-text); }
.cost-do { min-width: 0; }
.cost-none .cost-status, .cost-pending .cost-status { color: var(--warn-text); }
.cost-act { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.ca-msg { font-size: 12px; color: var(--muted); }
.cost-act input { font-size: 12px; padding: 3px 6px; width: 130px; }

.alloc-cols, .alloc-row {
  display: grid; grid-template-columns: minmax(160px, 1.4fr) 150px 90px 160px 100px;
  gap: 10px; align-items: center;
}
.alloc-cols {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--muted); padding: 0 4px 6px;
}
.alloc-row { padding: 5px 4px; border-bottom: 1px solid var(--border); }
.alloc-row select, .alloc-row input { font-size: 13px; padding: 4px 6px; }
/* c156 — the person and the role are FACTS here, not choices: they come from the four
   bands above and the panel decides only the share. They read as content rather than as
   controls, and the name carries the weight because the name is what the eye hunts.
   ⚠ `min-width: 0` on both: a grid track's default minimum is min-content, and the
   longest staff name is data that is not ours to choose (D21) — the same reason the
   select that used to sit here needed it in the phone block below. */
.al-who { font-size: 13px; font-weight: 600; color: var(--text); min-width: 0; }
.al-what { font-size: 12px; color: var(--muted); min-width: 0; }
.al-money { font-size: 13px; font-weight: 600; }
.alloc-total { font-size: 16px; font-weight: 700; margin-left: auto; }
.alloc-ok { color: var(--ok-text); }
.alloc-off { color: var(--warn-text); }

/* --- a person on a deal: closers (C26) and lead bringers (C27) -------------
   ONE class for both bands, because they are the same row and two copies of a
   grid drift within a week (D12/D20). Not .alloc-row's grid: this row is a
   person and a Remove button, with no role, no percent and no money — each is
   credited in full, so there is nothing here to divide.
   minmax(0, …) because the widest staff name is not ours to choose (D21), and
   justify-content:start so the Remove button does not absorb the slack. */
.person-row {
  display: grid; grid-template-columns: minmax(0, 420px) auto;
  gap: 10px; align-items: center; justify-content: start;
  padding: 5px 4px; border-bottom: 1px solid var(--border);
}
.person-row select { font-size: 13px; padding: 4px 6px; min-width: 0; }

/* --- the settings screen (C9) --------------------------------------------- */
.adm-row {
  display: grid;
  grid-template-columns: minmax(150px, 1fr) minmax(180px, 1.4fr) 150px minmax(180px, 1.6fr) minmax(220px, 1.6fr);
  gap: 10px; align-items: center;
  padding: 8px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.adm-pending { background: var(--warn-bg); }
.adm-key { font-weight: 600; }
.adm-what { color: var(--text); }
.adm-val { font-weight: 600; }
.adm-sub { display: block; font-size: 11px; font-weight: 400; color: var(--muted); }
.adm-do { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }

/* A division that sits under another one, drawn as an indented .adm-row. There is no
   `.div-row` companion class: the top-level case is a plain .adm-row and a second class
   setting the border .adm-row already sets would be dead weight (D5's no-op, in CSS).
   The indent is a border plus padding rather than a margin, so the guide line lands
   inside the row's own box and the grid above it keeps its columns. */
.div-child { padding-left: 18px; border-left: 2px solid var(--border); }
/* Each level steps further in. Without these, every depth shared `.div-child`'s single
   indent and a division sitting UNDER another one drew level with it — the tree read as
   flat and nobody could tell a state office from the branch above it. */
.div-child2 { padding-left: 36px; }
.div-child3 { padding-left: 54px; }
.appr-act, .set-act, .wt-act, .stf-act, .own-act {
  display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
}
.adm-do input, .adm-do select { font-size: 12px; padding: 3px 6px; max-width: 150px; }
/* The reason a setting changed is REQUIRED and is read months later, so it gets its own
   full line inside the cell instead of a 150px box shared with the number. Written as
   `.adm-do .st-why` deliberately: the cap above is `.adm-do input` (0,1,1) and a bare
   `.st-why` (0,1,0) would lose to it silently (D11). */
.adm-do .st-why { max-width: none; flex: 1 1 100%; }
.ap-msg, .st-msg, .wt-msg, .stf-msg, .own-msg { font-size: 12px; color: var(--muted); }

/* --- who may do what, and who can see what (C29 step 4) -------------------
   One grid shared by the heading and every row (D12). The cost of a wrong
   setting gets its own full line under the name rather than a shared cell,
   because it is the sentence that stops somebody flipping a switch (D19). */
.cap-cols, .cap-row {
  display: grid;
  /* The third track holds the reason box, which is REQUIRED and is what somebody reads
     months from now — it gets enough width to be read while it is typed, rather than a
     240px slot that truncates its own placeholder (D19, seen on a screenshot). */
  grid-template-columns: minmax(220px, 1.5fr) minmax(190px, 1.3fr) minmax(300px, 1.5fr);
  gap: 10px; align-items: start;
  padding: 8px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.cap-cols {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); font-weight: 600;
}
.cap-grp {
  margin: 16px 0 2px; font-size: 12px; font-weight: 600; color: var(--primary-text);
}
.cap-grp:first-child { margin-top: 0; }
.cap-label { font-weight: 600; }
.cap-cost { display: block; font-size: 11px; font-weight: 400; color: var(--muted); margin-top: 2px; }
.cap-holders { display: flex; gap: 4px; flex-wrap: wrap; }
.cap-act { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
/* The controls sit inside their own element because that element carries the capability
   name for the click handler. It has to BE the flex container: while `.cap-act` was the
   only one, the reason box was a grandchild and therefore not a flex item, so `flex:
   1 0 100%` on it did nothing at all and it truncated its own placeholder on the line
   beside the dropdown. Found by looking at it, not by a failing check (D14). */
.cap-set { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; width: 100%; }
.cap-set select, .cap-set input { font-size: 12px; padding: 3px 6px; min-width: 0; }
/* `1 1 100%` would not be enough even here — a shrinkable 100% basis still fits on the
   line. `1 0 100%` refuses to shrink, so the reason always gets its own full-width line.
   It is REQUIRED and it is what somebody reads months from now (D19). Written as
   `.cap-set .cap-why` (0,2,0) deliberately: a bare `.cap-why` (0,1,0) would lose to the
   `.cap-set input` cap above, silently (D11). */
.cap-set .cap-why { flex: 1 0 100%; max-width: none; }
.cap-msg { font-size: 12px; color: var(--muted); flex: 1 0 100%; }

.chg-row {
  display: grid;
  grid-template-columns: 150px minmax(200px, 1.3fr) 110px minmax(220px, 1.8fr);
  gap: 10px; align-items: start;
  padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.chg-when { color: var(--muted); font-size: 12px; }
.chg-what { font-weight: 600; }

.vis-cols, .vis-row {
  display: grid;
  grid-template-columns: minmax(160px, 1fr) minmax(240px, 1.6fr) minmax(200px, 1.4fr);
  gap: 10px; align-items: start;
  padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.vis-cols {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); font-weight: 600;
}
.vis-table { font-weight: 600; }
.vis-unknown { background: var(--warn-bg); }
.vis-raw { display: block; font-size: 11px; color: var(--muted); word-break: break-word; }

/* --- the weekly brief ----------------------------------------------------- */
.brief-body { max-width: 760px; font-size: 14px; line-height: 1.55; }
.brief-body h2 { font-size: 20px; margin: 0 0 12px; color: var(--primary-text); }
.brief-body h3 {
  font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); margin: 20px 0 6px;
}
.brief-body h4 { font-size: 13px; margin: 14px 0 4px; }
.brief-body p { margin: 0 0 8px; }
.brief-body ul { margin: 0 0 10px 18px; padding: 0; }
.brief-body li { margin-bottom: 3px; }
.brief-body hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; }

.crm-toast {
  position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
  background: var(--toast-bg); color: var(--on-primary); border-radius: var(--radius);
  padding: 9px 16px; font-size: 13px; z-index: 60; box-shadow: var(--shadow-md);
  max-width: 90vw;
}

/* --- the two CRM boards that outgrew their own breakpoint -------------------
   Both of these are wide grids that stacked only at 720px, and neither had ever
   been looked at by a gate: `layoutcheck` registered no CRM screen at all until
   the CRM surfaces were added, so nothing in this project had ever measured
   them. The moment something did, both turned out to be broken across the whole
   band of widths people actually work at.

   The numbers are MEASURED, and the measurement is why they are not what the
   arithmetic said. `.cost-row` is eight tracks — 110 + 150 + four money columns
   at 120 + 90 + 200, plus seven 10px gaps — which comes to 1100 and suggests a
   breakpoint of 1150. At 1151 it still overflowed by 39px, so the number here is
   1200. Broken as found: 123px of overflow at 1024, 247px at 900, 379px at 768.
   `.adm-row` is five tracks whose minimums come to 880 plus 40 of gaps; 1010
   holds, proved at every width from 1512 down to 390. Broken as found: 40px at
   970, 199px at 768.

   Arithmetic gets you the first guess. A width sweep is what settles it — and
   the first guess was wrong by 50px on the board that carries the money.

   What that COST, which is why neither is merely cosmetic: at 1024 the costing
   board's last column is the one holding "Enter cost" and the figure Finance
   types into it, and it was off the right-hand edge of the screen. The Settings
   band's last column is where a setting is changed and a staff level confirmed.
   Both are the working end of their screen.

   The fix is not to stack them — a laptop has the room, and folding an eight
   column board into one is a worse screen than a slightly taller one. Each keeps
   its columns and moves the two widest cells onto a line of their own. The
   figures stay in step with each other, which is the whole point of a money
   board: four columns you can read down.

   This block sits BEFORE the phone block deliberately. Both match under 720px
   and the later rule wins, so the phone layout has to come second or these would
   silently undo it. Same reason the C29 bands got their own number rather than a
   second copy of the phone block (D20). */
@media (max-width: 1200px) {
  .cost-row {
    grid-template-columns: 110px minmax(120px, 1.6fr) repeat(4, minmax(105px, 1fr));
    row-gap: 6px;
  }
  .cost-status { grid-column: 1 / 3; }
  .cost-do { grid-column: 3 / -1; }
}

@media (max-width: 1010px) {
  .adm-row {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 150px;
    row-gap: 6px;
  }
  /* the direct child only — `.adm-sub` is also used INSIDE `.adm-what` and
     `.adm-val`, where it is not a grid item and this would mean nothing */
  .adm-row > .adm-sub, .adm-do { grid-column: 1 / -1; }
}

/* --- phone: readable, never hidden ---------------------------------------- */
@media (max-width: 720px) {
  .tile-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* ⚠ ROOM ON THE PHONE — Shaf, 12 Aug 2026: "too tight". Measured before changing
     anything: rows 34px tall, 7px of vertical padding, 2px between a row's own two
     lines, and 0px between one row and the next.

     Two of the three are standards, not taste, which is why this is not a density
     drift:
       · those rows are TAPPABLE — they open the deal — and 34px is under the 44px
         tap minimum this same block sets for every other control. 12px of padding
         over a 20px line is exactly 44.
       · 2px inside a row against 0px between rows means a row's own two lines are
         no more grouped to each other than to the NEXT row's. That is why it reads
         as a wall rather than a list: the eye has no way to bundle a pair. 6px in,
         a hairline out, and the pairs separate.

     ⚠ WHAT THIS DOES NOT DO IS REOPEN "LAPANG". That variant was built, measured at
     +29% row height on a 74-row DESKTOP table, and rejected — and the rule left
     behind was "do not drift padding upward one screen at a time". This is inside
     `@media (max-width: 720px)`, desktop is untouched, and it is a phone judgement
     Shaf named himself. `themecheck` holds the desktop line independently. */
  .att-row { grid-template-columns: 1fr; gap: 6px; padding: 12px 4px; }
  #attList .att-head { margin-top: 26px; }
  #attList .att-why { margin: 0 0 12px; }
  /* ⚠ Matching the multi-class shapes deliberately. `.att-row.att-grouped` is
     (0,2,0) and the bare `.att-row` above is (0,1,0); `@media` contributes NOTHING
     to specificity, so without these two lines the desktop grid would win on a
     phone and the row would draw at two columns inside 390px. That trap has broken
     a real screen on this stylesheet before. */
  /* ⚠ THE GAP IS REPEATED HERE AND THAT IS NOT REDUNDANCY. These two are (0,2,0)
     and (0,3,0); the `.att-row` rule above is (0,1,0), so setting the gap there
     alone left every GROUPED row — which is every row on this screen — still at
     2px. The measurement said 2px while the stylesheet said 6px, which is the
     only reason it was caught. Same trap this file warns about three rules up,
     walked into by the person who wrote the warning. */
  .att-row.att-grouped { grid-template-columns: 1fr; gap: 6px; }
  .att-row.att-grouped.att-solo { grid-template-columns: 1fr; gap: 6px; }
  .act-row { grid-template-columns: 1fr; gap: 2px; }
  .kpi-row { grid-template-columns: 1fr auto; gap: 4px 10px; }
  .kpi-basis, .kpi-target { grid-column: 1 / -1; }
  .kpi-value { text-align: left; }
  .flow-col { flex-basis: 84vw; }
  .deal-kv { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* `.cost-row` used to stack here. It stacks at 820 now, with the other boards
     whose columns are wider than this screen — see the block above. */
  .alloc-cols { display: none; }
  /* 🛑 THIS WAS THE DEAL SCREEN'S SIDEWAYS PAGE SCROLL — NOT the quotations
     table, which the slice-11 design named and which turned out to contribute
     nothing at all until it was drawn. Measured at 390px: two `1fr` tracks
     resolved to 291px and 139px inside a 320px row, because a grid track's
     default minimum is MIN-CONTENT (D21) and the first track holds a <select>
     whose width is decided by the longest staff name — data that is not ours to
     choose. `minmax(0, 1fr)` is the rule doctrine already states for exactly
     this, and `.person-row select` two rules down has carried `min-width: 0`
     since it was written. One instance of a shared idea was fixed and the other
     was not, which is the shape D20 exists to catch.
     ⚠ Nothing about the commission WRITE changes here — this is where the
     controls sit at a narrow width, not what they send. */
  .alloc-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .alloc-row select, .alloc-row input { min-width: 0; }

  /* ══ THE MONEY REGION MEETS THE PHONE FLOORS — slice 3, 22 Aug 2026 ══
     Shaf put the deal record on N7's phone standard on 21 Aug (decision 7). The first
     sweep of the whole record, once a gate existed to do it, found 34 of 68 controls
     under the 44px tap floor; nineteen of them are these — the commission split's
     percentage boxes and note, the four role-share boxes, the three closing figures and
     the six win-document fields. Every one is a control that decides or records MONEY,
     on the screen the team runs the business on.
     ⚠ HEIGHT IS THE MISSING AXIS HERE, and it is the opposite of the usual miss. The
     widths were already solved — `.alloc-row` got `minmax(0, 1fr)` when it was the deal
     screen's sideways page scroll — so nobody ever asked how TALL these were: a text
     input is sized by its own line-height and lands at 30px, and `.al-pct` at 30px is a
     percentage of somebody's commission behind a target two-thirds of a finger.
     ⚠ `--fs-input` is already 16px in this block, which is the iOS zoom floor, but three
     controls carry their own smaller size and override it — those are named below rather
     than swept up by a blanket rule, because a blanket rule would also move controls on
     screens nobody has named for a phone (the mistake `body { font-size }` made here).
     ⚠ `e2e_deal`'s phone sweep measures min(width, height) on the real elements, so these
     are enforced rather than declared (D33), and each name comes off its ratchet only
     when the sweep proves it clear. */
  .alloc-row select, .alloc-row input, .al-pct { min-height: 44px; }
  #crmDeal .actions input { min-height: 44px; }
  #splitEdit input, #splitEdit select { min-height: 44px; }
  #cfClosing, #cfTcv, #cfCost { min-height: 44px; }
  #wvType, #wvRef, #wvDate, #wvAttach, #wvTcvEx, #wvTcvInc { min-height: 44px; }
  /* SLICE 4's region — the scored inputs and the two forward-looking judgements. Eleven
     controls, every one of them an input to the score, so a mis-tap here moves a number
     the pipeline forecast is built on. `#dealEdit` is the one panel that
     holds all eleven — including the two forward-looking judgements, which sit in the same
     renderer — so it is named rather than the ids listed one by one — a list of eleven ids is a list
     that goes stale the day a twelfth is added (D35). */
  #dealEdit select, #dealEdit input { min-height: 44px; }
  /* The three that set a font size of their own and therefore trip the 16px zoom floor. */
  /* ⚠ `#dealMoveTo` LEFT THIS LIST ON 22 Aug 2026 WITH THE DROPDOWN IT NAMED. The stage
     control is buttons now, and a rule scoped to an id no page emits can never match — it
     reads as coverage while covering nothing (D33). The two that remain are real inputs. */
  #splitReason, #allocNote { font-size: var(--fs-input); }
  /* A flex item does not shrink below its own min-content, so the note box and
     the Save button beside it pushed the PAGE sideways rather than the strip.
     Scoped to the deal screen: `.actions` is worn app-wide and a global wrap
     would rearrange strips nobody asked about. */
  #crmDeal .actions { flex-wrap: wrap; }
  #crmDeal .actions input { min-width: 0; flex: 1 1 150px; }
  .adm-row { grid-template-columns: 1fr; gap: 4px; }
}

/* The C29 bands stack EARLIER than the rest of the phone rules, and the number is
   arithmetic rather than taste: three tracks whose minimums are 220 + 190 + 300,
   plus two 10px gaps and the panel's own padding, need about 780px of window. Below
   that the grid stops shrinking — a track's default minimum is its content (D21) —
   and the panel pushes the whole page sideways. Measured at 30px of overflow on the
   capability board at 768, which is an iPad held upright.
   Stacking at 720 like everything else left a band of widths between 721 and ~790
   broken; 820 clears it with room. Same shape as the production header defect still
   waiting to be promoted: a breakpoint set for the content of the day, outgrown by
   the content that arrived later. These three grids are wider than the rest of this
   screen, so they get their own number — not a second copy of the block above (D20). */
@media (max-width: 820px) {
  .cap-cols, .vis-cols { display: none; }
  .cap-row, .chg-row, .vis-row { grid-template-columns: 1fr; gap: 4px; }
  /* The costing board joins them: its six-column form still needs about 790px,
     so stacking it at 720 with the phone rules left 721–790 pushing the page
     sideways — measured at 22px at 768, the same iPad-held-upright width that
     found the C29 bands.
     The two spans are reset EXPLICITLY. `grid-column: 3 / -1` from the block
     above is still in force here, and against a two-column grid line 3 is the
     end of it — which puts the controls in an implicit column rather than where
     anybody decided. It measured 0 overflow either way, which is exactly the
     kind of accident that survives a gate and surprises a person later. */
  .cost-row { grid-template-columns: 1fr 1fr; gap: 4px 10px; }
  .cost-status, .cost-do { grid-column: 1 / -1; }
}

/* =========================================================================
   C13 · the visual layer — icons, the stage rail, tile comparators,
   the split bar and the explained empty state.
   Every rule here uses tokens; not one raw hex is added.
   ========================================================================= */

/* ---- icons ----------------------------------------------------------------
   Sized in em so an icon always matches the text it sits beside, and coloured by
   currentColor so it inherits whatever the label inherits. Never given a colour
   of its own: an icon that carries meaning by colour alone is the thing doctrine
   already forbids of colour. */
.ico { width: 1.05em; height: 1.05em; flex: none; vertical-align: -0.16em; }
.ico-lg { width: 1.35em; height: 1.35em; vertical-align: -0.28em; }
.ico-sprite { display: none; }

/* ---- the stage rail --------------------------------------------------------
   Where is this deal, and what is next. The eleven steps are DATA (deal_stages),
   so the rail renders whatever the table holds and never hard-codes a stage.
   Three states only, and each is legible without colour: done carries a tick,
   now is filled and bold, still-to-come is hollow and muted. */
/* ⚠ THE RAIL SCROLLS INSIDE ITSELF, AND THAT IS CORRECT — twelve stages do not
   fit a narrow window and never will. It is NOT the 59px page scroll the deal
   screen had; that was the quotations table with no container, and the two were
   very nearly "fixed" as one thing. What was missing here is only that nothing
   SAID it scrolls, so on a phone the rail looked truncated rather than scrollable.

   🛑 BUILT TWICE, AND THE FIRST VERSION IS THE LESSON. It used the textbook
   four-layer `background-attachment: local` scroll shadow, which is the right
   technique in general and was WRONG HERE: `contrastcheck` walks up from every
   glyph compositing backgrounds, and the moment an ancestor carries a
   `background-image` it reports the text as UNMEASURABLE rather than guessing a
   pixel. Putting a gradient on the scroller took ALL TWELVE STAGE LABELS out of
   the contrast gate's sight — 8 unmeasurable sites became 20 — and the gate
   still printed green, because unmeasurable is not a failure. A change that
   blinds a gate to a dozen text sites, in exchange for an edge shadow, is a bad
   trade made silently (D28).

   So the edge is an INSET BOX-SHADOW instead, which paints over the background
   and under the content and leaves every label measurable exactly as before.
   ⚠ And it is drawn only when the rail REALLY scrolls: `renderRail()` measures
   scrollWidth against clientWidth and sets `.rail-scrolls`, so a rail that fits
   shows no edge at all (D5 — a mark that says "there is more this way" when
   there is not is a control that cannot deliver what it offers).
   ⚠ ZERO NEW COLOURS: `--border` is the token this app already uses for the edge
   of a thing, and it flips with the theme.
   🛑 AND IT SITS ON THE PANEL, NOT ON THE SCROLLER, WHICH IS THE SECOND THING
   MEASURING CHANGED. Put on `.rail` the shadow would tint the outer ~9px of the
   scrolling strip — and glyphs DO render there: a scroll container clips at its
   PADDING box, so scrolled content passes straight through any padding meant to
   protect it. That matters because the numbers are not comfortable. Computed,
   not assumed: `--muted` on `--panel` is 4.96:1 light and 7.11:1 dark, but
   `--muted` on `--border` is **3.89:1 light — under the 4.5 floor**. A stage
   label caught in the darkest part of that tint would have been unreadable, in
   the theme most people use, with every gate green.
   `.panel` carries 22px of padding, and that padding is OUTSIDE the scroller —
   nothing can ever render in it. So the edge is drawn there, it can never land
   behind text, and the worst ground behind any label stays exactly `--panel`.
   ⚠ `--shadow-sm` is repeated because `box-shadow` replaces rather than adds;
   dropping it would silently flatten the panel. */
.rail {
  display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding: 4px 0 2px;
}
#dealRail.rail-scrolls {
  box-shadow: var(--shadow-sm),
    inset 10px 0 8px -8px var(--border),
    inset -10px 0 8px -8px var(--border);
}
.rail-step { display: flex; flex-direction: column; align-items: center; min-width: 84px; flex: 1 1 0; position: relative; }
.rail-step::before { content: ""; position: absolute; top: 11px; left: -50%; width: 100%; height: 2px; background: var(--border); }
.rail-step:first-child::before { display: none; }
.rail-dot { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border);
            background: var(--panel); display: flex; align-items: center; justify-content: center;
            font-size: 11px; color: var(--muted); position: relative; z-index: 1; }
.rail-label { font-size: 11px; line-height: 1.3; text-align: center; margin-top: 6px; color: var(--muted); padding: 0 4px; }
.rail-sub { display: block; font-size: 10px; color: var(--muted); }
.rail-done .rail-dot { border-color: var(--ok); background: var(--ok); color: var(--on-primary); }
.rail-done .rail-label { color: var(--text); }
.rail-done::before { background: var(--ok); }
.rail-now .rail-dot { border-color: var(--primary-text); background: var(--primary); color: var(--on-primary); box-shadow: 0 0 0 4px var(--ok-bg); }
.rail-now .rail-label { color: var(--primary-text); font-weight: 700; }
.rail-todo .rail-dot { border-style: dashed; }
.rail-off .rail-dot { border-color: var(--bad-text); background: var(--bad-bg); color: var(--bad-text); }
.rail-off .rail-label { color: var(--bad-text); }
/* c158 — a stage with no recorded arrival that sits BEHIND the deal's own progress:
   it may have happened before this system existed. It must not tick (that would claim
   work) and must not read as "never happened" either, so it carries a dash and says
   "not recorded" in words — the sentence is the honest part, and unlike a colour it is
   something a gate and a screen reader can both read. */
.rail-unknown .rail-dot { border-style: dashed; color: var(--text-3); }
.rail-unknown .rail-label { color: var(--muted); }
/* A PAUSE is not an ending. On Hold left the forward path when c153a made it count as
   open pipeline; it is drawn here, in the warning family rather than the bad one. */
.rail-hold .rail-dot { border-color: var(--warn-text); background: var(--warn-bg); color: var(--warn-text); }
.rail-hold .rail-label { color: var(--warn-text); }

/* ---- tile comparator -------------------------------------------------------
   A figure with no reference point cannot be acted on. The tile already had a
   note slot; this is what belongs in it. Direction is a word AND a mark, never a
   colour on its own. */
.tile-cmp { display: flex; align-items: baseline; gap: 8px; font-size: 12px; margin-top: 2px; flex-wrap: wrap; }
.cmp-up   { color: var(--ok-text); font-weight: 700; }
.cmp-down { color: var(--alert-text); font-weight: 700; }
.cmp-flat { color: var(--muted); font-weight: 700; }
.cmp-of   { color: var(--muted); }
.meter { height: 5px; border-radius: 3px; background: var(--row-band); overflow: hidden; margin-top: 7px; }
.meter-fill { height: 100%; background: var(--primary-light); border-radius: 3px; }
.spark { display: block; width: 100%; height: 26px; margin-top: 8px; color: var(--primary-light); }

/* ---- split bar (part-to-whole, by person) ---------------------------------
   A 2px surface gap between segments so adjacent fills never touch, and every
   segment is direct-labelled underneath — which is what makes the two lower
   contrast series slots legal here. */
.split-bar { display: flex; gap: 2px; height: 14px; border-radius: 3px; overflow: hidden; background: var(--row-band); }
.split-seg { height: 100%; }
.split-s1 { background: var(--series-1); }
.split-s2 { background: var(--series-2); }
.split-s3 { background: var(--series-3); }
.split-s4 { background: var(--series-4); }
.split-keys { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 9px; font-size: 12px; }
.split-key { display: flex; align-items: center; gap: 6px; }
.split-chip { width: 10px; height: 10px; border-radius: 2px; flex: none; }
.split-name { color: var(--text); }
.split-pct { color: var(--muted); }

/* ---- the explained empty state --------------------------------------------
   D25's lesson made into a component: an empty screen is the most expensive
   failure there is, because nobody reports it as a bug — they report it as "the
   system doesn't have my data yet". So an empty state states the CAUSE and the
   thing that would fill it. Never a bare dash. */
.empty { text-align: center; padding: 26px 20px 24px; }
.empty .ico { color: var(--muted); opacity: 0.55; }
.empty-what { margin: 8px 0 2px; font-size: 14px; font-weight: 700; color: var(--text); }
.empty-why { margin: 0 auto; max-width: 48ch; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.empty-act { margin-top: 12px; }

/* =========================================================================
   C13b · progress that motivates without lying — the team thermometer,
   the hygiene streak, and personal milestones.
   Same rule as the meter above: the FILL is neutral. A bar cannot judge
   itself, so the pace marker and the words do the judging (D4).
   ========================================================================= */

/* ---- the shared thermometer ------------------------------------------------
   One target, one bar, everybody looking at the same thing. Deliberately NOT a
   ranking: partnership and referral are ~71% of revenue, so a component that
   ranks individuals would reward exactly the hoarding that kills the way this
   business actually wins. Personal progress uses the same bar at `.thermo-sm`,
   and RLS already keeps it personal — a session reads its own rows plus team. */
.thermo { padding: 2px 0 4px; }
/* 20px, not 8: the pace marker's label sits above the track, and at a high pace it
   would otherwise ride up into the figures on the right. Reserved space costs
   nothing and removes a collision that only appears late in a period. */
.thermo-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.thermo-label { font-size: 13px; font-weight: 700; color: var(--text); }
.thermo-figs { font-size: 12.5px; color: var(--muted); }
.thermo-now { font-size: 20px; font-weight: 700; color: var(--primary-text); }
.thermo-track { position: relative; height: 22px; border-radius: 11px; background: var(--row-band); overflow: visible; }
.thermo-fill { height: 100%; border-radius: 11px; background: var(--primary-light); }
/* The marker is where the team SHOULD be by now. It is the whole point: the bar
   stays neutral and the reader compares against a line, instead of the component
   deciding that 62% is good or bad without knowing how much of the period is gone. */
/* The surface ring is what lets the marker read on BOTH the empty track and the
   filled bar. Without it the marker is dark ink on a dark fill the moment the team
   passes pace — which is exactly when somebody most wants to see it. */
.thermo-pace { position: absolute; top: -5px; bottom: -5px; width: 2px; background: var(--text);
               box-shadow: 0 0 0 1.5px var(--panel); }
.thermo-pace::after { content: attr(data-pace); position: absolute; top: -17px; left: 50%; transform: translateX(-50%);
                      font-size: 10.5px; font-weight: 700; color: var(--text); white-space: nowrap; }
.thermo-foot { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
.thermo-sm .thermo-track { height: 14px; border-radius: 7px; }
.thermo-sm .thermo-fill { border-radius: 7px; }
.thermo-sm .thermo-now { font-size: 16px; }

/* ---- the hygiene streak ----------------------------------------------------
   Deliberately counts a BEHAVIOUR nobody can self-report: a clock is the judge.
   30 of 45 deals are stalled, so this is the number that actually needs moving.
   A broken streak states what broke it and what would restart it — it never
   just shows a smaller number, because a score that only shames gets ignored. */
.streak { display: flex; gap: 16px; align-items: flex-start; }
.streak-fig { flex: none; width: 74px; text-align: center; padding: 8px 0 6px; border-radius: var(--radius); background: var(--ok-bg); }
.streak-num { display: block; font-size: 26px; font-weight: 700; line-height: 1; color: var(--ok-text); }
.streak-unit { display: block; font-size: 11px; color: var(--ok-text); margin-top: 3px; }
.streak-broke .streak-fig { background: var(--warn-bg); }
.streak-broke .streak-num, .streak-broke .streak-unit { color: var(--warn-text); }
.streak-body { flex: 1 1 auto; min-width: 0; }
.streak-title { font-size: 13px; font-weight: 700; color: var(--text); margin: 2px 0 3px; }
.streak-rule { font-size: 12.5px; color: var(--muted); line-height: 1.55; margin: 0 0 9px; }
.streak-dots { display: flex; gap: 4px; flex-wrap: wrap; }
.streak-dot { width: 13px; height: 13px; border-radius: 3px; background: var(--row-band); }
.streak-on { background: var(--ok); }
.streak-off { background: var(--warn-text); }

/* ---- milestones ------------------------------------------------------------
   One-off and dated, never a cumulative score. There is nothing here to farm:
   a first win happens once. */
.mstone-row { display: flex; gap: 8px; flex-wrap: wrap; }
.mstone { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 999px;
          border: 1px solid var(--border); background: var(--panel); font-size: 12.5px; color: var(--text); }
.mstone-when { color: var(--muted); font-size: 11.5px; }
/* An unearned milestone must not look like an earned one. A dash in the date slot
   is not enough — at a glance the two chips read the same, which is the same class
   of mistake as a control whose state change is invisible (D5). */
.mstone-todo { border-style: dashed; color: var(--muted); background: transparent; }

/* ---- the hint button and its panel -----------------------------------------
   The second layer. Text that explains WHY lives here; text that tells somebody
   what to DO stays on the screen. The button is deliberately quiet — it sits at
   the end of a line and does not compete with the number it belongs to. */
/* 🛑 `span.hint`, NOT `.hint` — ONE CLASS WAS SERVING TWO UNRELATED COMPONENTS, and the
   collision was PAINTING WRONG on a screen everybody opens. 18 Aug 2026.
   This rule belongs to the info-button widget (`hint.js`): a <span> wrapping a `.hint-btn`
   and an absolutely-positioned `.hint-pop`, which is why it needs `position: relative` and
   `inline-flex`. But `.hint` is ALSO the app's "muted explanatory paragraph" — 59
   `<p class="hint">` across both pages and eight CRM parts.
   Making those paragraphs flex containers turns each contiguous run of text into its own
   anonymous flex item, and leading/trailing white space in a block box is STRIPPED — so the
   Dashboard's sentence painted as `65 more underNeeds a lookin My work.` The template was
   correct all along; every space was there in the source.
   ⚠ The discriminator is the ELEMENT, not the tag alone: `index.html` carries text-only
   `<span class="hint">` too, and they keep the widget's inline-flex harmlessly because a
   single text run has nothing to strip between. A `.hint-btn`-based selector was considered
   and rejected — it cannot be expressed in CSS without `:has()`, which this file does not
   otherwise rely on.
   ⚠ Specificity rises (0,1,0) → (0,1,1); nothing else in either stylesheet sets `display`
   on `.hint`, so no cascade flips. */
span.hint { position: relative; display: inline-flex; vertical-align: -0.1em; }
.hint-btn { border: 0; background: none; padding: 0 2px; margin: 0; cursor: pointer;
            color: var(--muted); line-height: 1; display: inline-flex; border-radius: 3px; }
.hint-btn:hover { color: var(--primary-text); }
.hint-btn:focus-visible { outline: 2px solid var(--primary-light); outline-offset: 2px; }
.hint-pop { position: absolute; top: calc(100% + 7px); left: -6px; z-index: 40;
            width: 268px; padding: 11px 13px; border-radius: var(--radius);
            background: var(--panel); border: 1px solid var(--border);
            box-shadow: var(--shadow-md); font-size: 12.5px; line-height: 1.6;
            color: var(--muted); font-weight: 400; text-align: left; }
.hint-pop-title { display: block; color: var(--text); font-size: 12.5px; margin-bottom: 3px; }
.hint-left { left: auto; right: -6px; }

/* =========================================================================
   C14 · the four-menu shell. Navigation is by OBJECT (Home, Deals, Delivery,
   Catalogue) because objects stay still while processes change; the WORK
   lives in one inbox under Home rather than being scattered across the bar.
   ========================================================================= */
.nav-btn .ico { margin-right: 6px; }
/* Catalogue is an <a> because it leaves this page; it must still LOOK like the
   three buttons beside it. An underline on one of four menu items reads as a
   different kind of thing, and it is not one. */
a.nav-btn { text-decoration: none; display: inline-flex; align-items: center; }
/* The secondary bar. It belongs to the primary above it, so it sits on the page
   background rather than the header — a second dark bar reads as a second app. */
.nav2 { display: flex; gap: 2px; align-items: center; padding: 0 24px;
        background: var(--panel); border-bottom: 1px solid var(--border); }
/* Found by class, never by a generated id. The secondary bar is rebuilt on every
   navigation, so an id written into it exists in no page the consistency gate can
   read — and uicheck was right to fail the build for it. A class the gate can see
   beats an id it cannot. */
.nav2-badge { }

/* Profile, notifications and settings live under the person, not in the main bar:
   they are things you do to YOUR account, not places the business lives. */
.umenu { position: relative; }
.umenu-btn { background: rgba(255,255,255,0.10); border: 0; color: var(--on-primary); font: inherit; font-size: 13px;
             padding: 6px 11px; border-radius: 999px; cursor: pointer; display: inline-flex;
             align-items: center; gap: 7px; }
.umenu-btn:hover { background: rgba(255,255,255,0.18); }
.umenu-pop { position: absolute; right: 0; top: calc(100% + 8px); z-index: 50; min-width: 210px;
             background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
             box-shadow: var(--shadow-md); padding: 6px; text-align: left; }
.umenu-who { padding: 8px 10px 9px; border-bottom: 1px solid var(--border); margin-bottom: 5px; }
.umenu-name { display: block; font-size: 13px; font-weight: 700; color: var(--text); }
.umenu-role { display: block; font-size: 11.5px; color: var(--muted); }
.umenu-item { display: flex; align-items: center; gap: 9px; width: 100%; background: none; border: 0;
              font: inherit; font-size: 13px; color: var(--text); padding: 8px 10px; border-radius: 4px;
              cursor: pointer; text-align: left; text-decoration: none; }
.umenu-item:hover { background: var(--row-band); }

/* ---- the work inbox --------------------------------------------------------
   One place, role-scoped, and it counts what is waiting on YOU. A group with
   nothing in it is not drawn at all; when every group is empty the whole thing
   says so in words rather than showing five empty headings. */
.work-group { margin-bottom: 20px; }
.work-head { display: flex; align-items: baseline; gap: 9px; margin: 0 0 9px; }
.work-title { font-size: 13px; font-weight: 700; color: var(--text); }
.work-count { font-size: 11.5px; color: var(--muted); }
.work-row { display: grid; grid-template-columns: 132px 1fr auto; gap: 12px; align-items: baseline;
            padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.work-row:last-child { border-bottom: 0; }
.work-what { color: var(--text); }
.work-sub { display: block; font-size: 11.5px; color: var(--muted); }

/* =========================================================================
   C14b · the shell on a phone.

   N7 held only Approvals to a phone standard, and said a new screen joins it
   "when someone names the job it does there". My work is now the LANDING
   screen — the first thing anyone sees, including on a phone — and Shaf named
   the job on 3 Aug by opening it on one: see what is waiting on me. So the
   shell and the inbox join the standard; the rest of the CRM stays
   desktop-shaped by design.
   ========================================================================= */
@media (max-width: 720px) {
  /* Brand and account on the first line, the four objects on the second as a
     SCROLLER. Wrapping the menu produced a ragged two-and-a-bit rows of dark
     bar; a scroller keeps one line at any width and any number of menus. */
  .topbar { flex-wrap: wrap; gap: 8px 0; padding: 10px 12px 0; }
  .topbar .brand { flex: 1 1 auto; }
  .topbar-meta { flex: 0 0 auto; }
  /* `.crm-page .main-nav` (0,2,0) already sets flex-wrap: wrap further up the file,
     and a media query adds NO specificity — so a bare `.main-nav` in here silently
     lost and the four objects went on wrapping. Matched, deliberately. */
  .crm-page .main-nav,
  .main-nav { order: 3; flex: 1 1 100%; margin-left: -12px; margin-right: -12px;
              padding: 2px 12px 4px; overflow-x: auto; flex-wrap: nowrap;
              scrollbar-width: none; }

  .brand { gap: 8px; min-width: 0; }
  .main-nav::-webkit-scrollbar { display: none; }
  .nav-btn { white-space: nowrap; flex: none; }

  /* The full legal name does not fit beside a chevron at 390px. */
  .umenu-btn { max-width: 46vw; }
  .umenu-btn #crmWho { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  .nav2 { padding: 0 12px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none; }
  .nav2::-webkit-scrollbar { display: none; }

  /* Three columns at 390px gave the deal name about 90px, so a normal name wrapped
     to four lines and the action wrapped to three and fell off the right edge.
     Stacked, every row reads in one pass. */
  .work-row { grid-template-columns: 1fr; gap: 3px; padding: 12px 0; }
  .work-row .mono { font-size: 11.5px; color: var(--muted); }
  .work-do { justify-self: start; }
  .crm-layout { padding-left: 12px; padding-right: 12px; }
  .rail-step { min-width: 72px; }

  /* The dashboard. Two tiles across at 390px leaves about 150px of content, and a
     seven-figure ringgit figure at 24px needs 200 — so the number was set to
     shrink rather than the row to overflow. The label and the basis keep their
     size; only the figure moves, because it is the only part that cannot wrap
     without becoming unreadable. */
  .tile { padding: 11px 12px; }
  .tile-value { font-size: 18px; }
  .tile-label, .tile-note { font-size: 11.5px; }

  /* The stage table scrolls sideways inside its panel instead of stretching the
     page. `max-height` off the viewport made a short table look truncated. */
  .browse-tablewrap { max-height: none; }
  .crm-table { min-width: 460px; }
}

/* The action a row is asking for. On a desktop it sits at the end of the row in
   muted ink; on a phone it drops to its own line and has to look like the thing
   you tap, so it carries the primary colour rather than being a grey aside. */
.work-do { color: var(--muted); font-size: 12.5px; }
@media (max-width: 720px) { .work-do { color: var(--primary-text); font-weight: 600; } }

/* ============================================================================
   THE PHONE TYPE SCALE — THEME_TIER1_PLAN §1a, built 12 Aug 2026 on Shaf's go.

   ⚠ INSIDE THIS MEDIA QUERY ONLY. Desktop does not move, and `themecheck` now
   guards that: it digests every BASE font-size declaration and fails if one
   changes, while deliberately excluding declarations inside @media — which is
   exactly where the scale is supposed to differ.

   ⚠ WHY THE INPUT SELECTORS ARE REPEATED RATHER THAN COLLAPSED INTO ONE RULE.
   `input, select, textarea { … }` is (0,0,1) and loses to `.field input`
   (0,1,1) — and a media query adds NO specificity of its own, so it would lose
   silently on the one thing this block exists to fix. The 25 selectors below are
   the REAL ones, read out of this stylesheet rather than typed, so the list
   cannot drift from the rules it overrides. Each is repeated at its own
   specificity and sits later in the file, which is what makes it win.

   ⚠ AND THEY ARE NOT COLLAPSED INTO ONE TOKEN VALUE ON DESKTOP EITHER: those 25
   rules carry nine different sizes today (11.5 → 14px), so pointing them all at
   one token would MOVE desktop for most of them. The token is raised here, where
   a single 16px is the correct answer for every one of them.
   ============================================================================ */
@media (max-width: 768px) {

  :root {
    --fs-base: 16px;
    --fs-table: 15px;
    --fs-small: 12.5px;
    --fs-label: 12px;
    --fs-input: 16px;   /* the iOS zoom floor */
    --tap: 44px;        /* Apple and Android minimum */
  }

  /* ⚠ `body { font-size: var(--fs-base) }` STOOD HERE AND WAS REMOVED, measured.
     Raising the base at ≤768px cascades into EVERY screen, including the ones N7
     deliberately leaves desktop-shaped — and the Clients register is named in N7
     as exactly that: "desktop-shaped by design, deliberately not held to 390px,
     because nobody has named a phone job for it."

     The cost was not theoretical. layoutcheck went 24 → 25 warnings and the new
     one was `crm-accounts @768px — THE PAGE ITSELF scrolls horizontally by 72px`.
     A contained inner scroll is a warn and is fine; the PAGE scrolling sideways is
     the one thing N7 forbids outright. Bigger text made a desktop-shaped table
     wider than the window it was never meant to fit.

     So the scale is applied to the surfaces N7 NAMES and to inputs, and nowhere
     else. That is the same rule N7 states for joining the standard at all: a
     screen joins when somebody names the job it does there, and not by inference
     from a cascade. --fs-base is still used, by .work-title below. */

  /* Every control a finger lands on clears the tap minimum. */
  .btn, .group-btn, .nav-btn, .sb-item, .umenu-item, .modal-close, .btn-icon {
    min-height: var(--tap);
  }

  /* ── The surfaces N7 actually holds to the phone standard ──────────────────
     ⚠ THIS IS DELIBERATELY NOT EVERY TEXT SIZE IN THE APP. 342 font-size
     declarations are still hard-coded, and raising all of them would need a
     mapping — which of them is "table", which is "small", which is "label" —
     that is a DESIGN decision, not a mechanical one. Guessing it would move
     desktop sizes nobody agreed to, and `themecheck` would rightly fail it.

     What is scaled here is the set N7 names, and nothing else: everything else
     is desktop-shaped BY DESIGN and opens on a phone expecting a bigger window.
     My work's two bands, the deal list's cards, and the shell. The rest waits on
     the mapping question, which belongs to Shaf. */
  #attList .att-ref, #attList .att-name { font-size: var(--fs-table); }
  #attList .att-detail, .att-why { font-size: var(--fs-small); }
  .work-row, .work-what { font-size: var(--fs-table); }
  .work-sub, .work-do { font-size: var(--fs-small); }
  .work-title { font-size: var(--fs-base); }
  /* ⚠ #crmLeads ADDED 13 Aug 2026. Leads joined the phone standard in the same
     commit that widened every card rule in crm-shell2.css — and this rule, which
     lives in the OTHER stylesheet, was missed. Measured: a deal card's cell came
     out at 15px and a lead card's at 13.5px, the identical pattern at two sizes.
     ⚠ The second-order effect is the one to remember: `--card-label-w` is a `ch`
     value, and `ch` resolves against the element's OWN font-size — so the Leads
     label column was 102px only because its font was still 13.5px, and fixing
     the type scale moves the token's real width to ~113px. A `ch` token and a
     font-size are coupled; change one and re-measure the other.
     ⚠ The `::before` line below is DEAD and kept only so the next person does not
     re-add it: it has identical specificity to crm-shell2.css's own ::before rule,
     which loads second and therefore wins at 11.5px. Measured 11.5px on both
     surfaces regardless of what this says (D33 — a declaration that cannot WIN). */
  [data-shell="2"] #crmList .crm-table tbody td,
  [data-shell="2"] #crmLeads .crm-table tbody td { font-size: var(--fs-table); }
  [data-shell="2"] #crmList .crm-table tbody td::before { font-size: var(--fs-label); }

  /* ⚠ 16px INPUTS MADE A FILTER BAR PUSH THE PAGE, and the fix is the trap this
     stylesheet already documents. `.browse-controls` wraps already, but a flex
     item's default `min-width: auto` floors it at its own MIN-CONTENT — so a date
     or search field at 16px simply refuses to shrink and the row cannot wrap
     small enough. Measured: `crm-accounts @768px` went from contained to **the
     PAGE itself scrolling sideways by 72px**, which is the one thing N7 forbids
     outright. Releasing the floor is the documented answer, not narrowing the
     type — the 16px is a correctness fix for iOS, and giving it up to save a
     filter bar would be trading a real defect for a cosmetic one. */
  .browse-controls input, .browse-controls select { min-width: 0; max-width: 100%; }

  /* the 25 real input selectors, read from this file */
  .field input, .field select { font-size: var(--fs-input); }
  .bom tbody tr.section-row .pkg-picker select { font-size: var(--fs-input); }
  .contract-years-field input, .rental-duration-field input, .rental-duration-field select { font-size: var(--fs-input); }
  .bom .qty-input, .bom .disc-input, .bom .sst-input, .bom .price-input { font-size: var(--fs-input); }
  .browse-controls input[type="search"] { font-size: var(--fs-input); }
  .browse-controls select { font-size: var(--fs-input); }
  .browse-controls input[type="date"], .browse-controls input[type="number"] { font-size: var(--fs-input); }
  .pf-line input, .pf-line select { font-size: var(--fs-input); }
  .gate-box input { font-size: var(--fs-input); }
  .cl-mini input { font-size: var(--fs-input); }
  .field textarea { font-size: var(--fs-input); }
  .code-wrap input { font-size: var(--fs-input); }
  .modal-key input { font-size: var(--fs-input); }
  .sch-row input, .sch-row select { font-size: var(--fs-input); }
  .sch-sub input[type="text"] { font-size: var(--fs-input); }
  .sch-seg input { font-size: var(--fs-input); }
  .sa-build-row input { font-size: var(--fs-input); }
  .if-sa-type select { font-size: var(--fs-input); }
  .comp-row input { font-size: var(--fs-input); }
  .pool-grid select, .pool-grid input { font-size: var(--fs-input); }
  .cost-act input { font-size: var(--fs-input); }
  .alloc-row select, .alloc-row input { font-size: var(--fs-input); }
  .person-row select { font-size: var(--fs-input); }
  .adm-do input, .adm-do select { font-size: var(--fs-input); }
  .cap-set select, .cap-set input { font-size: var(--fs-input); }
}

/* ---------------------------------------------------------------- sr-only --------
   🛑 THIS DID NOT EXIST UNTIL 17 Aug 2026, AND `uicheck` WAS ALLOW-LISTING IT.
   `sr-only` sat in that tool's ALLOW_UNSTYLED set — the list of "classes the browser or
   a third party owns" — so a class that hides content from sighted readers was exempt
   from the one check that asks whether a used class HAS a rule. Nothing in either
   stylesheet defined it, so anything marked screen-reader-only was FULLY VISIBLE.
   Found by LOOKING at a screenshot (D14): the multi-select's live count rendered as the
   words "none chosen" on screen, colliding with the field guide beside it. No gate could
   have caught it — the class was used, and the gate had been told not to mind.
   ⚠ It is removed from that allow-list in the same change, so it is now checked like any
   other class. An exemption is only safe while somebody else really does own the rule.
   The clip-rect technique rather than `display: none` on purpose: hidden content is not
   announced, and the whole point of this class is to be read aloud. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ============ R3 V2 (2026-08-20): the BQ view builder ============
   The customer's arrangement of the SAVED record, on the quote screen.
   "BQ view" is the on-screen name (Shaf); tables/doors keep the design term.
   Every colour is a token (D17); money is mono + tabular and never wraps;
   grids use minmax(0,1fr) and rows wrap (the .actions lesson). Desktop-shaped
   by design — no phone claim until Shaf names the job (N7). */
.bq-hint { font-size: 12.5px; color: var(--muted); margin: 6px 0; }
.bq-views { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
/* Long view names ellipsize instead of stretching the switcher (D22's long end). */
.bq-view-btn { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bq-newrow { display: inline-flex; gap: 6px; align-items: center; }
.bq-name-in { padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; min-width: 0; flex: 1 1 0; }
.bq-view-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 8px; }
.bq-view-name { font-weight: 600; }
.bq-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.bq-table th {
  text-align: left; padding: 6px 8px; font-size: 10.5px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
  border-bottom: 1px solid var(--border);
}
.bq-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; }
.bq-table th.num, .bq-table td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Column floors keyed to what the column IS, never to its position (the
   checkbox-column scar: an inserted column silently re-aims nth-child). */
/* ⚠ `auto`, NOT a percentage, and that is deliberate. Section and Sub-section
   left this table (R3 V5), so the label is now the only column carrying words and
   it should take whatever the three declared columns leave. Naming a percentage
   here would be a fourth number to keep in step with the other three (D35). */
.bq-c-label { width: auto; }
.bq-c-lines { width: 7%; }
.bq-c-val { width: 14%; }
/* 110px, measured: two .btn-sm side by side clipped to "…" at 84px — a control
   that has been cut off looks identical to one that was never drawn. The cell
   also opts out of the table's ellipsis for the same reason. */
.bq-c-acts { width: 110px; }
.bq-acts { overflow: visible; }
.bq-line-row td { color: var(--muted); border-bottom: 1px dashed var(--border); }
.bq-line-label { padding-left: 26px; }
.bq-acts { text-align: right; white-space: nowrap; }
/* ⚠ `.bq-addrow` IS GONE FROM THESE SELECTORS, NOT JUST FROM THE MARKUP. The bottom
   add-slot form was retired on 24 Aug and `uicheck`'s DEAD-SCOPE check (D33) reddened
   the same minute — 41 against a baseline of 38 — because a rule whose ancestor class
   no page emits can never win, however correct it looks. Deleting the markup and
   leaving the rules is exactly the shape that gate exists to catch. `.bq-editrow` is
   still live: it is the pencil editor. */
.bq-editrow { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.bq-editrow input, .bq-editrow textarea { flex: 1 1 140px; min-width: 0; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
/* The description is the customer's words and is multi-line, so it takes its own line
   rather than competing with the short fields on one wrapped row. */
.bq-editrow textarea { flex: 1 1 100%; font-family: var(--font); line-height: 1.4; resize: vertical; }
.bq-editcell { background: var(--row-band); }
/* The sheet's row wrapper is invisible to the grid, so its seven cells stay
   direct grid items; when the editor replaces its contents the single child
   spans the whole width. */
.bq-doc-row { display: contents; }
.bq-sheet .bq-editcell { grid-column: 1 / 8; padding: 8px 14px; border-top: 1px solid var(--doc-rule); }
.bq-unmapped { margin-top: 12px; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; background: var(--panel); }
.bq-un-head { margin: 0 0 8px; font-size: 12.5px; color: var(--text); }
.bq-un-line { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 4px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
.bq-un-line .num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bq-un-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bq-un-foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
.bq-label-cell { overflow: hidden; text-overflow: ellipsis; }
/* The builder's action buttons: hook classes carry their shared behaviour —
   never stretched by a wrapping flex row, labels never broken mid-word. One
   list, one rule (D20); the visual style is .btn/.btn-ghost/.btn-sm as ever. */
.bq-new, .bq-newcancel, .bq-create,
.bq-slot-edit, .bq-slot-del, .bq-slot-save, .bq-slot-cancel,
.bq-unmap,
.bq-ins-kind, .bq-ins-add, .bq-ins-cancel, .bq-pick-add, .bq-ins-inner { flex: 0 0 auto; white-space: nowrap; }
/* A slot row's cells top-align so a label carrying the stated-price chip does
   not drop the neighbouring figures to mid-height. */
.bq-slot-row td { vertical-align: top; }
/* V3: the three figures — a figure strip, deliberately NOT a warning band
   (D3: the delta is a decision). Labels quiet, values mono/tabular/nowrap. */
.bq-figures { display: flex; flex-wrap: wrap; gap: 18px; align-items: baseline; margin: 2px 0 10px; }
.bq-fig { display: inline-flex; gap: 8px; align-items: baseline; }
.bq-fig-l { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.bq-fig-v { font-size: 14px; font-weight: 600; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bq-fig-note { font-size: 12px; color: var(--muted); }
/* The group band: weight and fill carry it; no muted text inside — --muted on
   --row-band measures 4.47:1, under the floor (the peek-tile scar). */
.bq-grp-row td { background: var(--row-band); font-weight: 600; border-bottom: 1px solid var(--border); }
/* ⚠ THE SUB-GROUP BAND'S RULES ARE GONE, and so is the table they belonged to.
   The customer view is a document sheet now (Shaf, 23 Aug 2026), and its bands
   are `.bq-doc-grp` / `.bq-doc-sub` at the end of this file. These two rules
   were scoped to `.bq-sub-row`, which no page emits any more — `uicheck`'s
   DEAD-SCOPE caught them, which is the check working: a rule whose ancestor is
   emitted nowhere can never win, however correct it looks (D33). */
/* The stated pair stays narrow — a qty is two digits and a price is one figure;
   letting them flex like the text fields is what truncated the placeholders. */
.bq-f-qty { flex: 0 1 80px; }
.bq-f-price { flex: 0 1 130px; }
/* V4: the follow toggle — one labelled row; the why-not clause stays quiet. */
.bq-follow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 10px; font-size: 12.5px; }
.bq-follow-ck { margin: 0; }
.bq-follow-why { color: var(--muted); }

/* ═══════════ THE DEAL HEAD IS ONE CARD — slice 1 of the redesign, 21 Aug 2026 ═══════════
   Left: who and what. Right: the verdict. Everything below the card is a band that answers a
   longer question. Class names are `dh-` so nothing here collides with the dashboard's tiles,
   the rail, or the peek. The avatar reuses the list's `.pav` tints by token (one hash, one
   colour per person on every screen). */
.dh { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px 18px; align-items: stretch; }
/* 🛑 THE VERDICT'S 340px IS A FIXED TRACK, so every pixel the window loses comes out of the
   left column — and the left column now holds the four fact tiles. In THIS app the head also
   sits inside the CRM shell, which takes a sidebar off the width first, so the content column
   is far narrower than the bare page the design was drawn on. Below 1200px four tiles in that
   remainder are unreadable, and below 1000px the two-column head itself is the problem.
   Measured, not guessed: tiles were 120px at 1280, 87px at 900 and 22px at 641. */
/* ⚠ THE RESPONSIVE RULES FOR THIS HEAD LIVE AFTER `.dh-facts` AND `.dh-verdict`, further
   down — NOT here. Written here first and they were DEAD: `@media` adds no specificity, so a
   later `.dh-facts { repeat(4, …) }` at the same (0,1,0) beat them at every width. Measured
   before moving: the 1024px tiles were still four across at 141px each. That is the third
   time in one day this codebase has been caught by the media-query rule D11 already states —
   the trap is not the rule, it is that a dead override looks exactly like a live one. */
/* 🛑 THE CONTACT AND THE FACTS SIT IN THE LEFT COLUMN, BESIDE THE VERDICT — Shaf, 22 Aug
   2026: *"The contact section and the 4 box numbers should sit below the PIC & Acc owner but
   beside the green coloured box."* That is the approved design's own arrangement: its
   `.dr-head-l` holds title, identity, people, contact and figures together, and the verdict
   is a column beside all of it.
   ⚠ THIS REVERSES A SLICE-1 DECISION, KNOWINGLY. The two were given `grid-column: 1 / -1`
   after a screenshot read, reasoning that the verdict should not stretch past its own
   content. What that produced is the shape he is objecting to — a short green panel with
   white space under it, and the two widest things on the screen pushed beneath it. Stretching
   is the right answer: the panel grows to the height of what it judges, and `.dh-vgrid`'s
   `margin-top: auto` drops the four figures to its bottom edge, exactly as the design draws.
   ⚠ AND THEY MOVED IN THE DOM, not only in the grid. On a phone the head is ONE column, so
   source order becomes reading order — left where they were, the client's phone number would
   have come after the scoring panel. */
.dh-verdict { align-self: start; }
.dh-main { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.dh-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.dh-title .panel-title { margin: 0; }
/* THE DEAL NAME IS THE ONE `.dh-ed` THAT SITS INSIDE A HEADING — c197a. `.dh-ed` carries
   its own 13px/normal-weight/text colour everywhere else, which is correct for a chip
   beside other chips and wrong for a page title. Inherit the heading's face instead of
   overriding it a second time (D20 — a different context, not a second definition). */
.dh-title .panel-title.dh-slot .dh-ed { font-size: inherit; font-weight: inherit; color: inherit; }
.dh-title .panel-title.dh-slot .dh-ed-v { overflow-wrap: break-word; white-space: normal; }
/* ⚠ THE GAP WIDENED WHEN THE CHIPS LOST THEIR OUTLINES — 22 Aug 2026. At 9px the three
   items read as one run-on sentence the moment they stopped being boxes: "no separate end
   user · no partner  folder not set" looked like a single phrase. The outline had been
   doing the separating; with it gone the SPACE has to. Row gap stays tight so a wrapped
   row still reads as one row. */
.dh-sub { display: flex; align-items: center; gap: 6px 18px; flex-wrap: wrap; font-size: 13px; color: var(--muted); }
.dh-dot { color: var(--text-3); }
.dh-folder { font-size: 12.5px; }

/* ══════════ CHANGE IT WHERE IT IS SHOWN — slice 2, 22 Aug 2026 ══════════
   The problem this solves, stated as the person meets it: the division, the end user's
   division and the deal's folder were DISPLAYED in the head and EDITED three screens
   down, inside a group that is folded shut by default. You saw the fact where you could
   not change it and changed it where you could not see it.

   ⚠ A NEW CLASS RATHER THAN REUSING `.acc-ed`, and this is the load-bearing decision.
   The account screen's click-to-edit is scoped `#crmAccount` in BOTH delegated handlers
   and EVERY css rule. Widening that selector to reach `#crmDeal` hands this surface all
   of `#crmAccount`'s rules at once — which is the mistake recorded at crm-shell2.css
   :1405, where an inherited `overflow-wrap: anywhere` broke "Champion" into "Champ/ion"
   with every gate green. It would also import that editor's blur-reverts contract, which
   `crm-accountedit.js`:17 says in its own words is for classifications and NOT for money.
   So: one class, defined once, scoped to ITSELF and not to a screen (D20/D33).

   ⚠ THE TAP FLOOR IS ON BOTH AXES. `.btn-icon` is the standing scar — 26px wide by 44px
   tall satisfies a height-only rule and misses the axis a finger needs; this codebase has
   made that mistake four times (crm-shell2.css :1353, :1382). Below 640px this is square.

   ⚠ THE HOLE IS A TARGET, NOT A DASH (emt-ui design log 7). Where the value is empty the
   control IS the way to fill it, so the empty state carries the field's name plus the
   pencil and has width by construction — never a bare dash somebody has to guess is
   pressable. */
/* 🛑 THE VALUE NO LONGER WEARS A BOX — Shaf, 22 Aug 2026: "remove all the big pills,
   leave the pencil alone." It was `padding: 4px 9px; border-radius: 999px; border: 1px
   solid var(--text-3)`, and on a phone that produced the defect he photographed: a
   999px-radius lozenge inside a tile that is ALREADY a bordered rounded box. Measured
   at 390px before the change — the tile is 155px wide, the chip inside it 131px, and
   the date wrapped to FOUR line boxes inside its own control.
   ⚠ THE PENCIL IS UNTOUCHED, and it is the whole argument: the icon is what says the
   value is editable, so the outline was carrying no information the pencil was not
   already carrying — it was only drawing a second frame around the one value the tile
   exists to hold. This codebase's own note says it: a 999px radius on a 44px box is a
   lozenge, not a control.
   ⚠ THE TAP TARGET IS UNCHANGED AND STILL MEASURED. The phone rule below keeps
   min-height/min-width at 44px; what went is the visible edge, not the hit area, and
   `e2e_deal`'s phone sweep asserts min(width,height) over every control on the screen
   against an EMPTY list, so a control that fell under the floor would redden by name. */
.dh-ed {
  display: inline-flex; align-items: center; gap: 6px;
  font: inherit; font-size: 13px;
  padding: 4px 0; border-radius: 0;
  background: transparent; border: 0; color: var(--text);
  letter-spacing: 0.02em; cursor: pointer; text-align: left;
  max-width: 100%; min-width: 0;
}
/* No border left to colour — the hover is the text and the icon together. */
.dh-ed:hover, .dh-ed:focus-visible { color: var(--link); }
.dh-ed .ico { width: 14px; height: 14px; flex: 0 0 auto; opacity: .75; }
.dh-ed:hover .ico, .dh-ed:focus-visible .ico { opacity: 1; }
/* the value may be long — "Bahagian Sains Atmosfera, Pembersihan Asas dan Penyelidikan
   Perubahan Iklim Kebangsaan" is a real division on a real deal — so it truncates and the
   pencil never gets pushed off the end. The full text stays in the accessible name. */
/* 🛑 THE ELLIPSIS BELONGS TO THE IDENTITY CHIPS AND NOWHERE ELSE — corrected 22 Aug 2026
   by the design re-check, and it was a real defect at every ordinary laptop width.
   This rule was written for ONE case: a division named "Bahagian Sains Atmosfera, Pembenihan
   Awan dan Penyelidikan Perubahan Iklim Kebangsaan", 96 characters, which must truncate or
   it pushes the row open. When the three fact tiles gained pencils their values started
   flowing through the same class — and a date and a sum of money are not long text that can
   be abbreviated. Measured on a real deal: at 1280px the tiles are 120px and "10 Aug 2026"
   rendered as "1."; at 641px the tile is 22px and every value is one character.
   ⚠ NO GATE COULD SEE IT, AND THE REASON IS EXACT: `layoutcheck` looks for content that
   OVERFLOWS, and an ellipsis is precisely the thing that stops content overflowing. The
   page measured 0px of scroll at every one of those widths while stating nothing true about
   the deal's money or its dates. It was found by comparing the built screen against the
   approved design, which is the check that has no equivalent in any tool here. */
.dh-ed-v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* A fact WRAPS rather than truncating. Losing the second line of a date is annoying; losing
   the digits of a figure is a different kind of wrong.
   🛑 `overflow-wrap: anywhere` WAS EXACTLY THAT WRONG, and the comment above was written in
   the same edit that caused it. `anywhere` breaks at ANY character, so "RM 900,000.00" in a
   142px tile rendered as three lines — "RM ", "900,000.0", "0" — with the last digit
   orphaned. A reader sees RM 900,000.0 and a stray zero. `break-word` breaks only at real
   opportunities, so "RM" and the figure separate and the digits stay together. */
.dh-fig .dh-ed-v { overflow: visible; text-overflow: clip; white-space: normal;
  overflow-wrap: break-word; }
/* A hole is now told by its WORDS and its quieter colour, not by a dashed outline.
   The dashed chip earned its place inline among other chips, where a bare em dash is
   a 12px tap target with no width; inside a labelled tile the tile already supplies
   both the width and the frame, so the dash was a third box in the same 155px. */
.dh-ed-hole { color: var(--muted); }
.dh-ed-hole:hover, .dh-ed-hole:focus-visible { color: var(--link); }

/* 🛑 AND THE ABSENCE CHIPS IN THE HEAD, for the same reason and scoped the same way.
   `hole()` renders `.chip.chip-hole` — a dashed lozenge — and the head is where they
   pile up: "no separate end user · no partner", "nobody named", and the verdict's
   "not scored" / "not judged yet" / "needs a value". On a phone they stack into a
   column of dashed lozenges, which is most of what the screenshot showed.
   ⚠ SCOPED TO THE HEAD, NOT WIDENED ON THE SHARED CLASS. `.chip-hole` is Shaf's own
   14 Aug decision and it is still right where it was designed for — inline among other
   chips on the account screen, the peek and the leads queue, where a bare dash is a
   12px target with no width. This is the CONTEXT where that reason has already been
   met by the surrounding tile or card (D20 — a context, never a second copy).
   ⚠ The verdict override below sets `border-color` on these; with no border it is inert
   there, which is why the colour is restated here rather than left to it. */
#dealHead .chip.chip-hole {
  border: 0; padding: 0; font-weight: 400; color: var(--muted);
}
#dealHead .dh-verdict .chip.chip-hole { color: var(--inverse-muted); }

/* OPEN. One field at a time; the select replaces the button in place, so the value never
   moves on the page as it becomes editable. `--fs-input` is 16px on a phone, which is the
   iOS zoom floor — a select below it zooms the whole page on focus. */
/* 🛑 THE SLOT IS WHAT STOPS A LONG OPTION BLOWING THE PAGE OPEN, and it was missing until
   the phone gate caught it at 390px with 543px of page overflow — on the first run after
   that gate was wired, which is the gate paying for itself inside an hour.
   A <select> is as wide as its LONGEST OPTION. The fixture's worst-case division is
   "Bahagian Sains Atmosfera, Pembenihan Awan dan Penyelidikan Perubahan Iklim Kebangsaan"
   — 96 characters, manufactured precisely so a real one cannot be the first to find this
   (D22) — and it made the select 898px inside a 390px screen.
   ⚠ `min-width: 0` ON THE SELECT WAS ALREADY THERE AND COULD NOT HELP. A flex item's
   default `min-width: auto` is min-content, so THE SLOT could not shrink, and a child
   cannot shrink against a parent that is itself sized by that child. The floor has to be
   lifted at the first ancestor the row's width actually reaches — the same shape as D21's
   `minmax(0, 1fr)` rule, one box further out. */
.dh-slot { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }
/* 🛑 THE END USER AND ITS DIVISION WRAP AS ONE OR NOT AT ALL — 22 Aug 2026. `.dh-sub` is a
   wrapping flex row, so before this the division chip was free to land on the next line by
   itself, where "the end user itself" is a phrase attached to nothing. Shaf caught it in the
   live screen. The group is its own flex item; the chips inside it cannot be separated. */
.dh-party { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; flex-wrap: nowrap; }
/* 🛑 ONLY THE CHIP MAY SHRINK. `nowrap` forces every child onto one line, and flexbox then
   shrinks them ALL in proportion — including a 12-character deal code, which was rendering
   as "DQ-/2026-/0001" stacked over three lines at 390px while the 96-character division chip
   beside it kept 374px. The chip is the one item built to absorb this: it ellipsizes. So
   everything else refuses to shrink, and the slot takes the whole squeeze. */
/* 🛑 THREE KINDS OF CHILD, NOT TWO — corrected after the phone gate failed the previous
   version by name. "Only the chip may shrink" was too blunt: it froze the CLIENT'S NAME
   too, and "Jabatan Pengairan dan Saliran Negeri Perak" is 265px, so at 390px the division
   pencil was pushed to a right edge of 466 and the page scrolled 76px sideways.
     · the deal CODE must never shrink — it is twelve characters of identity, and the first
       version of this group broke it to "DQ-/2026-/0001" over three lines;
     · an ORGANISATION NAME is long text and may truncate, like the chip;
     · the SLOT holds whichever of the two is longest and absorbs the rest.
   The dot is 4px and stays put. */
.dh-party > .mono, .dh-party > .dh-dot { flex: 0 0 auto; white-space: nowrap; }
/* ⚠ AND SHRINK ORDER MATTERS AS MUCH AS WHO SHRINKS. Making the org merely "shrinkable"
   was not enough: flexbox splits a shortfall in PROPORTION to base size, so the client name
   started giving up characters while the division chip beside it still had hundreds of
   pixels spare — "Jabatan Pengaira…" at 1280 next to a 511px chip. The slot shrinks two
   hundred times more eagerly, so it is emptied to its own minimum before the client's name
   loses a single letter. Measured at ten widths after the change. */
.dh-party > .dh-org { flex: 0 1 auto; flex-shrink: 1; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.dh-party > .dh-slot { flex: 0 1 auto; flex-shrink: 200; min-width: 0; }
/* the contact picker, closed until asked for — the button and its message on one row, the
   form revealed beneath it. Both surfaces that draw the contact strip share this. */
.ct-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.ct-pickform { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
/* the same shape for the person-in-charge card, and deliberately its OWN pair of classes
   rather than reusing `.ct-*`: those belong to the contact strip, which the peek also
   draws, and one class serving two surfaces with different containers is how a rule ends
   up with two meanings (D20). */
.dh-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.dh-pickform { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }

/* ══ THE STAGE CONTROL — 22 Aug 2026 ══
   One primary action, a quiet way to the rest, and the explanation behind the (i). The two
   groups inside the menu are the point: the working states and the endings are different
   kinds of decision and a flat list invited the second by accident. */
.mv-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 0 2px 2px; }
.mv-menu { display: flex; flex-direction: column; gap: 6px; padding: 10px 2px 2px; }
.mv-grp { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: .05em;
  text-transform: uppercase; color: var(--text-3); }
.mv-opts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
/* An ending is not a step along the way, and it does not look like one. The warm border is
   the same one the app already uses for "look at this" — enough to make the hand hesitate,
   not so much that it reads as forbidden (D3 — a warning never blocks). */
.mv-opt.mv-end { border-color: var(--warn-text); color: var(--warn-text); }
.mv-opt.mv-end:hover, .mv-opt.mv-end:focus-visible { background: var(--warn-bg); }

/* ⚠ THE REASON BOXES WERE 153px IN A 1,194px ROW. `#splitReason`'s placeholder — "Why this
   deal moves off the default — required, ten characters" — measures 369px, so two thirds of
   it was invisible, and the invisible part is the RULE the save enforces. The phone block has
   carried `flex: 1 1 150px` for these since it was written; the desktop had nothing, and the
   row has ~700px of unused space. */
/* 🛑 SCOPED TO min-width, AND THE FOURTH TIME IN ONE DAY THIS TRAP HAS BITTEN ME. Written
   unscoped it sat at line 4754, AFTER the phone block's own
   `#crmDeal .actions input { flex: 1 1 150px }` at :3957 — same selector, same specificity,
   and `@media` adds none — so a 260px flex basis was in force at 390px and the deal record
   scrolled sideways by 12px and 76px. The phone gate failed it on the next run, by name.
   ⚠ The lesson is not "remember the rule": D11 states it plainly and I had already been
   caught by it twice this session. It is that an override written near the thing it
   overrides LOOKS correct, and only source order decides. When a rule exists for phone,
   write the desktop one as `min-width` rather than trusting where it lands. */
@media (min-width: 721px) { #crmDeal .actions input { flex: 1 1 260px; min-width: 0; } }

/* The three people bands, side by side (Shaf, 22 Aug 2026 — the approved design's layout).
   `align-items: start` so a band with three rows does not stretch the two beside it. */
.crm-trio { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: start; }
@media (max-width: 900px) { .crm-trio { grid-template-columns: minmax(0, 1fr); } }

/* The commission split as one bar — proportion first, the numbers underneath. */
.sp-bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; gap: 2px; margin: 2px 0 0; }
/* No text inside the bar — the figures live in the legend. See the renderer's comment: the
   series colours lighten in dark mode, so text on them cannot clear the AA floor in both
   themes, and these carry no text to fail. */
.sp-seg { min-width: 0; }
.sp-1 { background: var(--series-1); }
.sp-2 { background: var(--series-2); }
.sp-3 { background: var(--series-3); }
.sp-4 { background: var(--series-4); }
.sp-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 8px; font-size: 12.5px; color: var(--muted); }
.sp-key { display: inline-flex; align-items: center; gap: 6px; }
.sp-sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; flex: 0 0 auto; }
.dh-edit { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; max-width: 100%; }
.dh-edit select, .dh-edit input { max-width: 100%; }
/* D19 — a field guide is a full-width line UNDER the row it describes, never a column
   beside it. `flex-basis: 100%` is what forces the wrap; without it the guide becomes a
   third flex child and the select is sized by whatever is left over. Caught by reading the
   open editor in a screenshot, which is the only way this class of thing is ever caught. */
/* ⚠ `min-width: 0` IS LOAD-BEARING HERE. The guide sits inside `.dh-edit`, inside a
   `.dh-slot`, inside a `.dh-party` that is `flex-wrap: nowrap` — so nothing above it can
   give way, and a flex item's default `min-width: auto` is its min-content. At 390px with
   the editor open that pushed the page 12px sideways, which the phone gate failed by name.
   The guide is prose: it may break, and it is the one thing in this chain that should. */
.dh-edit .field-guide { flex: 1 1 100%; margin: 4px 0 0; min-width: 0; overflow-wrap: break-word; }
.dh-edit select, .dh-edit input { font-size: var(--fs-input); min-width: 0; flex: 1 1 200px; }
.dh-ed-x {
  display: inline-grid; place-items: center;
  width: 30px; height: 30px; padding: 0;
  background: transparent; border: 1px solid var(--border); border-radius: var(--radius);
  color: var(--muted); cursor: pointer;
}
.dh-ed-x:hover, .dh-ed-x:focus-visible { color: var(--text); border-color: var(--text-3); }
.dh-ed-x .ico { width: 14px; height: 14px; }
/* 🛑 THE STRIP FINDS ITS OWN COLUMN COUNT — Shaf, 22 Aug 2026, taking Option B: the
   contact joined this strip as a third card. THREE FIXED COLUMNS WOULD NOT SURVIVE THIS
   HEAD: `.dh` is `minmax(0,1fr) 340px` and the verdict's 340px is a FIXED track, so every
   pixel the window loses comes out of THIS column — and the CRM shell takes a sidebar off
   the width before that. At 1024px a rigid third would be about 135px, which is narrower
   than the account owner's own guide sentence wants.
   ⚠ `auto-fit` + a 240px floor is the same reasoning the fact tiles settled on, arrived at
   from the other end: state the narrowest a card may be and let the count follow, rather
   than stating a count and discovering the width. Three across when there is room, two
   when there is not, one on a phone — and the existing 768px rule below still forces one. */
.dh-people { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
/* The contact spans the row it lands on, so a third card never sits alone beside a hole —
   which is the empty space this whole change was made to remove, just moved down a row.
   MEASURED, not guessed: `auto-fit` with a 240px floor gave 3 columns at 1512 (strip 836px)
   and 2 at both 1280 and 1024 (strip 604 and 596), so the third card wrapped and left a
   297px gap beside it at the two widths people actually work at. */
.dh-people > .dh-contact { grid-column: 1 / -1; }
/* Three across only where the strip genuinely has room. The verdict's 340px is a FIXED
   track and the shell takes a sidebar first, so this is a claim about the STRIP, checked at
   every gate width rather than inferred from the viewport number. */
@media (min-width: 1400px) {
  .dh-people { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .dh-people > .dh-contact { grid-column: auto; }
}
.dh-pcard { display: flex; align-items: flex-start; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; min-width: 0; }
.dh-av { width: 34px; height: 34px; border-radius: 50%; display: inline-grid; place-items: center; font-weight: 700; font-size: 13px; flex: 0 0 auto; color: var(--text); }
.dh-av.pav-1 { background: var(--pav-1); }
.dh-av.pav-2 { background: var(--pav-2); }
.dh-av.pav-3 { background: var(--pav-3); }
.dh-av.pav-4 { background: var(--pav-4); }
.dh-av.pav-5 { background: var(--pav-5); }
.dh-av.pav-6 { background: var(--pav-6); }
.dh-who { min-width: 0; display: flex; flex-direction: column; gap: 3px; flex: 1; }
.dh-who .field-guide { margin: 2px 0 0; }
.dh-name { font-size: 14px; line-height: 1.3; font-weight: 600; color: var(--text); }
.dh-door { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.dh-door select { font-size: var(--fs-input); min-width: 0; flex: 1 1 180px; }
/* The contact card: `.dh-pcard` chrome so the strip reads as one row, and it KEEPS the
   row-band fill so the person on the other side of the deal is not read as one of ours.
   Column rather than the pcard's row, because it has no avatar to sit beside — the label
   stacks over the content the shared renderer emits. */
.dh-contact {
  background: var(--row-band); border-radius: var(--radius);
  padding: 10px 12px; flex-direction: column; gap: 4px;
}
.dh-contact .ct-none { margin: 0 0 6px; }
/* 🛑 TWO ACROSS AT EVERY WIDTH, and the four-across base is gone rather than
   breakpointed. Measured: the longest real value — "10 Aug 2026 — 12 days overdue" — needs
   about 330px, which four across only reaches at roughly a 2030px viewport once this shell's
   sidebar is taken off. No machine in the company is that wide, so a four-column rule was
   describing a case that does not occur while making every real case worse: widening the
   window from 1400 to 1401 made a value go from one line to three. */
.dh-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
/* 🛑 PLACED AFTER THE BASE RULE ON PURPOSE — see the note beside `.dh`. The verdict's 340px
   is a FIXED track, so every pixel the window loses comes out of the left column that holds
   these four tiles, and this app puts the head inside a shell whose sidebar has already
   taken ~250px. Measured on a real deal: four tiles were 141px wide at 1024 with the value
   wrapping to three lines. Two across below 1200, one column of head below 1000. */
/* 1400, not 1200, and the number is measured rather than chosen: at 1280 — the commonest
   laptop width in the company — four tiles are 120px each and "RM 900,000.00" needs two
   lines. At two across the same tile is ~250px and every value reads on one. */
@media (max-width: 1000px) { .dh { grid-template-columns: minmax(0, 1fr); } }
.dh-fig { border: 1px solid var(--border); border-radius: var(--radius); padding: 9px 11px; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.dh-fig-v { font-size: 14px; font-weight: 600; color: var(--text); overflow-wrap: anywhere; }
.dh-fig-m { font-size: 11.5px; color: var(--muted); line-height: 1.35; }
.dh-fig.dh-stalled .dh-fig-v, .dh-fig.dh-stalled .dh-fig-m { color: var(--warn-text); }
.dh-verdict { background: var(--inverse-bg); color: var(--inverse-text); border-radius: var(--radius); padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
/* the panel's labels are NOT `.kv-k`: the shell's `#crmDeal .kv-k` rule outranks any class here and
   painted them --muted on the dark surface (2.2:1 in light, caught by contrastcheck 21 Aug). */
.dh-vk { display: block; font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--inverse-muted); }
.dh-verdict .chip-hole { color: var(--inverse-muted); border-color: var(--inverse-muted); }
.dh-verdict .hint-btn { color: var(--inverse-muted); border-color: var(--inverse-muted); background: transparent;
  width: 26px; height: 26px; display: inline-grid; place-items: center; }
.dh-vcaps { display: flex; align-items: center; gap: 8px; }
.dh-score { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.dh-n { font-size: 38px; line-height: 1; font-weight: 600; color: var(--inverse-text); }
/* the denominator rides with the number and is deliberately quieter — it is context, not
   the figure. Matches the approved design's `.dr-score .of`. */
.dh-of { font-size: 16px; font-weight: 600; color: var(--inverse-muted); }
.dh-band { font-size: 12px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--inverse-text); }
.dh-caveat { font-size: 12px; color: var(--inverse-muted); }
/* The four component scores. Sits under the directive where the design puts it, and is the
   line that stops the panel opening a hole on a deal whose directive no longer applies. */
.dh-parts { margin: 0; line-height: 1.45; }
.dh-directive { margin: 0; font-size: 14px; line-height: 1.4; font-weight: 600; color: var(--inverse-text); text-wrap: pretty; }
.dh-vrule { height: 1px; background: var(--inverse-line); }
/* 🛑 THE VOID IS REMOVED BY NOT STRETCHING, AND MY PREVIOUS TWO ATTEMPTS BOTH FAILED —
   worth recording because each failed in a way that looked like a fix.
     · First I tried to FILL it, by adding the component-scores line. A gap defined as
       "whatever the left column has spare" cannot be filled by adding content: it got
       BIGGER, because the same round made the left column taller.
     · Then I moved `margin-top: auto` from the grid onto the RULE, which only moved the
       hole above the divider instead of below it — measured 314px at 1280px, worse than
       the 278px I had called the bug. It also inverted the approved design, which puts the
       auto margin on the GRID and none on the rule.
   The cause was never the margin. It is `height: 100%` on a panel whose content, on an
   unscored won deal, is two short lines: stretch it to a 600px column and 400px of dark
   green is the arithmetic, not a bug to be papered over. The panel sizes to its own content
   now and sits at the top of its row; the slack becomes ordinary white space beside the
   facts, which is what the eye expects. The design's own margin arrangement is restored. */
.dh-vgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 12px; margin-top: auto; }
.dh-vcell { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.dh-big { font-size: 15.5px; font-weight: 600; color: var(--inverse-text); overflow-wrap: anywhere; }
.dh-small { font-size: 11.5px; color: var(--inverse-muted); line-height: 1.35; }
@media (max-width: 640px) {
  /* the deal record is a PHONE surface since 21 Aug 2026 (Shaf, decision 7) */
  .dh { grid-template-columns: minmax(0, 1fr); gap: 12px; }
  .dh-people { grid-template-columns: minmax(0, 1fr); }
  .dh-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .dh-door select { flex-basis: 100%; }
  .dh-door .btn { min-height: 44px; }
  .dh-vgrid { gap: 12px 10px; }
  /* 🛑 EVERY INFO BUTTON, NOT JUST THE VERDICT'S — widened 22 Aug 2026 because the narrow
     rule let the next one straight through. `.dh-verdict .hint-btn` was sized when slice 1
     found that one at 14px; the moment a second info button was added — to the stage control
     — it arrived at 14px too, and the phone sweep caught it by name on its first run.
     Scoping a floor to the one instance that failed is how the same defect ships twice
     (D20/D36): fix the class, not the sighting. `.hint-btn` is the app's one info-button
     widget, so this is where the floor belongs. */
  .hint-btn { width: 44px; height: 44px; display: inline-grid; place-items: center; }
  /* 🛑 AND THE SCOPED ONE HAS TO BE BEATEN BY AN EQUALLY SCOPED ONE. `@media` ADDS NO
     SPECIFICITY — D11 records that in exactly these words — so `.dh-verdict .hint-btn`
     (0,2,0), which sets 26px for the desktop panel, wins over the bare `.hint-btn` above
     (0,1,0) even inside this block. Measured, not reasoned: the sweep went 14px → 26px and
     named the control, which is the generic rule landing everywhere EXCEPT the one place a
     narrower rule already stood. */
  .dh-verdict .hint-btn { width: 44px; height: 44px; }

  /* ⚠ BOTH AXES, MEASURED — `min-height` alone is the four-times mistake. The edit
     chips carry text, so their width is already past 44; the floor is what stops a
     one-word division ("HQ") from collapsing to a 40px target. The close button is
     square by construction. `e2e_deal`'s phone sweep measures min(width, height) on
     every one of them, so this is enforced rather than declared. */
  /* 🛑 THE NOWRAP GROUP GIVES WAY AT PHONE WIDTH, and the reason is that its own purpose
     does not apply here. It exists so a WIDE row cannot wrap between a pair that must be
     read together — "the end user itself" stranded from its end user. At 390px the row is
     stacking anyway, every pair is already adjacent, and the group's rigidity had become the
     defect instead: the pencil inside it cannot shrink below the 44px tap floor, so the row
     was 9px too wide and the page scrolled. Measured, then fixed at the width where the
     protection is worth nothing. */
  .dh-party { flex-wrap: wrap; }
  .dh-party > .dh-org { overflow: visible; text-overflow: clip; white-space: normal; }
  /* ⚠ THE FLOOR STAYS, THE INSET GOES. 44px on BOTH axes is what the phone sweep
     measures; the 12px of horizontal padding existed to hold the pill off the text
     and, with no pill, only pushed the value away from its own label. */
  .dh-ed { min-height: 44px; min-width: 44px; padding: 6px 0; }
  .dh-ed-x { width: 44px; height: 44px; }
  .dh-edit select, .dh-edit input { flex-basis: 100%; min-height: 44px; }

  /* 🛑 THE THREE CONTROLS ALREADY IN THE HEAD, MEASURED AND FOUND SHORT — 22 Aug 2026.
     Slice 1 reported "zero targets under 44px" at 390px, which was true of what it had
     just built and not of the screen. The first sweep of the WHOLE record found 34 under
     the floor, and these three are the ones inside slice 2's own region:
       · the person-in-charge picker measured 294 × 21 — a fifth of the floor on the axis
         a thumb actually needs, on the control that hands a deal (and its commission) to
         somebody else;
       · the client-contact picker 296 × 40;
       · every "who worked on the proposal" row picker 225 × 29.
     `.dh-door select` already had `flex-basis: 100%` here, so the WIDTH was solved and the
     height was never asked — which is the same both-axes miss recorded at crm-shell2.css
     :1353 and :1382, arriving from the opposite direction for once.
     ⚠ A select's height comes from its own line-height, not from the row it sits in, so
     `min-height` is the only thing that moves it. `e2e_deal`'s phone sweep measures the
     real element, so these are enforced rather than declared (D33). */
  .dh-door select, .dh-contact select, .pt-staff { min-height: 44px; }
}

/* ============================================================================
   THE CUSTOMER VIEW'S SHEET — the quotation, on screen. Shaf, 23 Aug 2026:
   "The goal is that the user can view the screen as close as the final quote to
   send to the customer."

   THE SHEET IS THEIRS, THE RAIL IS OURS. The document's own palette is used
   rather than the app's chrome, because the point is that it reads like the
   file the customer opens: those greens are `excel-export.js`'s C_DARK / C_MID /
   C_LIGHT / C_CREAM, and they are stated here as raw hex ON PURPOSE — they are
   the DOCUMENT's colours, not the app's, and tokenising them would invite a
   future theme change to silently repaint a customer's quotation.
   ⚠ That means they do NOT flip with the theme, which is correct for ink on a
   page and is exactly why they are confined to `.bq-sheet`: every one of them
   sits on a background this block also sets, so none of them depends on what is
   behind it (the `--logo-grn` scar — a colour that cannot flip is safe only
   while its background never moves).

   ⚠ THE RAIL IS THE SEVENTH COLUMN OF THE SAME GRID. An earlier draft aligned a
   parallel column by hand-written row heights; measured, it was already 15-23px
   out and the drift WANDERED, so no single correction fixed it and one
   description wrapping to a different number of lines threw every control below
   it out. A row and its controls are now the same row, structurally.
   ============================================================================ */
/* 🛑 THE SHEET SCROLLS SIDEWAYS RATHER THAN CLIPPING. `layoutcheck` caught the
   first version ERRORING at 768px — "78px of content is clipped with no way to
   reach it", and 456px at 390px — because the seven columns come to ~460px of
   fixed width plus the description, while `.bq-sheet` carries `overflow: hidden`
   for its own rounded corners. Content a person cannot reach is the worst form of
   this: not "needs scrolling", unreachable.
   ⚠ The radius has to stay on the SHEET (that is the element with the fill), so
   the scrolling goes on a wrapper — the same shape `.browse-tablewrap` uses for
   every wide table on this platform.
   ⚠ IT CONVERTS AN ERROR INTO A WARNING, ON PURPOSE, and the warn count rises by
   that trade. This screen is deliberately NOT on the phone standard (`nfr.md` N7
   — five columns of money that must reconcile is the harder case), so honest
   sideways scrolling is the accepted treatment rather than a reflow that would
   have to decide which of the customer's columns to drop. */
/* 🛑 THE SHEET HAS ALWAYS SCROLLED SIDEWAYS. NOTHING EVER SAID SO — Shaf, 24 Aug 2026.
   `overflow-x: auto` was here from the start, so the money columns were always
   REACHABLE at 768px; macOS draws OVERLAY scrollbars, which are invisible until you
   are already scrolling, so the affordance existed and announced itself to nobody.
   Measured before this rule: at 768px the wrapper is 384px against 700px of content
   — Qty, Unit Price, Total and every subtotal sit off the right edge with no visible
   hint that they are there at all. A control nobody can discover is D5.
   ✅ `::-webkit-scrollbar { height }` opts this element OUT of overlay scrollbars and
   into the classic always-drawn kind. It still only appears when the content actually
   overflows (`auto`, not `scroll`), so the 1512px case is untouched.
   ⚠ DOC-FAMILY TOKENS, NOT THE APP'S. The sheet paints its own paper — `--doc-paper`
   is #FFFFFF in BOTH themes — so a flipping token on this element is the `--logo-grn`
   scar. Every colour here is non-flipping, like the paper it sits on.
   ⚠ Non-text contrast, so no gate reads it (contrastcheck says so in its own header):
   thumb `--doc-muted` on track `--doc-cream-soft` measures 5.28:1 against the 3:1
   floor a component carrying meaning must clear. Measured, not assumed. */
.bq-sheetwrap {
  overflow-x: auto;
  /* The document's frame lives here now — see the note on `.bq-sheet`. */
  border: 1px solid var(--doc-rule);
  border-radius: var(--radius-lg);
  scrollbar-width: thin;                              /* Firefox */
  scrollbar-color: var(--doc-muted) var(--doc-cream-soft);
}
.bq-sheetwrap::-webkit-scrollbar { height: 10px; }
.bq-sheetwrap::-webkit-scrollbar-track {
  background: var(--doc-cream-soft);
  border-radius: 999px;
}
.bq-sheetwrap::-webkit-scrollbar-thumb {
  background: var(--doc-muted);
  border-radius: 999px;
  border: 2px solid var(--doc-cream-soft);
}
.bq-sheetwrap::-webkit-scrollbar-thumb:hover { background: var(--doc-ink); }
.bq-sheet {
  /* 🛑 THE SHEET PAINTS ITS OWN PAPER, AND LEAVING THIS OUT WAS A REAL DEFECT.
     Without it the item rows inherited `--panel`, which FLIPS — so `--doc-text`
     (#1A1A1A) sat on #1B221D in dark mode at 1.06:1 and the customer's document
     was invisible. `contrastcheck` could not have said so: no fixture paints
     these classes, so they are in the 169 it reports NEVER PAINTED.
     ⚠ This is the `--logo-grn` scar exactly — a colour that cannot flip is safe
     only while its background never moves — reproduced in the same commit whose
     comment cited it as the reason to be careful. That comment claimed "every one
     of them sits on a background this block also sets"; it did not, and saying so
     is not the same as doing it. The paper is now set here, so the claim is true. */
  background: var(--doc-paper);
  /* 460px of fixed columns + ~240px before the description starts wrapping badly. */
  min-width: 700px;
  display: grid;
  grid-template-columns: var(--bq-qid-w) minmax(0, 1fr) 52px 48px 118px 126px 62px;
  /* 🛑 THE FRAME MOVED TO THE WRAPPER, AND IT IS NOT COSMETIC. `.bq-sheet` carried
     `overflow: hidden` for its rounded corners — and an ancestor with a clipping
     overflow BETWEEN a sticky element and its scroll container makes `position:
     sticky` completely inert: the cell sticks to the sheet, which does not scroll.
     So the frozen Q-ID and Description reported `position: sticky` in computed style
     and did not stay put. **Declared, correct-looking, and doing nothing** — D33 in a
     new costume, and only measuring the real element after scrolling caught it.
     The wrapper is already a scroll container (`overflow-x: auto`), so it clips to
     its own radius: the frame looks identical and sticky works. */
  margin-top: 4px;
}
.bq-doc-band {
  grid-column: 1 / 7;
  padding: 11px 14px;
  background: var(--doc-ink); color: var(--doc-cream);
  text-align: center; font-size: 12.5px; font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
}
.bq-doc-th {
  padding: 7px 10px;
  background: var(--doc-mid); color: var(--doc-cream);
  font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
}
.bq-th-c { text-align: center; }
.bq-th-r { text-align: right; }
.bq-rail {
  background: var(--row-band);
  border-left: 1px solid var(--doc-rule);
  display: flex; align-items: center; justify-content: flex-end; gap: 4px;
  padding: 0 8px;
}
.bq-doc-grp {
  grid-column: 1 / 7;
  padding: 8px 14px;
  background: var(--doc-cream); color: var(--doc-ink);
  font-weight: 700; font-size: 11.5px; letter-spacing: 0.05em; text-transform: uppercase;
  border-top: 1px solid var(--doc-rule);
}
.bq-doc-sub {
  grid-column: 1 / 7;
  padding: 7px 14px 7px 26px;
  color: var(--doc-ink); font-weight: 600; font-size: 12px;
  border-top: 1px solid var(--doc-rule-soft);
}
.bq-doc-qid, .bq-doc-desc, .bq-doc-uom, .bq-doc-n {
  padding: 8px 10px; border-top: 1px solid var(--doc-rule-soft); color: var(--doc-text); font-size: 12.5px;
}
.bq-doc-desc { line-height: 1.45; }
/* ===== FREEZE Q-ID AND DESCRIPTION — Shaf, 24 Aug 2026 ======================
   His call, after seeing the sheet at 768px: the money columns were reachable by
   scrolling but the DESCRIPTION scrolled away from its own figures, so a row could
   not be read as a row. Q-ID and Description now stay put while the money scrolls
   under them — the spreadsheet behaviour, and the only arrangement where a narrow
   window costs a scroll and never the meaning of a line.

   ⚠ A STICKY CELL MUST BE OPAQUE or the scrolling content shows straight through
   it. Every frozen cell therefore carries its own row's background — paper for an
   item row, the header's own green for the heading.
   ⚠ THE OFFSET IS THE FIRST COLUMN'S WIDTH and is written once as a token, because
   two rules need the same number and a hand-copied 54px is a number nothing
   re-derives (D35). It matches `grid-template-columns`'s first track above.
   ⚠ `box-shadow`, NEVER a gradient: a background-image anywhere up the ancestor
   chain takes every descendant's text OUT of contrastcheck's sight (`emt-ui`), and
   this edge sits over twelve money labels. */
.bq-doc-qid, .bq-th-qid { position: sticky; left: 0; z-index: 2; }
.bq-doc-desc, .bq-th-desc { position: sticky; left: var(--bq-qid-w); z-index: 2; }
/* Item rows sit on the paper; the heading carries the band's own green. */
.bq-doc-qid, .bq-doc-desc { background: var(--doc-paper); }
.bq-th-qid, .bq-th-desc { background: var(--doc-mid); }
/* The edge that says content is passing underneath. Drawn on the LAST frozen cell
   only, so it reads as one boundary rather than two. */
.bq-doc-desc, .bq-th-desc { box-shadow: 6px 0 6px -6px rgba(0, 0, 0, 0.18); }
/* ⚠ THE BANDS HAD TO BE PINNED TOO, AND THE SCREENSHOT IS WHAT SAID SO. With only the
   two columns frozen, scrolling right left the GROUP and sub-group bands as blank cream
   strips — their text is left-aligned and had slid away, so a row was readable while the
   band saying WHICH GROUP it belonged to was not. The subtotals kept their words purely
   because they are right-aligned, which made the sheet inconsistent as well as poorer.
   These span every column, so pinning at `left: 0` moves nothing at rest and keeps the
   label at the viewport's edge once the sheet scrolls under it. Below the frozen cells'
   z-index, because they share no row and the cells must win any overlap. */
.bq-band-t { position: sticky; left: 14px; display: inline-block; }
/* The sub-group band is indented 26px, so its label pins to the same edge it sits on
   rather than jumping left the moment the sheet scrolls. */
.bq-doc-sub .bq-band-t { left: 26px; }
/* The title is centred at rest; once the sheet scrolls it pins to the left edge,
   because a centred title whose middle has scrolled away reads as a broken word. */
.bq-doc-band .bq-band-t { left: 14px; }
/* ⚠ THE FROZEN PAIR MUST NOT EAT THE WINDOW IT IS SAVING. Measured at 768px with the
   freeze in and this rule OUT: 54 + 240 frozen left **89px** of viewport for the money
   — less than one column, so Total could never sit beside its description and the
   freeze bought nothing at the width it was built for.
   The description track is `minmax(0, 1fr)` over a 700px floor with 460px of fixed
   columns, so the floor IS the dial: 610 leaves the description 150px and the money
   ~178px, which holds `Total (RM)` whole beside the row it belongs to.
   ⚠ A media query adds NO specificity (`emt-ui`), so this sits AFTER the base rule and
   wins on order — deliberately, not by luck. */
@media (max-width: 900px) {
  .bq-sheet { min-width: 610px; }
}
/* ===== V-slice 3 — the insert points =======================================
   A quiet gutter between every row, and the end of every band. It is OUR chrome,
   not the customer's document, so it spans the rail too (`1 / 8`, the same span
   the slot editor uses) — but it sits on the paper, so its colours come from the
   non-flipping doc family (see `--doc-muted`).
   ⚠ THE RESTING HEIGHT IS DELIBERATELY SMALL AND IT IS A TRADE, stated rather
   than glossed: a 32px desktop target on every boundary would add ~320px of
   gutter to a ten-row sheet and stop it reading as a document, which is the one
   thing this screen exists to do. 26x16 desktop, and the quote table is
   explicitly NOT on the phone standard (`nfr.md` N7). */
.bq-sheet .bq-ins {
  grid-column: 1 / 8;
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  min-height: 16px; padding: 0 10px;
  background: var(--doc-paper);
}
.bq-ins-btn {
  min-width: 26px; height: 16px; padding: 0 7px;
  border: 1px solid transparent; border-radius: 999px;
  background: none; color: var(--doc-muted);
  font-size: 13px; line-height: 1; cursor: pointer;
}
.bq-ins-btn:hover, .bq-ins-btn:focus-visible {
  border-color: var(--doc-rule); background: var(--doc-cream-soft); color: var(--doc-text);
}
/* Open, it is a form and needs a form's room. A class rather than `:has()` so a
   file-reading gate can see the two states. */
/* ⚠ THE OPEN STRIP STAYS IN THE DOC FAMILY, AND THAT IS A CONTRAST DECISION, not a
   colour preference. `--row-band` was the first choice and `emt-ui` already records
   why it is wrong here: `--muted` on `--row-band` is 4.47:1, a hair UNDER the floor,
   so a band filled with it cannot carry a muted label. And these classes are in
   contrastcheck's NEVER-PAINTED list — no fixture opens an insert strip — so the gate
   would have reported them clean by never drawing them (D28). Measured by hand on the
   real element instead (D33): `--doc-muted` on `--doc-cream-soft` is 5.28:1, and the
   whole family is non-flipping, which is what a strip sitting on white paper needs. */
.bq-sheet .bq-ins-open {
  min-height: 0; padding: 8px 12px;
  background: var(--doc-cream-soft);
  border-top: 1px solid var(--doc-rule);
}
.bq-ins-ask { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--doc-muted); }
.bq-ins-in {
  flex: 1 1 220px; min-width: 0; padding: 5px 8px;
  border: 1px solid var(--border); border-radius: 6px;
  font-family: var(--font); font-size: 13px; line-height: 1.4; resize: vertical;
}
.bq-ins-where { flex: 1 1 100%; font-size: 11.5px; color: var(--doc-muted); }
/* The group form's second box — what goes INSIDE the group. Same rule as the first
   (D20: one shape for one kind of control), narrower basis so both fit one row. */
.bq-ins-in2 {
  flex: 1 1 180px; min-width: 0; padding: 5px 8px;
  border: 1px solid var(--border); border-radius: 6px;
  font-family: var(--font); font-size: 13px; line-height: 1.4;
}
.bq-doc-qid { text-align: center; font-size: 12px; }
.bq-doc-uom { text-align: center; color: var(--muted); font-size: 12px; }
/* Money is mono, tabular and NEVER truncated — this is the defect the redesign
   was called for: the old `.bq-c-val` was 14% of the panel with the cell set to
   ellipsise, so at the real content width every band figure read "RM 190,4…".
   A fixed 126px column holds 999,999.99 at 12.5px with room to spare, and the
   column heading says RM once instead of all 26 rows repeating it. */
.bq-doc-n { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bq-doc-st, .bq-doc-gt, .bq-doc-sub-t { padding: 8px 10px; border-top: 1px solid var(--doc-rule); font-size: 12px; }
.bq-doc-st { grid-column: 1 / 6; text-align: right; background: var(--doc-cream-soft); color: var(--doc-ink); font-weight: 600; }
.bq-doc-stv { grid-column: 6; }
.bq-doc-gt { grid-column: 1 / 6; text-align: right; background: var(--doc-mid); color: var(--doc-cream); font-weight: 700; border-top-color: var(--doc-ink); }
.bq-doc-gtv { grid-column: 6; }
/* 🛑 WHITE, NOT `--doc-cream`, AND THE REASON IS A MEASUREMENT NOTHING ELSE WOULD
   HAVE MADE. Cream on `--doc-light` is 4.08:1 — under the 4.5 floor — and
   `contrastcheck` could never have said so: no fixture opens the customer tab
   with a seeded quote, so every `.bq-doc-*` class sits in the 169 it reports as
   NEVER PAINTED and therefore unjudged. Read its coverage line as a worklist,
   not a footnote (D28).
   White on the same fill is 5.34:1. The BAND keeps the document's exact colour,
   so the screen still matches the file; only the ink on this one row moves. */
.bq-doc-sub-t { grid-column: 1 / 6; text-align: right; background: var(--doc-light); color: var(--on-primary); font-weight: 700; letter-spacing: 0.04em; border-top-color: var(--doc-ink); }
.bq-doc-sub-tv { grid-column: 6; font-size: 13px; }
.bq-doc-st.bq-doc-stv, .bq-doc-gt.bq-doc-gtv, .bq-doc-sub-t.bq-doc-sub-tv {
  text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap;
}
.bq-doc-foot {
  grid-column: 1 / 7;
  padding: 7px 14px;
  font-size: 11.5px; color: var(--muted);
  border-top: 1px solid var(--doc-rule-soft); background: var(--panel);
}
/* The rail's buttons. `--text-3` for the edge, never `--border`: a separator
   token is not a component edge (1.27:1 against the panel is no visible box). */
.bq-gbtn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 24px; height: 24px; padding: 0 5px;
  border: 1px solid var(--text-3); border-radius: 5px;
  background: var(--panel); color: var(--muted);
  font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 11px;
  cursor: pointer;
}
.bq-gbtn:hover { color: var(--text); }
.bq-foldbtn[aria-expanded="true"] { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
/* THE FOLD — ours, and it says so. Deliberately in NEITHER of the document's
   colours, so there is never a moment where you cannot tell what the customer
   sees from what you are working on. */
.bq-fold {
  grid-column: 1 / 8;
  background: var(--row-band);
  border-top: 1px solid var(--text-3); border-bottom: 1px solid var(--text-3);
  padding: 9px 14px 11px 26px;
}
.bq-fold-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin-bottom: 7px; }
.bq-fold-l { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.bq-fold-note { font-size: 11.5px; color: var(--muted); }
.bq-fold-sum { margin-left: auto; font-size: 11.5px; color: var(--muted); }
.bq-fold-row {
  display: grid; grid-template-columns: minmax(0, 1fr) 108px 108px 82px;
  gap: 10px; align-items: baseline;
  padding: 4px 0; border-top: 1px dashed var(--border); font-size: 12px; color: var(--muted);
}
.bq-fold-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bq-fold-act { text-align: right; }
.bq-fold-empty { margin: 0; font-size: 12px; color: var(--muted); font-style: italic; }
/* V-slice 4: the picker is the fold's other MODE, so it reuses `.bq-fold-row`'s
   grid and adds only what the mode itself needs (D12/D20). */
.bq-pick-row { cursor: pointer; }
.bq-pick-row:hover { background: var(--row-band); }
.bq-pick-ck { margin-right: 6px; vertical-align: middle; }
.bq-pick-foot { display: flex; justify-content: flex-end; padding-top: 8px; }
.bq-pick-mode { flex: 0 0 auto; white-space: nowrap; }
.bq-view-said { font-size: 12px; color: var(--muted); margin-left: auto; }
/* Joins the builder's shared button-behaviour list: never stretched by a
   wrapping flex row, label never broken mid-word (D20 — one list, one rule). */
.bq-foldall { flex: 0 0 auto; white-space: nowrap; margin-left: 8px; }
/* Pushes the destructive control away from Save/Cancel — a delete must never sit
   where the primary action usually is (`emt-ui`). */
.bq-ed-spacer { flex: 1 1 auto; }
/* The fold's figures carry `class="num"`, and the sheet is not a table — the
   platform's `.num` rules are scoped to table cells (`.bq-table td.num` and
   friends), so nothing reached these and the money rendered proportional.
   Tabular figures are the actual mechanism a column of RM values is scanned by
   (`emt-ui`: mono alone is not enough if the face defaults to proportional). */
.bq-fold .num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
