  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  /* ══════════════════════════════════════════════════════════════════════
     THIS FILE IS COPIED INTO landing/style.css. Edit here, then re-copy —
     `tests/frontend/landing-deploy.test.js` fails the moment the two differ.
     landing/preview.html is painted by the product's own stylesheet on purpose
     (see its head comment) and ships from a SEPARATE deploy, so it cannot link
     this one; a copy pinned by a test is what keeps it from silently aging.

     Nocturne — the dark shell.

     The palette is the design system's: an indigo-tinted ground (#161826) with
     a neutral ramp lifted off it, hairline dividers rather than boxes, and one
     accent. Every token below is a *role*, not a colour, which is what let the
     whole app flip from the white theme in one edit: nothing outside this block
     names a surface or an ink directly.
     ══════════════════════════════════════════════════════════════════════ */
  :root {
    --bg: #161826;          /* app ground */
    --bg2: #1e2030;         /* raised surface — rows, cards, inputs */
    --bg3: #272a3b;         /* hover / active tint */
    --border: #2c2f40;      /* hairline — the default divider */
    --border-bright: #3f4356;  /* the stronger rule: table heads, dashed drops */
    /* ── Brand accent ──────────────────────────────────────────────────────
       --brand-primary is the ONLY colour the runtime overrides: the branding
       bootstrap in each page's <head> and applyBranding() in app.js set it as an
       inline style on <html>, which beats this :root declaration on the same
       element. Every other accent token derives from it here in CSS, so one
       injected value reaches all 85 var(--teal*) uses plus the literals that
       used to bypass them.

       Leaving it unset is the supported default and reproduces the original
       Curious Code teal exactly — which is why no Python or JS anywhere repeats
       this hex. #007f67 measures 4.96:1 against white; agents/branding.py clamps
       every company colour to at least 4.5:1 so a tenant's accent can never
       carry white text less legibly than this one does.

       color-mix() replaces the hand-written rgba() literals: it takes the alpha
       from a variable that is not known until runtime. Mixing with `transparent`
       in srgb is premultiplied per CSS Color 5, so 6% here is identical to the
       rgba(0,127,103,0.06) it replaces. */
    --brand-primary: #007f67;
    --brand-primary-hover: #00a382;   /* on a dark ground a hover BRIGHTENS */

    /* The interface accent. A colour clamped for legibility against WHITE is by
       construction too dark to read against #161826 — #007f67 measures 3.5:1
       there — so the accent every text, border and glyph use resolves through is
       the brand hue lifted toward white. Still derived, so a tenant's colour
       still reaches all 85 uses; --brand-primary itself stays the fill for the
       handful of places that carry white text on top of it. */
    --teal: color-mix(in srgb, var(--brand-primary) 66%, #ffffff);
    --teal-dim: color-mix(in srgb, var(--brand-primary) 22%, transparent);
    --teal-glow: 0 0 0 1px var(--brand-primary),
                 0 0 24px color-mix(in srgb, var(--brand-primary) 22%, transparent);

    /* Semantic status green — NOT the brand accent, and deliberately not derived
       from it. It sits in a traffic light with --amber and --red (the favicon's
       done/running/error badges read these three straight out of this file), so
       letting a red- or orange-branded tenant paint "done" in their brand red
       would make it indistinguishable from "failed". Pinned for every company.
       Lifted with the rest of the palette: the white theme's #007f67 / #b36b00 /
       #c0392b are all under 4.5:1 on the dark ground. */
    --ok: #2eb98d;
    /* .badge-high / .badge-ok paint with these. Derived from --ok rather than pinned so a
       change to the status green cannot leave its tint behind, and lifted from the white
       theme's 10%/30%: on the dark ground a 10% tint of anything is not visible at all. */
    --ok-dim: color-mix(in srgb, var(--ok) 22%, transparent);
    --ok-line: color-mix(in srgb, var(--ok) 45%, transparent);
    --red-dim:    color-mix(in srgb, var(--red) 20%, transparent);
    --red-line:   color-mix(in srgb, var(--red) 42%, transparent);
    --amber-dim:  color-mix(in srgb, var(--amber) 20%, transparent);
    --amber-line: color-mix(in srgb, var(--amber) 42%, transparent);
    --grey-dim:   color-mix(in srgb, var(--grey) 22%, transparent);
    --grey-line:  color-mix(in srgb, var(--grey) 45%, transparent);

    --amber: #d99b2e;
    --red: #e0645a;
    --grey: #75798c;
    --text: #e9e9ed;
    --text-dim: #9397ab;
    --text-faint: #75798c;   /* the design's quietest label — column heads, meta */

    /* Match traffic light. Lives here beside --ok/--amber/--red rather than in
       .match-dot's rule because the estimate dialog, the line-item table and the
       legend all paint it, and three copies eventually mean three colours. */
    --match-exact: #2eb98d;
    --match-interpolated: #d99b2e;
    --match-ai: #e0645a;

    /* Over/under a recorded actual. Deliberately NOT --ok/--red: those drive the
       match-type traffic light two columns away, and an overrun is not a
       data-quality warning. Tokens rather than the literals they used to be so the
       light palette can restate them -- #2eb98d on a white ground measures 2.2:1. */
    --variance-over:  #e0645a;
    --variance-under: #2eb98d;

    /* The one non-traffic-light hue: the catalog's "from a price list" source badge. */
    --blue: #8fa8e0;
    --blue-dim: rgba(122, 150, 214, .16);

    /* Depth. A scrim and two shadow depths, tokenised for the reason every colour
       above is: on a white ground a .55 black shadow reads as a smudge, and a
       near-black scrim as a blackout. Shadows are whole values, not just colours --
       the light palette softens the alpha, not the geometry. */
    --scrim: rgba(6, 7, 14, .68);
    --shadow-sm: 0 6px 18px rgba(0, 0, 0, .5);
    --shadow-md: 0 16px 40px rgba(0, 0, 0, .55);
    --shadow-lg: 0 24px 60px rgba(0, 0, 0, .55);

    /* Tells the UA which way to paint scrollbars, form controls and the caret. */
    color-scheme: dark;

    --mono: 'Inter', system-ui, -apple-system, sans-serif;
    --sans: 'Inter', system-ui, -apple-system, sans-serif;

    --radius-sm: 4px;
    --radius-md: 8px;

    /* The rail's width, so body padding and the fixed aside cannot drift. */
    --rail-w: 214px;
    /* Collapsed, the rail keeps only the glyphs: 14px of padding either side of a
       .rail-link's own 6px + its 15px icon. Narrower and the icons stop being
       centred on anything; wider and it stops reading as a strip. */
    --rail-w-collapsed: 56px;
    /* The estimate chat's width, so the body padding and the fixed panel cannot
       drift — the same arrangement --rail-w has on the other side of the page.
       It is the DEFAULT rather than the value: the panel is resizable, and the
       preference is written back as an inline --chat-w on <html>, which beats
       this declaration exactly as the branding bootstrap's --brand-primary does.
       380px is what holds a two-or-three-sentence reply at 12.5px without the
       bubbles becoming a column of fragments. */
    --chat-w: 380px;
  }

  /* ══════════════════════════════════════════════════════════════════════
     Daybreak — the light palette.

     Nothing here is a new rule: it restates the SAME role tokens :root declares
     above, and every selector in this file and in every page's own <style>
     already resolves through them. That is the whole feature — the dark theme
     having been built as roles rather than colours is what makes the light one a
     block of variables instead of a second stylesheet.

     Selected by data-theme="light" on <html>, written by the theme bootstrap in
     each page's <head> before first paint and re-applied by applyTheme() in
     app.js once /me lands. Dark is BARE :root rather than [data-theme="dark"] on
     purpose: an unset preference, a failed bootstrap and a signed-out page all
     render byte-identically to the app as it was before this block existed — the
     same invariant --brand-primary's default keeps.

     What is deliberately NOT restated here:
       - --brand-primary. It is the tenant's own colour, and agents/branding.py
         already clamps it to >= 4.5:1 against WHITE — so it is the light ground it
         was always fit for. applyBranding() sets it inline on <html>, which beats
         any [data-theme] rule anyway.
       - The favicon badges. They are drawn outside the document, on browser
         chrome this page does not own and cannot measure, so they stay pinned to
         :root's traffic light in both themes (FAVICON_BADGES in app.js).
     ══════════════════════════════════════════════════════════════════════ */
  :root[data-theme="light"] {
    --bg: #ffffff;          /* app ground */
    --bg2: #f5f6f9;         /* raised surface — rows, cards, inputs */
    --bg3: #e9ebf1;         /* hover / active tint */
    --border: #e3e5ec;      /* hairline — the default divider */
    --border-bright: #c6cad6;  /* the stronger rule: table heads, dashed drops */

    /* The dark theme lifts the brand hue toward white because a colour clamped for
       white text is by construction too dark to read on #161826. Here that clamp is
       exactly the right one already, so the interface accent IS the brand colour —
       and a tenant's inline --brand-primary still reaches all 85 var(--teal) uses
       through it. The hover DARKENS, which is also what agents/branding.py's
       derive_hover() computes, so a branded tenant's inline hover is already right. */
    --brand-primary-hover: color-mix(in srgb, var(--brand-primary) 78%, #000000);
    --teal: var(--brand-primary);
    --teal-dim: color-mix(in srgb, var(--brand-primary) 10%, transparent);
    --teal-glow: 0 0 0 1px var(--brand-primary),
                 0 0 0 3px color-mix(in srgb, var(--brand-primary) 16%, transparent);

    /* The traffic light restated for a white ground — the dark theme's #2eb98d,
       #d99b2e and #e0645a measure 2.2:1, 2.3:1 and 3.2:1 here. Green stays a green
       of its own rather than the brand teal, so "done" and "the accent" remain two
       distinguishable things for the default tenant; amber is darker than the old
       white theme's #b36b00, whose 4.18:1 left the one WARNING colour the weakest
       of the three, where on the dark ground it is the strongest.
         on #ffffff:  --ok 5.15:1  --amber 5.38:1  --red 5.44:1
         on #161826:  --ok 7.08:1  --amber 7.28:1  --red 5.15:1  */
    --ok: #0a7d54;
    --ok-dim: color-mix(in srgb, var(--ok) 10%, transparent);
    --ok-line: color-mix(in srgb, var(--ok) 30%, transparent);
    --red-dim:    color-mix(in srgb, var(--red) 10%, transparent);
    --red-line:   color-mix(in srgb, var(--red) 30%, transparent);
    --amber-dim:  color-mix(in srgb, var(--amber) 10%, transparent);
    --amber-line: color-mix(in srgb, var(--amber) 30%, transparent);
    --grey-dim:   color-mix(in srgb, var(--grey) 12%, transparent);
    --grey-line:  color-mix(in srgb, var(--grey) 30%, transparent);
    --amber: #9a5c00;
    --red: #c0392b;
    --grey: #6b7183;
    --text: #1b1d2a;
    --text-dim: #5b6076;
    --text-faint: #767b8f;   /* the design's quietest label — column heads, meta */

    --match-exact: #0a7d54;
    --match-interpolated: #9a5c00;
    --match-ai: #c0392b;

    --variance-over:  #c0392b;
    --variance-under: #0a7d54;

    --blue: #3a5ba8;
    --blue-dim: rgba(58, 91, 168, .10);

    /* The same geometry at a fraction of the alpha: a .55 black shadow on white
       reads as a smudge rather than as depth, and a near-black scrim as a blackout. */
    --scrim: rgba(27, 29, 42, .38);
    --shadow-sm: 0 6px 18px rgba(27, 29, 42, .14);
    --shadow-md: 0 16px 40px rgba(27, 29, 42, .14);
    --shadow-lg: 0 24px 60px rgba(27, 29, 42, .18);

    color-scheme: light;
  }

  body {
    background: var(--bg);
    color: var(--text);
    font-family: var(--sans);
    font-size: 13.5px;
    line-height: 1.55;
    min-height: 100vh;
    padding: 0 0 80px;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  /* ══ The app rail ══════════════════════════════════════════════════════
     Every signed-in page hangs off one persistent left rail rather than
     re-deriving its own way back: the tool, the projects, the catalog and the
     engineering knowledge are four places, and a page that only knows how to go
     "back to the toolbox" makes the other three a two-hop trip.

     It is position:fixed and injected by renderAppRail() in app.js rather than
     wrapping the page in a flex shell, which is what keeps every existing
     page's markup — and every position:fixed modal, save bar and popover
     already on it — working untouched. body.app-shell is the opt-in; a page
     without the class (the four auth pages) simply never grows the padding.
     ══════════════════════════════════════════════════════════════════════ */
  body.app-shell {
    padding-left: var(--rail-w);
    /* Both paddings, in ONE declaration: a second `transition` on this selector
       further down the file would replace this one rather than add to it, and the
       rail would start snapping instead of sliding with nothing to say why. */
    transition: padding-left .16s ease, padding-right .16s ease;
  }

  .app-rail {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    /* Above the page, below every popover and overlay: the description pop-out
       (60), the info bubbles (50) and the modal scrims (100+) all have to be
       able to paint over the rail, and the full-screen catalog table covers it
       on purpose. */
    width: var(--rail-w);
    z-index: 40;
    display: flex;
    flex-direction: column;
    gap: 14px;
    padding: 14px 10px;
    background: var(--bg);
    border-right: 1px solid var(--border);
    overflow-y: auto;
    transition: width .16s ease;
  }
  /* The title row and the company name under it. Wrapped rather than left as two
     children of the rail, whose 14px gap is a gap between SECTIONS — the company
     is a caption on the title, not a section of its own. */
  .rail-top { display: flex; flex-direction: column; gap: 1px; }
  /* The brand and the collapse control share one row, so collapsing has one
     element to restack rather than two to keep in step. */
  .rail-head { display: flex; align-items: center; gap: 4px; }
  /* The product title, which is also the switch between the two products — so it
     is a <button>, not the link to "/" it used to be. It keeps .rail-brand's name
     and shape because it is still the same thing in the same place: what changed
     is that pressing it now offers somewhere to go rather than assuming one. */
  .rail-brand {
    flex: 1 1 auto; min-width: 0;
    display: flex; align-items: center; gap: 8px;
    padding: 2px 6px;
    border: none; border-radius: var(--radius-sm);
    background: none; font-family: var(--sans);
    text-align: left; text-decoration: none; color: inherit;
    cursor: pointer;
    transition: background .15s;
  }
  .rail-brand:hover { background: var(--bg2); }
  .rail-brand[aria-expanded="true"] { background: var(--bg3); }
  /* The mark is the app's, not the tenant's, until a tenant has one — the same
     .logo-mark contract the headers used, so html[data-brand-logo] reaches it. */
  .rail-mark {
    width: 18px; height: 18px; flex: none;
    border-radius: 5px;
    background: var(--brand-primary);
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--brand-primary) 60%, var(--text));
  }
  .rail-name {
    font-size: 14px; font-weight: 500; letter-spacing: -0.01em; color: var(--text);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* Pushed to the far edge of the title, so the caret marks the whole control as
     a switch rather than decorating the word. */
  .rail-brand > svg { flex: none; margin-left: auto; color: var(--text-faint); }
  .rail-brand:hover > svg { color: var(--text-dim); }
  /* Out of the title row: the title is now a control, and the company name is not
     part of what pressing it does. It gets the full width here rather than the
     84px it was squeezed into beside the wordmark. */
  .rail-company {
    padding: 0 6px;
    font-size: 11px; color: var(--text-faint);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }

  /* ── The product switcher's menu ──
     A single position:fixed node parked on <body>, the shape the catalog's index
     tooltips take and for the same reason: .app-rail is overflow-y:auto, which
     clips an absolutely positioned child, and collapsed it is 56px wide with no
     room for two product names. app.js positions it from the button's rect. */
  .product-pop {
    position: fixed;
    z-index: 60;
    width: 216px;
    padding: 4px;
    background: var(--bg2);
    border: 1px solid var(--border-bright);
    border-radius: 8px;
    box-shadow: var(--shadow-lg);
  }
  .product-item {
    display: flex; align-items: center; gap: 8px;
    padding: 7px 8px; border-radius: var(--radius-sm);
    text-decoration: none; color: var(--text);
    transition: background .15s;
  }
  .product-item:hover { background: var(--bg3); }
  .product-item-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
  .product-item-name { font-size: 13px; font-weight: 500; letter-spacing: -0.01em; }
  .product-item-desc { font-size: 11px; color: var(--text-faint); }
  /* The row is the only place both products are on screen at once, so which one
     you are in has to read without comparing against the title above. */
  .product-item-check { margin-left: auto; flex: none; display: flex; color: var(--teal); }
  .product-item-check:empty { display: none; }
  /* A product this company was not sold. Dimmed rather than disabled-looking: it is
     still a live control — pressing it is how you ask for the product — so it keeps its
     hover. The lock shares the check's slot, in --text-faint rather than the accent, so
     "yours" and "not yours" cannot be mistaken for each other at a glance. */
  .product-item.is-locked .product-item-name,
  .product-item.is-locked .product-item-desc { color: var(--text-faint); }
  .product-item.is-locked .product-item-check { color: var(--text-faint); }
  .rail-nav { display: flex; flex-direction: column; gap: 2px; }
  .rail-link {
    display: flex; align-items: center; gap: 9px;
    padding: 6px; border-radius: var(--radius-sm);
    font-size: 12.5px; color: var(--text-dim);
    text-decoration: none;
    transition: background .15s, color .15s;
  }
  .rail-link svg { flex: none; }
  .rail-link:hover { background: var(--bg2); color: var(--text); }
  .rail-link.active { background: var(--bg3); color: var(--text); }
  /* ── Recents ──
     The previous estimates, which used to be a list at the foot of the estimator
     page. It gets its OWN scroll box rather than letting the rail scroll: the five
     links and the basis footer are navigation, and the forty-third estimate must
     not be able to push them off the screen. */
  .rail-recents {
    flex: 1 1 auto; min-height: 0;
    display: flex; flex-direction: column; gap: 2px;
  }
  .rail-section-label {
    padding: 0 6px 2px;
    font-size: 10px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase;
    color: var(--text-faint);
  }
  .rail-recent-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
  /* Today / Yesterday / Previous 7 days …, the way a chat history reads. The list
     arrives newest-first, so these are contiguous and cost no sort. */
  .rail-recent-group {
    padding: 8px 6px 3px;
    font-size: 10.5px; color: var(--text-faint);
  }
  .rail-recent-group:first-child { padding-top: 2px; }
  .rail-recent {
    display: block;
    padding: 5px 6px; border-radius: var(--radius-sm);
    text-decoration: none; color: var(--text-dim);
    transition: background .15s, color .15s;
  }
  .rail-recent:hover { background: var(--bg2); color: var(--text); }
  /* The same mark the current page's link carries: the estimate on screen is where
     you are, and the rail says so the one way it already says it. */
  .rail-recent.active { background: var(--bg3); color: var(--text); }
  .rail-recent-title {
    display: block;
    font-size: 12.5px; line-height: 1.35;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .rail-recent-meta {
    display: flex; align-items: center; gap: 6px;
    font-size: 10.5px; color: var(--text-faint);
  }
  .rail-recent-total { margin-left: auto; }
  /* Draft or finalized at the size the rail has room for. The word itself is on the
     item's title attribute, next to the description the 214px could not hold. */
  .rail-recent-dot {
    width: 5px; height: 5px; flex: none; border-radius: 50%;
    background: var(--text-faint);
  }
  .rail-recent.is-finalized .rail-recent-dot { background: var(--ok); }
  .rail-recent-note { padding: 4px 6px; font-size: 11.5px; color: var(--text-faint); }

  .rail-foot {
    margin-top: auto;
    display: flex; flex-direction: column; gap: 6px;
    padding: 10px 8px 2px;
    border-top: 1px solid var(--border);
  }
  .rail-foot-label { font-size: 11px; color: var(--text-faint); }
  .rail-foot-value { font-size: 12px; color: var(--text-dim); }

  .rail-collapse {
    flex: none;
    display: flex; align-items: center; justify-content: center;
    width: 24px; height: 24px; padding: 0;
    border: none; border-radius: var(--radius-sm);
    background: none; color: var(--text-faint);
    cursor: pointer;
    transition: background .15s, color .15s;
  }
  .rail-collapse:hover { background: var(--bg2); color: var(--text); }
  /* One glyph, turned around, rather than two paths that could disagree about
     which direction the rail is about to move in. */
  .rail-collapse svg { transition: transform .16s ease; }

  /* ── Collapsed ──
     The state lives on <html>, exactly the way data-theme does, and for the same
     reason: body padding has to be right before first paint, and in the <head>
     there is no <body> yet to put a class on. Expanded is the BARE default — no
     attribute — so a browser that has never been collapsed renders the rail
     byte-identically to what it was before this existed. */
  html[data-rail="collapsed"] body.app-shell { padding-left: var(--rail-w-collapsed); }
  html[data-rail="collapsed"] .app-rail { width: var(--rail-w-collapsed); }
  /* The mark and the control cannot both fit across 56px, so they stack. */
  html[data-rail="collapsed"] .rail-head { flex-direction: column; gap: 8px; }
  html[data-rail="collapsed"] .rail-link { justify-content: center; }
  html[data-rail="collapsed"] .rail-collapse svg { transform: rotate(180deg); }
  /* Everything that is words. The labels survive as the links' title attribute,
     which syncRailCollapsed() adds and removes with the state — a tooltip
     repeating a label the reader can already see is chrome. */
  html[data-rail="collapsed"] .rail-name,
  html[data-rail="collapsed"] .rail-brand > svg,
  html[data-rail="collapsed"] .rail-company,
  html[data-rail="collapsed"] .rail-link span,
  html[data-rail="collapsed"] .rail-recents,
  html[data-rail="collapsed"] .rail-foot { display: none; }
  /* The mark alone, still pressable and still the switch — its name is on the
     title attribute syncRailCollapsed() writes, beside the links' own. */
  html[data-rail="collapsed"] .rail-brand { justify-content: center; padding: 4px 0; }

  /* ── Header ── */
  header {
    border-bottom: 1px solid var(--border);
    padding: 0 18px;
    height: 46px;
    display: flex;
    align-items: center;
    gap: 12px;
  }
  /* The rail carries the mark and the way back, so the header keeps only the
     page's own name and its actions — which is what buys the 46px. */
  body.app-shell header > .back-link,
  body.app-shell header > .logo-mark { display: none; }
  .logo-mark {
    width: 32px; height: 32px;
    border: 1.5px solid var(--teal);
    border-radius: 4px;
    display: flex; align-items: center; justify-content: center;
    font-family: var(--mono);
    font-size: 13px;
    color: var(--teal);
    letter-spacing: -1px;
  }
  /* Company logo. Painted as a background on the existing mark element rather than
     swapped in as an <img>, so the branding bootstrap in each page's <head> can
     apply it before first paint — an <img> could only be inserted once app.js
     parses at end of body, which guarantees the default mark flashes first.
     Two selectors because the toolbox home has its own brand markup.

     `contain` plus a hairline of padding letterboxes an arbitrary company asset
     instead of distorting it; the header's fixed 32px flex box means no layout
     shift while the image loads. */
  html[data-brand-logo] .logo-mark,
  html[data-brand-logo] .rail-mark,
  html[data-brand-logo] .tb-brand-mark {
    background-image: var(--brand-logo);
    background-position: left center;
    background-repeat: no-repeat;
    background-size: contain;
    border: none;
    color: transparent;   /* hides the "CC" text without editing eight page headers */
    padding: 1px;
    /* The box keeps its 32px height and widens to the artwork's real aspect
       ratio. Many companies publish no square icon — Hydro-Quebec's only asset is
       a 3:1 wordmark — and forcing one into a 32px square renders it 32x10 and
       illegible. applyBranding() measures the loaded image and sets the ratio;
       until then 1 keeps the original square, so nothing jumps on a cold load. */
    width: calc(32px * var(--brand-logo-aspect, 1));
    max-width: 132px;
  }
  html[data-brand-logo] .rail-mark {
    box-shadow: none;
    background-color: transparent;
    width: calc(18px * var(--brand-logo-aspect, 1));
    max-width: 96px;
  }
  /* A wordmark is wider than it is tall — Hydro-Quebec's only asset is 3:1 — and
     96px of it does not fit a 56px rail. Declared here rather than in the
     collapsed block above because these two selectors weigh the same, so the
     later one is the one that wins. */
  html[data-rail="collapsed"] .rail-mark { max-width: 32px; }
  /* The home page's default mark is an inline <svg> inside the box, and a
     background paints behind it — so take it out of flow when a logo is present. */
  html[data-brand-logo] .tb-brand-mark > svg { display: none; }

  .header-title {
    font-size: 13px;
    font-weight: 500;
    letter-spacing: 0.01em;
    color: var(--text-dim);
  }
  .header-title span { color: var(--text); }

  .back-link {
    font-family: var(--sans);
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
    text-decoration: none;
    display: flex;
    align-items: center;
    gap: 5px;
    transition: color .2s;
    white-space: nowrap;
    margin-right: 4px;
  }
  .back-link:hover { color: var(--teal); }
  .back-link::before { content: '←'; font-size: 13px; }
  /* Right-hand header group (language toggle + icon buttons). Mirrors the toolbox
     home's header so every page presents the same controls at the same spacing. */
  .header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }

  /* Segmented toggle — the EN/FR switch, and the Tools/Projects switch on the home
     page. Shared here so the two stay identical; the vars below resolve to the same
     values as the home page's --rule / --ink-3 / --accent set. */
  .tb-view-toggle {
    display: flex;
    align-items: center;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    overflow: hidden;
  }
  .tb-toggle-btn {
    background: transparent;
    border: none;
    padding: 3px 10px;
    font-family: var(--mono);
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--text-dim);
    cursor: pointer;
    transition: background .15s, color .15s;
  }
  .tb-toggle-btn + .tb-toggle-btn { border-left: 1px solid var(--border); }
  .tb-toggle-btn.active { background: var(--brand-primary); color: #fff; }
  .tb-toggle-btn:not(.active):hover { background: var(--bg3); color: var(--text); }

  /* Outlined header button — the EN/FR toggle used to wear this before moving to the
     segmented .tb-view-toggle above. Still used for one-off header actions such as
     project.html's "Edit details". */
  .lang-btn {
    background: none; border: 1px solid var(--border); border-radius: 3px;
    padding: 3px 10px; font-family: var(--mono); font-size: 11px;
    font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase;
    color: var(--text-dim); cursor: pointer; transition: border-color 0.15s, color 0.15s;
  }

  /* Icon-only header buttons (settings, sign out). The glyph is a CSS mask rather than
     inline SVG so it tracks currentColor and the artwork lives here instead of being
     copy-pasted into all nine page headers. Icons carry no text, so they must be
     labelled with data-i18n-title / data-i18n-aria-label — never data-i18n, which
     setLanguage() would use to overwrite the element's content. */
  .icon-btn {
    background: none;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 4px;
    color: var(--text-dim);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    text-decoration: none;
    transition: border-color 0.15s, color 0.15s;
  }
  .icon-btn:hover { border-color: var(--border-bright); color: var(--text); background: var(--bg2); }
  .icon-btn::before {
    content: '';
    width: 14px;
    height: 14px;
    background-color: currentColor;
    -webkit-mask: var(--icon) center / contain no-repeat;
    mask: var(--icon) center / contain no-repeat;
  }
  .icon-settings {
    --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");
  }
  .icon-signout {
    --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'/%3E%3Cpolyline points='16 17 21 12 16 7'/%3E%3Cline x1='21' y1='12' x2='9' y2='12'/%3E%3C/svg%3E");
  }
  .icon-profile {
    --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
  }

  /* User menu — the panel the header's profile icon opens (toggleUserMenu in app.js).
     Fixed-positioned and anchored from JS rather than absolutely placed inside the
     header, because the two header layouts differ: the toolbox home's .tb-header is
     sticky and every other page's <header> is not. positionUserMenu() re-anchors it
     on scroll and resize, so both behave identically. */
  .user-pop {
    position: fixed;
    z-index: 200;
    background: var(--bg2);
    border: 1px solid var(--border-bright);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-md);
    min-width: 220px;
    max-width: 320px;
    padding: 12px 14px;
  }
  .user-pop-row + .user-pop-row { margin-top: 10px; }
  .user-pop-label {
    font-family: var(--mono);
    font-size: 9.5px;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--text-faint);
    margin-bottom: 2px;
  }
  .user-pop-value {
    font-size: 13px;
    color: var(--text);
    line-height: 1.4;
    overflow-wrap: anywhere;
  }

  /* ── Main layout ── */
  main {
    max-width: 1180px;
    margin: 0 auto;
    padding: 26px 22px 0;
  }

  /* ── Section label ── */
  .section-label {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--text-faint);
    margin-bottom: 12px;
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .section-label::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--border);
  }
  /* label with explain button — don't use ::after when explain present */
  .section-label-row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 12px;
  }
  .section-label-row .section-label {
    flex: 1;
    margin-bottom: 0;
  }
  .section-label-row .section-label::after { display: none; }

  /* ── Drop zone ── */
  .dropzone {
    border: 1px dashed var(--border-bright);
    background: var(--bg2);
    border-radius: var(--radius-md);
    padding: 36px;
    text-align: center;
    cursor: pointer;
    transition: border-color 0.15s, background 0.15s;
    position: relative;
  }
  .dropzone:hover, .dropzone.drag-over {
    border-color: var(--teal);
    background: var(--teal-dim);
  }
  .dropzone input[type=file] {
    position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
  }
  .dropzone-icon { font-size: 32px; margin-bottom: 10px; opacity: 0.5; }
  .dropzone-label {
    font-family: var(--mono);
    font-size: 12px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-bottom: 4px;
  }
  .dropzone-sub { font-size: 11px; color: var(--text-dim); opacity: 0.6; }
  .dropzone.has-file .dropzone-icon { opacity: 1; }
  .file-name {
    font-family: var(--mono);
    font-size: 13px;
    color: var(--teal);
    margin-top: 8px;
  }

  /* ── Pipeline dropzones ── */
  .dropzone-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
  }
  .file-list {
    margin-top: 8px;
    display: flex;
    flex-direction: column;
    gap: 3px;
  }
  .file-list-item {
    font-family: var(--mono);
    font-size: 11px;
    color: var(--teal);
    display: flex;
    align-items: center;
    gap: 6px;
  }
  .file-list-item::before { content: '·'; opacity: 0.5; }
  .file-remove-btn {
    background: none;
    border: none;
    color: var(--teal);
    opacity: 0.45;
    cursor: pointer;
    font-size: 14px;
    line-height: 1;
    padding: 0 2px;
  }
  .file-remove-btn:hover { opacity: 1; }

  /* ── Run button ── */
  #step-pipeline-run { margin-bottom: 40px; display: flex; gap: 12px; align-items: center; margin-top: 24px; }
  .btn-run {
    background: var(--teal);
    color: var(--bg);
    border: none;
    border-radius: 4px;
    padding: 12px 32px;
    font-family: var(--mono);
    font-size: 13px;
    font-weight: 500;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    cursor: pointer;
    transition: opacity 0.15s, transform 0.1s;
  }
  .btn-run:disabled {
    opacity: 0.28;
    cursor: not-allowed;
    transform: none;
  }
  .btn-run:not(:disabled):hover { opacity: 0.88; }
  .btn-run:not(:disabled):active { transform: scale(0.97); }

  .btn-cancel {
    background: transparent;
    color: var(--red);
    border: 1px solid color-mix(in srgb, var(--red) 40%, transparent);
    border-radius: 4px;
    padding: 8px 20px;
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    cursor: pointer;
    transition: border-color 0.15s, background 0.15s;
    margin-top: 14px;
    display: none;
  }
  .btn-cancel:hover {
    border-color: var(--red);
    background: color-mix(in srgb, var(--red) 12%, transparent);
  }

  .btn-reset {
    background: transparent;
    color: var(--text-dim);
    border: 1px solid var(--border-bright);
    border-radius: 4px;
    padding: 12px 24px;
    font-family: var(--mono);
    font-size: 12px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    cursor: pointer;
    transition: border-color 0.15s, color 0.15s;
    display: none;
  }
  .btn-reset:hover { border-color: var(--text-dim); color: var(--text); }

  /* ── Progress ── */
  #progress-section { display: none; margin-bottom: 40px; }
  .progress-bar-track {
    height: 2px;
    background: var(--border);
    border-radius: 1px;
    overflow: hidden;
    margin-bottom: 14px;
  }
  .progress-bar-fill {
    height: 100%;
    width: 30%;
    background: var(--teal);
    border-radius: 1px;
    animation: indeterminate 1.8s ease-in-out infinite;
  }
  .progress-bar-fill.determinate {
    animation: none;
    width: 0%;
    transition: width 0.55s cubic-bezier(0.4, 0, 0.2, 1);
  }
  @keyframes indeterminate {
    0%   { transform: translateX(-100%); width: 40%; }
    50%  { width: 60%; }
    100% { transform: translateX(350%); width: 40%; }
  }
  .progress-status {
    font-family: var(--mono);
    font-size: 12px;
    color: var(--teal);
    letter-spacing: 0.06em;
  }
  .progress-status::before { content: '▶ '; opacity: 0.6; }

  /* ── Step indicator ── */
  .step-list {
    display: flex;
    align-items: flex-start;
    margin-top: 22px;
    margin-bottom: 4px;
  }
  .step-item {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    position: relative;
  }
  /* connector line between steps */
  .step-item:not(:last-child)::after {
    content: '';
    position: absolute;
    top: 13px;
    left: calc(50% + 14px);
    right: calc(-50% + 14px);
    height: 1.5px;
    background: var(--border);
    z-index: 0;
    transition: background 0.4s;
  }
  .step-item.done:not(:last-child)::after  { background: var(--teal); }
  .step-item.active:not(:last-child)::after {
    background: linear-gradient(to right, var(--teal) 0%, var(--border) 100%);
  }
  .step-dot {
    width: 28px; height: 28px;
    border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    font-size: 11px;
    font-family: var(--mono);
    position: relative; z-index: 1;
    border: 1.5px solid var(--border-bright);
    background: var(--bg2);
    color: var(--text-dim);
    transition: border-color 0.3s, background 0.3s, color 0.3s;
  }
  .step-item.done .step-dot {
    border-color: var(--teal);
    background: var(--teal);
    color: var(--bg);
    font-size: 13px;
  }
  .step-item.active .step-dot {
    border-color: var(--teal);
    background: var(--bg);
    color: var(--teal);
    animation: pulse-step 1.6s ease-in-out infinite;
  }
  @keyframes pulse-step {
    0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--brand-primary) 35%, transparent); }
    50%       { box-shadow: 0 0 0 7px transparent; }
  }
  .step-spinner {
    width: 12px; height: 12px;
    border: 2px solid color-mix(in srgb, var(--brand-primary) 25%, transparent);
    border-top-color: var(--teal);
    border-radius: 50%;
    animation: spin 0.75s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* ══ Skeletons ═════════════════════════════════════════════════════════
     Every app page paints its chrome instantly and then fills itself from
     four to six independent round trips, several of which leave their region
     STONE EMPTY while they wait — the rail's recents, the project page's three
     cards, the units list. The page therefore assembled itself in front of the
     reader, which is what read as a buggy transition. A stub of the right
     SHAPE, shown for exactly that window, is the whole feature.

     Two things it deliberately does not do. It declares no :root token of its
     own: --bg3 and --text-faint are already restated under [data-theme="light"],
     and mixing the sheen TOWARD --text-faint lightens on the dark ground and
     darkens on the white one, so one declaration reads in both themes and
     theme.test.js's "every painted token must be restated" rule is untouched.
     And it carries no data-i18n and no text, so setLanguage()'s sweep cannot
     reach it and the feature adds no translation key.

     This is also the repo's first prefers-reduced-motion block. It is scoped to
     .skeleton alone rather than sweeping up the four animations that predate it
     — those are a separate decision, and taking them silently would be one. */
  .skeleton {
    display: block;
    border-radius: var(--radius-sm);
    background-color: var(--bg3);
    background-image: linear-gradient(100deg,
      transparent 20%,
      color-mix(in srgb, var(--bg3) 55%, var(--text-faint)) 45%,
      transparent 70%);
    background-size: 220% 100%;
    background-repeat: no-repeat;
    animation: skeleton-sweep 1.4s linear infinite;
  }
  @keyframes skeleton-sweep {
    from { background-position: 120% 0; }
    to   { background-position: -120% 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    /* Flat, still visible. A stalled backend looking identical to a fast one is
       the cost; a reader who asked for no motion getting a shimmer is worse. */
    .skeleton { animation: none; background-image: none; }
  }
  /* One line of stubbed text. The height is the app's 13.5px body leading rather
     than a round number, so a stub column is the height the real one will be and
     nothing reflows when the data lands. */
  .skeleton-line { height: 9px; margin-bottom: 6px; }
  .skeleton-line:last-child { margin-bottom: 0; }
  .skeleton-dot { width: 5px; height: 5px; flex: none; border-radius: 50%; }
  /* Stubs sit inside the container they are standing in for, so a row of them
     needs the row's own layout rather than a second one. */
  .skeleton-row { display: flex; align-items: center; gap: 8px; }
  .skeleton-row .skeleton-line { margin-bottom: 0; }

  .step-label {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-top: 9px;
    text-align: center;
    transition: color 0.3s;
  }
  .step-item.done .step-label   { color: var(--teal); }
  .step-item.active .step-label { color: var(--teal); font-weight: 500; }
  .step-time {
    font-family: var(--mono);
    font-size: 10px;
    color: var(--text-dim);
    margin-top: 3px;
    text-align: center;
    min-height: 14px;
    transition: color 0.3s;
  }
  .step-item.done .step-time   { color: var(--teal); opacity: 0.7; }
  .step-item.active .step-time { color: var(--teal); }
  .step-desc {
    font-family: var(--mono);
    font-size: 11px;
    color: var(--teal);
    letter-spacing: 0.05em;
    margin-top: 18px;
    text-align: center;
    opacity: 0.85;
  }
  .step-desc::before { content: '▶ '; opacity: 0.5; }

  /* ── Error ── */
  #error-section {
    display: none;
    background: color-mix(in srgb, var(--red) 10%, transparent);
    border: 1px solid color-mix(in srgb, var(--red) 34%, transparent);
    border-radius: 6px;
    padding: 16px 20px;
    margin-bottom: 32px;
    font-family: var(--mono);
    font-size: 12px;
    color: var(--red);
  }
  /* error prefix is set programmatically for i18n */

  /* ── Results ── */
  #results-section { display: none; }
  .result-block { margin-bottom: 40px; }

  /* ── Load breakdown (base vs weather-driven) ── */
  /* Sits directly above .chart-container, whose control buttons hang at
     top:-6px — hence the roomy bottom margin. */
  .load-split {
    display: flex;
    flex-direction: column;
    gap: 14px;
    margin-bottom: 22px;
  }
  .load-split-head {
    display: flex;
    align-items: baseline;
    gap: 10px;
    margin-bottom: 6px;
  }
  .load-split-source {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--text-dim);
  }
  .load-split-total {
    font-size: 11px;
    color: var(--text-dim);
    opacity: 0.75;
  }
  .load-split-bar {
    display: flex;
    height: 14px;
    border-radius: 4px;
    overflow: hidden;
    /* 2px surface gaps between segments, drawn by the container showing
       through rather than by borders, so the ends stay flush with the radius. */
    gap: 2px;
    background: var(--bg);
  }
  .load-split-bar > span { display: block; min-width: 2px; }
  .load-split-keys {
    display: flex;
    flex-wrap: wrap;
    gap: 4px 16px;
    margin-top: 7px;
    font-size: 11px;
    color: var(--text-dim);
  }
  .load-split-key { display: inline-flex; align-items: center; gap: 6px; }
  .load-split-key > i {
    width: 9px;
    height: 9px;
    border-radius: 2px;
    flex: none;
  }
  /* The percentage carries the identity when colour cannot — the fills sit
     below 3:1 contrast on white, so this is not decoration. */
  .load-split-key > b { color: var(--text); font-weight: 600; }

  /* ── Chart ── */
  .chart-container {
    position: relative;
    height: 320px;
    margin-bottom: 28px;
  }

  /* ── Weather Analyst ── */
  /* The import form. Plain fields rather than a dropzone: this tool takes
     parameters, not documents. */
  .wx-field { margin-bottom: 6px; }
  .wx-field label {
    display: block;
    font-family: var(--mono);
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-bottom: 6px;
  }
  .wx-field-row { display: flex; gap: 18px; flex-wrap: wrap; }
  .wx-input {
    background: var(--bg2);
    border: 1px solid var(--border-bright);
    border-radius: 4px;
    padding: 9px 12px;
    font-family: var(--sans);
    font-size: 14px;
    color: var(--text);
    width: 100%;
    max-width: 460px;
    box-sizing: border-box;
  }
  .wx-field-row .wx-input { max-width: 200px; }
  .wx-input:focus { outline: none; border-color: var(--teal); }
  .wx-hint { margin: 8px 0 0; font-size: 12px; color: var(--text-dim); }
  .wx-checks { display: flex; gap: 22px; flex-wrap: wrap; }

  /* Address autocomplete — position: relative is the dropdown's containing block,
     so it tracks the input's own max-width rather than the full section width. */
  .wx-address-wrap { position: relative; max-width: 460px; }
  .wx-suggest-list {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    z-index: 50;
    margin-top: 4px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 4px;
    box-shadow: var(--shadow-sm);
    max-height: 260px;
    overflow-y: auto;
  }
  .wx-suggest-list[hidden] { display: none; }
  .wx-suggest-item {
    display: block;
    padding: 8px 12px;
    font-size: 13px;
    color: var(--text);
    cursor: pointer;
    white-space: normal;
    line-height: 1.35;
  }
  .wx-suggest-item:hover, .wx-suggest-item.active { background: var(--bg2); }
  .wx-suggest-item + .wx-suggest-item { border-top: 1px solid var(--border); }

  /* Stacked panels sharing one x-axis. Height is auto (label + fixed-height canvas
     wrap below) rather than a fixed height on the whole panel, so the label's own
     box no longer competes with Chart.js's canvas sizing for the same space — that
     was what let one panel's canvas bleed into the next panel's title. The gap
     between panels is generous enough to keep a title clear of the chart above it,
     while still reading as one stacked chart rather than three unrelated ones. */
  .weather-stack .chart-container { height: auto; margin-bottom: 28px; }
  .weather-stack .chart-container:last-child { margin-bottom: 20px; }
  .weather-stack .wx-canvas-wrap { position: relative; height: 200px; }
  .wx-panel-label {
    font-family: var(--mono);
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    color: var(--text-dim);
    margin-bottom: 6px;
  }

  /* Drill breadcrumb — the Weather Analyst's explorer and the building profile's
     ventilation-consumption chart both drill month → day → hour, so the trail is a
     property of that gesture rather than of weather. Renamed from .wx-* when the
     second one arrived, the same reasoning .transposed-scroll carries. */
  .drill-breadcrumb {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    margin-bottom: 6px;
  }
  .drill-crumb {
    background: none;
    border: none;
    padding: 0;
    font-family: var(--mono);
    font-size: 12px;
    color: var(--teal);
    cursor: pointer;
  }
  .drill-crumb:hover { text-decoration: underline; }
  .drill-crumb-current { color: var(--text); cursor: default; font-weight: 600; }
  .drill-crumb-sep { color: var(--text-dim); font-size: 12px; }
  .drill-hint { margin-left: auto; font-size: 11px; color: var(--text-dim); }
  /* The consumption chart's trail sits above a .chart-container whose filter
     buttons hang at top:-6px, so it needs room the weather stack's does not. */
  .cu-breadcrumb { margin-bottom: 14px; }
  .wx-meta { font-size: 12px; color: var(--text-dim); margin-bottom: 14px; }
  .wx-table-note { margin-top: 8px; font-size: 12px; color: var(--text-dim); }
  .wx-warning {
    background: color-mix(in srgb, var(--amber) 12%, transparent);
    border: 1px solid var(--amber);
    border-radius: 4px;
    padding: 10px 14px;
    font-size: 13px;
    color: var(--text);
    margin-bottom: 16px;
  }

  /* ── Explain toggle ── */
  .btn-explain {
    background: var(--teal);
    border: none;
    border-radius: 4px;
    padding: 6px 14px;
    font-family: var(--mono);
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: white;
    cursor: pointer;
    transition: background-color 0.15s, box-shadow 0.15s;
    white-space: nowrap;
    flex-shrink: 0;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  .btn-explain.open { background-color: var(--teal); box-shadow: 0 2px 8px color-mix(in srgb, var(--brand-primary) 30%, transparent); }

  /* ── Chart controls (year filter + "add data"), pinned above the chart ── */
  .chart-controls-wrap {
    position: absolute;
    top: -6px;
    right: 6px;
    z-index: 5;
    display: flex;
    gap: 6px;
  }
  .chart-btn-wrap { position: relative; }
  .btn-chart-control {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    border: 1px solid var(--border-bright);
    background: var(--bg);
    color: var(--text-dim);
    font-size: 15px;
    line-height: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: border-color 0.15s, color 0.15s, background 0.15s;
  }
  .btn-chart-control:hover, .btn-chart-control.open { border-color: var(--teal); color: var(--teal); }
  .btn-chart-control.active { background: var(--teal); border-color: var(--teal); color: var(--bg); }
  .chart-control-menu {
    display: none;
    position: absolute;
    top: 30px;
    right: 0;
    min-width: 190px;
    background: var(--bg);
    border: 1px solid var(--border-bright);
    border-radius: 6px;
    box-shadow: var(--shadow-sm);
    padding: 4px;
  }
  .chart-control-menu.open { display: block; }
  .chart-add-item {
    display: block;
    width: 100%;
    text-align: left;
    background: transparent;
    border: none;
    border-radius: 4px;
    padding: 6px 10px;
    font-family: var(--sans);
    font-size: 12px;
    color: var(--text);
    cursor: pointer;
  }
  .chart-add-item:hover { background: var(--teal-dim); color: var(--teal); }
  .chart-add-item.active { background: var(--teal-dim); color: var(--teal); font-weight: 600; }
  /* An action already in force — "All systems" when every one is already on the
     chart. Dimmed rather than hidden, so the menu does not change shape as the
     selection changes and the reader can see that "all" is where they are. */
  .chart-add-item:disabled { opacity: .45; cursor: default; }
  .chart-add-item:disabled:hover { background: transparent; color: var(--text); }

  /* Multi-select rows inside a .chart-control-menu: a checkbox per item, for a
     filter where several can be on at once. .chart-add-item stays single-shot by
     design — it acts and closes — so a filter that stays open needs its own row. */
  .chart-check-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 10px;
    border-radius: 4px;
    font-family: var(--sans);
    font-size: 12px;
    color: var(--text);
    cursor: pointer;
  }
  .chart-check-item:hover { background: var(--teal-dim); }
  .chart-check-item input { accent-color: var(--teal); margin: 0; flex: none; }
  /* The last system on the chart cannot be removed; the row says so on hover. */
  .chart-check-item:has(input:disabled) { opacity: .6; cursor: default; }
  .chart-check-item > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .chart-menu-sep { height: 1px; background: var(--border); margin: 4px 6px; }
  /* Long filter lists scroll inside the menu rather than running off the pane. */
  .chart-control-menu.is-scroll { max-height: 280px; overflow-y: auto; }
  .chart-add-empty {
    padding: 6px 10px;
    font-family: var(--sans);
    font-size: 12px;
    color: var(--text-dim);
    opacity: 0.7;
  }

  .explain-panel {
    display: none;
    border-left: 2px solid var(--teal);
    padding: 16px 20px;
    margin-bottom: 24px;
    background: var(--teal-dim);
    border-radius: 0 6px 6px 0;
  }
  .explain-panel.open { display: block; }
  .explain-title {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--teal);
    margin-bottom: 12px;
    opacity: 0.8;
  }
  .explain-row {
    display: flex;
    gap: 16px;
    flex-wrap: wrap;
    margin-bottom: 16px;
  }
  .explain-stat {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .explain-stat-label {
    font-family: var(--mono);
    font-size: 9px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--text-dim);
  }
  .explain-stat-value {
    font-family: var(--mono);
    font-size: 13px;
    color: var(--text);
  }
  .explain-source-card {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 10px 14px;
    font-size: 12px;
    line-height: 1.6;
    margin-bottom: 8px;
    white-space: pre-wrap;
  }
  .explain-source-card:last-child { margin-bottom: 0; }
  .explain-source-card strong {
    font-family: var(--mono);
    font-size: 10px;
    color: var(--text-dim);
    display: block;
    margin-bottom: 4px;
  }
  .explain-disclaimer {
    font-size: 11px;
    color: var(--text-dim);
    font-style: italic;
    margin-top: 8px;
  }

  /* ── Table ── */
  .table-wrap { overflow-x: auto; }
  table {
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
    font-family: var(--mono);
    font-variant-numeric: tabular-nums;
  }
  thead tr {
    border-bottom: 1px solid var(--border-bright);
  }
  th {
    padding: 8px 12px;
    text-align: left;
    font-size: 10px;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--text-faint);
    font-weight: 500;
    white-space: nowrap;
  }
  td {
    padding: 9px 12px;
    border-bottom: 1px solid var(--border);
    color: var(--text);
    vertical-align: top;
  }
  tbody tr { opacity: 0; animation: row-in 0.3s ease forwards; }
  @keyframes row-in {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  tbody tr:last-child td { border-bottom: none; }
  tbody tr:hover td { background: var(--bg2); }

  /* Transposed table: first column is the row label */
  .table-transposed th:first-child,
  .table-transposed td:first-child {
    color: var(--text-dim);
    font-size: 10px;
    letter-spacing: 0.1em;
    white-space: nowrap;
    background: var(--bg2);
    border-right: 1px solid var(--border);
  }

  .null-val { color: var(--text-dim); opacity: 0.45; }

  /* ── Partial calendar months ──
     A month the bills only partly cover. Three non-redundant signals, because
     any one of them alone fails a reader somewhere: the tint carries down the
     whole row (colour, seen at a glance), the left rule survives a greyscale
     print or a colour-blind reader, and the ⚠ in the Days cell says which
     number is the evidence. The banner above the table is the fourth and the
     only one that explains *why* it matters.

     Amber, not red: the row is not wrong, it is incomplete — the same
     distinction --amber already draws from --red in the confidence badges. */
  tbody tr.row-partial td { background: color-mix(in srgb, var(--amber) 9%, transparent); }
  tbody tr.row-partial:hover td { background: color-mix(in srgb, var(--amber) 16%, transparent); }
  tbody tr.row-partial td:first-child { box-shadow: inset 2px 0 0 var(--amber); }
  .cell-flag {
    margin-left: 6px;
    color: var(--amber);
    font-size: 11px;
    cursor: help;
  }
  /* The banner reuses .wx-warning's look; only its placement differs — it sits
     directly above the table it describes rather than above a whole section. */
  .partial-months-note { margin: 4px 0 10px; }

  /* ── Ventilation Systems: delete + merge ──
     Shared by ventilation.html and the Building Profile, since both render
     buildAhuTable() from app.js. No permanent chrome on the delete button,
     matching .cell-editable and .eu-del-btn: the table is dense and it only
     needs to be findable once the eye is already on that column. */
  .ahu-del-btn {
    background: none; border: none; cursor: pointer; padding: 0 2px;
    font-size: 12px; line-height: 1; color: var(--border-bright);
    transition: color .15s;
  }
  .table-transposed th:hover .ahu-del-btn { color: var(--text-dim); }
  .ahu-del-btn:hover { color: var(--red); }

  /* ── Transposed tables: sticky navigation frame ──
     Shared by the Ventilation Systems table and the Zones table, both of which
     grow *sideways* with every record and downwards with every property — and
     whose controls used to scroll away from both. The wrap below is therefore
     the scroll container in both axes, with a bounded height. That is what makes
     `position: sticky` inside the table resolve against something that actually
     moves: with the page (or the profile's pane body) doing the scrolling instead,
     `top: 0` on a header cell sticks to a scrollport that never shifts relative
     to the table, and nothing appears to happen. Bounding the height is also
     what keeps the horizontal scrollbar reachable — it rides the bottom edge of
     the box instead of parking itself below thirty rows of properties.

     Deliberately named after the table shape, not after either section: two
     copies of this frame would eventually stick differently. */
  .ahu-table-host { display: flex; flex-direction: column; min-height: 0; }
  .transposed-scroll {
    overflow: auto;
    max-height: 60vh;
    /* The sticky first column would otherwise be flush against the box edge. */
    border: 1px solid var(--border);
    border-radius: 4px;
  }
  /* Header row (the record ids) pins to the top; the property-label column pins
     to the left. Both need an opaque background of their own — the cells they
     travel over are painted, not transparent — and both trade their border for
     an inset shadow, because under `border-collapse: collapse` the border
     belongs to the row and scrolls out from under a stuck cell. */
  .transposed-scroll .table-transposed th {
    position: sticky; top: 0; z-index: 2;
    background: var(--bg);
    box-shadow: inset 0 -1px 0 var(--border-bright);
  }
  .transposed-scroll .table-transposed td:first-child {
    position: sticky; left: 0; z-index: 1;
    border-right: none;
    box-shadow: inset -1px 0 0 var(--border);
  }
  /* The corner cell is stuck on both axes, so it has to outrank both. It also
     restates its own tint: the `th` rule above ties with .table-transposed's
     `th:first-child` on specificity and would win it on source order alone. */
  .transposed-scroll .table-transposed th:first-child {
    left: 0; z-index: 3;
    background: var(--bg2);
    border-right: none;
    box-shadow: inset -1px 0 0 var(--border), inset 0 -1px 0 var(--border-bright);
  }

  /* Sticky to whatever scrolls the host — the pane body on the building
     profile, the page on ventilation.html. It sits above the table's own scroll
     box, so it stays reachable however far down either one is scrolled. */
  .ahu-toolbar {
    position: sticky; top: 0; z-index: 6;
    display: flex; align-items: center; gap: 6px;
    justify-content: flex-end;
    background: var(--bg);
    /* Room above for the count badge, which overhangs its button by 5px. */
    padding: 7px 0 8px;
  }
  .ahu-merge-selected-btn[hidden] { display: none; }
  .ahu-merge-toggle.active {
    background: var(--teal); color: var(--bg); border-color: var(--teal);
  }
  .btn-chart-control.is-busy { opacity: .55; cursor: default; }
  /* Count badge on the "merge selected" button — the one thing that control
     says which the checkboxes do not already show at a glance. */
  .ahu-merge-selected-btn { position: relative; }
  .ahu-merge-count {
    position: absolute; top: -5px; right: -5px;
    min-width: 14px; height: 14px; padding: 0 3px;
    border-radius: 7px; box-sizing: border-box;
    background: var(--teal); color: var(--bg);
    font-family: var(--sans); font-size: 9px; font-weight: 700;
    line-height: 14px; text-align: center;
  }
  .ahu-merge-check {
    display: block; margin: 0 auto 4px; cursor: pointer;
  }

  .ahu-merge-overlay {
    display: flex; position: fixed; inset: 0; z-index: 200;
    align-items: center; justify-content: center;
    background: var(--scrim);
  }
  .ahu-merge-box {
    background: var(--bg2); border: 1px solid var(--border-bright);
    box-shadow: var(--shadow-lg);
    border-radius: 6px; padding: 22px 26px 20px;
    max-width: 560px; width: 92%; max-height: 82vh;
    display: flex; flex-direction: column;
  }
  .ahu-merge-box h3 { margin: 0 0 4px; font-size: 15px; }
  .ahu-merge-hint { font-size: 12px; color: var(--text-dim); margin-bottom: 14px; }
  .ahu-merge-fields { overflow-y: auto; flex: 1; margin-bottom: 16px; }
  .ahu-merge-field { padding: 10px 0; border-bottom: 1px solid var(--border); }
  .ahu-merge-field:last-child { border-bottom: none; }
  .ahu-merge-field-label {
    display: block; font-size: 11px; font-weight: 600;
    color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em;
    margin-bottom: 6px;
  }
  .ahu-merge-option {
    display: flex; align-items: baseline; gap: 8px;
    font-size: 13px; padding: 3px 0; cursor: pointer;
  }
  .ahu-merge-option-src { font-size: 10.5px; color: var(--text-dim); }
  .ahu-merge-actions { display: flex; gap: 10px; justify-content: flex-end; }
  .ahu-suggest-zones-btn[hidden] { display: none; }

  /* ── Ventilation consumption summary + its detail dialog ──
     The summary's system id is a button, not a link: it opens the derivation in
     place. Styled as a plain underlined identifier rather than a control, because
     it is still the row's name first and an affordance second. */
  .cu-id-btn {
    background: none; border: none; padding: 0; cursor: pointer;
    font: inherit; color: var(--brand-primary);
    text-decoration: underline; text-underline-offset: 2px;
  }
  .cu-id-btn:hover { text-decoration-thickness: 2px; }

  /* An end use nobody has calculated. Dimmed rather than blank so it reads as
     "not answered" instead of "zero". */
  .consumption-summary .cu-uncalculated { color: var(--text-dim); }
  .consumption-summary tfoot td,
  .consumption-summary tfoot th {
    border-top: 2px solid var(--border); font-weight: 600;
  }

  /* The dialog is wider and taller than the merge box it borrows: it holds a
     derivation, not a short list of choices. */
  .cu-detail-box { max-width: 720px; max-height: 88vh; }
  .cu-detail-box .cu-blocks { overflow-y: auto; flex: 1; margin: 4px 0 16px; }

  .cu-block-title {
    font-size: 11px; font-weight: 600; color: var(--text-dim);
    text-transform: uppercase; letter-spacing: .05em;
    margin: 16px 0 6px;
  }
  .cu-hint { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; }
  .cu-input-table { width: 100%; margin-bottom: 4px; }
  .cu-role { font-size: 12px; color: var(--text-dim); }

  .cu-block { border-top: 1px solid var(--border); padding: 12px 0; }
  .cu-block:first-child { border-top: none; }
  .cu-block-head {
    display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  }
  .cu-block-name { font-size: 13px; font-weight: 600; }
  .cu-block-total { font-size: 14px; font-variant-numeric: tabular-nums; }
  .cu-note { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
  .cu-warn {
    font-size: 12px; margin-top: 8px; padding: 7px 9px; border-radius: 4px;
    color: var(--text); border: 1px solid var(--amber);
    background: color-mix(in srgb, var(--amber) 8%, transparent);
  }

  .cu-derivation { margin-top: 10px; }
  .cu-formula {
    font-family: var(--mono); font-size: 13px;
    padding: 8px 10px; border-radius: 4px;
    background: var(--bg2); border: 1px solid var(--border);
    margin-bottom: 8px;
  }
  .cu-line {
    display: flex; align-items: baseline; justify-content: space-between;
    gap: 12px; flex-wrap: wrap; padding: 4px 0; font-size: 13px;
  }
  .cu-line-label { color: var(--text-dim); }
  .cu-line-value { text-align: right; font-variant-numeric: tabular-nums; }
  .cu-line-hint {
    flex-basis: 100%; font-size: 11.5px; color: var(--text-dim); margin-top: 1px;
  }
  .cu-dim { color: var(--text-dim); }

  /* The one hour written out. Monospaced so the terms line up under each other
     and the multiplication can actually be followed; it scrolls rather than
     wrapping mid-expression, which would make it unreadable. */
  .cu-worked {
    padding: 9px 10px; border-radius: 4px;
    background: var(--bg2); border: 1px solid var(--border);
  }
  .cu-worked-time { font-size: 11.5px; color: var(--text-dim); margin-bottom: 4px; }
  .cu-worked-math {
    font-family: var(--mono); font-size: 12px;
    overflow-x: auto; white-space: nowrap; padding-bottom: 2px;
  }
  .cu-worked-note { font-size: 11.5px; color: var(--text-dim); margin-top: 5px; }

  .cu-actions { align-items: center; }
  .cu-recalc-note { flex: 1; font-size: 12px; color: var(--text-dim); }

  /* ── Zone ↔ ventilation system association ──
     The zone picker borrows the merge dialog's overlay/box rather than the
     anchored .save-float-pop family: it opens from a cell that can be deep
     inside a horizontally scrolling table, where an anchored popover would be
     clipped by the wrapper's own overflow. */
  .zone-picker-box { max-width: 460px; }
  .zone-picker-list { min-height: 40px; }
  .zone-picker-option { padding: 5px 0; }
  .zone-picker-empty { font-size: 12.5px; color: var(--text-dim); padding: 6px 0 10px; }
  .zone-picker-add { display: flex; gap: 6px; margin-bottom: 8px; }
  .zone-picker-add .zs-add-input { flex: 1; }
  .zone-picker-note {
    font-size: 11px; color: var(--text-dim); margin-bottom: 12px;
  }

  /* ── Modal dialog vocabulary ──
     The overlay, the form fields and the two buttons were copy-pasted into each
     page's <style> as new modals appeared — until project.html copied the Edit
     details markup without them, and the dialog rendered as plain content at the
     bottom of the page: no overlay to hide it, no box to hold it. Shared here for
     the reason .match-dot is, with the same failure mode one step further along —
     a widget duplicated per page is one that eventually renders differently per
     page, and one page always ends up with no copy at all.
     .modal-box sizing stays page-local: each dialog holds different content and
     the widths genuinely differ (400px, 440px, 500px). */
  .modal-overlay {
    display: none; position: fixed; inset: 0;
    background: var(--scrim); z-index: 100;
    align-items: center; justify-content: center;
  }
  .modal-overlay.open { display: flex; }

  .form-field { margin-bottom: 14px; }
  .form-field label {
    display: block; font-size: 11.5px; color: var(--text-dim);
    margin-bottom: 4px; font-weight: 500;
  }
  .form-field input, .form-field textarea {
    width: 100%; box-sizing: border-box;
    border: 1px solid var(--border-bright); border-radius: var(--radius-sm);
    padding: 8px 10px; font-size: 13px; font-family: inherit;
    color: var(--text); background: var(--bg2);
    transition: border-color .2s;
  }
  .form-field input:focus, .form-field textarea:focus { outline: none; border-color: var(--teal); }
  .form-field textarea { min-height: 70px; resize: vertical; }

  .btn-primary {
    background: var(--teal); color: var(--bg); border: none;
    border-radius: var(--radius-sm); padding: 7px 18px;
    font-size: 12.5px; font-family: inherit; font-weight: 600;
    cursor: pointer; transition: filter .15s;
  }
  .btn-primary:hover { filter: brightness(1.1); }
  .btn-secondary-sm {
    background: transparent; border: 1px solid var(--border-bright);
    border-radius: var(--radius-sm); padding: 7px 16px;
    font-size: 12.5px; font-family: inherit; color: var(--text-dim);
    cursor: pointer; transition: border-color .15s, color .15s;
  }
  .btn-secondary-sm:hover { border-color: var(--text-dim); color: var(--text); }

  /* ── Estimate provenance indicators ──
     Moved out of cost_estimator.html's page-local <style> when the building
     profile's read-only estimate dialog started drawing the same two marks. A
     traffic light duplicated per page is a traffic light that eventually means
     two different things. */
  /* Shape carries the meaning as well as the colour: circle / triangle / square.
     Three discs differing only in hue are unreadable to a red-green colour-blind
     reader and indistinguishable in a printed or greyscale estimate, which is
     exactly where "AI estimate -- review required" most needs to stand out. The
     triangle is cut with clip-path rather than the border trick so all three keep
     the same box, the same baseline and the same background token -- a zero-sized
     bordered element would sit differently in the legend than in the table cell. */
  .match-dot {
    display: inline-block;
    width: 10px;
    height: 10px;
    cursor: help;
    flex-shrink: 0;
  }
  .match-dot.exact {
    border-radius: 50%;
    background: var(--match-exact);
  }
  .match-dot.interpolated {
    /* 11px wide: a triangle of equal width reads narrower than the disc beside it. */
    width: 11px;
    clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
    background: var(--match-interpolated);
  }
  .match-dot.ai_estimate {
    border-radius: 1px;
    background: var(--match-ai);
  }

  /* Engineering-rule indicator. Brand-coloured rather than part of the match traffic
     light: it answers a different question ("which company standard produced this
     line?") and is independent of how well the cost matched the catalog. */
  .rule-mark {
    display: inline-block;
    margin-left: 6px;
    font-size: 12px;
    font-weight: 600;
    line-height: 1;
    color: var(--teal);
    cursor: help;
    flex-shrink: 0;
  }

  /* ── Read-only estimate dialog (building profile → Estimates) ──
     Borrows the merge dialog's overlay/box for the same reason the zone picker
     does, and for one more: the profile's full-screen Escape handler stands down
     while an .ahu-merge-overlay is on screen, so Escape closes the dialog instead
     of also collapsing the pane behind it. Wider than 560px because a line-item
     table with seven columns cannot be read at the merge dialog's width. */
  .estimate-view-box { max-width: 900px; }
  .estimate-view-hint { display: flex; align-items: center; gap: 10px; }
  .estimate-view-body { padding-right: 4px; }
  .estimate-view-empty {
    font-size: 12.5px; color: var(--text-dim); padding: 14px 0;
  }
  .estimate-view-section { margin-bottom: 18px; }
  .estimate-view-section:last-child { margin-bottom: 4px; }
  .estimate-view-desc {
    font-size: 13px; line-height: 1.55; white-space: pre-wrap;
  }
  .estimate-view-docs { display: flex; flex-wrap: wrap; gap: 6px; }
  .estimate-view-doc {
    display: inline-block; padding: 2px 8px;
    background: var(--bg2);
    border: 1px solid var(--border-bright); border-radius: 10px;
    font-size: 11.5px; color: var(--text-dim); white-space: nowrap;
  }

  .estimate-view-answer {
    display: flex; align-items: baseline; gap: 10px;
    padding: 5px 0; border-bottom: 1px solid var(--border);
    font-size: 12.5px;
  }
  .estimate-view-answer:last-child { border-bottom: none; }
  .estimate-view-answer-q { flex: 1; color: var(--text-dim); }
  .estimate-view-answer-v { font-weight: 600; text-align: right; }
  .estimate-view-src {
    font-size: 10px; text-transform: uppercase; letter-spacing: .05em;
    white-space: nowrap; min-width: 96px; text-align: right;
    color: var(--text-dim);
  }
  /* An assumption and a waived question are the two the reader has to be able to
     pick out — they are the values nobody confirmed. */
  .estimate-view-src-assumed { color: var(--amber); }
  .estimate-view-src-waived  { color: var(--red); }

  .estimate-view-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
  .estimate-view-table th {
    text-align: left; font-size: 10px; font-weight: 700;
    letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim);
    padding: 0 8px 6px 0; border-bottom: 1px solid var(--border);
  }
  .estimate-view-table td { padding: 5px 8px 5px 0; vertical-align: top; }
  .estimate-view-table .num { text-align: right; padding-right: 8px; }
  .estimate-view-cat td {
    padding-top: 12px; font-weight: 600;
    border-bottom: 1px solid var(--border);
  }
  .estimate-view-num { color: var(--text-dim); text-align: center; width: 28px; }
  .estimate-view-item-desc { white-space: normal; }
  .estimate-view-item-total { font-weight: 600; }
  .estimate-view-flags { white-space: nowrap; width: 34px; }

  .estimate-view-totals {
    display: flex; justify-content: flex-end; gap: 22px;
    padding: 12px 0 14px; margin-top: 4px;
    border-top: 1px solid var(--border);
  }
  .estimate-view-total { display: flex; align-items: baseline; gap: 6px; font-size: 12.5px; }
  .estimate-view-total-label { color: var(--text-dim); }
  .estimate-view-total-value { font-weight: 600; }
  .estimate-view-grand .estimate-view-total-value { font-size: 15px; color: var(--teal); }
  /* Over/under a recorded actual -- see --variance-over/--variance-under in :root. */
  .estimate-view-over  .estimate-view-total-value,
  .estimate-view-item-actual.estimate-view-over  { color: var(--variance-over); }
  .estimate-view-under .estimate-view-total-value,
  .estimate-view-item-actual.estimate-view-under { color: var(--variance-under); }
  .estimate-view-item-actual { font-family: var(--mono); white-space: nowrap; font-variant-numeric: tabular-nums; }
  /* The footer link wears the primary-button pill, so it needs the two things an <a>
     does not get from it. It is an anchor rather than a button so it can be opened in a
     new tab, which leaves the profile where the engineer left it. */
  .estimate-view-open { display: inline-block; text-decoration: none; }

  .assoc-cell { white-space: normal; }
  .assoc-chip {
    display: inline-block; margin: 1px 3px 1px 0; padding: 1px 6px;
    border: 1px solid var(--border); border-radius: 10px;
    font-size: 11px; line-height: 1.5; white-space: nowrap;
  }
  .assoc-edit-btn {
    background: none; border: 1px dashed var(--border); color: var(--text-dim);
    border-radius: 10px; cursor: pointer; font-size: 11px;
    line-height: 1.5; padding: 1px 7px; vertical-align: baseline;
  }
  .assoc-edit-btn:hover:not(:disabled) { border-color: var(--brand-primary); color: var(--brand-primary); }
  .assoc-edit-btn:disabled { opacity: .4; cursor: default; }

  /* ── Occupancy profile ──
     A shape is only readable as a shape, so the cell carries 24 bars rather than
     24 numbers, and the editor carries both: a bar you can see and a box you can
     type an exact value into. The cell's sparkline is baseline-aligned with the
     summary text beside it so the row keeps the height every other row has. */
  .zs-prof-cell { white-space: nowrap; }
  .zs-prof-spark {
    display: inline-flex; align-items: flex-end; gap: 1px;
    height: 14px; margin-right: 6px; vertical-align: -3px;
  }
  .zs-prof-bar { width: 2px; background: var(--brand-primary); border-radius: 1px; }
  .zs-prof-bar.is-zero { background: var(--border); }
  .zs-prof-label { font-size: 11px; color: var(--text-dim); margin-right: 6px; }

  .zs-prof-box { max-width: 860px; }
  .zs-prof-meta { display: flex; gap: 8px; margin: 10px 0 4px; }
  .zs-prof-text {
    flex: 1; padding: 5px 8px; font: inherit; font-size: 12.5px;
    border: 1px solid var(--border); border-radius: 4px;
    background: var(--bg); color: var(--text);
  }
  /* Scrolls rather than shrinking: 24 columns narrow enough to always fit would
     leave a number box too small to type "100" into. */
  .zs-prof-grid {
    display: flex; gap: 3px; overflow-x: auto;
    padding: 6px 2px 2px; margin-bottom: 6px;
  }
  .zs-prof-col { display: flex; flex-direction: column; align-items: center; gap: 3px; }
  .zs-prof-track {
    width: 26px; height: 64px; display: flex; align-items: flex-end;
    background: var(--bg3); border-radius: 2px;
  }
  .zs-prof-fill { width: 100%; background: var(--brand-primary); border-radius: 2px; }
  .zs-prof-num {
    width: 34px; padding: 2px 3px; font: inherit; font-size: 11px; text-align: center;
    border: 1px solid var(--border); border-radius: 3px;
    background: var(--bg); color: var(--text);
  }
  /* The spinners would take a third of a 34px box and are useless for a value
     typed two digits at a time. */
  .zs-prof-num::-webkit-outer-spin-button,
  .zs-prof-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  .zs-prof-num { -moz-appearance: textfield; appearance: textfield; }
  .zs-prof-tick { font-size: 10px; color: var(--text-dim); }
  .zs-prof-grid-days .zs-prof-col { flex: 1; }
  .zs-prof-grid-days .zs-prof-track { width: 100%; }
  .zs-prof-actions { flex-wrap: wrap; }

  .zm-pop { max-height: 60vh; overflow-y: auto; }
  .zm-pop-row { display: block; }
  .zm-pop-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
  .zm-pop-served { font-size: 11.5px; color: var(--text-dim); }
  .zm-pop-zone {
    display: flex; align-items: baseline; gap: 7px;
    font-size: 12.5px; padding: 2px 0 2px 10px; cursor: pointer;
  }
  .zm-pop-reason { font-size: 11px; color: var(--text-dim); padding: 3px 0 0 10px; }
  .zm-pop-subtitle { margin-top: 12px; }
  .zm-pop-create {
    background: none; border: 1px dashed var(--border); color: var(--text-dim);
    border-radius: 4px; cursor: pointer; font-size: 11.5px;
    padding: 3px 9px; margin: 2px 0 0 10px;
  }
  .zm-pop-create:hover:not(:disabled) { border-color: var(--brand-primary); color: var(--brand-primary); }
  .zm-pop-create:disabled { opacity: .5; cursor: default; }

  /* ── Inline cell editing ──
     These tables are dense and wide, so an editable cell gets no permanent
     chrome: just a text cursor and a dotted underline that only appears on
     hover. The cell keeps its exact layout until it is actually clicked. */
  .cell-editable { cursor: text; }
  .cell-editable:hover {
    background: var(--teal-dim);
    box-shadow: inset 0 -1px 0 0 var(--border-bright);
  }
  .cell-editing { padding: 3px 6px; background: var(--teal-dim); }
  .cell-editor {
    width: 100%;
    min-width: 80px;
    padding: 4px 6px;
    font-family: var(--mono);
    font-size: 12px;
    color: var(--text);
    background: var(--bg);
    border: 1px solid var(--brand-primary);
    border-radius: 3px;
    outline: none;
  }
  textarea.cell-editor { min-width: 220px; resize: vertical; line-height: 1.5; }
  /* Rejected input: the old value is already back in the cell, so this just
     tells the user their entry did not take. */
  .cell-invalid { animation: cell-reject 0.9s ease; }
  @keyframes cell-reject {
    0%, 60% { background: color-mix(in srgb, var(--red) 22%, transparent); box-shadow: inset 0 0 0 1px var(--red); }
    100%    { background: transparent;          box-shadow: none; }
  }

  /* ── Floating bar stack ──
     Owns the fixed positioning for every floating bar, so the unsaved-analysis
     prompt and the unsaved-edits bar stack instead of landing on top of each
     other. Bottom of the viewport, so a bar stays reachable from anywhere in a
     long table or with the building profile's pane full screen. */
  .float-stack {
    position: fixed;
    left: 50%;
    bottom: 24px;
    transform: translateX(-50%);
    z-index: 200;
    display: flex;
    flex-direction: column-reverse;
    align-items: center;
    gap: 12px;
    pointer-events: none;  /* an empty stack must not swallow clicks */
  }
  .float-stack > * { pointer-events: auto; }
  /* The estimate chat is a column on the right, so half of a viewport-centred bar would
     sit under it. Shifted by half the panel rather than re-centred on the page: the bar
     has never accounted for the rail either, and moving it by the width that just
     appeared is the change that keeps it where it was relative to what it acts on. */
  body.chat-open .float-stack { left: calc(50% - var(--chat-w) / 2); }
  @media (max-width: 900px) {
    /* There, the panel is an overlay and the page kept its width. */
    body.chat-open .float-stack { left: 50%; }
  }

  /* ── Floating save bar ──
     Appears only once edited data already lives in a project. */
  .save-float-bar {
    position: relative;  /* containing block for .save-float-dismiss / -pop */
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 10px 12px 10px 18px;
    background: var(--bg);
    border: 1px solid var(--border-bright);
    border-radius: 999px;
    box-shadow: var(--shadow-sm);
    font-family: var(--mono);
    font-size: 12px;
    animation: save-bar-in 0.22s ease;
  }
  @keyframes save-bar-in {
    from { opacity: 0; transform: translateY(12px); }
    to   { opacity: 1; transform: none; }
  }
  .save-float-count { color: var(--text); white-space: nowrap; }
  .save-float-bar.is-error   { border-color: var(--red); }
  .save-float-bar.is-error   .save-float-count { color: var(--red); }
  .save-float-bar.is-saved   { border-color: var(--ok); }
  .save-float-bar.is-saved   .save-float-count { color: var(--ok); }
  .save-float-btn {
    padding: 6px 18px;
    border: none;
    border-radius: 999px;
    background: var(--brand-primary);
    color: #fff;
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.15s ease;
    /* "Save to <project>" carries a name of any length, and in French carries a
       longer verb with it. Truncate rather than let the pill wrap to two lines. */
    max-width: 260px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .save-float-btn:hover:not(:disabled) { background: var(--brand-primary-hover); }
  .save-float-btn:disabled { opacity: 0.6; cursor: default; }
  /* Discard. Sits on the pill's top-right corner rather than in the flex row so
     it reads as "close this" and can't be mistaken for a second action. */
  .save-float-dismiss {
    position: absolute;
    top: -8px;
    right: -8px;
    width: 20px;
    height: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    background: var(--bg);
    border: 1px solid var(--border-bright);
    border-radius: 50%;
    color: var(--text-dim);
    font-family: var(--mono);
    font-size: 14px;
    line-height: 1;
    cursor: pointer;
    transition: color 0.15s ease, border-color 0.15s ease;
  }
  .save-float-dismiss:hover:not(:disabled) { color: var(--red); border-color: var(--red); }
  .save-float-dismiss:disabled { opacity: 0.4; cursor: default; }

  /* ── Save-to-project prompt ──
     Same pill as the save bar above, shown once an analysis finishes. The main
     action and the "somewhere else" caret share one pill so they read as one
     control with a secondary way in, not as two competing buttons. */
  .save-float-split { display: flex; align-items: stretch; }
  .save-float-split .save-float-btn {
    border-radius: 999px 0 0 999px;
    padding-right: 13px;
  }
  .save-float-caret {
    padding: 6px 13px 6px 9px;
    border: none;
    border-left: 1px solid rgba(255,255,255,0.3);
    border-radius: 0 999px 999px 0;
    background: var(--brand-primary);
    color: #fff;
    font-family: var(--mono);
    font-size: 9px;
    line-height: 1;
    cursor: pointer;
    transition: background 0.15s ease;
  }
  .save-float-caret:hover:not(:disabled) { background: var(--brand-primary-hover); }
  .save-float-caret:disabled { opacity: 0.6; cursor: default; }

  /* Project picker, opened from the caret. Above the pill, since the pill is
     already at the bottom of the viewport. */
  .save-float-pop {
    position: absolute;
    bottom: 100%;
    right: 0;
    margin-bottom: 20px;  /* clears the dismiss button on the pill's top-right corner */
    min-width: 252px;
    max-width: 340px;
    max-height: 280px;
    overflow-y: auto;
    padding: 4px 0;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    box-shadow: var(--shadow-sm);
    text-align: left;
    text-transform: none;
    letter-spacing: normal;
  }
  .save-float-pop-msg { padding: 8px 14px; font-size: 12px; color: var(--text-dim); }
  .save-float-pop-form { padding: 12px 14px; }
  .save-float-pop-form label {
    display: block;
    font-size: 11px;
    color: var(--text-dim);
    margin-bottom: 4px;
  }
  .save-float-pop-form input {
    width: 100%;
    box-sizing: border-box;
    margin-bottom: 12px;
    padding: 7px 9px;
    border: 1px solid var(--border);
    border-radius: 4px;
    background: var(--bg);
    color: var(--text);
    font-family: var(--sans);
    font-size: 13px;
  }
  .save-float-pop-form input:focus { outline: none; border-color: var(--teal); }
  .save-float-pop-actions { display: flex; gap: 8px; justify-content: flex-end; }
  .save-float-pop-cancel {
    padding: 6px 12px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--bg2);
    color: var(--text);
    font-family: var(--mono);
    font-size: 11px;
    cursor: pointer;
  }
  .save-float-pop-cancel:hover { border-color: var(--border-bright); }

  /* ── End-use association prompt ──
     The pill itself is .save-float-bar plus this modifier — it needs no shape
     of its own, only the popover below does. */
  .eu-assoc-bar { }
  .eu-assoc-pop {
    /* Same anchored-popover family as .save-float-pop, just wider: it holds a
       label + control per source/component row rather than a one-line list. */
    position: absolute;
    bottom: 100%;
    right: 0;
    margin-bottom: 20px;
    min-width: 420px;
    max-width: 560px;
    max-height: 360px;
    overflow-y: auto;
    padding: 12px 14px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    box-shadow: var(--shadow-sm);
    text-align: left;
    text-transform: none;
    letter-spacing: normal;
  }
  .eu-assoc-pop-title {
    font-size: 12px;
    color: var(--text-dim);
    margin-bottom: 10px;
  }
  .eu-assoc-pop-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 6px 0;
    border-top: 1px solid var(--border);
  }
  .eu-assoc-pop-row:first-of-type { border-top: none; }
  .eu-assoc-pop-label {
    font-family: var(--sans);
    font-size: 12.5px;
    color: var(--text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .eu-assoc-pop-select, .eu-assoc-pop-custom {
    flex: 0 0 auto;
    min-width: 160px;
    padding: 5px 8px;
    border: 1px solid var(--border);
    border-radius: 4px;
    background: var(--bg);
    color: var(--text);
    font-family: var(--sans);
    font-size: 12.5px;
  }
  .eu-assoc-pop-select:focus, .eu-assoc-pop-custom:focus { outline: none; border-color: var(--teal); }
  .eu-assoc-pop-error {
    margin-top: 10px;
    padding: 8px 10px;
    border-radius: 4px;
    background: var(--red-dim);
    color: var(--red);
    font-family: var(--sans);
    font-size: 12px;
  }

  /* ── Confidence badges ── */
  .badge {
    display: inline-block;
    padding: 1px 7px;
    border-radius: 2px;
    font-size: 9px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    font-weight: 500;
    font-family: var(--mono);
  }
  /* --ok rather than --teal: this is the "good" end of a traffic light, not brand
     chrome. See the --ok comment in :root. */
  .badge-high     { background: var(--ok-dim);        color: var(--ok);    border: 1px solid var(--ok-line); }
  .badge-medium   { background: var(--amber-dim);  color: var(--amber); border: 1px solid var(--amber-line); }
  .badge-low      { background: var(--red-dim);  color: var(--red);   border: 1px solid var(--red-line); }
  .badge-assumed  { background: var(--grey-dim); color: var(--grey); border: 1px solid var(--grey-line); }

  /* ── WIP badge ── */
  .wip-badge {
    display: inline-flex;
    align-items: center;
    padding: 2px 8px;
    border-radius: 2px;
    font-family: var(--mono);
    font-size: 9px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    font-weight: 600;
    background: var(--grey-dim);
    color: var(--grey);
    border: 1px solid var(--grey-line);
    margin-top: 6px;
  }
  /* Semantic "check passed" — pinned like .badge-high, not brand-tinted. */
  .badge-ok { background: var(--ok-dim); color: var(--ok); border: 1px solid var(--ok-line); }

  /* ── Extraction notes card ── */
  .notes-card {
    background: var(--bg2);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 16px 20px;
    font-size: 13px;
    line-height: 1.65;
    color: var(--text);
    white-space: pre-wrap;
  }
  .notes-card p { margin-bottom: 6px; }
  .notes-card p:last-child { margin-bottom: 0; }

  /* ── Notes cell expand/collapse ── */
  .notes-cell {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    white-space: normal;
    line-height: 1.5;
  }
  .notes-cell.expanded {
    display: block;
    overflow: visible;
    -webkit-line-clamp: unset;
  }
  .notes-toggle {
    background: transparent;
    border: none;
    color: var(--teal);
    font-family: var(--mono);
    font-size: 9px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    cursor: pointer;
    padding: 2px 0 0;
    display: block;
    opacity: 0.65;
    transition: opacity 0.15s;
  }
  .notes-toggle:hover { opacity: 1; }

  /* ── Subsection heading ── */
  .sub-heading {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    color: var(--text-dim);
    margin-bottom: 10px;
    margin-top: 28px;
    padding-bottom: 6px;
    border-bottom: 1px solid var(--border);
  }
  .sub-heading:first-child { margin-top: 0; }

  /* ── Option toggle ── */
  .option-toggle {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    padding: 14px 18px;
    background: var(--bg2);
    border: 1px solid var(--border);
    border-radius: 6px;
    cursor: pointer;
    user-select: none;
    transition: border-color 0.15s, background 0.15s;
  }
  .option-toggle:hover { border-color: var(--border-bright); background: var(--bg3); }
  .option-toggle input[type=checkbox] {
    margin-top: 2px;
    width: 14px; height: 14px;
    accent-color: var(--teal);
    flex-shrink: 0;
    cursor: pointer;
  }
  .option-toggle-label {
    font-family: var(--mono);
    font-size: 12px;
    font-weight: 500;
    color: var(--text);
    display: block;
    margin-bottom: 2px;
  }
  .option-toggle-desc {
    font-size: 11px;
    color: var(--text-dim);
    line-height: 1.4;
  }
  .option-toggle-sub {
    padding: 7px 14px 7px 42px;
    margin-top: -5px;
    border-top: none;
    border-radius: 0 0 6px 6px;
    background: var(--bg3);
  }
  .option-toggle-sub:hover { background: var(--bg3); }
  .option-toggle-sub input[type=checkbox] { width: 12px; height: 12px; margin-top: 1px; }
  .option-toggle-sub .option-toggle-label { font-size: 11px; font-weight: 400; }
  .option-toggle-sub .option-toggle-desc  { font-size: 10px; }
  .badge-hist {
    display: inline-block;
    padding: 1px 7px;
    border-radius: 2px;
    font-size: 9px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    font-weight: 500;
    font-family: var(--mono);
    background: var(--grey-dim);
    color: var(--grey);
    border: 1px solid var(--grey-line);
  }

  /* ── Formula block ── */
  .formula-block {
    font-family: var(--mono);
    font-size: 11px;
    line-height: 1.9;
    color: var(--text);
    background: var(--bg);
    border: 1px solid var(--border);
    border-left: 2px solid var(--teal);
    border-radius: 0 4px 4px 0;
    padding: 10px 16px;
    white-space: pre;
    margin-bottom: 8px;
    overflow-x: auto;
  }

  /* ── Export button ── */
  .btn-export {
    display: inline-block;
    background: transparent;
    color: var(--teal);
    border: 1px solid color-mix(in srgb, var(--brand-primary) 40%, transparent);
    border-radius: 4px;
    padding: 8px 20px;
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    text-decoration: none;
    cursor: pointer;
    transition: border-color 0.15s, background 0.15s;
  }
  .btn-export:hover {
    border-color: var(--teal);
    background: var(--teal-dim);
  }

  /* ── Empty state ── */
  .empty-badge {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 6px 14px;
    background: var(--teal-dim);
    border: 1px solid color-mix(in srgb, var(--brand-primary) 20%, transparent);
    border-radius: 4px;
    font-family: var(--mono);
    font-size: 11px;
    color: var(--teal);
    letter-spacing: 0.06em;
  }

  /* ── Per-file doc-type selector (ventilation multi-file) ── */
  .file-list-item-mep {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
  }
  .doc-type-select {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.06em;
    background: var(--bg2);
    border: 1px solid var(--border);
    border-radius: 3px;
    color: var(--text-dim);
    padding: 2px 6px;
    cursor: pointer;
  }
  .doc-type-select:focus { outline: 1px solid var(--teal); }

/* ── Project context bar ── */
.project-bar {
  position: relative;
  border-bottom: 1px solid var(--border);
  padding: 0 18px;
  background: var(--bg);
  display: flex;
  align-items: stretch;
}
.project-bar-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  background: none;
  border: none;
  padding: 8px 0;
  font-family: var(--sans);
  font-size: 12px;
  color: var(--text);
  cursor: pointer;
  transition: color .2s;
}
.project-bar-btn:hover { color: var(--teal); }
.project-bar-link {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: 16px;
  padding: 8px 0 8px 16px;
  border-left: 1px solid var(--border);
  font-family: var(--sans);
  font-size: 12px;
  color: var(--text-dim);
  text-decoration: none;
  transition: color .2s;
}
.project-bar-link:hover { color: var(--teal); }
.project-bar-link:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.project-bar-link[hidden] { display: none; }
.project-bar-link svg { flex-shrink: 0; }
.project-bar-label {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-dim);
  margin-right: 2px;
}
.project-dropdown {
  position: absolute;
  top: 100%;
  left: 18px;
  z-index: 50;
  background: var(--bg2);
  border: 1px solid var(--border-bright);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  min-width: 240px;
  max-width: 360px;
}
.project-dropdown-item {
  display: block;
  padding: 8px 14px;
  font-size: 13px;
  color: var(--text);
  cursor: pointer;
  text-decoration: none;
  transition: background .15s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.project-dropdown-item:hover { background: var(--bg3); }
.project-dropdown-item.active { color: var(--teal); font-weight: 600; }
.project-dropdown-sub {
  display: block;
  font-size: 11px;
  color: var(--text-dim);
  font-weight: 400;
  margin-top: 1px;
}
.project-dropdown-sep { height: 1px; background: var(--border); margin: 4px 0; }
.project-dropdown-manage { color: var(--teal); font-size: 12px; }

/* ── Building profile sections ──
   The stack of collapsible section cards these classes dressed is gone: the
   profile is an index rail plus one detail pane (see .profile-rail / .profile-pane
   in project.html). What survives here is the count badge and the meta line,
   which both moved onto the pane head. */
.profile-section-meta {
  font-size: 11px;
  color: var(--text-dim);
}
.profile-section-count {
  font-size: 11px;
  color: var(--text-dim);
  background: var(--bg3);
  border-radius: 10px;
  padding: 1px 7px;
}
.profile-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12.5px;
}
.profile-table th {
  background: var(--bg2);
  padding: 7px 10px;
  text-align: left;
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
.profile-table td {
  padding: 6px 10px;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
  color: var(--text);
}
.profile-table tr:last-child td { border-bottom: none; }
.profile-table tr:hover td { background: var(--bg2); }
.profile-empty {
  padding: 20px 16px;
  font-size: 13px;
  color: var(--text-dim);
  text-align: center;
}
.profile-empty a { color: var(--teal); text-decoration: none; }
.profile-empty a:hover { text-decoration: underline; }

/* ── Project list cards ── */
.project-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 16px;
  margin-top: 20px;
}
.project-card {
  background: var(--bg2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 16px 18px;
  cursor: pointer;
  transition: border-color .2s, box-shadow .2s;
  text-decoration: none;
  color: inherit;
  display: block;
}
.project-card:hover {
  border-color: color-mix(in srgb, var(--teal) 55%, transparent);
  background: var(--bg3);
}
.project-card-name {
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
  margin-bottom: 4px;
}
.project-card-meta {
  font-size: 12px;
  color: var(--text-dim);
  line-height: 1.6;
}
.project-card-date {
  font-size: 11px;
  color: var(--text-dim);
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

/* ── Paywall ──────────────────────────────────────────────────────────────
   The free-run limit is enforced on the backend with a 402; this is what the
   user sees when one comes back. It lives in style.css rather than in a page's
   own <style> because every run page can raise it — index.html's .tb-modal-*
   vocabulary is page-local and reaches none of them. */
.paywall-overlay {
  position: fixed;
  inset: 0;
  background: var(--scrim);
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 300;
  padding: 20px;
}
.paywall-overlay.open { display: flex; }
.paywall-box {
  background: var(--bg2);
  border: 1px solid var(--border-bright);
  border-radius: var(--radius-md);
  padding: 28px 30px;
  max-width: 420px;
  width: 100%;
  box-shadow: var(--shadow-lg);
}
.paywall-eyebrow {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 10px;
}
.paywall-box h3 { margin: 0 0 12px; font-size: 17px; font-weight: 600; color: var(--text); }
.paywall-box p  { margin: 0 0 20px; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
.paywall-actions { display: flex; gap: 10px; justify-content: flex-end; }
.paywall-error {
  font-size: 12px;
  color: var(--red);
  margin: 0 0 14px;
  display: none;
}

/* Free-run counter on a toolbox card and beside a run button.
   Just "2/3" — the sentence it stands for is the element's title, which is what the
   help cursor and the dotted underline are there to advertise. Both are on the badge
   rather than a wrapper so the hover target is exactly the thing being explained. */
.run-quota {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  color: var(--text-dim);
  cursor: help;
  /* Tight, so the underline hugs the digits rather than floating below them. */
  line-height: 1.2;
  border-bottom: 1px dotted currentColor;
}
.run-quota.spent { color: var(--red); }

/* ── Chatting with an estimate ─────────────────────────────────────────────
   Deliberately declares NO :root token. Every colour here is mixed at the point of use
   out of roles that already exist, which is the rule the skeleton primitive follows and
   for the same reason: a token added to :root has to be restated under
   :root[data-theme="light"] or it keeps its dark value on a white ground, and
   theme.test.js is what would have to grow to notice. Mixing toward --text-faint
   lightens on the dark ground and darkens on the light one, so one declaration reads in
   both.
   It sits BESIDE the page rather than over it, so it takes no rung at all in the
   ordinary case. See #chat-section below. */

/* ── The side panel ──
   It used to be a dock hanging off the bottom-right corner, which put the conversation
   ON TOP of the table it is about: reading a line the panel covered meant collapsing it,
   and correcting that line meant opening it again. It is now a COLUMN — the third track
   of a page that already had two — so the two things an engineer is comparing are on
   screen at once.

   `position: fixed` plus padding on the body, exactly as .app-rail does on the other
   side, and for the identical reason: nothing in the page's markup had to move, so every
   modal, floating bar and popover still works untouched. It is the mirror image of
   `body.app-shell { padding-left: var(--rail-w) }`.

   The rung is 45: above the page and the rail (40) — the rail cannot reach it, but a
   .project-dropdown or a .wx-suggest-list at 50 opening near the right edge should still
   come out over it — and below #desc-pop (60) and the modal scrims (100), so finalizing
   covers the conversation rather than fighting it.
   `display` is still written inline as `flex` by syncChatView, which now writes it for
   the panel being OPEN as well as for an estimate being on screen. */
#chat-section {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 45;
  width: var(--chat-w);
  flex-direction: column;
  background: var(--bg2);
  border-left: 1px solid var(--border);
}
/* The counterpart to the rail's padding-left. Applied to the BODY rather than to <main>
   so the header, the project bar and the page all stop at the same edge — a header that
   ran under the panel would put the sign-out button behind it. */
body.app-shell.chat-open { padding-right: var(--chat-w); }
/* …but not while the grip is being dragged: a 160ms ease on every pointermove makes the
   page lag a hand's width behind the edge being dragged. */
body.chat-resizing, body.chat-resizing #chat-section { transition: none; }
body.chat-resizing { cursor: col-resize; user-select: none; }

/* Everything above the composer. It is the part that scrolls, so it owns the height the
   column has left over — `min-height: 0` because a flex item's default `auto` minimum
   would let a long transcript push the composer off the bottom of the screen instead. */
#chat-section .chat-panel {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  padding: 0 12px;
  overflow: hidden;
}

/* The grip. A hit area wider than the line it draws, for the reason every drag handle
   is: 1px is not a target. It hangs half outside the panel so the cursor changes before
   the pointer has left the table. */
.chat-grip {
  position: absolute;
  left: -3px;
  top: 0;
  bottom: 0;
  width: 7px;
  cursor: col-resize;
  z-index: 1;
}
.chat-grip::after {
  content: '';
  position: absolute;
  left: 3px;
  top: 0;
  bottom: 0;
  width: 1px;
  background: transparent;
  transition: background .12s ease;
}
.chat-grip:hover::after,
body.chat-resizing .chat-grip::after { background: var(--teal); }

/* The way back in, and the whole of the closed state. It sits where the collapsed dock
   used to, which is the one thing about the old arrangement worth keeping: that corner
   is where an engineer's eye already goes for the conversation. Fixed rather than in the
   toolbar so it is reachable from the bottom of a long estimate, where a correction is
   just as likely to be needed as at the top. */
.chat-reopen {
  position: fixed;
  right: 20px;
  bottom: 20px;
  z-index: 45;
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 8px 13px;
  border: 1px solid var(--border-bright);
  border-radius: 999px;
  background: var(--bg2);
  color: var(--text-dim);
  font-family: var(--sans);
  font-size: 12.5px;
  cursor: pointer;
  box-shadow: var(--shadow-lg);
}
.chat-reopen:hover { color: var(--text); border-color: var(--text-faint); }
.chat-reopen[hidden] { display: none; }
/* The count is the only thing that says an estimate HAS a conversation before it is
   opened — a bare glyph reads as an invitation, not as a record. */
.chat-reopen-count {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: .04em;
  padding: 1px 6px;
  border-radius: 999px;
  background: var(--bg3);
  color: var(--text-faint);
}
.chat-reopen-count[hidden] { display: none; }

/* 46px and a bottom hairline, which is the page header's own geometry: the two sit at
   the same height across the seam, so the panel reads as part of the shell rather than
   as something parked against it. It is OUTSIDE .chat-panel's padding — full-bleed, so
   its rule runs the whole width of the column the way the header's does. */
.chat-head {
  flex: none;
  height: 46px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 0 10px 0 14px;
  border-bottom: 1px solid var(--border);
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.chat-collapse {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text-faint);
  cursor: pointer;
}
.chat-collapse:hover { background: var(--bg3); color: var(--text); }

/* The composer sits at the foot of the column, on the panel's own ground. It carried the
   dock's shadow while it was a pill floating over the page; inside a panel that would be
   a raised edge with nothing under it to lift off.
   The BACKGROUND is still deliberately left to .measure-field. Two reasons, and the
   second is the one that bites: index.html's page-local <style> is parsed after this
   file, so a same-specificity override here is dead on arrival — and winning it with an
   id would also outrank `.measure-field.drag-over`, silently costing the drop target the
   one thing that says a file may be let go here. */
.chat-composer { flex: none; margin: 0 12px 12px; }

/* The transcript is the only part that grows, so it takes the height the head and the
   composer leave. `min-height: 0` for the reason .chat-panel needs it: without it a long
   conversation pushes the composer off the bottom instead of scrolling. The pixel cap it
   used to carry belonged to a dock anchored to the bottom of the viewport; a column that
   owns its height needs none. */
.chat-transcript {
  display: flex;
  flex-direction: column;
  gap: 10px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 14px 2px 16px;
}

.chat-msg {
  max-width: 88%;
  padding: 8px 11px;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  font-size: 13px;
  line-height: 1.55;
}
/* The engineer's own words sit right and read as sent; the assistant's sit left and read
   as the page answering. The accent edge is the only place the brand colour appears —
   a filled bubble would put 13px of body text on a tinted ground in both themes. */
.chat-msg-engineer {
  align-self: flex-end;
  background: var(--bg3);
  border-color: var(--border-bright);
}
.chat-msg-assistant {
  align-self: flex-start;
  background: var(--bg2);
  border-left: 2px solid var(--teal);
}
.chat-msg-meta {
  font-size: 10.5px;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: 3px;
}
/* The engineer's side names WHO ASKED, which is an address rather than a label, and the
   two cannot be styled alike. Uppercasing is a treatment for a word: applied to an
   address it produces JULIEN@ACME.COM, which is both ugly and a misrepresentation - the
   local part of an address is case-sensitive, so the rendered form is not the thing.
   And an address is arbitrarily long where a label is not, so it truncates onto its
   title rather than wrapping the bubble's header over two lines. */
.chat-msg-meta.is-author {
  text-transform: none;
  letter-spacing: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chat-msg-body { white-space: pre-wrap; }
.chat-msg .file-cards { margin-top: 6px; }

/* What the turn did to the table, in one line. The reply says it in prose; this says it
   in a form the eye finds without reading. */
.chat-ops-summary {
  margin-top: 6px;
  font-size: 11.5px;
  color: var(--text-dim);
}

.chat-undo,
.chat-fork {
  display: inline-block;
  margin-top: 7px;
  padding: 3px 9px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-bright);
  background: none;
  color: var(--text-dim);
  font-family: var(--sans);
  font-size: 11.5px;
  cursor: pointer;
  text-decoration: none;
}
.chat-undo:hover { color: var(--text); border-color: var(--text-faint); }
/* The fork is an offer, not a correction: it is the only way forward on a finalized
   estimate, so it carries the accent the Undo beside it deliberately does not. */
.chat-fork {
  color: var(--teal);
  border-color: var(--teal-dim);
}
.chat-fork:hover { border-color: var(--teal); }

.chat-note {
  margin-bottom: 10px;
  padding: 8px 11px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--amber-line);
  background: var(--amber-dim);
  font-size: 12px;
  color: var(--text-dim);
}

.chat-empty {
  padding: 14px 2px;
  font-size: 12.5px;
  color: var(--text-faint);
}

/* The composer is a .measure-field, so it inherits the frame, the focus ring and the
   drag-over highlight. Only the height differs: a correction is a sentence, where a
   measure is a paragraph — and collapsed it is a pill, so it gives up the second line
   until there is a panel above it to belong to. The textarea carries rows="2", which is
   an attribute and not a style, so min-height is what actually decides this. */
.chat-composer textarea.chat-input { min-height: 56px; }

/* Narrow viewports: the panel goes back to being an OVERLAY, which is the one place the
   dock it replaced was the right answer. Below this width the page and a readable table
   cannot both fit beside it, and a 380px column would leave the estimate a gutter — so
   the body keeps its full width and the panel is dismissed rather than lived with. It
   takes a shadow again here, being over the page rather than beside it. */
@media (max-width: 900px) {
  body.app-shell.chat-open { padding-right: 0; }
  #chat-section {
    width: min(var(--chat-w), calc(100vw - 32px));
    box-shadow: var(--shadow-lg);
  }
  .chat-grip { display: none; }   /* nothing to resize into */
}

/* The transcript in the read-only estimate dialog. Collapsed by default and last in the
   box: the reader clicked through for the numbers, and a conversation above them would be
   the wall of text #recap-params is collapsed to avoid. The bubbles themselves reuse
   .chat-msg, so the two surfaces cannot drift apart on what a message looks like. */
.estimate-view-chat > summary {
  cursor: pointer;
  margin-bottom: 8px;
}
.estimate-view-chat .chat-msg {
  max-width: 100%;
  margin-bottom: 8px;
}

/* The turn is in flight. Three dots in the transcript, where the answer is going to
   appear - a spinner on the send button alone left the page looking frozen for the whole
   model call, which is the one thing a chat must never do. No new :root token, the rule
   the skeleton primitive set. */
.chat-msg-pending { padding-top: 12px; padding-bottom: 12px; }
.chat-dots { display: flex; align-items: center; gap: 4px; height: 8px; }
.chat-dots span {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--text-faint);
  animation: chat-dot 1.2s ease-in-out infinite;
}
.chat-dots span:nth-child(2) { animation-delay: .18s; }
.chat-dots span:nth-child(3) { animation-delay: .36s; }
@keyframes chat-dot {
  0%, 60%, 100% { opacity: .3; transform: translateY(0); }
  30%           { opacity: 1;  transform: translateY(-3px); }
}

/* The skeleton's precedent: reduced motion leaves the dots VISIBLE but still. An empty
   bubble would be a worse answer than a motionless one. */
@media (prefers-reduced-motion: reduce) {
  .chat-dots span { animation: none; opacity: .6; }
}

/* A busy button is WORKING, not unavailable. :disabled dims to .28, which all but hides
   the ring .is-busy draws - so the two together read as "broken" rather than "wait".
   Applies to the clarify wait on #btn-ce-run as well, which had the same problem. */
.btn-send.is-busy:disabled { opacity: 1; cursor: progress; }
