/* ==========================================================================
   Quotes and Campaigns — console pages
   (quotes.html, quote.html, quote-new.html, quote-share.html,
    campaigns.html, campaign.html)

   Spec: docs/requirements/estimates/01-console.md (EST-1xx)

   Two modules, one stylesheet, because they are one document vocabulary: a
   campaign and a quote are both a titled selection of products in sections,
   and the `est-` prefix names that shape rather than either module. What
   differs is what the shape is allowed to say — a campaign carries no total,
   no line decision and no client, because it has no client to have one.

   The builder's shape: a quote is areas → lines → options. An area is a
   titled section with a subtotal; a line is one need with a quantity; an
   option is a candidate product. Exactly one option per line is SELECTED and
   is the delta baseline — every other option renders its price difference
   against it, never against a global reference (D3/D10).
   ========================================================================== */

/* `--font-mono` is declared in `global.css`, which none of the quote pages
   load — so the two rules below that ask for it were resolving to nothing and
   silently rendering in the body sans. Declared here with the same stack until
   the token moves into `app.css`, where every page would get it. */
:root { --font-mono: "JetBrains Mono", ui-monospace, monospace; }

/* An explicit `display` beats the browser's own `[hidden] { display: none }`,
   so every component here that sets one has to opt back in — otherwise
   `el.hidden = true` silently does nothing. `.alert` is app.css's, and the two
   page-level ones on the builder open hidden. */
.est-notice[hidden],
.alert[hidden],
.est-pick-row[hidden],
.est-stat[hidden] { display: none; }
/* `.toolbar .search` sets `position` but no `display`, so this one is belt-and-braces:
   the search is closed until the `+` opens it and must stay closed. */
.est-pick-toolbar .search[hidden] { display: none; }

/* ---------- The joint under the KPI summary -------------------------------
   THE PANEL STARTS THE SAME DISTANCE DOWN ON BOTH PAGES. app.css gives a box
   sitting above an attached tab strip 25px and everything else `--page-gap`
   (14px), on the reasoning that a strip needs the wider standoff. That is
   right about the strip and wrong about the reader: on Campaigns the strip IS
   the top edge of the panel — it carries the panel's border and its corners —
   so what the eye measures there is 25px from the summary to the top of the
   panel. Quotes has no strip, so the same joint was measuring 14px, and the
   two pages sat their tables at visibly different heights.

   Matched here rather than in app.css because the platform rule is about a
   strip and this is about a page without one; widening every card in the
   portal to 25px is a much larger claim than the one being made.

   Scoped to the Quotes register alone: this sheet is also loaded by Campaigns,
   Campaign, and the quote pages, and Campaigns already gets its 25px from the
   platform rule — unscoped, it would be handed the gap twice. */
body[data-active="quotes"] .kpi-summary { margin-bottom: 25px; }

/* ---------- One client or several ----------------------------------------
   A quote is addressed to one client or to a list of them, and that is a
   fact about the document rather than a view anybody toggles. `data-est-to` is
   set once on the page; anything that differs carries `data-when` and the pair
   that does not apply is gone.

   `display: none` under the mode rather than `[hidden]` for the reason stated
   at the top of this file — half of what varies lives inside flex rows that
   set their own display, and `[hidden]` would lose to every one of them. */
[data-est-to="one"] [data-when="many"],
[data-est-to="many"] [data-when="one"] { display: none; }

/* `.subtabs--inline` — the ownership strip that rides this page's toolbar —
   moved to app.css when Clients started using it too. */

/* ---------- List table ---------------------------------------------------
   Columns take a share of the table under `table-layout: fixed`, so they
   resize with the browser and never with the data. The money sits last and is
   right-aligned by `.data-table`'s own last-column rule, so its right padding
   matches the first column's left padding. Status is centred: a badge is a
   shape rather than a figure, and a column of shapes is found down the middle
   where a column of numbers is read down its right edge. */
.est-table { table-layout: fixed; }
/* The two name columns are the ones that can be handed anything — a project
   title and a design studio's full trading name are as long as somebody typed
   them — so they take the room, and the date column starts further right
   because of it. What the date column gives up it does not need: its longest
   line is a date over "Rep M. Alvarez", and a column sized for that is a column
   sized for all of them. */
.est-table th.col-est    { width: 25%; }
.est-table th.col-client { width: 22%; }
.est-table th.col-scope  { width: 13%; }
.est-table th.col-valid  { width: 12%; }
.est-table th.col-status { width: 16%; }
.est-table th.col-total  { width: 12%; }
/* All Quotes reads the client and the quote in one column rather than two, so
   that column takes both shares: the three columns after it start on the same
   edges the other tabs' do, and the row is a name with its reference under it
   instead of the same quote read twice across a gap. */
.est-table--all th.col-est    { width: 48%; }
.est-table--all th.col-source { width: 12%; }
/* The number and the person share the line under the client. The bar is a mark
   between two facts rather than a character to be read, so it stands back from
   both of them and is drawn in the border colour, not the text one. */
.est-table--all td.col-est .sub .sep { margin: 0 6px; color: var(--color-border-strong); }
/* Decided reads two verdicts, the client's and the salesperson's, so it gives
   the room the date column would have had to the second of them. */
.est-table--decided th.col-est      { width: 22%; }
.est-table--decided th.col-client   { width: 20%; }
.est-table--decided th.col-scope    { width: 12%; }
.est-table--decided th.col-status   { width: 18%; }
.est-table--decided th.col-decision { width: 16%; }
.est-table--decided th.col-total    { width: 12%; }
/* Holds carry two of the widest cells on the page — a count of items over a
   count of slabs, and a deadline over how it was set — so both take room the
   status column does not need for a one- or two-word badge. */
.est-table--holds th.col-est    { width: 20%; }
.est-table--holds th.col-client { width: 20%; }
.est-table--holds th.col-scope  { width: 17%; }
.est-table--holds th.col-valid  { width: 17%; }
.est-table--holds th.col-status { width: 14%; }
.est-table--holds th.col-total  { width: 12%; }
/* A question is longer than anything else in these tables, so it takes half
   the row and the columns that name it stay short. */
.est-table--q th.col-est      { width: 18%; }
.est-table--q th.col-client   { width: 17%; }
.est-table--q th.col-item     { width: 16%; }
.est-table--q th.col-question { width: 39%; }
.est-table--q th.col-asked    { width: 10%; }
/* Campaigns count people rather than money, so the three columns that do the
   counting are short and the two that name the campaign and its audience take
   what is left. Answers holds one digit, so what sets its width is the word in
   the header rather than anything in the column; everything that column gives
   up goes to the campaign name, which is the one cell that can be handed a
   sentence. */
/* Audience, Sent and Opened each start five points further right than they did.
   The five comes out of Opened, the last of the three to move, and goes to the
   campaign name at the far left — which is why all three slide by the SAME
   amount and Answers does not move at all. Take it from anywhere else and the
   columns fan out by different distances instead of travelling together.
   Opened can afford it: its longest value is "29 of 48" and its header is the
   longer of the two, so the column is sized by the word OPENED either way. */
.est-table--cmp th.col-est    { width: 42.5%; }
.est-table--cmp th.col-client { width: 27%; }
.est-table--cmp th.col-source { width: 14%; }
.est-table--cmp th.col-scope  { width: 9%; }
.est-table--cmp th.col-total  { width: 7.5%; }
/* Products is one or two digits in a column sized by its heading, so left-
   aligned it strands the figure a long way from the word above it. Centred
   under the heading, the way the status column is — a short value found by the
   column it is in, not read down an edge like the money on a quote. */
.est-table--cmp th.col-source,
.est-table--cmp td.col-source { text-align: center; }
/* The send date carries the sub-line — "Sent" and "20 days ago" are the label
   and the arithmetic around it — so it takes the weight without taking the
   size: still the quiet second line, with the one thing you look up on it
   findable at a glance. */
.est-table--cmp td.col-est .sub .tnum { font-weight: 600; }
.est-table th.col-status,
.est-table td.col-status,
.est-table th.col-decision,
.est-table td.col-decision { text-align: center; }
.est-table td.col-status > *,
.est-table td.col-decision > * { display: inline-flex; }
.est-table td.col-status .cell-stack { display: inline-flex; text-align: center; }
.est-table td.col-question {
  color: var(--color-text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.est-table tbody tr { cursor: pointer; }
.est-table .col-est .main .badge { margin-left: 4px; }

/* SHARED AND COMPLETED, PULLED APART. This column is the one place in the portal
   that stacks all five states vertically, one under another, where the platform
   normally shows a single badge at a time — and read that way two of the five
   are near-twins: `--color-neutral-50` is #F1F5F9, a COOL grey, and
   `--color-info-50` is #E7EEF6. Lightening the blue toward the surface only
   closed the gap, until Shared and Completed were the same swatch.

   So each moves away from the other rather than the blue moving alone:

     SHARED     mixed off `--color-neutral-ink`, which is slate rather than a
                blue-grey, so its ground reads as plain light grey with the blue
                cast taken out of it. Nothing is happening on a shared quote, and
                that is what a colourless chip says.
     COMPLETED  mixed off `--color-info` NEAT instead of off the pre-tinted
                `-50`. That is what buys the separation: the ground comes out
                lighter than the platform badge it replaces AND more saturated,
                so it is unmistakably blue rather than a slightly bluer grey.

   §19 IS INTACT. Neutral is still neutral and info is still info — the hue per
   state has not moved, and the strip chips above still match the badges their
   rows carry. What changes is only how far each ground is carried toward the
   surface, and it is `--color-surface` rather than a literal white so the dark
   theme steps toward its own ground instead of blooming.

   `.est-total-row` catches the same quote statuses where a campaign lists the
   quotes it minted; that page loads this sheet, so the two agree without either
   restating the rule. */
.est-table .col-status .badge-neutral,
.est-total-row .badge-neutral {
  background: color-mix(in oklab, var(--color-neutral-ink) 7%, var(--color-surface));
  border-color: color-mix(in oklab, var(--color-neutral-ink) 18%, var(--color-surface));
}
.est-table .col-status .badge-info,
.est-total-row .badge-info {
  background: color-mix(in oklab, var(--color-info) 12%, var(--color-surface));
  border-color: color-mix(in oklab, var(--color-info) 34%, var(--color-surface));
}

/* A MENU ITEM THAT IS ITSELF THE STATE rather than a door to something — the
   Archived & Rejected view, under Page Settings, which is on or off and says
   so. The tick keeps its space when it is not showing, so the label sits in the
   same column either way: an item whose text slides sideways as it is switched
   reads as a different item each time the menu is opened. Colour and weight
   carry the state, never an underline. */
.page-settings .dropdown-item .dd-check { opacity: 0; }
.page-settings .dropdown-item.is-on .dd-check { opacity: 1; color: var(--color-primary); }
.page-settings .dropdown-item.is-on { font-weight: 600; }

/* THE STRIP WHILE THE PAST VIEW IS OPEN. Not one of its chips describes what is
   on screen, and a lit chip over the wrong list is worse than no chip: it goes
   quiet and inert until the view is closed. Left alone underneath, so closing
   it puts back the exact selection that was in force. The save icon goes with
   it — saving "the past view" as what the page opens on is not on offer. */
.subtabs.is-off { opacity: .4; pointer-events: none; }

/* A new campaign needs no empty state. The typing row is the invitation, the
   same way it is on a quote — an empty-state panel with an Add button would be
   a thing to click before you can work, sitting directly above the row you can
   already type into. */

/* NO CAMPAIGN COLUMN WIDTHS. The campaign grid used to be five columns wide
   because it printed a sentence where a quote prints a price — "Each client's
   own list" — and five of the quote's six figures leave 15% for the browser to
   hand out wherever it likes. It now carries a price and a total like the quote
   does, on the same six columns, so it takes the quote's own widths and the two
   grids stay one grid.

   The stock badge moved with it, out of the price cell and onto the material's
   own name: a price column is read down its right edge, and a badge standing in
   it breaks the column. Whether the material is there is a fact about the
   material, which is what the first column is. */

/* An unanswered question rides the status cell rather than taking a stage of
   its own: asking is not answering, so the quote is still Sent and the flag is
   the count of what is outstanding on it. Narrower than a status badge and
   icon-led, so the eye reads the status first and the flag second. */
.est-flag {
  display: inline-flex; align-items: center; gap: 3px;
  margin-left: 4px; padding-left: 5px; padding-right: 6px;
  font-variant-numeric: tabular-nums;
}
.est-flag svg { width: 12px; height: 12px; flex: 0 0 auto; }

/* Where a quote came from — a fact about the record rather than a state it
   is in, so it reads as a labelled icon and not as another badge competing
   with the status two columns along. */
.est-source {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--color-text-muted);
  white-space: nowrap;
}
.est-source svg { width: 13px; height: 13px; flex: 0 0 auto; color: var(--color-text-subtle); }

/* A date that needs watching: expiring soon, or a hold deadline coming up.
   It used to wear a warn badge, which put a filled pill in Valid Until beside
   the status badge two columns along -- two chips on one row, only one of
   them a state. The dot says the same thing without the block, and it is the
   mark the KPI metas and the glance bar above this table already use for it. */
.est-due { display: inline-flex; align-items: center; gap: 6px; }

/* A group row sits in the same list as the quotes it minted, so it needs to
   be told apart at a glance without becoming a second table. A left edge is
   enough: the reference already reads differently, and the badge beside it
   says the word. */
.est-table tbody tr.est-row--group td:first-child {
  box-shadow: inset 3px 0 0 0 var(--color-primary);
}
.est-table tbody tr.est-row--group .main { font-weight: 600; }
/* Where a child came from. It is a link inside a sub-line, so it takes the
   sub-line's size and only picks up colour on hover — a row of blue lineage
   lines would compete with the reference above them. */
.est-from a { color: inherit; }
.est-from a:hover { color: var(--color-primary-700); }

/* ---------- Detail header ------------------------------------------------
   A live quote carries a long badges row — a title, a client, a price list,
   a date, an owner. `.page-header` wraps, so a row that long would push the
   page actions onto a line of their own; page actions sit top-right on every
   page in this portal and must not move because the title got longer. Giving
   the left block a zero flex-basis lets the badges wrap INSIDE it instead. */
.est-head > :first-child { flex: 1 1 0; min-width: 0; }
.est-crumb {
  display: flex; align-items: center; gap: 6px;
  margin-bottom: 4px; font-size: 12px; color: var(--color-text-subtle);
}
.est-crumb a { color: var(--color-text-muted); }
/* The number and the status are one line. `align-items: baseline` would hang the
   box off the heading's baseline and leave its bottom edge below the number's
   descenders; centred, the two read as one object. */
.est-title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }

/* WHO IT IS FOR, THEN WHAT IT IS CALLED. The bar reads "Marbles & Granite |
   QUO-2026-0043" once a client is on the quote, and the number alone until then.
   The separator is drawn here rather than written into the DOM, so the name and
   the number are each their own text and neither carries punctuation belonging
   to the other — and hiding the name takes the bar back to the number with
   nothing left over. It is muted because it is joinery, not a third thing being
   said. */
.page-title [data-est-client]::after {
  content: "|";
  margin: 0 9px;
  color: var(--color-text-subtle);
  font-weight: 400;
}
/* THE STATUS READS OFF THE PLATFORM'S WARMTH RAMP (`--wm-*` in app.css), the
   same one the CRM board reports a relationship with, and it is built the way
   `.cl-warm` is built: the hue is a variable, the ground is that hue mixed into
   the surface, the mark carries it neat, and the WORD stays the page's own ink.
   That is what holds it up across four themes without a second palette, and what
   makes a quote's state and a client's state read as the same kind of
   reading.

   SHARED is `--wm-contacted` — "alive, but cool", and the first rung there is:
   a quote is saved and out with somebody, or it does not exist. It was grey,
   which said nothing is happening here, and blue is what the ramp puts on a
   thing that is alive and has not warmed up yet. Another state only sets `--wm`.

   A `.badge` is 11px in a 2px box, sized to sit in a row of chips. This one
   stands against 22px type, where that box reads as a footnote pinned to a
   headline, so it is scaled to stand as tall as the number. The TEXT stays a
   clear step under the heading: the number is what the page is called, and a
   status set at the same size argues with it for that job. */
.est-status {
  --wm: var(--wm-contacted);
  font-size: 14px; padding: 5px 12px;
  background: color-mix(in oklab, var(--wm) 13%, var(--color-surface));
  /* The edge is the same hue further along the mix — a pale ground with no edge
     floats beside type this big. `.badge` already reserves the 1px for it, so
     giving it a colour moves nothing. */
  border-color: color-mix(in oklab, var(--wm) 38%, var(--color-surface));
  color: var(--color-text);
}
/* THE CLIENT HAS STARTED READING IT — the next step along the ramp, and the one
   step that has to be legible as a colour rather than inferred from the word.
   In Review means somebody opened the quote and began answering, which is the
   moment a rep is waiting to see, so the chip carries the ramp's amber openly:
   at the 13% ground the other steps use it came out barely off white, and a
   status you have to read to notice is a status doing none of the work. The
   edge goes with it, or a stronger ground sits in a wash of an outline. */
.est-status--engaged {
  --wm: var(--wm-engaged);
  background: color-mix(in oklab, var(--wm) 24%, var(--color-surface));
  border-color: color-mix(in oklab, var(--wm) 55%, var(--color-surface));
}
/* THE REP HAS SIGNED IT OFF. The last rung either party climbs, and the ramp's
   landed green is what the rest of the portal puts on a thing that came off.
   The 13% ground is enough here: by this point the word is the news, not the
   colour — nobody is scanning a page for a status they have just set. */
.est-status--approved { --wm: var(--wm-active); }
/* Colour is never the only carrier — the CRM chip states its temperature with a
   mark as well, and `.badge .dot` is the portal's. It takes the hue neat rather
   than `currentColor`, which here is the page's ink. */
.est-status .dot { background: var(--wm); }
/* The title trails the number in the heading, a step lighter so the number is
   still what the eye lands on and the two never read as one long name. */
.est-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.est-badges .subtle { font-size: 12px; }

.est-form-note { margin: 4px 0 0; font-size: 12px; }

/* How This Works is a help icon beside Share now, not a card holding a third of
   the page open all session. `.page-actions` stretches its children, and the
   icon button's fixed 34px would stand a pixel short of the buttons beside it. */
.page-actions .icon-btn { height: auto; }

/* ---------- Inline header fields -----------------------------------------
   Client, Valid Until and Title are edited on the quote itself, not asked
   for in a dialog before it exists. They read as text until you reach for
   them. */
.est-field {
  display: inline-flex; align-items: center; gap: 5px;
  border: 1px solid transparent; border-radius: var(--radius);
  background: none; cursor: pointer; text-align: left;
  padding: 2px 6px; margin-left: -6px;
  font: inherit; font-size: 12px; color: inherit;
}
.est-field:hover { border-color: var(--color-border); background: var(--color-surface); }
.est-field svg { width: 12px; height: 12px; color: var(--color-text-subtle); }
/* The pencil is always there. A chip with no pencil reads as a label, not as the
   way to change the client, the price list or the title — and a pencil that
   waits for a hover is one a finger never sees and a mouse only finds by
   accident. It is the page's lightest ink, which is what keeps six of them from
   crowding the row. */
.est-field .est-edit { opacity: 1; }
.est-field--unset { color: var(--color-danger); border-color: var(--color-danger); }
.est-field--unset svg,
.est-field--unset .est-edit { color: var(--color-danger); opacity: 1; }

/* UNTIL THERE IS A CLIENT, THE TITLE SAYS SO. The chip that used to carry this
   warning is gone — the standing pencil beside the heading replaced it — so the
   warning moves onto the two things left in that row: the number, which is all
   the quote can be called while it is for nobody, and the pencil that fixes it.
   The same `--color-danger` the chip wore; nothing new is introduced.

   Driven off the client span being empty rather than a class the script has to
   remember to take off again. There is one fact here — whether the title has a
   name in it — and this reads that fact directly, so the warning cannot survive
   an assignment or go missing before one. */
.est-title-row:has([data-est-client][hidden]) .page-title,
.est-title-row:has([data-est-client][hidden]) [data-client-edit] { color: var(--color-danger); }
/* It is still a button, so it still answers a pointer — in its own colour
   rather than the platform blue, which would say the field was fine. */
.est-title-row:has([data-est-client][hidden]) [data-client-edit]:hover {
  background: var(--color-danger-50); color: var(--color-danger);
}

/* THE PAGE TITLE, AS A FIELD. A new campaign has no name until somebody writes
   one, and writing it should not mean deleting a number first — so this is an
   open box that opens empty and asks, not a heading behind a pencil, and not a
   filing number wearing the title's clothes. It has to carry the <h1> type
   itself now that it is no longer one; `font: inherit` would hand it the row's
   14px body text.

   Sized to a name rather than to the row: full width left the status badge
   beside it stranded at the far end of the page. 34ch holds a real campaign
   name — "August Container — Marbles & Travertine" — without scrolling it out
   of its own box, which is the whole point of typing into the title. It still
   gives at a narrow window. */
.est-title-field {
  display: block; width: min(100%, 34ch);
  /* THE BOX IS DRAWN AT REST. The chips below can wait to be reached for —
     they are read as facts and edited rarely — but this one is the first thing
     a new campaign wants from you, and a title that looks exactly like every
     other page title is a field nobody knows is there.
     `--color-control` on `--color-control-border`, the portal's own treatment
     for a control: a hairline in `--color-border` over the header's grey is a
     box you cannot see (repo memory.md §13 — nothing sits on the ground's own
     colour). Focus then does what every other field on the platform does. */
  border: 1px solid var(--color-control-border); border-radius: var(--radius);
  background: var(--color-control); padding: 2px 6px; margin-left: -6px;
  font-family: 'Schibsted Grotesk', system-ui, sans-serif;
  font-size: 22px; font-weight: 600; letter-spacing: -0.02em;
  color: inherit;
}
/* THE ASK IS THE ONLY THING IN THE BOX. It sits at title size so the box keeps
   its height whether or not it holds a name, but in the subtle grey and at
   normal weight — an unnamed campaign must never read as a named one. */
.est-title-field::placeholder { color: var(--color-text-subtle); font-weight: 400; }
.est-title-field:hover { border-color: var(--color-text-subtle); }
.est-title-field:focus {
  outline: none;
  border-color: var(--color-primary); background: var(--color-surface);
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}

/* ---------- Stat row (detail header) -------------------------------------
   Value first, label tight beside it — never spread to opposite ends
   (project memory.md §6). */
.est-stats { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: var(--page-gap); }
/* The same standoff `.kpi-summary` takes above a tab strip, for the same reason:
   an attached strip reads as the top edge of the panel below it, so at --page-gap
   the stat row, the strip and the grid stack into one undifferentiated band and
   the figures look like a row of the document. 25px separates the two groups. */
.est-stats:has(+ .tabs) { margin-bottom: 25px; }
.est-stat {
  display: inline-flex; align-items: baseline; gap: 7px;
  padding: 10px 14px;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius);
}
.est-stat .v { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
.est-stat .l { font-size: 12px; color: var(--color-text-muted); }
.est-stat--ok .v { color: var(--color-success); }
.est-stat--wait .v { color: var(--color-warn); }
/* On the builder this row is a caption on a full-width document, not the page's
   headline, so it runs lean: the numbers still lead, at a size that stops
   competing with the quote under them. */
.est-stats--lean .est-stat { padding: 7px 11px; }
/* ON THE TAB STRIP. The four figures are a reading of the same campaign the tabs
   are readings of, so they close that row rather than standing on a row of their
   own: a band of page and a second horizontal rule, spent on four numbers.

   The trailing edge is the platform's place for what a row carries besides its
   own controls — the same `margin-left: auto` the status filter and Add Group
   use — and they stay one line: the strip already scrolls sideways rather than
   squeezing (app.css), and a stat row that wrapped would double the strip's
   height. Values drop a size, because at 18px they out-shouted the tabs. */
.est-stats--strip {
  margin: 0 0 0 auto; align-self: center; flex: none;
  flex-wrap: nowrap; gap: 6px;
}
.est-stats--strip .est-stat { padding: 5px 10px; }
.est-stats--strip .est-stat .v { font-size: 15px; }
.est-stats--strip .est-stat .l { white-space: nowrap; }
.est-stats--lean .est-stat .v { font-size: 15px; }

/* ---------- One bar, not seven pills --------------------------------------
   Seven figures in seven bordered boxes is seven frames and fourteen edges
   standing over a document that has not started yet: the row was the busiest
   thing on a page whose whole job is the quote under it. One box now, with a
   hairline between readings — the separating is done by a line a pixel wide
   instead of by six borders and six gaps, and the figures are what is left to
   look at. */
.est-stats--bar {
  flex-wrap: nowrap;
  align-items: stretch;
  gap: 0;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
}
.est-stats--bar .est-stat {
  background: none;
  border: 0;
  border-radius: 0;
  padding: 8px 14px;
}
.est-stats--bar .est-stat + .est-stat { border-left: 1px solid var(--color-border); }
/* The container box is the only one that answers a press, and its own border is
   gone, so the hover lands on the ground instead of the edge. */
.est-stats--bar .est-stat--set:hover { background: var(--color-surface-alt); }

/* ---------- The label goes under the number -------------------------------
   Side by side, every box was as wide as its own caption — $81,536 in a box
   three words long — and seven of them ran the row past the page. The figure
   leads on a line of its own and the words it belongs to sit under it in the
   label's small type, which is the shape the portal gives a value and its
   qualifier everywhere else.

   A box whose reading is two parts — a count and what is inside it, a
   percentage and its unit — wraps that pair in `.est-stat-line`, so the break
   falls between the reading and its caption rather than between every span. */
.est-stats--stack .est-stat { flex-direction: column; align-items: flex-start; gap: 1px; }
.est-stat-line { display: inline-flex; align-items: baseline; gap: 6px; }

/* Two readings, one row. Subtotal, Total and Items are what the quote IS,
   and read down the same left edge as the title and the strip; Approved and
   Awaiting are what the client has DONE with it, and bank right. The gap
   between them carries the distinction, so nothing has to label it.

   The mode switch now takes that spare space and leads the right-hand pair, so
   `--push` is on it rather than on Approved. Kept for quote.html, which has
   no mode switch and still needs its own stats to part company. */
.est-stat--push { margin-left: auto; }
.est-stat--grand { border-color: var(--color-border-strong); }
.est-stat--grand .l { color: var(--color-text); font-weight: 600; }
/* Stacked, the caption is a caption. Beside its number the heavier label read as
   a peer of it; sitting under the number it reads as a small heading over the
   box instead. The emphasis belongs to the figure — which keeps its green, and
   the box its stronger edge — so the word goes back to the row's own weight and
   holds its darker ink alone. */
.est-stats--stack .est-stat--grand .l { font-weight: 400; }
.est-stat--no .v { color: var(--color-danger); }

/* The salesperson's own verdict, standing beside the client's. Deliberately
   quieter than `.est-status`: the client's answer is the news, and this is what
   has been done about it. */
.est-decision { align-self: center; }

/* ---------- Group quote -----------------------------------------------
   A send, not a quote. Its figures are counts of people, its materials
   carry no decision, and the one thing on it that needs a salesperson is a
   material two clients have both accepted. */
.est-crumb-sep { color: var(--color-text-subtle); }
.est-about.est-about--lead { margin-top: 0; margin-bottom: var(--page-gap); }

/* A material on offer to twenty-one people. Five columns rather than a quote's
   six — the row's grid is set with `.est-mat--offer` further down — and the
   Total column has no total to show, since nothing here has a buyer, so it
   carries the count of clients who want it instead. */
.est-mat--offer .est-mat-total { text-align: right; }
/* The count stands on its own line over a small plain word, the same stack the
   quantity cell beside it makes of bundles over slabs — a figure and what it
   counts, read at two sizes, rather than one 12px sentence. */
.est-mat-want {
  display: inline-flex; flex-direction: column; align-items: flex-end; line-height: 1.2;
  font-size: 12px; color: var(--color-text); white-space: nowrap;
}
.est-mat-want-n { font-size: 15px; }
.est-mat-want-l { font-size: 10px; font-weight: 400; color: var(--color-text-subtle); }
.est-mat-want--none { color: var(--color-text-subtle); }

/* Two clients, one set of slabs, and a decision that is the salesperson's. */
.est-contest { margin-bottom: 20px; }
.est-contest .card-header .badge { margin-left: auto; }
.est-clash + .est-clash { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--color-border); }
.est-clash-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.est-clash-name { font-size: 14px; font-weight: 600; }
.est-clash-meta { font-size: 12px; color: var(--color-text-muted); }
/* The overask is the whole problem stated as one number, so it does not get
   parked at the row's far edge away from the material it belongs to. */
.est-clash-over {
  font-size: 12px; font-weight: 600; color: var(--color-warn);
  white-space: nowrap;
}
.est-clash-rows { display: flex; flex-direction: column; margin-top: 10px; }
/* The row reads as one sentence — who, how much, when, at what list — so each
   part sits immediately after the last and the button takes the leftover width
   (project memory.md §16). A quantity pinned to the right edge would be nearer
   the next client's name than the one it belongs to. */
.est-clash-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 9px 12px;
  background: var(--color-control);
  border: 1px solid var(--color-control-border);
  border-radius: var(--radius);
}
.est-clash-row + .est-clash-row { margin-top: 6px; }
.est-clash-row .dot-status { flex: 0 0 6px; }
.est-clash-row .btn { flex: none; margin-left: auto; }
.est-clash-who { font-size: 13px; font-weight: 600; min-width: 0; }
.est-clash-est { margin-left: 6px; font-size: 11px; font-weight: 400; color: var(--color-text-muted); }
.est-clash-qty { font-size: 13px; white-space: nowrap; }
.est-clash-when { font-size: 11px; color: var(--color-text-subtle); white-space: nowrap; }

/* The recipients list on a group carries a trailing column the quote's
   never did: the quote this person became, or why they did not. */
/* Every row is its own grid, so an `auto` track sizes to that row's content and
   the column wanders down the list. The tracks that hold variable-width things
   — a price list, a status badge — are fixed for the same reason the quote's
   recipient list fixes them. */
.est-recip.est-recip--group { grid-template-columns: minmax(0, 1fr) 132px 104px minmax(0, 1fr); }
/* The recipients panel does not only report the list, it is where the list is
   changed — so every row ends in the control that takes that recipient off, and
   the column is there on every row whether or not the row can use it. Sized, not
   `auto`: a track that appears and disappears with the control would step the
   four columns beside it in and out down the length of the list. */
.est-recip.est-recip--manage { grid-template-columns: minmax(0, 1fr) 132px 104px minmax(0, 1fr) 26px; }
.est-recip-x { width: 26px; height: 26px; }
.est-recip-trail { display: flex; justify-content: flex-end; min-width: 0; }
.est-recip-est { font-size: 12px; font-weight: 600; }
.est-recip-note {
  font-size: 11px; color: var(--color-text-subtle);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.est-page-alert--foot { margin-top: var(--page-gap); }

/* ---------- The container target -----------------------------------------
   THE ONLY FIGURE ON THE ROW THAT CAN BE SET, so it is the only one shaped like
   a control: same box, same numbers, plus the pencil every editable chip on this
   page carries. It was a panel of its own — a sentence, a progress bar and a
   paragraph saying the bar gated nothing — under a row of figures it belonged
   in. Beside the slab count it reads the way it is used: 111, target 120.

   No error state when the quote falls short. Reaching the target is information
   and never a condition on submitting or confirming; the row goes green when it
   is reached and says nothing when it is not. */
.est-stat--set {
  font: inherit;
  cursor: pointer;
  text-align: left;
  transition: border-color 150ms;
}
.est-stat--set:hover { border-color: var(--color-border-strong); }
/* The pencil is the chips' pencil, at the chips' size and in the chips' ink —
   it is the same promise, and the row's figures are the last place to introduce
   a second one. Sized here because the figure box, unlike `.est-field`, has no
   icons of its own to have set a size for. */
.est-stat--set .est-edit {
  width: 12px; height: 12px;
  margin-left: 1px;
  color: var(--color-text-subtle);
}

/* ---------- The bar's one button ------------------------------------------
   Add Group, in the slot Review Quote and Convert To Hold held (0220): one
   button, no frame around it, on the bar that states the quote, centred on the
   figures and banked right by the bar's auto margin. The three answer counts
   that used to share the bar with it are gone too (0218) — the tab strip's
   chips count each state now.

   It shows on the PRODUCTS tab only: it adds to the list Products shows, and on
   Info it would act on something the reader cannot see. Read off `.tab.active`
   with `:has()`, so the bar is right however the tab was reached. */
.est-bar-action { margin-left: auto; margin-right: 10px; align-self: center; flex: none; }
.est-bar-action--products { display: none; }
.est-stats:has(~ .tabs .tab[data-tab="products"].active) .est-bar-action--products { display: inline-flex; }

/* ---------- The tab strip's own button (campaign.html) --------------------
   A child of the strip, not a row wrapped around it: the panels attach to
   `.tabs--attached` as its SIBLINGS, so a wrapper would put them out of that
   selector's reach and the join would come apart. Centred on the row, because
   the tabs' height comes from their own padding and the button is shorter.

   It shows on the PRODUCTS tab only: it acts on the materials, so it belongs
   beside the list of them, and on another tab it would be an action on
   something the reader cannot see. Read off `.tab.active` rather than set by
   script, so the strip is right however the tab was reached — a click, or the
   `?tab=` deep link. quote.html's Add Group left the strip for the figure bar
   (0220); campaign.html's still rides here. */
.est-tabs-action { display: none; margin-left: auto; align-self: center; flex: none; }
.tabs:has(.tab[data-tab="products"].active) .est-tabs-action { display: inline-flex; }
/* The same button the builder's type strip ends with (`.est-type-bar
   .est-add-area`): outline, 26px, on white. */
.est-tabs-action {
  height: 26px; min-height: 0; padding: 0 9px;
  border-color: var(--color-border-strong); background: var(--color-surface);
}
.est-tabs-action svg { width: 14px; height: 14px; }
.est-tabs-action:hover { background: var(--color-surface-alt); }
/* THE STATE CHIPS on quote.html's strip are campaign.html's Recipients row,
   class for class — `.subtabs--inline.rowfilter`, both in app.css — so there is
   nothing of their own to size here. They come and go with the Products tab
   through `data-for-tab` (app.js), and app.js appends the save-as-default icon
   as the row's last child. */

/* THE STRIP ATTACHES TO WHITE (app.css, "the tab shell"). Every other page gets
   that from the white band a table header draws above itself; the products list
   opens on a group head, which is a header band in `--color-table-head`, so the
   tab was landing on grey. The first card takes the same lead-in of its own
   white above the band — `--table-head-band`, the platform's one number for it —
   and squares the corners the strip sits over. */
[data-tab-panel="products"] > [data-builder] > .est-area:first-child {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
[data-tab-panel="products"] > [data-builder] > .est-area:first-child > .est-area-head {
  border-top: var(--table-head-band) solid var(--color-surface);
  border-radius: 0;
}

/* ---------- Area section -------------------------------------------------
   On quote.html an area IS a card, floated on a band of page: that page has
   no type strip to join and its areas each open with their own header, so a card
   apiece is the right shape for them. The builder's are not — see the block
   under `.est-grid-card` below. */
.est-area {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}
.est-area + .est-area { margin-top: 20px; }
/* A HEAD ONLY WHERE THERE IS A NAME. The bar that once opened EVERY area of the
   builder held its name, a rename, a delete and a "Type here" that became
   "Typing here" — and on a new quote, which has one nameless section, that was
   a coloured band across the top of the document saying the cursor was in the
   only place it could be, above a table it was not part of.

   So the ungrouped section still has none: the card and the gap of page around
   it are what say a section began. A NAMED group has one, written by
   egAreaHead — the name is the only thing telling Kitchen from Bathroom, and
   at the foot, right-aligned against a subtotal on a table wide enough to
   scroll, it was past the fold and nobody saw it.

   It carries the name, the group's note, and one menu at the trailing edge —
   rename and remove, the only two things that can be done to a group, behind
   the same mark every other row on the platform keeps its actions behind. The
   old bar's mistake was not that it had controls; it was that it had them on
   every section including the nameless one, and wrote the state of the cursor
   across the top of the document.

   quote.html's is the same element, opening each of its areas with the name, an
   add button and an actions menu. */
.est-area-head {
  display: flex; align-items: center; gap: 10px;
  /* THE HEAD IS A HEADER BAND, and it is built exactly the way every table on
     the platform builds one.

     THE GROUND is `--color-table-head`, the token `.data-table thead th` paints
     with. The rows under it are articles rather than table rows, which is why the
     band matters: without it a group opened on the same white as the products it
     heads, and the only thing saying a group began was the hairline under its
     name. No second blue is introduced; one would have to be maintained beside
     the one every table already reads.

     THE STAND-OFFS are the platform's two numbers — `--table-head-band` above
     (memory.md §18) and `--table-inset` at the sides (§20). A band that runs into
     the card's own border is the lid of the box rather than something inside it,
     and under a tab strip it butts straight into the tabs with nothing to say a
     panel began. The top one is a border, the way the tables draw theirs; the
     sides are margin, because there the band genuinely stops short.

     THE BAND KEEPS ITS OWN PADDING. The inset was taken out of it once, to hold
     the name on the same 14px as the product names below — and that left the
     writing 4px from the edge of a filled box, which reads as a band with no
     padding at all. A table does not solve it that way: the whole table is
     inset, header and rows together, so the two line up 10px further in. That is
     what `.est-mats` does below.

     AND THE CORNERS ARE SQUARE. With white on all three sides the band sits well
     inside the card's own 5px curve; rounding it as well only cuts a crescent out
     of the band. Same reasoning as `.est-grid-head:first-child th`. */
  background: var(--color-table-head);
  margin-inline: var(--table-inset);
  padding: 10px 12px 10px 14px;
  border-top: var(--table-head-band) solid var(--color-surface);
  border-bottom: 1px solid var(--color-border);
}
.est-area-head h3 { margin: 0; font-size: 15px; letter-spacing: -0.01em; }
.est-area-head .est-area-note { font-size: 12px; color: var(--color-text-muted); }
/* THE + ASKS FOR A TYPE FIRST, from a menu hung off its own left edge: the
   platform's menus open against the right edge of what holds them, and this
   one holds the leftmost thing on the band. */
/* The group's own menu closes the band, after the subtotal — which already
   carries the `margin-left: auto` that pushes the pair right, so this one must
   not take a second share of the free space and split them apart. */
.est-area-sub + .est-area-acts { margin-left: 0; }
.est-add-type { position: relative; display: inline-flex; vertical-align: middle; }
.est-add-type .dropdown-menu { left: 0; right: auto; min-width: 168px; }
/* The trailing edge is the platform's place for what a row carries besides its
   own label — the same `margin-left: auto` the subtotal takes on quote.html's
   heads, and the toolbar's own trailing controls. */
.est-area-acts { margin-left: auto; flex: none; }

/* THE NOTE IS A CAPTION, NOT A PARAGRAPH. A campaign group carries a line the
   recipient reads under its heading, and it sits here as a second line under
   the same heading — written at the length it will be read at. One line and no
   wrap: a note typed long would otherwise push the products it heads down the
   screen, and a group head that grows with its text stops being a head. The
   ellipsis is where it stops, and the full sentence is on the title. */
.est-area-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.est-area-title .est-area-note {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* The area the typing row is in. Marked on the section itself rather than with a
   badge on its head: the thing being pointed at is a whole section of the
   document, and a mark the size of a chip says it about a smaller object.

   NO COLOUR. It was a gold ring, which is a treatment no other table in this
   portal wears — a coloured edge here means a warning everywhere else, and a
   reader who knows the rest of the portal reads it as one. The step up is in
   EDGE WEIGHT instead, which is what `--color-border-strong` is for and what
   every other stronger edge in this file already uses (`.est-sub-row--area`,
   `.est-tip`).

   AND THEN IT HAS NOTHING TO DRAW ON. The mark lived on the area's own outline,
   and the areas are sections of one panel now — there is no border left to
   darken, and a 3px stripe down the section's left edge at the only weight this
   file has for "a stronger edge" is a mark nobody sees. A mark that cannot be
   read is worse than none: it is the same blank screen plus a rule that has to
   be maintained.

   So the section is not marked, and what says where the cursor is is the cursor:
   the typing row is visibly inside one area, and the area's name closes its own
   subtotal line under it. The class is still set — `egRegroup` writes it — so a
   mark that CAN be read has somewhere to hang if this turns out to be missed. */

/* Label and figure stay tight to each other (project memory.md §6) — the PAIR
   is what banks right, and it is the LAST thing in the row, so every area's
   subtotal lands on the same edge as every other one and as the Total column
   under them. A column of figures is read down; one that steps in and out by
   the width of whatever sat beside it is read one line at a time.

   The word drops a step below the figure it labels: "Subtotal" is known before
   it is read, and the number is the thing being looked for.

   quote.html only. On the BUILDER the subtotal has moved down to the foot of
   the table it totals (`.est-sub-row`), where a group can close with a figure
   of its own — a head cannot carry two. */
.est-area-sub {
  margin-left: auto; flex: none;
  font-size: 11px; color: var(--color-text-muted);
}
.est-area-sub strong { font-size: 13px; color: var(--color-text); font-weight: 600; }
/* What the selection holds, ahead of what it comes to — separated, or the two
   read as one sentence and the count looks like part of the label. */
.est-area-count { color: var(--color-text-subtle); }
.est-area-count::after { content: "·"; margin: 0 7px; }

/* ---------- The type strip ------------------------------------------------
   IT IS THE PORTAL'S TAB STRIP — `.tabs` and `.tab` from app.css, the same row
   Stock and Catalog head their product types with, down to the icon, the count
   leading the label, the bottom rule under the strip and the primary-tinted
   active tab. It used to be eight bare labels floating on the page ground with a
   filled blue `+` after each, which is a row that exists nowhere else in this
   portal; a strip of product types that looks like a strip of product types is
   read without being learned.

   What it DOES is unchanged, and is the one way it is not a tab row: pressing a
   type does not filter the grid — every item on the quote stays on screen —
   it puts the typing row under that type. So the strip carries no `data-tabs`
   (the global tab JS would look for panels to switch), and the active tab means
   "this is where the next item lands", not "this is the page you are on". */
.est-type-bar { align-items: center; }
.est-type-bar[hidden] { display: none; }

/* THE TAB IS THE CONTROL. It carried a `+` of its own for a while — the tab was
   a <span> holding a button — which meant pressing the word did nothing and only
   the small mark beside it worked. On a strip that looks like every other tab
   strip in the portal, that is a control that lies about itself: nobody presses
   a 22px target beside a label they have already aimed at.

   So the tab is a `<button class="tab">`, the same element Stock and Catalog
   use, and pressing it calls `egAddType` — which puts the typing row under that
   type in the current area and takes the cursor. The `+` is gone because the
   tab now IS the `+`. Keyboard reaches it for the same reason. */

/* Add Area rides the far end of the strip. The strip runs the quote's whole
   width, which is the scope this control has: on a section's row it was a
   control acting on the document sitting among controls acting on that section.
   `margin-left: auto` takes the strip's spare space, so it lands hard right of
   the last type however many there are.

   It must not read as a ninth type, so it keeps a button's border and fill
   against the strip's own ground — and stands clear of the join, which the tabs
   sit on and it does not. The strip hangs 1px into the panel to make that join
   (`.tabs--attached`), so the button lifts by that 1px plus a hair of daylight. */
.est-type-bar .est-add-area {
  margin-left: auto; align-self: center; flex: none;
  height: 26px; min-height: 0; padding: 0 9px; margin-bottom: 7px;
  border-color: var(--color-border-strong);
  background: var(--color-surface);
}
.est-add-area svg { width: 14px; height: 14px; }
.est-type-bar .est-add-area:hover { background: var(--color-surface-alt); }

/* ---------- Totals, and the way into Preview -------------------------------
   Both ride the stat row, not the page's action row: the actions all DO
   something to the quote — Share it, download it, duplicate it — and these
   two only decide how the same quote is READ, which is what the stats beside
   them do too. Totals takes the row's spare space, so the pair leads Approved
   and Awaiting on the right. Stretched rather than centred, so their edges line
   up with the stat boxes either side.

   Totals is a switch with a word on each side, which is the house form for a
   binary choice — the segmented pair that stood here asked which of two things
   you wanted, and this is one thing that is either on or off. The knob points
   at the side in force and that side is written dark, so the position and the
   meaning say the same thing. It reads `Total [ ] Hide`: hiding is what gets
   turned on, so it takes the right, where a switch turned on points.

   It carries no box. The white panel it used to sit in belonged to the
   segmented track, and a switch nowhere else in the portal is boxed — a
   switch and its words sit on whatever they are on. Between two stat boxes
   that reads as the one thing on the row you can move, which is what it is.

   `.est-sums`, not `.est-totals`: that name is already quote.html's stacked
   figure block, and this file is shared. `.est-pv-btn`, not `.est-preview`, for
   the same reason — that one is the preview surface on this very page. */
.est-sums {
  flex: none; margin-left: auto;
  display: flex; align-items: center; gap: 10px;
  padding: 0 4px;
}
/* Colour and weight carry which side is in force — never an underline, and
   never a label that moves. Both words stay put; only their weight changes. */
.est-sums__l {
  font-size: 12px; font-weight: 500; color: var(--color-text-muted);
  white-space: nowrap; cursor: pointer;
  transition: color 120ms, font-weight 120ms;
}
.est-sums__l.is-active { color: var(--color-text); font-weight: 600; }
/* A plain outline button — nothing page-specific about it but the height, which
   is the stat row's rather than the button's own. */
.est-pv-btn { flex: none; align-self: stretch; }

/* View mode's rules stood here — the handles, the row actions and the footer
   hints stripped off the grid so the same rows could be read instead of worked
   on. There is no View any more: it was the document with the scaffolding gone
   and the client's own reading missing, and everything it showed, Preview shows
   better. Preview hides the grid outright, so nothing needs to be hidden inside
   it. */

/* ---------- About the quote -------------------------------------------
   What the side panel held, under the document instead of beside it: three
   across for the short cards, then the two that need the width. It starts a
   clear step below the last area, because it stops being the quote. */
.est-about { margin-top: 24px; align-items: start; }
.est-about--wide { grid-column: span 2; }
/* Client Decisions takes the whole row: it is a feed of sentences, and
   sentences read left to right. */
.est-about--full { grid-column: 1 / -1; }

/* SHARING · RECORD INSIDE ONE CARD. Two columns divided by a rule rather than
   two cards divided by their own borders. Running them down the card instead
   would have doubled its height and — worse — handed every `.est-total-row`
   the card's full width to push its value across with `margin-left: auto`,
   which is a tight pair in a third of a row and a canyon in a whole one. That
   is also why the card stays `--wide` rather than taking the whole row: two
   halves of the page would spread the same rows just as badly, so it keeps two
   thirds and each column is the third it was as a card of its own. The padding
   is on the sections, not in a `gap`, so the rule sits midway between the
   columns instead of hard against one. */
.est-about-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.est-about-split > section { display: flex; flex-direction: column; padding: 0 20px; }
.est-about-split > section:first-child { padding-left: 0; }
.est-about-split > section:last-child { padding-right: 0; }
.est-about-split > section + section { border-left: 1px solid var(--color-border); }

@media (max-width: 1100px) {
  .est-about { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
  .est-about { grid-template-columns: 1fr; }
  .est-about--wide { grid-column: auto; }
  /* Stacked, the divider turns and the padding goes with it — a left border on
     a full-width section is a mark against nothing. */
  .est-about-split { grid-template-columns: 1fr; }
  .est-about-split > section { padding: 16px 0 0; }
  .est-about-split > section:first-child { padding-top: 0; }
  .est-about-split > section + section { border-left: 0; border-top: 1px solid var(--color-border); }
}

/* A price list that cannot be changed from here — this version's prices are
   locked — is still a property of the quote, so it reads as one and simply
   never offers the pencil. */
/* A settled value: it reads as what it is, not as something waiting to be
   pressed. The pencil goes — an edit control that opens nothing is the clearest
   way to promise an edit that cannot happen — and the chip keeps the page's own
   text colour rather than greying out, because the fact is not disabled, only
   the changing of it. */
.est-field--static { cursor: default; }
.est-field--static:hover { border-color: transparent; background: none; }
.est-field--static a { color: inherit; font-weight: 500; }
.est-field--static .est-edit { display: none; }
.est-field--static[disabled] { color: var(--color-text); opacity: 1; }

/* ---------- Materials ----------------------------------------------------
   A selection is a set of comparable materials offered together, and the
   client takes as many of them as they want. Nothing is either/or, so there
   is no primary row and no difference against one: every row carries its own
   price, its own quantity, its own money and its own verdict, and reads the
   same as every other. Six columns, and the last three land on the same edge
   down the whole quote. */
/* THE ROWS TAKE THE SAME SIDE STAND-OFF AS THE BAND ABOVE THEM (`--table-inset`).
   A table is inset as one thing — header and body together — which is why its
   column headings sit over their own cells; insetting only the band would have
   left the group's name 10px to the right of the product names it heads. Only
   the three pages that read a selection have `.est-mats`; the builders lay their
   products out in tables, which are inset by `.table-wrap` already. */
.est-mats { display: flex; flex-direction: column; padding-inline: var(--table-inset); }

.est-mat, .est-entry-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 132px 128px 108px 104px 28px;
  align-items: center; gap: 12px;
  padding: 10px 14px;
}
.est-mat + .est-mat { border-top: 1px solid var(--color-border); }
.est-mat:hover { background: var(--color-surface-alt); }
/* A campaign's row is the same one minus the State column — how much is left in
   the yard is not something the twenty-one people being sold to are told. The
   width it held goes back to the name. Declared here rather than beside the
   campaign's other rules because it has to land after the columns it edits. */
.est-mat--offer {
  grid-template-columns: minmax(0, 1fr) 132px 128px 108px 28px;
}

/* ---------- Editing a row where it is written -----------------------------
   THE TWO FIGURES THAT MOVE. A price and a bundle count are changed far more
   often than anything else on a row, and both are one number: the number is the
   control, and the row menu's dialogs are what you open when you want to see
   what a change comes to before making it.

   The affordance is the pointer and the colour on hover — no underline (repo
   memory: clickable text is never underlined), and no box drawn at rest, which
   would turn a row of readings into a row of empty fields.

   `[data-inline-edit]` is set by the pages that wire it, so a page carrying the
   same rows without the behaviour does not offer a pointer that does nothing. */
[data-inline-edit] .est-mat-price,
[data-inline-edit] .est-mat-qty-main { cursor: pointer; }
[data-inline-edit] .est-mat-price:hover .tnum,
[data-inline-edit] .est-mat-qty-main:hover { color: var(--color-primary-700); }
/* The field takes the cell it replaces, and no more: a full-width input in a
   132px column pushes the columns beside it out of line for as long as it is
   open, which moves the very figures the number is being compared against. */
.est-inline-in {
  width: 100%; max-width: 104px;
  padding: 3px 7px; font-size: 12px;
  font-variant-numeric: tabular-nums;
}

/* ---------- The campaign's own activity log -------------------------------
   Shares of the table, not pixels, on `table-layout: fixed` (repo memory): the
   columns scale together with the window and never with what is in them. The
   log is read down the When column and across only when a line is in question,
   so the change — the one part that varies in length — is the widest.

   Source is last and right-aligned, like every other status column on the
   platform. */
.cmp-act .col-when   { width: 13%; }
.cmp-act .col-who    { width: 18%; }
.cmp-act .col-object { width: 22%; }
.cmp-act .col-action { width: 13%; }
.cmp-act .col-change { width: 25%; }
.cmp-act .col-src    { width: 9%; text-align: right; }
.cmp-act td.col-change { font-size: 12px; }

/* A group with nothing in it. It says so where the products would be — a
   section showing only its heading reads as one that failed to load — and it
   carries the way to fill it, which is the only thing to do with it. */
.est-area-empty {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  padding: 20px 14px;
  font-size: 12px; color: var(--color-text-muted);
}

/* A rejected material stays on the record and stops competing for attention. */
.est-mat--rejected { opacity: 0.62; }

.est-mat-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.est-mat-name { font-size: 13px; font-weight: 600; }
.est-mat-meta {
  font-size: 11px; color: var(--color-text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.est-mat-price {
  display: flex; align-items: center; justify-content: flex-end; gap: 6px;
  font-size: 13px; text-align: right; font-variant-numeric: tabular-nums;
}
.est-mat-price .u { font-size: 11px; color: var(--color-text-muted); }
.est-mat-was { font-size: 11px; color: var(--color-text-subtle); text-decoration: line-through; }
.est-mat-noprice { font-size: 11px; color: var(--color-danger); text-align: right; }

/* The bundle count is what the client answers with; the slabs and the square
   footage it works out to sit under it, because they are consequences of it
   rather than a second thing to decide. */
.est-mat-qty {
  display: flex; flex-direction: column; align-items: flex-end;
  font-size: 12px; text-align: right; color: var(--color-text-muted);
}
.est-mat-qty-main { white-space: nowrap; }
.est-mat-qty-sub { font-size: 10px; color: var(--color-text-subtle); white-space: nowrap; }
/* The client asked for a different number than was offered. It reads as a
   change waiting on the salesperson, not as another dimension of the row. */
.est-mat-qty-asked { color: var(--color-warn); }

.est-mat-total {
  font-size: 13px; font-weight: 600; text-align: right;
  font-variant-numeric: tabular-nums;
}
/* THE STATE IS A BUTTON, AND THE BUTTON OPENS A SMALL DIALOGUE BESIDE IT. The
   badge is the one the row always carried — same fill, same dot — and a hover
   border is the only tell that it is pressable; a list box in its place was
   turned down (0216), and so was a menu hung under it (0219). */
.est-mat-state { display: flex; justify-content: flex-end; }
.est-mat-status { cursor: pointer; line-height: inherit; }
.est-mat-status:hover { border-color: currentColor; }
.est-mat-status svg { width: 10px; height: 10px; }

/* THE DIALOGUE. It opens to the badge's LEFT, centred on it, where the row has
   room — the columns to the right are a menu and an edge. It is a card, not a
   menu: the material's name over the moves, and under each move a line saying
   what it does, at a pace a decision deserves. Opened and closed by the same
   `.dropdown-menu.open` every menu on the portal uses; only the place and the
   shape are overridden here. 7px is the house ceiling on a radius. */
.dropdown-menu.est-step-pop {
  top: 50%; right: calc(100% + 12px); left: auto; transform: translateY(-50%);
  width: 276px; padding: 14px 12px 8px; border-radius: 7px;
  text-align: left; cursor: default;
}
.est-step-head {
  display: flex; flex-direction: column; gap: 2px;
  padding: 0 6px 10px; margin-bottom: 6px;
  border-bottom: 1px solid var(--color-border);
}
.est-step-name { font-size: 13px; font-weight: 600; }
.est-step-sub { font-size: 11px; color: var(--color-text-subtle); }
.est-step-opt {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 9px 6px; border: 0; border-radius: 5px; background: none;
  font: inherit; text-align: left; color: var(--color-text); cursor: pointer;
  transition: background-color 120ms;
}
.est-step-opt:hover { background: var(--color-primary-50); }
.est-step-opt--no:hover { background: var(--color-danger-50); }
/* The direction is a glyph in its own small box, on the control ground (§13). */
.est-step-ico {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 28px; height: 28px; border-radius: 5px;
  background: var(--color-control); color: var(--color-text-muted);
}
.est-step-ico svg { width: 15px; height: 15px; }
.est-step-opt--no .est-step-ico { color: var(--color-danger); }
.est-step-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.est-step-l { font-size: 13px; font-weight: 600; }
.est-step-opt--no .est-step-l { color: var(--color-danger); }
.est-step-h { font-size: 11px; color: var(--color-text-subtle); }
.est-mat-menu { display: flex; justify-content: flex-end; }

/* Addressed to several clients, two of the six columns have more to say and
   the row gives them the room rather than truncating: the price is a span
   across the lists the recipients buy at, and the state has to name whoever
   claimed it — "Claimed" on its own would leave the operator hunting. */
[data-est-to="many"] .est-mat {
  grid-template-columns: minmax(0, 1fr) 152px 128px 108px 170px 28px;
}
[data-est-to="many"] .est-mat-price { flex-direction: column; align-items: flex-end; gap: 1px; }
[data-est-to="many"] .est-mat-state {
  flex-direction: column; align-items: flex-end; gap: 2px; min-width: 0;
}
/* One recipient passing on a material takes nothing away from the others, so
   there is nothing here to dim — it is still on offer to twenty more. */
[data-est-to="many"] .est-mat--rejected { opacity: 1; }
.est-mat-who {
  font-size: 10px; color: var(--color-text-subtle);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;
}
.est-mat-range { white-space: nowrap; }
.est-mat-list { font-size: 10px; color: var(--color-text-subtle); white-space: nowrap; }

/* ---------- Recipients ----------------------------------------------------
   The list of who a quote went to. It is not a picker — nothing here is
   chosen — so the row is a plain band with the two things you came for on the
   right: the link that is theirs, and the page as they read it. */
.est-recips { display: flex; flex-direction: column; max-height: 420px; overflow-y: auto; }
.est-recip {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 140px 132px auto;
  align-items: center; gap: 12px;
  padding: 9px 4px;
}
.est-recip + .est-recip { border-top: 1px solid var(--color-border); }
.est-recip-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.est-recip-list { font-size: 11px; color: var(--color-text-muted); white-space: nowrap; }
.est-recip-state { display: flex; justify-content: flex-start; }
.est-recip-act { display: flex; justify-content: flex-end; gap: 2px; }
.est-recip[hidden] { display: none; }

/* The notice and the price-list warning belong to the selection, not to a
   row, so they clear the grid and take the panel's own width. */
.est-mats > .est-notice,
.est-mats > .est-page-alert { margin: 10px 14px 12px; }

/* ---------- The typing row ----------------------------------------------
   THE ROW IS THE DIALOG (0224). A group's + opens one of these under its head,
   on the material row's own six columns — declared with `.est-mat` above — so
   the product being added lines up with the products it joins. The cells are
   quote-new.html's: it carries `.est-entry` as well as its own class, so the
   32px `.est-cell` on the control ground, the disabled cell that gives up its
   box, and the numeric cell all apply unchanged. The autocomplete under the
   product cell is the same `.est-ac--photo` list, photograph and all. */
.est-entry-row {
  padding: 6px 14px;
  border-bottom: 1px solid var(--color-border);
}
.est-entry-row .est-ac-wrap { min-width: 0; }
/* The list is allowed past its cell so a line — photo, name, size, price — is
   never broken in two; it is capped well inside the card. */
.est-entry-row .est-ac { right: auto; min-width: 100%; width: max-content; max-width: 640px; }
/* A quantity that cannot be typed yet shows nothing, not a 0. */
.est-entry-row .est-cell:disabled::placeholder { color: transparent; }
/* The price is read, not typed: the client's list answers it the moment a
   product is picked, and Set Fixed Price on the committed row is where it is
   argued with. Muted, as the new-quote row's total is, until there is one. */
.est-entry-price {
  font-size: 13px; text-align: right; font-variant-numeric: tabular-nums;
  color: var(--color-text-muted);
}
.est-entry-price .u { font-size: 11px; }
.est-entry-qty { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
.est-entry-qty .est-cell { width: 64px; }
.est-entry-unit { font-size: 11px; color: var(--color-text-muted); white-space: nowrap; }
.est-entry-total {
  font-size: 13px; font-weight: 600; text-align: right;
  font-variant-numeric: tabular-nums; color: var(--color-text-muted);
}
/* No state yet — nothing has been asked of the client — so the row has five
   children and its × still sits in the sixth column, under the rows' menus. */
.est-entry-row .est-mat-menu { grid-column: 6; }
/* A group with nothing left to offer says so on the list's own muted line
   rather than opening onto nothing. */
.est-ac-opt--none { color: var(--color-text-muted); cursor: default; }
.est-ac-opt--none:hover { background: none; }

/* ---------- An item's question -------------------------------------------
   A question belongs to one line, so it is rendered under that line rather
   than in a message list somewhere else on the page: the answer is about the
   material, and the two have to be read together. The ground is the control
   ground, never the card's own white (project memory.md §13). */
.est-question {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 12px;
  background: var(--color-control);
  border: 1px solid var(--color-control-border);
  border-left: 2px solid var(--color-warn);
  border-radius: var(--radius);
}
/* IT LIVES IN THE ROW. The question takes the row's second grid line, starts a
   third of the way across and ends flush with the row's own right edge: wide
   enough for one sentence beside its two buttons, and still hanging off the
   right-hand columns where the figures it is about are written. */
.est-mat > .est-question { grid-column: 1 / -1; margin-left: 33.333%; }
.est-question > svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 1px; color: var(--color-warn); }
/* Answer then Dismissed, side by side at the bubble's right and centred on its
   two lines, so the buttons never make the bubble taller than the text. */
.est-question-actions { display: flex; gap: 6px; flex: 0 0 auto; align-self: center; }
.est-question-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.est-question-who { font-size: 11px; color: var(--color-text-muted); }
.est-question-who strong { color: var(--color-text); font-weight: 600; }
/* The date closes the first line rather than taking one of its own. */
.est-question-who .t { color: var(--color-text-subtle); font-size: 10px; }
.est-question-who .t::before { content: "\00a0\00b7\00a0"; }
.est-question-text { font-size: 12px; color: var(--color-text); }
.est-question .btn { flex: none; }

/* ---------- The builder grid ---------------------------------------------
   The quote IS the input. There is no dialog between the operator and the
   document: a row of cells sits at the bottom of an area and is typed into,
   the way a spreadsheet is typed into.

   Columns take a share of the table under `table-layout: fixed`, so they
   resize with the browser and never with the data. Total sits last and is
   right-aligned; its right padding matches Product's left padding. */
.est-grid { table-layout: fixed; }
/* On the <colgroup>, not the header cells: the grid has no single <thead> — each
   product type writes its own — so there is no one row for the fixed layout to
   take its widths from. Price is the widest of the three number columns because
   its heading names the unit ("Price / Sq Ft"), which is the point of it. */
.est-grid col.col-product { width: 32%; }
.est-grid col.col-detail  { width: 23%; }
.est-grid col.col-qty     { width: 10%; }
.est-grid col.col-price   { width: 15%; }
.est-grid col.col-total   { width: 15%; }
/* The actions column — one 34px `.icon-btn` and the table's own right padding,
   which is what `.data-table .col-actions` is on every other page. Sized to the
   control, not to a share of the row: five percent of a narrow table is less
   than the button standing in it. */
.est-grid col.col-act     { width: 5%; }
/* THE BUILDER'S ESTIMATE IS ONE PANEL. It IS a `.card` — the white panel the
   type strip attaches to, and the one box the whole document sits in. That is
   the shape every other detail page in this portal takes (one card with internal
   dividers, not a stack of cards), and it is what lets the strip ATTACH the way
   Catalog's and Stock's do: `.tabs--attached` gives up its own bottom rule and
   sits 1px into the panel, so the panel's own top border becomes the strip's
   rule and the active tab paints over the join. A strip has to have something to
   attach TO, and a run of separate cards with page showing between them is not
   one thing.

   `overflow: visible` is what lets the autocomplete out of the cell it is typed
   in; a card would otherwise be free to clip it. */
/* A band of the panel above the first column header, so the panel reads as a
   box the strip opens onto rather than as the table's own top edge. Without it
   the header band butts straight against the tabs and there is nothing between
   them to say a box began. Top only: the header bands and the rows run the
   panel's full width, which is what a table does here. */
.est-grid-card { overflow: visible; padding-top: 12px; }
.est-grid-card .table-wrap { overflow: visible; }
/* NO TOP RULE ON THE BUILDER'S TABLES. `.table-wrap` draws one to separate
   itself from the toolbar capping it, and app.css cancels it for a table that
   is a card's first child — which these are not: the builder nests them a
   section deep, under `[data-areas] > .est-area`. So the first group's table
   drew a hairline across the card's own 12px of white padding, a rule under
   nothing, and every later group drew one directly beneath its header's
   `border-bottom` — the same boundary, twice, 1px apart. The card edge and the
   group header already say where a table starts. */
.est-grid-card .table-wrap { border-top: 0; }

/* Inside that panel an area is a SECTION, not a card. A white card on a white
   panel is not a card — it is a hairline pretending to be one (memory.md §13),
   and the band of page that used to separate two areas cannot exist inside a
   panel that is painting it white.

   So the separator is a rule. It is the STRONG border: it divides two sections
   of the document, where the hairlines inside an area divide its rows, and the
   two must not be the same weight. Each section already closes with its own
   named subtotal, which is what says a section ended; this only draws where. */
.est-grid-card .est-area {
  background: none; border: 0; border-radius: 0; box-shadow: none;
}
/* AND ITS HEAD KEEPS THE PANEL'S WHITE. The header band above belongs to an area
   that is a card — a group opened on the page, with nothing but its own name to
   say where it started. In the builder a group heads TABLES, and those carry
   their own column bands (`.est-grid-head th`) which are deliberately the panel
   surface: banding the name as well would stack two headers of different colours
   on top of each other and make the strong rule between sections redundant. */
.est-grid-card .est-area-head {
  background: none; border-radius: 0;
  /* And with no band to stand off anything, the head goes back to the panel's
     full width and its own padding — the stand-offs above are for a coloured
     band inside a card, and this is neither. */
  margin: 0; padding: 10px 12px 10px 14px; border-top: 0;
}
.est-grid-card .est-area + .est-area {
  margin-top: 0; border-top: 1px solid var(--color-border-strong);
}
/* Two hints, one line: what the areas above are for on the left, what the keys
   in the typing row do on the right. Both are read once and then never again,
   which is why they are down here and not beside the controls they describe.
   It is the panel's `.card-footer` now that there is a panel for it to close —
   same padding, same rule, same quiet type as every other card's footer. */
.est-grid-foot { gap: 12px; }
/* The keys hint keeps its own width and never breaks: it is a list of key names
   and a line that snaps one of them onto a line by itself reads as a fragment.
   The sentence on the left is prose and gives the width up instead. */
.est-grid-foot .subtle { margin-left: auto; flex: none; font-size: 12px; text-align: right; }
.est-grid-foot .subtle:first-child { flex: 1 1 auto; min-width: 0; }
/* Only the LAST one banks right — with nothing to its left a lone hint reads as
   stranded, and two both pushed right would split the row's spare space between
   them and land in the middle. */
.est-grid-foot .subtle:not(:last-child) { margin-left: 0; text-align: left; }

/* ---------- Per-type headings --------------------------------------------
   Each product type in an area heads its own rows with its own column headings —
   *Block ID · Qty* over blocks, *Size & Finish · Sq Ft* over tiles — and every
   type the area holds is banded at once. The type is not named on a line of its
   own: it leads the headings in the first column, which is the same row and one
   less of them. */
.est-grid-head th {
  font-family: 'Hanken Grotesk', sans-serif;
  font-weight: 700; font-size: 11px;
  text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--color-text-muted);
  background: var(--color-surface);
  text-align: left;
  padding: 9px var(--table-pad-x);
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Every heading is a plain table cell so it sits over its own column — Total
   included, which is the last one and therefore right-aligned. */
.est-grid-head th.num { text-align: right; }
.est-grid-head th.col-total { padding-right: var(--table-pad-x); }
/* The first heading row is the top of the table, and with no head above it, the
   top of the CARD. It keeps square corners: the band sits inside the border, so
   the card's own 5px curve is drawn around it and rounding the cells as well
   only cuts a crescent of white between the two. */
.est-grid-head:first-child th { border-top: 0; }

/* The band names its own group in the first heading, and that word is the title
   of everything under it — not a column label like the four beside it. So it
   takes the page's text colour while they stay muted: one darker word per band
   is what separates three tables stacked in a card. */
.est-grid-head th.col-product { color: var(--color-text); }

/* ---------- Subtotal rows ------------------------------------------------
   A group closes with its own figure and the area closes with the sum of them.
   The label sits hard against its number rather than at the far side of the
   row (project memory.md §6): the pair is read as one thing, and the number
   still lands under the Total column it totals.

   No full-width rule above them — the label's own weight says a section ended,
   and a hairline across five columns reads as one more band. */
.est-sub-row td {
  padding: 9px var(--table-pad-x);
  border-top: 1px solid var(--color-border);
  background: var(--color-surface);
}
.est-sub-label {
  text-align: right;
  font-size: 11px; color: var(--color-text-muted);
}
.est-sub-row .col-total { font-size: 13px; font-weight: 600; }

/* The area's figure is the one that closes the section, so it carries the
   weight the group figures under it do not — and no bottom border, because the
   card's own edge is already there. */
.est-sub-row--area .est-sub-label { color: var(--color-text); font-weight: 600; }
.est-sub-row--area .col-total { font-size: 14px; }
.est-sub-row--area td { border-top-color: var(--color-border-strong); }

/* A committed row. The type-specific cell it was typed into collapses into
   Details, because an area holds mixed types and five columns that mean the
   same thing on every row are readable where seven that change under you are
   not. */
/* THE CELL IS AS TALL AS ITS ROW, which means it has to stay a table cell.
   This was `display: flex`, and that overrides `display: table-cell`: the
   browser wraps such a cell in an anonymous one, the anonymous cell stretches
   to the row, and the flex box inside it does not — so this box came out 10px
   short of the 55px the actions column sets. Nothing looked wrong about the
   text, but the row separator is a `::after` anchored to the bottom of this
   box, so the product column's mark sat 10px above every other column's and one
   row read as two of different heights. (`height: 100%` does not fix it; the
   percentage has no resolved height to answer to.)

   Nothing needed the flex box: every child here is already inline-level —
   `.reorder-grip`, `.thumb` and `.badge` are all `inline-flex` — so the row
   lays out the same way with `vertical-align` doing what `align-items` did and
   a margin doing what `gap` did. No `nowrap`: where the column is too narrow
   the name wrapping is the right answer, and it is what the 900px rules below
   already ask of the column beside it. */
.est-row .col-product > * { vertical-align: middle; }
.est-row .col-product > * + * { margin-left: 8px; }
/* The second column is what the first one IS — an ID, a thickness, a finish —
   and it is read after the name, never instead of it. A step down in size is
   what says so; at the same 13px the two columns argued for the same attention.
   (12px, well clear of the 10px floor in memory.md §4.) */
.est-row .col-detail { font-size: 12px; }
/* The NAME is what gives way. Everything else in this cell is a control with a
   job and a fixed size; the name is the only thing that can lose a few
   characters and still be the same row. Without `min-width: 0` a flex item will
   not shrink below its own text, so a long product name — or a control grown to
   a fingertip on a tablet — pushed the Remove button clean out of the column
   and under the cell next door, where the row could no longer be deleted. */
.est-row-name {
  font-weight: 500;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Offering a second product against this one is a normal move, so it is a
   button at rest rather than an icon that has to be hovered for. It IS the
   portal's small outline button — `.btn.btn-outline.btn-sm`, the same one the
   toolbars use — rather than a 21px lookalike with its own border, fill and
   type. All that is left here is that it must not stretch in the flex cell. */
.est-alt-btn { flex: none; }

/* ALWAYS PRESENT, on every pointer. Removing a row is one of the two things
   anybody does to a row, and it was revealed only by hovering — which made it
   unreachable on a touch screen and unfindable on a mouse.

   It is `.icon-btn`, the portal's icon button, at the size every other table's
   action column uses (`.col-actions-2` is documented as "2 × 34px .icon-btn").
   It was a 22px lookalike with its own hover colour; a row action that behaves
   differently here than in Stock or Clients is one the operator has to learn
   twice.

   AND IT IS THE LAST COLUMN. Standing inside the product cell it was paid for by
   the one thing in there that can shrink — the name — and it landed on a
   different pixel on every row, because whatever sat to its left was a different
   width each time. In a column of its own it lines up down the right edge, which
   is where this portal ends a table (`.data-table .col-actions`). */
.est-grid .col-act { padding-left: 0; text-align: right; }
.est-row-actions { display: inline-flex; gap: 2px; }
.est-row-act { flex: none; }

/* The handle is visible at rest, not on hover: "you can move this" is a fact
   about the row, and a fact you have to hover to discover is not one the
   operator has. `.reorder-grip` is app.css's — the same handle the reorder
   lists use — so this adds only the pull into the cell's left padding. */
.est-row-grip { margin-left: -4px; }
.est-row-grip:active { cursor: grabbing; }

/* A block ID is a number nobody recognises, so the row keeps the photo the
   picker showed. Smaller than the list pages' 61px tile — this is a line of a
   document, not a browsing card. */
.est-row-thumb { width: 40px; height: 24px; }

/* ---------- Indent ------------------------------------------------------
   The items step in from the band that heads them, so an area reads as a thing
   with contents rather than as a stack of rows that happen to be adjacent. Only
   the first column moves: the other four headings still sit over their own
   cells, and the first heading is empty, so there is nothing up there to fall
   out of line with. */
.est-grid { --est-indent: 14px; }
.est-grid .est-row .col-product { padding-left: calc(var(--table-pad-x) + var(--est-indent)); }
.est-grid .est-entry td:first-child { padding-left: calc(8px + var(--est-indent)); }

/* ---------- Price cell ---------------------------------------------------
   The price list belongs to the quote, so a row has exactly one pricing
   decision of its own: a number typed over the list. The cell is therefore the
   number itself, editable where it sits — and it keeps `.input`'s control
   ground, because a transparent box on a white row is a box nobody can find
   (memory.md §13). It is also what separates Price from Qty and Total: one of
   the three can be argued with, and the ground is what says which. */
.est-price-wrap { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.est-price-in {
  width: 96px; height: 26px; min-width: 0; flex: 0 0 auto;
  padding: 0 6px; text-align: right;
  font-size: 13px;
}
.est-price-in:hover { border-color: var(--color-border-strong); }

/* The takeoff, correctable in place — the same box as the price beside it,
   because they are the same kind of fact: a number on this row that the operator
   is allowed to argue with. Narrower, since a quantity is rarely more than four
   digits where a price can be five and a decimal. A block, a bundle and an
   alternate have no box at all; they keep the plain figure and its unit word. */
.est-qty-wrap { display: flex; align-items: center; justify-content: flex-end; }
.est-qty-in {
  width: 76px; height: 26px; min-width: 0; flex: 0 0 auto;
  padding: 0 6px; text-align: right;
  font-size: 13px;
}
.est-qty-in:hover { border-color: var(--color-border-strong); }

/* The list price this one replaced, struck through and kept beside it: a
   discount nobody can see on the document is one nobody gets credit for. It is
   always the CURRENT list price, so changing the quote's price list restates
   what the same agreed number is a discount against. */
/* An agreed price is not a typo — it holds its own weight against the list
   price the Total column is measuring it against. */
.est-row--custom .est-price-in { font-weight: 600; }

/* ---------- What an alternate costs --------------------------------------
   The difference between two unit prices belongs under the unit price, not in
   the Total column — that column answers what the line comes to, on every row.
   One quiet line under the box: the money in the page's subtle ink, the
   percentage beside it carrying the direction, which is the only colour a
   price cell is allowed. */
.est-price-delta {
  display: block; margin-top: 3px;
  font-size: 11px; font-weight: 500; text-align: right; white-space: nowrap;
  color: var(--color-text-subtle); font-variant-numeric: tabular-nums;
}
.est-price-delta .pct { margin-left: 5px; }
.est-price-delta--up .pct { color: var(--color-warn); }
.est-price-delta--down .pct { color: var(--color-success); }

/* ---------- A line that was cut ------------------------------------------
   Two figures in one column: what it comes to, and under it what it would have
   come to at list. The pair only reads as a saving if the second is plainly
   subordinate — a step down in size, the page's quietest ink, and struck, which
   is the one mark that says "not this one" without a word beside it. */
.est-cut .amt { display: block; }
.est-cut .was {
  display: block; margin-top: 1px;
  font-size: 11px; color: var(--color-text-subtle); text-decoration: line-through;
  white-space: nowrap; font-weight: 400;
}

/* ---------- Dragging an item --------------------------------------------
   The mark is a rule on the edge the item would land against, and the whole
   area lights when it would land at the end of one — including the empty area
   that has no row to draw an edge on. */
.est-row--dragging { opacity: 0.4; }
.est-drop-before td { box-shadow: inset 0 2px 0 0 var(--color-primary); }
.est-drop-after td { box-shadow: inset 0 -2px 0 0 var(--color-primary); }
.est-area--drop { outline: 2px solid var(--color-primary); outline-offset: -1px; }

/* An alternate is the same row, indented and quieter — it sits UNDER its item
   and carries one extra fact its item does not: what switching costs, written
   under its price.

   20px, AND MEASURED FROM THE ITEM'S CONTENT, not from its cell. An item leads
   with a drag handle; an alternate has none, so an indent counted from the cell
   padding was spent getting level with the row above before it stepped anywhere
   — the two thumbnails ended up 8px apart and the ↳ carried the whole
   relationship on its own. `--est-row-lead` is what the handle and its gap cost
   (a 16px glyph pulled 4px left, then the flex row's 8px), so adding it puts the
   alternate on the item's own left edge, and the 20px is then 20px you can see.

   The arrow sits a fixed step left of that content rather than back at the
   handle's column: at this indent it would otherwise stand half an inch away
   from the row it belongs to and read as a mark on the cell instead. */
.est-grid { --est-alt-indent: 20px; --est-row-lead: 20px; }
.est-grid .est-row--alt .col-product {
  padding-left: calc(var(--table-pad-x) + var(--est-indent) + var(--est-row-lead) + var(--est-alt-indent));
  position: relative;
}
.est-row--alt .col-product::before {
  content: '\21B3';
  position: absolute;
  left: calc(var(--table-pad-x) + var(--est-indent) + var(--est-row-lead) + var(--est-alt-indent) - 18px);
  color: var(--color-text-subtle);
}
.est-row--alt .est-row-name { font-weight: 400; color: var(--color-text-muted); }

/* A unit typed rather than chosen — the yard does not hold this one. That is a
   CUSTOM ORDER, not a fault: something the client can still be sold and the
   yard brings in. So the mark is the page's own accent rather than a warning
   colour, and its icon adds to the order instead of crossing it out. */
.est-custom {
  position: relative;
  display: inline-flex; vertical-align: -2px;
  margin-left: 6px;
  color: var(--color-primary); cursor: help;
}
.est-custom svg { width: 14px; height: 14px; }

/* The browser's own tooltip is one grey line that arrives late and cannot say a
   sentence. This one is a small card in the portal's own type: a name for what
   the mark means, then the sentence that makes it read as an offer rather than
   a shortage. `.est-grid-card .table-wrap` is overflow-visible, so it is free
   to stand above the row. */
.est-tip {
  position: absolute; left: 50%; bottom: calc(100% + 9px);
  transform: translateX(-50%);
  width: max-content; max-width: 270px;
  padding: 10px 12px;
  background: var(--color-surface); color: var(--color-text);
  border: 1px solid var(--color-border-strong); border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
  font-size: 12px; font-weight: 400; line-height: 1.5;
  text-align: left; white-space: normal;
  opacity: 0; visibility: hidden; transition: opacity 120ms;
  pointer-events: none; z-index: 40;
}
.est-tip strong { display: block; margin-bottom: 3px; font-weight: 600; }
.est-custom:hover .est-tip,
.est-custom:focus-visible .est-tip { opacity: 1; visibility: visible; }
/* `:focus-visible` is the keyboard's focus and does not match a tap, so on a
   touch screen the mark had nothing to say. Tapping it focuses it — the mark
   carries `tabindex` — so plain `:focus` is what opens it there. Tapping
   anywhere else blurs it and it closes. */
@media (hover: none) {
  .est-custom:focus .est-tip { opacity: 1; visibility: visible; }
}

/* ---------- The typing row ----------------------------------------------
   The same shape `.entry-grid` has on the import and receive pages, which is
   this portal's data-entry row: the row itself is NOT tinted, and what says
   "type here" is the control ground under each cell (`--color-control`, from
   `.input` — memory.md §13). It used to be a cream band with white boxes on it,
   which was a colour no other table in the portal uses, on the one row of the
   document that is not part of the document yet.

   So the cells simply keep `.input`'s own ground, border, hover and focus ring.
   Nothing is restated here but the size. */
.est-entry td { padding: 4px 6px; }
.est-entry td:first-child { padding-left: 8px; }
.est-entry td:last-child { padding-right: 14px; }
.est-entry .est-cell {
  width: 100%; height: 32px;
  padding: 0 8px;
  font-size: 13px;
}
/* A cell that cannot be typed into yet — the block ID before a block is picked
   — goes back to being part of the row, because a white box that refuses the
   cursor is worse than no box. */
.est-entry .est-cell:disabled {
  color: var(--color-text-subtle);
  background: transparent; border-color: transparent;
}
/* A cell that answers itself — the quantity behind a block ID — must not look
   like one waiting to be typed into. */
.est-entry .est-cell[readonly],
.est-entry .est-cell[readonly]:hover,
.est-entry .est-cell[readonly]:focus {
  color: var(--color-text-muted);
  background: transparent; border-color: transparent;
  cursor: default;
}
.est-entry .est-cell.num { text-align: right; }
.est-entry [data-entry-total] { color: var(--color-text-muted); }
/* The discard has left this cell for the actions column, where every other row's
   is — sharing the Total cell with the figure put the typing row's total on a
   different pixel from the totals above it, and a column of money is read down
   its right edge. */
.est-entry .col-total { text-align: right; }
.est-entry--alt td { background: var(--color-primary-50); }
.est-grid .est-entry--alt td:first-child { padding-left: calc(8px + var(--est-indent) + var(--est-row-lead) + var(--est-alt-indent)); }

/* ---------- Autocomplete -------------------------------------------------
   Both searching cells use it: the product name, then the individual entries
   carrying that name. The second one is the narrower column, so the list is
   allowed to grow past its cell rather than wrapping a size onto two lines. */
.est-ac-wrap { position: relative; }
/* Same panel `.combo-menu` is on clients, stock and the product wizard — the
   portal's own combobox — down to the offset, the stack order, the strong
   border and the scroll height. Only the width differs, and only because these
   two lists carry photographs where a `.combo` carries a line of text. */
.est-ac {
  position: absolute; z-index: 40; left: 0; right: 0; top: calc(100% + 4px);
  min-width: 240px;
  margin: 0; padding: 4px; list-style: none;
  max-height: 232px; overflow-y: auto;
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong); border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
}
.est-ac[hidden] { display: none; }
.est-ac-opt {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px; border-radius: 5px; cursor: pointer;
}
.est-ac-opt:hover,
.est-ac-opt.active { background: var(--color-primary-50); }
.est-ac-opt .n { font-size: 13px; font-weight: 500; }
.est-ac-opt .m { margin-left: auto; font-size: 11px; color: var(--color-text-muted); }
/* Four block IDs are four numbers. The photo is what tells them apart, so it
   comes with the option rather than waiting for the row to be committed. */
.est-ac-opt .thumb.is-photo { width: 46px; height: 28px; }
/* A photo pushes the text out, and the block-ID list is the narrow column's —
   so it is allowed the room the picture costs. */
.est-ac--photo { min-width: 316px; }
/* The detail list hangs off the second column, close to the right edge of the
   grid — it grows leftwards so it never pushes the card sideways. */
[data-ac-detail] { left: auto; right: 0; }

/* A name on the service list was typed by somebody, so one of them can be typed
   wrong. The pencil corrects it where it lives — the same repair the area chips
   offer, and just as quiet: it waits for the option to be looked at rather than
   putting an edit control beside every name in the list. */
.est-ac-edit {
  flex: 0 0 auto; margin-left: 4px;
  padding: 3px; border: 0; border-radius: 5px; background: none; cursor: pointer;
  color: var(--color-text-subtle); line-height: 0;
  transition: color 120ms, background 120ms;
}
.est-ac-edit svg { width: 13px; height: 13px; }
.est-ac-edit:hover { background: var(--color-surface-alt); color: var(--color-text); }

/* The option IS the field while the name is being corrected — same row, same
   place in the list, so nothing moves under the cursor. */
.est-ac-opt--edit { padding: 4px 5px; }
.est-ac-opt--edit:hover { background: none; }
.est-ac-opt--edit .est-ac-edit { opacity: 1; color: var(--color-primary); }
.est-ac-field { flex: 1; min-width: 0; height: 30px; font-size: 13px; }

/* ---------- Charges -------------------------------------------------------
   The arithmetic that follows the items: tax, a card fee, freight, a discount
   off the job. None of it is a line — a line is a quantity times a unit price,
   and 7% of the subtotal is neither — so it sits after the document rather
   than inside it, where an invoice has always put it.

   Right padding is 12px, not 14: it is `.data-table`'s own --table-pad-x, so
   every amount here ends on exactly the edge the Total column ends on. The
   token is set on the table and cannot be inherited across to here. */
.est-charges { margin-top: 20px; }
.est-charge {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 12px 9px 14px;
}
.est-charge + .est-charge { border-top: 1px solid var(--color-border); }
.est-charge .n {
  display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto;
  font-size: 13px;
}
.est-charge .n svg { width: 14px; height: 14px; color: var(--color-text-subtle); }
.est-charge .c { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.est-charge .b { font-size: 11px; color: var(--color-text-muted); white-space: nowrap; }
/* Whatever comes last takes the row's spare space, so the figures run down one
   edge — the area heads do the same, and for the same reason: a column of
   numbers that steps in and out by the width of whatever sat beside it is read
   one line at a time instead of down. */
.est-charge .v {
  margin-left: auto; flex: 0 0 auto;
  text-align: right; font-size: 13px; font-variant-numeric: tabular-nums;
}
.est-charge-off + .v { margin-left: 0; }
/* Money off is good news, and reads as the same kind of number an alternate
   cheaper than its item does. */
.est-charge-neg { color: var(--color-success); }

/* Destructive, so it is quiet — in colour, like every other control on this page,
   not by waiting for a hover that a finger never makes. It goes BEFORE the
   figure, so no amount steps in by the width of it. */
.est-charge-off { margin-left: auto; }

/* The rate, with the unit it is in on the side that unit is written. A charge
   that can be argued either way carries both and each keeps its own number. */
.est-charge-in { display: inline-flex; align-items: center; gap: 4px; }
.est-charge-in input {
  width: 74px; height: 26px; min-width: 0;
  padding: 0 6px; text-align: right; font-size: 13px;
}
.est-charge-in input:hover { border-color: var(--color-border-strong); }
.est-charge-in .pre,
.est-charge-in .suf { font-size: 12px; color: var(--color-text-muted); }
.est-charge-in .pre:empty,
.est-charge-in .suf:empty { display: none; }
/* The % / $ switch rides inside a row of 26px controls, so it is sized to them
   rather than to the dialogs `.est-two` was built for — and it takes the same
   height, or the switch and the field it governs sit on two different lines. */
.est-two--sm { height: 26px; }
.est-two--sm button { padding: 0 9px; font-size: 11px; }

.est-charge--sub .b { margin-left: 2px; }
/* Where the next charge will appear, which is where the control to add one
   belongs. It is not a figure row, so it takes no rule above the Total. */
.est-charge--add { padding: 4px 12px 4px 8px; }
.est-charge--add .subtle { font-size: 12px; }
/* A memo, not a step. It takes no rule above it — a rule is what says "another
   figure applied here" — and its name is quiet, so the column of steps beside
   it still reads as the arithmetic and this reads as a note about it. */
.est-charge--memo { padding-top: 2px; padding-bottom: 9px; }
/* No rule above it — it belongs to the subtotal it is describing. The charge
   BELOW it keeps its own rule, hidden memo or not, because that one is a step. */
.est-charge + .est-charge--memo { border-top: 0; }
.est-charge--memo .n { font-size: 12px; color: var(--color-text-muted); font-weight: 400; }
.est-charge--memo .v { font-size: 12px; }
.est-charge--memo[hidden] { display: none; }

.est-charge--grand { border-top: 1px solid var(--color-border-strong); flex-wrap: wrap; }
.est-charge--grand .n { font-weight: 600; font-size: 14px; }
.est-charge--grand .v { font-size: 16px; font-weight: 600; }
/* Full-width so it wraps under the Total rather than beside it, and right-
   aligned onto the same edge — the row-level pair stacks in its column, and the
   document's pair has to stack the same way to be read as the same statement. */
.est-charge--grand .was {
  flex: 0 0 100%; margin-top: 1px;
  text-align: right; font-size: 12px; color: var(--color-text-subtle);
  text-decoration: line-through; font-variant-numeric: tabular-nums;
}
.est-charge--grand .was[hidden] { display: none; }

@media (max-width: 760px) {
  /* The base a percentage was taken on is the first thing to go: it is a check
     on a figure that is still right there beside it. */
  .est-charge { flex-wrap: wrap; }
  .est-charge .b { display: none; }
  .est-charge--add { flex-wrap: nowrap; }
  .est-charge--add .subtle { display: none; }
}

/* ---------- Add Area ------------------------------------------------------
   One line under the label saying what an area is FOR. It used to explain a grid
   of thirty-five room names sitting below it; the grid has gone, because an area
   is whatever the job is grouped by and a vocabulary offered is a vocabulary
   imposed. A <p> would otherwise put its own margin between label and field. */
.area-help { margin: 0 0 6px; font-size: 12px; line-height: 1.45; }

/* ---------- Numbering (Quotes › Page Settings › Options) -------------- */
.opt-dialog__h { margin: 0 0 4px; font-size: 13px; }
/* A second setting in the same dialog is a separate decision, not more of the
   first one — a heading alone reads as a continuation of the section above it. */
.opt-dialog__h--sec { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--color-border); }
.opt-dialog__sub { margin: 0 0 12px; font-size: 12px; color: var(--color-text-muted); line-height: 1.5; }
.est-num-preview {
  display: flex; align-items: center; gap: 10px;
  margin-top: 14px; padding: 10px 12px;
  background: var(--color-surface-alt);
  border: 1px solid var(--color-border); border-radius: var(--radius);
}
.est-num-preview .l { font-size: 12px; color: var(--color-text-muted); }
.est-num-preview .v { margin-left: auto; font-size: 14px; font-weight: 600; }

/* ---------- Add Item dialog ----------------------------------------------
   The picker is the list pages' own furniture, in a dialog: the `.tabs--attached`
   product-type strip, then the toolbar the open tab owns, then the list. The
   strip filters one list rather than switching between seven copies of it, so a
   row picked under Slabs stays picked while the reader looks at Bundles.

   The strip attaches to the TOOLBAR, not to the list — the toolbar carries the
   panel's top edge and the list continues it downward, so the three read as one
   box however the toolbar grows. Same join catalog and stock make; the parts are
   siblings here because the dialog has no card to put them in. */
/* Switching type changes the list's height, so reserve the scrollbar's width and
   the dialog stops jumping sideways as you move along the strip. (app.css does
   this for `[data-modal-tabs]`; this strip is a `.tabs` row, so it needs its own.) */
.modal-body:has(> .est-pick-tabs) { scrollbar-gutter: stable; }

/* `.tab`'s padding and type are `clamp()`ed against the VIEWPORT, so on a wide
   monitor they take their widest values inside a dialog that never widens with
   it — seven tabs would overflow a 760px box that a page strip has room for.
   Pinned to the low end of the same curves: same strip, sized to its container. */
.est-pick-tabs .tab { padding: 9px 8px; font-size: 12px; }
.est-pick-tabs .tab svg { width: 14px; height: 14px; }
.est-pick-toolbar {
  padding: 9px 11px;
  border: 1px solid var(--color-border);
  border-bottom: 0;
  /* Square top-left: the active first tab must not sit over a rounded corner. */
  border-radius: 0 var(--radius) 0 0;
}
.est-pick-toolbar .search { min-width: 0; }
.est-pick {
  max-height: 268px; overflow-y: auto;
  border: 1px solid var(--color-border); border-radius: var(--radius);
}

/* THE PICKER IN A SLIDE-OVER. 268px is a window sized for a centred dialog,
   which has to leave room around it; a slide-over is floor to ceiling and its
   list should be too. The pane holds the strip and the search still while the
   list alone scrolls — an audience is read down, and a search that scrolls away
   from the list it is filtering is a search you have to go back up to reach. */
/* A PICKER IS NARROW. `.slideover-form` is 640px because a record's form needs a
   4-span field to be ~200px wide; this panel holds a strip, a search and a column
   of names, none of which grows with the panel — at 640 the rows were a name at
   the far left and a count at the far right with a hand's width of nothing
   between them. 380 is the width of the widest thing in it. */
.slideover--pick { width: min(380px, 100%); }

.est-pick-pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
/* Everything that is not the list keeps its own height — the owner row above
   the sharing list and the note under it are statements, not things to
   compress — and the list takes what is left. */
.est-pick-pane > * { flex: none; }
/* The list is as tall as what is in it, and shrinks to a scroller only when
   there is more than the panel holds. Stretching it to fill drew a bordered box
   the height of the screen with four names at the top of it and nothing under
   them — an empty container reads as a list still loading. */
.est-pick-pane > .est-pick { flex: 0 1 auto; min-height: 0; max-height: none; }
/* The recipients list is a PAGE now, not a pane: it reads down a tab of its own
   between the products and the answers, and only the picker is left in the
   slide-over. In a card it takes the platform's side stand-off like everything
   else that runs the width of a panel (`--table-inset`, memory.md §20) and its
   own height — the page scrolls, not a box inside it. */
[data-tab-panel="recipients"] .est-recips {
  max-height: none; overflow: visible;
  padding-inline: var(--table-inset);
}
/* TWO RECORDS TO A LINE. A recipient is a name, the list they read the campaign
   at, a state, and the record their answer became — four short things that were
   taking the whole width of the card between them, half of it in the gaps. At
   half the card all four still fit and the tab shows twice as much of the list
   before anybody scrolls. The three trailing tracks come in with the width: the
   record at the end is a code, not a sentence, so it is sized rather than left
   sharing what is over with the name. Kept above the width where the row starts
   reshaping itself — below it the list reads down one column as before. */
/* ---------- The row mark both lists draw ---------------------------------
   THE SEPARATOR EVERY TABLE ON THE PLATFORM ALREADY USES. `.data-table` stopped
   ruling its rows edge to edge: each cell states the boundary at full weight
   where its own content starts and has spent it before the next column
   (app.css, `.data-table tbody td::after`). The campaign's products and its
   recipients are tables in everything but markup, so they draw that mark rather
   than the hairline they had — held for the first third of the row, faded out
   across the rest, and inset to where the row's own content begins. */
[data-tab-panel="products"] .est-mat,
[data-tab-panel="recipients"] .est-recip { position: relative; }
[data-tab-panel="products"] .est-mat + .est-mat,
[data-tab-panel="recipients"] .est-recip + .est-recip { border-top: 0; }
[data-tab-panel="products"] .est-mat + .est-mat::after,
[data-tab-panel="recipients"] .est-recip + .est-recip::after {
  content: ""; position: absolute; top: 0; height: 1px;
  left: 4px; right: 4px;
  background: linear-gradient(to right,
    var(--color-border-strong) 0%,
    var(--color-border-strong) 35%,
    var(--color-border) 65%,
    transparent 100%);
}
/* A product row is padded further in than a recipient's, and the mark begins
   where the name begins on each. */
[data-tab-panel="products"] .est-mat + .est-mat::after { left: 14px; right: 14px; }

@media (min-width: 1101px) {
  /* COLUMNS, NOT ROWS. The list is in the order the campaign is read in —
     answered first, then opened, then the ones still to open it — and a grid
     laid across the page deals that order out left, right, left, and the
     answers end up on both sides of the panel. A column layout keeps the run
     unbroken: the first half reads down the first column and the rest carries
     on down the second, so the answers finish before the second column starts.
     The rule between them comes with the layout rather than being built out of
     the records' own borders. */
  [data-tab-panel="recipients"] .est-recips {
    display: block;
    column-count: 2;
    column-gap: 34px;
    column-rule: 1px solid var(--color-border-strong);
  }
  [data-tab-panel="recipients"] .est-recip { break-inside: avoid; }
  /* The state track is cut for Not Opened, the widest badge, with the room the
     84px it was gave Answered. Narrower and the badge, which never wraps, runs
     out of its track into the gap. */
  [data-tab-panel="recipients"] .est-recip.est-recip--manage {
    grid-template-columns: minmax(0, 1fr) 92px 96px 104px 26px;
    gap: 10px;
  }
  /* The record that starts the second column is the top of a column, not a
     join in the middle of the list — its mark would hang under the toolbar's
     own line with nothing above it. The list marks that record as it builds;
     below this width it is the middle of one column again and the class means
     nothing. */
  [data-tab-panel="recipients"] .est-recip.est-recip--colbreak::after { content: none; }
}
/* `~`, not `+`: on the campaign's recipients panel the report list sits between
   the search and the picker, and whichever of the two is showing is the one
   directly under the toolbar. The rule is about the join to the search box, and
   the list that is hidden takes no space to join. */
.est-pick-toolbar ~ .est-pick {
  border-top: 0;
  border-radius: 0 0 var(--radius) var(--radius);
}
.est-pick-empty {
  padding: 26px 16px; text-align: center;
  font-size: 12px; color: var(--color-text-muted);
}
.est-pick-row {
  display: flex; align-items: center; gap: 10px;
  width: 100%; padding: 9px 11px;
  border: 0; border-bottom: 1px solid var(--color-border);
  background: none; cursor: pointer; text-align: left; font: inherit;
}
.est-pick-row:last-child { border-bottom: 0; }
.est-pick-row:hover { background: var(--color-surface-alt); }
.est-pick-row[aria-pressed="true"] { background: var(--color-primary-50); }
.est-pick-row .thumb { width: 34px; height: 34px; flex: 0 0 auto; }
/* A group's square carries its size and the word for what it is counting, one
   under the other. The number alone was a figure nobody could name — 20 what —
   and naming it in a third column cost the row the width its description needs
   and reported on an act that is all-or-nothing anyway: pressing a group adds
   every client on it. Wider than a 36px initials square because a count and its
   label need the room; still square-ish, so the column stays a column. */
.thumb--count {
  width: 46px; height: 40px;
  flex-direction: column; gap: 0; line-height: 1.1;
}
.thumb--count strong { font-size: 14px; font-weight: 600; }
.thumb--count .l { font-size: 10px; font-weight: 500; opacity: .8; }

.est-pick-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.est-pick-name { font-size: 13px; font-weight: 500; }
.est-pick-meta { font-size: 11px; color: var(--color-text-muted); }
.est-pick-price { font-size: 13px; font-variant-numeric: tabular-nums; }
.est-pick-price .u { font-size: 11px; color: var(--color-text-muted); }
.est-pick-row[aria-pressed="true"] .est-pick-check { color: var(--color-primary); }
.est-pick-check { width: 16px; height: 16px; color: transparent; flex: 0 0 auto; }

/* A date field is as wide as a date. Stretched across the dialog it reads as a
   box waiting for a sentence (memory.md §9); the native control already carries
   its own calendar button, so this is the format plus that. */
.est-date-in { width: auto; max-width: 190px; flex: 0 0 auto; }

/* The date is the answer and keeps the top of the dialog; under it, the two
   ways of counting from today — a length you type, then the four lengths
   anybody asks for. They write into the date rather than replacing it, so
   nothing here is a second way of saying the same thing, only a faster way of
   typing it. */
.est-date-row { display: flex; align-items: center; gap: 8px; }
/* A number of days and the word for them — both sized to what they hold, so the
   pair reads as one phrase instead of two boxes stretched across the dialog. */
.est-num-in { width: auto; max-width: 96px; flex: 0 0 auto; }
.est-unit-in { width: auto; max-width: 116px; flex: 0 0 auto; }
.est-from-field { margin-top: 14px; }
/* The note is about the dialog, not about the shortcuts directly above it, so
   it stands off them rather than reading as their caption. */
.est-from-field + .est-form-note { margin-top: 14px; }
/* Four across, because `1w` and `2w` and `1m` are two characters each and a row
   of them reads as a scale — 1w 2w 1m 2m is the whole ladder in one line, and a
   ladder that wraps stops being one. The long labels these carried needed two
   lines to do it. */
.est-date-quick {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 6px; margin-top: 8px; max-width: 268px;
}
.est-quick {
  font: inherit; font-size: 12px; padding: 5px 8px; cursor: pointer; white-space: nowrap;
  background: var(--color-control); border: 1px solid var(--color-border);
  border-radius: var(--radius); color: var(--color-text-muted);
}
.est-quick:hover { border-color: var(--color-border-strong); color: var(--color-text); }
/* Lit when the date IS that shortcut, however it got there. It is a reading of
   the field, not a memory of which button was pressed. */
.est-quick[aria-pressed="true"] {
  background: var(--color-primary-50); border-color: var(--color-primary);
  color: var(--color-primary-700); font-weight: 600;
}

/* ---------- A quote that does not add itself up ------------------------
   Three countertops offered against one job are alternatives, and a figure that
   sums them is the price of buying all three — which nobody is being asked to
   do. So the roll-ups go and the rows keep their money: each option still says
   what it costs, and nothing on the page says what they cost together.

   What goes is only ever a SUM. `.est-sub-row` is written by egRegroup, which
   leaves it out entirely when the totals are off; this is what empties the
   figure on any that remain. The charges block is the document's own arithmetic
   and goes whole. */
.est-no-totals .est-stat--grand,
.est-no-totals .est-charges { display: none; }
/* Emptied, not hidden: the cell still holds the Total column open, so the rows
   above it do not shift sideways when the setting is flipped. */
.est-no-totals .est-sub-row .col-total { visibility: hidden; }
/* With no figure to bank against, the area's name reads as the label it is
   rather than as something right-aligned toward a number that is not there. */
.est-no-totals .est-sub-row .est-sub-label { text-align: left; }

/* ---------- Manage Services ----------------------------------------------
   Two switches per service, and fourteen services — so the switches run down
   two columns and the header names them once at the top, instead of every row
   repeating "Taxable" and "Ships" twenty-eight times over. That is the one case
   memory.md §16 leaves to a column: the label is not per-row here, it is the
   column's, and the value still sits immediately under the word that names it.

   The name column takes the width; the two switch tracks are sized to the
   switch (34px in app.css) plus the room its heading needs, and a spacer after
   them keeps the pair from drifting to the far edge of a wide dialog. */
.est-svc-head,
.est-svc-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 62px 52px;
  align-items: center; gap: 10px;
  padding: 7px 11px;
}
.est-svc-head {
  background: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: var(--radius) var(--radius) 0 0;
  font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
  color: var(--color-text-muted);
}
/* The two headings sit over their own switches, which are 34px in a wider
   track — left-aligned they would stand a word away from the control. */
.est-svc-head span + span { text-align: left; }
.est-svc-list {
  max-height: 268px; overflow-y: auto;
  border: 1px solid var(--color-border); border-top: 0;
  border-radius: 0 0 var(--radius) var(--radius);
}
.est-svc-row + .est-svc-row { border-top: 1px solid var(--color-border); }
.est-svc-n { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The way into the dialog, at the foot of the service list — the same shape
   `.combo-opt.is-add` gives "Create … in Catalog" at the foot of a combo. It is
   not one of the services, so it takes a rule above it and the accent ink. */
/* Pinned to the foot of the list rather than sitting after the last service:
   eight are offered and the list scrolls at that height, so a row appended
   behind them is a way in nobody ever sees. `-4px` is the list's own padding,
   so it beds against the bottom edge instead of floating a hair above it. */
.est-ac-opt--manage {
  position: sticky; bottom: -4px; z-index: 1;
  margin-top: 4px; padding-top: 8px;
  background: var(--color-surface);
  border-top: 1px solid var(--color-border); border-radius: 0;
  color: var(--color-primary-700); font-weight: 500;
}
.est-ac-opt--manage .n { color: inherit; }

/* ---------- Assign dialog -------------------------------------------------
   Groups and clients share one searchable list, because with a thousand
   clients a select is unusable. Everything on a quote prices from a single
   list, so the first pick locks it and rows on another list go dead; picking a
   group stands in for its members, so they go dead too rather than counting
   the same client twice. */
/* A group heading inside a list is `.dropdown-section-label`'s job elsewhere in
   the portal, so it takes that typography: 11px at .06em, not 10px at .04em.
   10px is the font floor and is reserved for the least important line on a
   screen (memory.md §4); the word that says which half of a list you are
   reading is not that line. */
.est-pick-head {
  padding: 7px 11px;
  background: var(--color-surface-alt);
  border-bottom: 1px solid var(--color-border);
  font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
  color: var(--color-text-muted);
}
.est-pick-row .thumb svg { width: 15px; height: 15px; }
.est-pick-row:disabled { cursor: not-allowed; }
.est-pick-row:disabled:hover { background: none; }
.est-pick-row:disabled .thumb,
.est-pick-row:disabled .est-pick-body { opacity: .45; }

/* Binary choice, so both sides are labelled and the active one is dark and
   bold — the reader never has to work out what "off" means. */
.est-two {
  display: inline-flex; align-items: stretch;
  border: 1px solid var(--color-border); border-radius: var(--radius); overflow: hidden;
}
.est-two button {
  border: 0; background: var(--color-surface); cursor: pointer;
  padding: 7px 13px; font: inherit; font-size: 12px; color: var(--color-text-muted);
}
.est-two button + button { border-left: 1px solid var(--color-border); }
.est-two button[aria-pressed="true"] { background: var(--color-text); color: var(--color-surface); font-weight: 600; }
.est-two-hint { font-size: 11px; color: var(--color-text-muted); margin-top: 5px; }

/* A percentage and a slab count are two- and three-character answers, so the
   fields are sized to them rather than to the dialog (project memory.md §9). */
.est-pct-in { width: 92px; }
.est-qty-field { width: 110px; }

/* Three options none of which is long. A control stretched to the dialog says
   nothing with the space it takes (project memory.md §9). */
#hd-mode { width: 260px; max-width: 100%; }

/* A question quoted inside a dialog needs the room the rows around it have. */
.modal-body > .est-question { margin: 12px 0; }
/* The pre-send review is a block in a dialog that opens with a link, so it
   needs its heading to part company with what is above it. */
.est-review { margin-bottom: 14px; }
.modal-body > .est-link + .opt-dialog__h { margin-top: 18px; }
.modal-body > .est-link + .est-notice { margin-top: 14px; }

/* ---------- Add affordances ----------------------------------------------
   `.btn.btn-ghost.btn-sm` — the portal's quiet button, which already is a
   borderless label that takes a `--color-primary-50` wash on hover. This was a
   hand-built copy of it a step smaller. All that is page-specific is the ink:
   an add reads as the accent, where a plain ghost button is muted. */
.est-add { color: var(--color-primary-700); }
.est-add--mat { margin: 2px 0 4px 8px; font-weight: 400; color: var(--color-text-muted); }
.est-add--mat:hover { color: var(--color-primary-700); }
.est-add--area { margin-top: 2px; }

/* ---------- Side panel --------------------------------------------------- */
.est-totals { display: flex; flex-direction: column; gap: 8px; }
.est-total-row { display: flex; align-items: baseline; gap: 10px; font-size: 13px; }
.est-total-row .l { color: var(--color-text-muted); }
.est-total-row .v { margin-left: auto; font-variant-numeric: tabular-nums; }
.est-total-row--grand { border-top: 1px solid var(--color-border); padding-top: 8px; font-size: 15px; font-weight: 600; }
.est-total-row--ok .v { color: var(--color-success); }
.est-total-row--no .v { color: var(--color-danger); }

.est-link {
  display: flex; align-items: center; gap: 6px;
  padding: 7px 9px;
  background: var(--color-surface-alt); border: 1px solid var(--color-border);
  border-radius: var(--radius);
  font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted);
}
.est-link .u { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.est-link-icon { width: 13px; height: 13px; flex: 0 0 auto; }
.est-link .icon-btn { margin-left: auto; }
.est-badges .badge svg { width: 12px; height: 12px; }

/* Decisions arrive from an anonymous link, so the feed carries a time and no
   name — and says so rather than leaving a blank column (D6/OQ4). */
.est-feed { display: flex; flex-direction: column; gap: 10px; }
.est-feed-item { display: flex; gap: 9px; font-size: 12px; }
/* The markup writes `.dot-status`, so a rule aimed at `.dot` never landed on
   anything: the dot kept flex's default shrink, and the item with the longest
   text squeezed its 6px mark into a thin bar while its neighbours stayed round.
   It is a fixed mark that gives no ground, sat on the optical centre of the
   first line (12px × 1.5 = 18px, so 6px down) rather than the top of the block. */
.est-feed-item .dot-status { flex: 0 0 6px; width: 6px; height: 6px; align-self: flex-start; margin-top: 6px; }
.est-feed-item .t { display: block; color: var(--color-text-subtle); font-size: 11px; }
.est-anon {
  font-size: 11px; color: var(--color-text-muted); line-height: 1.5;
  padding-top: 10px; margin-top: 2px; border-top: 1px solid var(--color-border);
}

.est-ver { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 0; }
.est-ver + .est-ver { border-top: 1px solid var(--color-border); }
.est-ver .n { font-weight: 600; }
.est-ver .badge { margin-left: auto; }

/* ---------- Inline notices -----------------------------------------------
   A WARNING OR AN INFO NOTE IS `.alert`, app.css's own, the one thirty other
   pages use. The `--warn` and `--info` variants that used to live here were a
   second implementation of it that read louder than the original: a
   full-saturation `--color-warn` edge where `.alert-warn` uses a soft amber, and
   the page's muted grey where `.alert-warn` carries its own dark amber ink. They
   are gone; the markup carries `alert alert-warn` / `alert alert-info`.

   What stays is the NEUTRAL note — a quiet aside inside a dialog, which `.alert`
   has no variant for. It keeps `.alert`'s metrics so the two read as one family
   when a dialog holds both. */
.est-notice {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 10px 12px; margin-bottom: 14px;
  border: 1px solid var(--color-border); border-radius: var(--radius);
  background: var(--color-surface-alt);
  font-size: 13px; color: var(--color-text-muted); line-height: 1.5;
}
.est-notice svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 1px; }
.est-notice strong { color: var(--color-text); }
/* `.alert` is a component, not a block in a stack, so it brings no outer
   margin. The two that stand on the page rather than inside a dialog need one. */
.est-page-alert { margin-bottom: var(--page-gap); }

/* ---------- Preview (the quote as it prints) ------------------------------
   The grid is how a quote is BUILT — a typing row, drag handles, an add mark on
   every band. None of that is on the paper, so this is a document: a line-item
   table, one numbered product per line, the spec spelled out in named fields
   under the name, and the money closing it at the foot. */
.est-preview[hidden] { display: none; }
.est-pv-hint {
  display: flex; align-items: center; gap: 7px;
  margin: 0 0 14px;
  font-size: 12px; color: var(--color-text-muted);
}
.est-pv-hint svg { width: 14px; height: 14px; flex: none; }
.est-pv-empty { margin: 0; font-size: 13px; color: var(--color-text-muted); }

/* A SECTION PER TYPE, built out of the pieces quote.html already uses for the
   same job: `.est-area-head` for the band, `.est-mats` and `.est-mat` for the
   products under it. Only the shell is local — the section cannot carry
   `.est-area` itself, because the builder walks that class to find the areas it
   rebuilds and would try to rebuild these. */
.est-pv-sec {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}
.est-pv-sec + .est-pv-sec { margin-top: 16px; }
/* Four columns, not quote.html's six: nothing is decided here and nothing is
   edited here, so the state badge and the row menu it keeps room for are both
   gone and the money moves out to the edge. */
.est-pv-sec .est-mat {
  grid-template-columns: minmax(0, 1fr) 150px 120px 120px;
  align-items: start;
  padding-block: 12px;
}
/* Nothing on the row answers a press, so nothing on it lights up under one. */
.est-pv-sec .est-mat:hover { background: none; }
.est-pv-sec .est-mat-name { font-size: 14px; }
.est-pv-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.est-pv-tag { flex: none; }
/* The figures sit on the name's own line, not centred against a spec block that
   can run to two rows. */
.est-pv-sec .est-mat-price,
.est-pv-sec .est-mat-qty,
.est-pv-sec .est-mat-total { padding-top: 1px; }

/* THE SPEC, IN NAMED FIELDS. quote.html writes it as one grey run-on line —
   "Natural Slab · 3 cm · Polished · 126"×63"" — which is the string the catalog
   happens to store, not a description of the slab. Each part carries its own
   label here, because the reader is checking one at a time: the ID against a
   photograph, the thickness against a template, the size against a doorway.

   Label over value, in a row of narrow columns rather than a stacked list: a
   spec is three or four short facts, and stacking them would run every product
   to half a screen. */
.est-doc-spec {
  margin: 5px 0 0;
  display: flex; flex-wrap: wrap; gap: 4px 16px;
}
.est-doc-spec div { min-width: 0; }
.est-doc-spec dt {
  font-size: 10px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--color-text-subtle);
}
.est-doc-spec dd { margin: 1px 0 0; font-size: 12px; color: var(--color-text); }

/* ONE RIGHT EDGE FOR EVERY FIGURE ON THE PAGE. A column of money is read down
   that edge, and the closing totals were missing it: the sections are cards, so
   their figures sit inside a border, the table's own inset and the row's
   padding, while the totals block is not a card and was landing flush against
   the container — three facts further right than everything it sums.

   So it is pushed in by exactly those three, and the head is brought onto the
   rows' 14px while we are here (it carried 12px, which put a section's subtotal
   two pixels off the column of its own rows). Scoped to preview: `.est-area-head`
   is quote.html's as well, and its heads have a different job. */
.est-pv-sec .est-area-head { padding-right: 14px; }
.est-doc-sum {
  margin-top: 16px;
  display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
  padding-right: calc(1px + var(--table-inset) + 14px);
}
.est-doc-sum-row { display: flex; align-items: baseline; gap: 18px; font-size: 13px; }
.est-doc-sum-row .n { color: var(--color-text-muted); }
.est-doc-sum-row .v { min-width: 110px; text-align: right; font-weight: 600; }
.est-doc-sum-row--grand {
  padding-top: 7px; border-top: 1px solid var(--color-border-strong);
  font-size: 15px;
}
.est-doc-sum-row--grand .n { color: var(--color-text); font-weight: 600; }

/* ---------- Responsive --------------------------------------------------- */
@media (max-width: 900px) {
  .tabs[data-tabs] { overflow-x: auto; flex-wrap: nowrap; }
  .tabs[data-tabs] .tab { flex: 0 0 auto; white-space: nowrap; }
}

/* Touch. Everything a finger presses takes the 44px minimum.

   THE TRIGGER IS THE INPUT, NOT THE WIDTH. This was `max-width: 768px`, which
   is a phone — and a tablet in landscape is 1024px, so the device this page is
   actually used on standing at a slab rack got none of it. `hover: none` asks
   the question that matters: is this being pressed with a finger. The width
   clause stays for a narrow window on a laptop, where the room is genuinely
   short even though the pointer is fine. */
@media (max-width: 768px), (hover: none) {
  /* The tab is the add control now, and `.tab`'s own fluid padding already
     stands it well over 44px on a touch screen — there is nothing left here to
     grow. Add Area is the one control on the strip that is not a tab. */
  .est-type-bar .est-add-area, .est-tabs-action { height: 44px; }
  /* quote.html's area header, which carries controls. */
  .est-area-head { min-height: 44px; }
  /* The row's own controls: remove, the alternate button, and the two figures
     that are typed into. A price and a quantity are the cells most often
     corrected on site, so they are the last things that should need a fingertip
     aimed at 26px. */
  .est-row-act { width: 44px; height: 44px; }
  .est-alt-btn { height: 32px; padding: 0 10px; }
  .est-price-in,
  .est-qty-in { height: 38px; }
  .est-entry .est-cell { height: 38px; }
  /* The autocomplete is not a convenience here — it is how a product gets onto
     the quote at all, so its options are the most-pressed thing on the page.
     A 33px row and a 19px pencil are both under a fingertip. */
  .est-ac-opt { padding: 12px 11px; }
  .est-ac-edit { min-width: 40px; height: 40px; }
  .est-ac-field { height: 40px; }

  /* THE COLUMNS PAY FOR THE FINGERTIPS. The widest the first column ever needs
     to be, for the 44px controls above — the width-only step below carries the
     rest. Details, which is a phrase and may wrap, is what gives it up. */
  .est-grid col.col-product { width: 46%; }
  .est-grid col.col-detail  { width: 18%; }
  .est-grid col.col-price   { width: 13%; }
  .est-grid col.col-total   { width: 12%; }
  /* Details is the one cell that reads as well over two lines as over one — it
     is a phrase, not a figure — so it is what gives the width up. */
  .est-row .col-detail { white-space: normal; }
}

/* THE COLUMNS PAY FOR THE CONTROLS, on a mouse too. The first column carries a
   handle, a photo, the name, the Alternate button and two row actions, and now
   that those last three are the portal's own components rather than a smaller
   set built here, they cost more of the row. At 32% the only thing in the cell
   that can shrink is the name — so a laptop at 1280 was rendering "Cristallo"
   with nothing to spare, and a narrower window rendered "Crista…".

   A row that does not say what it is has lost the argument. So the shares tilt
   toward the name below the width where it stops fitting, and Details gives it
   up: it is a phrase rather than a figure, so it reads as well over two lines.
   Width, not `hover: none` — the squeeze is the row being short of room, which
   happens to a mouse as readily as to a fingertip. `hover: hover` is here only
   to stand out of the block above's way: a tablet at 1024 matches both, and its
   controls are 44px, so it needs that block's wider first column, not this
   one's. */
@media (min-width: 769px) and (max-width: 1280px) and (hover: hover) {
  .est-grid col.col-product { width: 42%; }
  .est-grid col.col-detail  { width: 22%; }
  .est-grid col.col-qty     { width: 10%; }
  .est-grid col.col-price   { width: 14%; }
  .est-grid col.col-total   { width: 12%; }
  .est-row .col-detail { white-space: normal; }
}

@media (max-width: 760px) {
  /* A material stops being a six-column row and stacks: what it is on top,
     then the money it comes to, then the verdict. The quantity must stay
     beside the price it multiplies. */
  .est-mat {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
      "body  menu"
      "price qty"
      "total state";
    row-gap: 6px; column-gap: 12px;
  }
  .est-mat-body { grid-area: body; }
  .est-mat-menu { grid-area: menu; }
  .est-mat-price { grid-area: price; justify-content: flex-start; }
  .est-mat-qty { grid-area: qty; align-items: flex-end; }
  .est-mat-total { grid-area: total; text-align: left; }
  .est-mat-state { grid-area: state; }
  /* The typing row stacks: the product cell across the top with its × at the
     end, the price beside the quantity, the total under them. Fixed tracks —
     an auto track sized itself to the number cell and left the product cell a
     hairline wide. The quantity spans the last two, 130px for the cell and its
     unit word; the price keeps what is left beside it. */
  .est-entry-row {
    grid-template-columns: minmax(0, 1fr) 84px 34px;
    grid-template-areas:
      "body  body  menu"
      "price qty   qty"
      "total total total";
    row-gap: 6px; column-gap: 12px;
  }
  .est-entry-row .est-ac-wrap { grid-area: body; }
  .est-entry-row .est-mat-menu { grid-area: menu; }
  .est-entry-price { grid-area: price; text-align: left; }
  /* The unit drops under the figure rather than the figure being cut. */
  .est-entry-price .u { display: block; }
  .est-entry-qty { grid-area: qty; }
  .est-entry-total { grid-area: total; text-align: left; }
  .est-stat { flex: 1 1 auto; }
  /* The bar gives up its single line before it gives up the figures. */
  .est-stats--bar { flex-wrap: wrap; }
  /* The question keeps its shape but the reply button drops under it — at this
     width a button on the right leaves the quote three words wide. */
  .est-question { flex-wrap: wrap; }
  .est-question-actions { margin-left: 26px; }
  /* The row is two columns at this width, so the question takes both. */
  .est-mat > .est-question { margin-left: 0; }
  /* A contested row is four facts and a decision. At this width the decision
     drops to its own line under them rather than squeezing the client's name
     to nothing. */
  .est-clash-row { flex-wrap: wrap; }
  .est-clash-row .btn { margin-left: 18px; }
  .est-recip.est-recip--group { grid-template-columns: minmax(0, 1fr) auto; }
  .est-recip.est-recip--manage { grid-template-columns: minmax(0, 1fr) auto 26px; }
  .est-recip--group .est-recip-list,
  .est-recip--group .est-recip-trail { grid-column: 1 / -1; justify-content: flex-start; }
}

/* ── Campaigns · Requests queue ──────────────────────────────────────────────
   The same queue the storefront and the showrooms carry, split differently: on
   this tab every row arrived off a campaign, so the campaign takes a column of
   its own at the head of the row and the client who answered follows it.

   TWO LINES, NOT THREE. The company and the person who wrote are one fact —
   you address the answer to a name at a company — so the person rides the
   company's own line a size down rather than taking a line of their own. The
   request number is off the row: `request-detail.html` still opens on it, and
   a queue whose every row ends in an identifier nobody quotes back is a
   column of noise.

   And no Type column: it is the one column whose answer never changes on a
   page where every row came in the same way. Nor a Dates column — each date
   rides under the name it happened to, "Sent" under the campaign and
   "Received" under the client. Both those widths go to the two name columns,
   which carry the longest strings on the row and now stand side by side. */
body[data-active="campaigns"] .req-queue th.rq-cmp { width: 27%; }
body[data-active="campaigns"] .req-queue th.rq-src { width: 27%; }
body[data-active="campaigns"] .req-queue .rq-src .main .req-contact {
  /* No margin: the markup already puts a word space between the company and
     this span, and a margin on top of it opened a gap wide enough to read as
     two separate cells. What sets the spacing is the dash's own margin below,
     against that one space. */
  margin-left: 0;
  font-size: 10px;
  font-weight: 400;
  color: var(--color-text-subtle);
}
/* The company and the person are one line, so something has to say where the
   name ends — a gap alone reads as a line that ran on. In the mark rather than
   the markup, the way `.est-area-count` sets its own: ten rows of the same
   separator is a rule, not content. */
body[data-active="campaigns"] .req-queue .rq-src .main .req-contact::before {
  content: "-";
  margin-right: 4px;
}

/* ---------- Viewers ------------------------------------------------------
   The sharing panel on quotes and campaigns. Behaviour in js/record-sharing.js.

   The row is the include control and the rights switch sits beside it, outside
   the button — a button inside a button is not markup — so the row wrapper
   carries the separator, the hover and the picked wash that `.est-pick-row`
   would otherwise draw across only half the line. */

/* Narrower than `.slideover-form`'s 640px. This panel holds one column of
   names and nothing else — at the form width the rows ran on into empty space
   and the rights switch ended up an inch from the name it governs. */
#modal-sharing .slideover { width: min(440px, 100%); }

.share-row {
  display: flex; align-items: center; gap: 10px;
  padding-right: 11px;
  border-bottom: 1px solid var(--color-border);
}
.share-row:last-child { border-bottom: 0; }
.share-row:hover { background: var(--color-surface-alt); }
.share-row:has(.share-row__pick[aria-pressed="true"]) { background: var(--color-primary-50); }
.share-row__pick { flex: 1 1 auto; min-width: 0; border-bottom: 0; }
.share-row__pick:hover,
.share-row__pick[aria-pressed="true"] { background: none; }
/* A two-sided switch, unboxed: no track, no border, nothing behind it. Colour
   and weight carry which side is in force — the same way the totals switch on
   the builder says Total or Hide. */
.share-row__rights {
  flex: 0 0 auto;
  display: flex; align-items: center; gap: 7px;
}
.share-row__r {
  font-size: 11px; font-weight: 500; color: var(--color-text-muted);
  white-space: nowrap; cursor: pointer;
  transition: color 120ms, font-weight 120ms;
}
.share-row__r.is-active { color: var(--color-text); font-weight: 600; }

/* The owner is stated, not offered: it is the one name on the dialog that
   cannot be changed here, so it stands above the list on the control ground
   rather than as a locked row inside it (repo memory.md §13 — nothing sits on
   the panel's own colour). */
.share-owner {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 11px; margin-bottom: 12px;
  background: var(--color-control); border-radius: var(--radius);
}
.share-owner .thumb { width: 34px; height: 34px; flex: 0 0 auto; }
.share-owner .cell-stack { flex: 1; min-width: 0; }
.share-note { margin-top: 12px; }


/* ---------- Campaign · the Info tab -------------------------------------
   A campaign is measured in PEOPLE, never in money, so every chart here counts
   somebody: how many were sent it, how many opened it, how many asked. The
   money belongs to the quotes the answers opened and is counted on those.

   Three shapes, and each is the only one that fits what it says. A funnel of
   steps that fall out of one another is bars on a shared scale; an audience
   where every person is in exactly one of three states is a ring, because the
   three make a whole; when it was read is a column per day. Nothing here is a
   chart for the sake of one — a figure that would read as well in a row of the
   Record card is left in the Record card. */

/* A labelled bar. The head sits ABOVE its bar rather than beside it, and the
   figure banks right against the end of the track it measures: the bar is the
   comparison and the number is the exact reading of it, so the two are the same
   object and the label is what has to be got out of the way. */
.cmp-barrow { display: flex; flex-direction: column; gap: 6px; padding: 9px 0; border-bottom: 1px solid var(--color-border); }
.cmp-barrow:first-child { padding-top: 0; }
.cmp-barrow:last-child { border-bottom: 0; }
.cmp-barhead { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.cmp-barhead .l { color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-barhead .v { color: var(--color-text); font-weight: 500; flex: none; }
/* The track takes the control ground, never the panel's own white (memory.md
   §13): an unfilled bar on a white card is not a bar, it is nothing. */
.cmp-bar { height: 7px; border-radius: 5px; background: var(--color-control); overflow: hidden; }
.cmp-bar > span { display: block; height: 100%; border-radius: 5px; background: var(--color-primary); }
.cmp-bar--ok > span { background: var(--color-success); }
/* The step everything else is a fraction OF. It is always full, so it carries no
   reading of its own and takes the quietest fill on the card. */
.cmp-bar--base > span { background: var(--color-neutral); }

/* THE SAME ROW ON ONE LINE. Label, track, reading — three columns abreast
   instead of a head above a bar that ran the full width of the card. A bar is
   read as a length against the bars beside it, so four tracks a third of the
   card long are compared exactly as well as four tracks the whole card long,
   and at full width the track was spending the card's width on nothing. Inline
   the row costs one line instead of two, the labels line up in a column and the
   figures line up in another.
   `display: contents` on the head so its two spans join the row as siblings of
   the track rather than as a block above it; the figure is ordered last so it
   still banks right against the end of the track it measures. Opt-in, because
   a card whose bars carry long labels or wide figures has nothing left over to
   give the track. */
.cmp-bars--inline { display: grid; grid-template-columns: max-content minmax(0, 1fr) auto; column-gap: 12px; }
.cmp-bars--inline > * { grid-column: 1 / -1; }
.cmp-bars--inline .cmp-barrow { display: grid; grid-template-columns: subgrid; align-items: center; gap: 12px; }
.cmp-bars--inline .cmp-barhead { display: contents; }
/* THE LABEL COLUMN IS AS WIDE AS ITS LONGEST WORD, and the bar begins where it
   ends. It was a 38% column with the labels banked right against the track, so
   that every label finished on one edge and every bar began on the next pixel
   — but 38% was a guess, and the difference between the guess and "Awaiting an
   answer" was card width spent on nothing, taken from the one thing on the row
   that is read as a length. So the card is one grid and each row a subgrid of
   it: the first track is `max-content`, which makes the column exactly the
   longest label, the labels read left like every other column of words on the
   page, and the bars start on the shared edge right after it. The track takes
   all the rest (memory.md §16 — size a label's column to its content, and let
   the leftover go somewhere useful). */
.cmp-bars--inline .cmp-barhead .v { order: 1; min-width: 74px; text-align: right; }

/* The ring, its own reading in the middle, and the four states BESIDE it rather
   than under it. Stacked, the card spent the ring's height and then the legend's
   height again on four short lines, and the legend is four short lines exactly
   as tall as nothing — the ring is 132px and the list beside it is under 100px,
   so the row costs what the ring already cost and the card loses that whole
   second block. The note keeps the full width underneath both.
   Value first and tight against its label (memory.md §6). */
.cmp-ring { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 13px 18px; }
.cmp-ring-n { font-size: 22px; font-weight: 600; }
.cmp-ring-l { font-size: 11px; }
.cmp-legend { flex-direction: column; align-items: flex-start; gap: 7px; }
.cmp-legend strong { color: var(--color-text); font-weight: 600; margin-right: 5px; }
.cmp-ring > .est-form-note { grid-column: 1 / -1; align-self: stretch; }

/* The totals close Where It Stands under the bars they are the sum of. No rule
   of its own: the last `.cmp-barrow` keeps the hairline it would have dropped as
   `:last-child`, and that line is already the divider — a second one under it
   would draw the same edge twice. */
.est-totals--foot { padding-top: 11px; }

/* A bar that has passed its limit is capped at full width and turns — a bar
   drawn past the end of its own track is not a bar, and the figure beside it
   already says by how much. */
.cmp-bar--over > span { background: var(--color-danger); }

/* ---------- The reminder's address block ---------------------------------
   WHO A REMINDER GOES TO IS NOT A FIELD IN THE DIALOG — it is read off the
   campaign, and it is the one thing that has to be understood before a word of
   the message is written. So it is stated the way an email states it: From and
   To, above the subject, in the control fill rather than on the panel's own
   white (memory.md §13). Same shape as the portal's Send Email dialog, which is
   what a reader has already learned this block means. */
.cmp-mail {
  background: var(--color-control);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
  padding: 10px 12px;
  margin-bottom: 14px;
}
.cmp-mail-row { display: flex; gap: 10px; align-items: baseline; }
.cmp-mail-row + .cmp-mail-row { margin-top: 8px; }
.cmp-mail-k {
  flex: none; width: 42px;
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--color-text-muted); font-weight: 500;
}
.cmp-mail-v { display: block; font-size: 13px; min-width: 0; overflow-wrap: anywhere; }
/* The recipients who are NOT getting it, under the ones who are: it is the
   second half of the same fact, and on the same line it read as a caveat to
   the address. */
.cmp-mail-sub { display: block; margin-top: 3px; font-size: 11px; color: var(--color-text-subtle); }

/* Opens per day. Fourteen columns, one per day, on a shared scale — a day that
   drew nothing keeps a 4% stub rather than vanishing, so the row of days stays
   readable as a row of days and a gap reads as a gap. */
.cmp-days { display: flex; align-items: flex-end; gap: 4px; height: 96px; }
.cmp-days span { flex: 1; background: var(--color-primary); border-radius: 2px 2px 0 0; }
.cmp-days-x {
  display: flex; justify-content: space-between;
  margin-top: 6px; font-size: 10px; color: var(--color-text-subtle);
  font-variant-numeric: tabular-nums;
}

/* The Info tab opens on a ROW of cards rather than one card, so app.css's list of
   "square off what the strip lands on" selectors does not reach it and the card
   under the active tab keeps a rounded corner the join comes apart on. Same
   correction, for a panel whose first child is a grid. */
.tabs--attached ~ [data-tab-panel] > .grid:first-child > .card {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

/* ---------- The conversation ---------------------------------------------
   ONE STREAM, TWO SIDES. The client's messages and the rep's are the same
   object and are laid out as one column of blocks rather than two of them:
   what matters when you open this is what was said last, not who is on which
   side of the panel. So the sides are told apart by ground and alignment and
   nothing else — no tails, no avatars, nothing that costs width in a 640px
   panel that is mostly quoted material names.

   Neither side is white: the panel already is (repo memory.md §13), so the
   client takes the control ground every nested box takes and the rep takes the
   primary tint that means "us" everywhere else on the platform. */
.chat { display: flex; flex-direction: column; gap: 10px; }
.chat-day {
  align-self: center; margin: 6px 0 2px;
  font-size: 11px; color: var(--color-text-muted);
}
.chat-msg {
  max-width: 88%;
  padding: 8px 11px 9px;
  border: 1px solid var(--color-control-border);
  border-radius: var(--radius);
  background: var(--color-control);
}
.chat-msg--us {
  align-self: flex-end;
  background: var(--color-primary-50);
  border-color: var(--color-primary-50);
}
.chat-meta { display: flex; align-items: baseline; gap: 8px; }
.chat-who { font-size: 12px; font-weight: 600; }
.chat-time { font-size: 11px; color: var(--color-text-subtle); }
/* WHAT THE MESSAGE IS ABOUT reads as a label on the message, not as a line of
   it: it is the one part a person scans for when they are looking for what was
   said about one material. */
.chat-re {
  display: inline-flex; align-items: center; gap: 4px;
  margin-top: 4px;
  font-size: 11px; color: var(--color-text-muted);
}
.chat-re svg { width: 12px; height: 12px; }
.chat-text { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }

/* The composer is a stack, not a button row, so the foot takes a column. */
.chat-compose { flex-direction: column; align-items: stretch; gap: 8px; }
.chat-compose-row { display: flex; align-items: center; gap: 8px; }
.chat-compose-l { font-size: 12px; color: var(--color-text-muted); }
.chat-compose-row .select { flex: 1; }
.chat-compose-send { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.chat-compose-note { font-size: 11px; }
