/*
 * Installer-widget stylesheet.
 *
 * Served as a static asset and pulled in by `<InstallerStyles>` rather than
 * `import`ed, for exactly the reasons given at the top of chrome.css,
 * category.css and delivery.css: a bundled import attaches the chunk to the
 * shared `[brand]` layout segment and Next then emits a <link> to it on every
 * brand's every page. A hoisted <link> to a public asset is rendered only by the
 * surface that needs it — and this widget is opened from a handful of places.
 *
 * Transplanted from design/produnk/InstallerWidget.dc.html (drawn at 1440 around
 * an 820px panel) and mechanically tokenised: colours and typefaces read
 * `--brand-*`, radii read the platform constants in globals.css. Board values
 * ship verbatim — the odd numbers (94px photo circles at −47px, 62px card leads,
 * 130px label column, 520px key measure) are measured, not sloppy.
 *
 * THE FRAME IS THE DELIVERY MODAL'S, DELIBERATELY. Its stylesheet's own header
 * says "nothing here is written as a reusable modal kit — the second modal to be
 * built is what will say where the seam belongs." This IS that second modal, and
 * what it says is: the dialog host, the scrim, the panel, the rise animation and
 * the close cross are IDENTICAL in both sheets, and the header band differs
 * (this one carries a form). So the seam is real and it is those five rules —
 * FLAGGED as the extraction the third modal should force, rather than done here,
 * because promoting them means editing delivery.css and re-recording a locked
 * pixel gate on a lane this pass does not own.
 *
 * THE LEGACY LOOK IS NOT TRANSPLANTED, ITS HIERARCHY IS. `css/installers.css` on
 * produnk.com draws the same card in the legacy palette: a GREEN Order button, a
 * BLUE "Years Experience" chip, a BLUE selected row, a RED sale tag. All four
 * become the storefront's one accent family (`--brand-color-accent` and its
 * soft/strong tints), which is the whole "no blue" rule — see the board's note.
 *
 * Class prefix `sf-inst-`.
 */

/* ------------------------------------------------------- the focus ring -- */

/*
 * EVERY INTERACTIVE CONTROL IN THIS SHEET (Ryan's ruling, 2026-08-26 — the
 * platform pattern, its grounds and the contrast arithmetic are documented once,
 * at the top of chrome.css). Written here rather than beside each component
 * because a rule spread across a sheet is a rule the next control gets missed
 * by. New control: add its class to the group whose ground it lands on.
 */
.sf-inst__submit:focus-visible,
.sf-inst__field:focus-visible,
.sf-inst__foot-close:focus-visible,
.sf-inst-zone__toggle:focus-visible,
.sf-inst-price__filter:focus-visible,
.sf-inst-price__order:focus-visible,
.sf-inst-card__reviews:focus-visible,
.sf-inst-card__photo-btn:focus-visible,
.sf-inst-card__strip:focus-visible,
.sf-inst-card__area-toggle:focus-visible,
.sf-inst-card__add:focus-visible,
.sf-inst-card__link:focus-visible,
/* W38's three (2026-09-02): the escape hatch, the sort and one review's photo
 * thumbnail. The pager's own ring is declared beside the pattern in
 * sections.css, exactly as it is for every other surface that draws one. */
.sf-inst-rev__back:focus-visible,
.sf-inst-rev__more:focus-visible,
.sf-inst-card__strip-nav:focus-visible,
.sf-inst-rev__item-photo:focus-visible {
  outline: var(--sf-focus-ring, 2px solid var(--brand-color-accent, #c4530a));
  outline-offset: var(--sf-focus-ring-offset, 2px);
}

/*
 * THE CLOSE CROSS AND THE SCRIM ARE THE SHELL'S NOW (W26, 2026-09-02). Their
 * rings moved to `modal.css` with the elements they belong to —
 * `.sf-modal__close` and `.sf-modal__scrim` — so this roster covers only the
 * controls the WIDGET draws, which is the half of this sheet that survives the
 * frame extraction.
 */

/* ----------------------------------------------------------- the frame -- */

/*
 * THE FRAME LEFT THIS SHEET (W26, 2026-09-02), and this file is where it was
 * always going to leave from: `installer-modal.tsx`'s header said the five frame
 * behaviours were "REPEATED here rather than extracted, deliberately, and it is
 * a flagged debt", and that "THE THIRD MODAL SHOULD DO THE EXTRACTION". Ryan's
 * W26 standing rule made it due before a third modal arrived.
 *
 * GONE FROM HERE, INTO `public/storefront/modal.css`: the dialog host reset,
 * `::backdrop`, the scrim's wash and inset, the panel's ground/radius/shadow/
 * column, the rise keyframes, the close cross, and the phone rung's scrim
 * treatment. Every element still carries its `sf-inst__*` class, so
 * `instmodal.mjs` reads what it always read.
 *
 * AND TWO OF THOSE NUMBERS WERE WRONG, WHICH IS THE OTHER HALF OF THE PASS (W24).
 * This modal's scrim was `padding: 30px` (delivery's is 36) and its phone rung
 * was `padding: 20px 0` with `border-radius: 0` — a panel at x=0, w=390 on a 390
 * phone, with no edge to read. Ryan, walking the two side by side: "not even
 * close to being the same." The shell carries the delivery modal's figures, so
 * the reference did not move; this one came to it.
 *
 * WHAT REMAINS IS THE ONE FRAME NUMBER THAT IS THIS BOARD'S: 820, the panel
 * width InstallerWidget.dc.html draws — and it matters more than it looks,
 * because `.sf-inst-page` below reuses it so the two instantiations have the
 * same measure and one pixel gate covers both.
 */
.sf-inst__panel {
  width: 820px;
}

/*
 * THE PAGE INSTANTIATION reuses every rule below the frame and none of the
 * frame itself: `/installers` renders the same widget body inside the site's own
 * container.
 *
 * 820, THE PANEL'S OWN WIDTH, AND IT MATTERS MORE THAN IT LOOKS. Every block in
 * the body sets its own 36px inset off the panel edge, so a page wrapper of any
 * other width would give the two instantiations different measures and every
 * line in them would wrap differently. Same 820, same 36, same everything below
 * — which is what makes one pixel gate, run on the page, a gate on both.
 */
.sf-inst-page {
  /* THE PANEL'S OWN SURFACE. Without it the page instantiation draws on the
   * site's `paper` (#FBFBFA) where the modal draws on `surface` (#FFFFFF) — a
   * four-level difference that is invisible at the acceptance threshold and
   * TOTAL at threshold 2 (playbook §4, the tint trap). The two instantiations
   * have to stand on the same ground or only one of them is gated. */
  background: var(--brand-color-surface, #ffffff);
  /* NO BORDER, like the panel: the modal separates itself from the page with a
   * shadow over a scrim, and a 1px rule here would take 2px out of the 820 and
   * give the two instantiations different measures. The surface tint against
   * the site's paper is the separation. */
  border-radius: var(--radius-control);
  color: var(--brand-color-ink, #17181a);
  font-family: var(--brand-font-body, system-ui, sans-serif);
  /* globals.css sets `line-height: 1.65` on `body`; the board sets none, so
   * every block that does not name its own leading has to be `normal` or the
   * page instantiation draws deeper than the modal one. */
  line-height: normal;
  margin: 0 auto;
  max-width: 820px;
  padding-block: 0 56px;
  text-align: left;
}

/* ---------------------------------------------------------- the header -- */

.sf-inst__head {
  background: var(--brand-color-well, #f1f0ec);
  padding: 28px 36px 26px;
  position: relative;
  text-align: center;
}

/* On the page there is no scrim and no cross; the band keeps its tint. */
.sf-inst-page .sf-inst__head {
  border-radius: var(--radius-control);
}

/*
 * `.sf-inst__close` STOOD HERE AND IS GONE (W26, 2026-09-02). The cross is
 * `.sf-modal__close` now, and it is the delivery modal's box rather than this
 * one's near-copy: `display: flex` with the mark centred in it, a `radius-inner`
 * corner for the focus ring, and an ink hover this sheet never declared. The
 * class is still ON the element and simply declares nothing here.
 */

/*
 * `.sf-inst__sticky-title` IS RETIRED BY NAME (W68, Ryan 2026-09-03). W55 gave
 * this pop-up a sticky band and this sheet drew its title; W68 gave every pop-up
 * one, so the treatment moved to `.sf-modal__header-title` in `modal.css` where
 * the rest of the frame lives. What stays here is the half that is genuinely
 * this widget's: hiding the HEAD's own heading on the rung where the band draws
 * it, so a reader sees the name once and a listener hears it once.
 *
 * NO LONGER FENCED TO A RUNG (W69, Ryan 2026-09-03): the band draws at every
 * width now, so this hides the widget's own `<h2>` at every width too — the
 * selector requires `.sf-inst__panel` as an ancestor, which is the MODAL
 * instantiation's own wrapper (`ModalShell`'s `block="sf-inst"` class), so the
 * standalone `/installers` page — which never sits inside that wrapper and never
 * gets a band — keeps its one real, always-visible heading exactly as
 * `instgate.mjs` has always measured it.
 */
.sf-inst__panel .sf-inst__title {
  display: none;
}

.sf-inst__title {
  color: var(--brand-color-ink, #17181a);
  font-size: 21px;
  font-weight: 700;
  margin: 0;
  line-height: normal;
}

.sf-inst__lede {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 15px;
  line-height: 1.55;
  margin: 8px auto 0;
  max-width: 540px;
}

.sf-inst__form {
  align-items: center;
  display: flex;
  gap: 10px;
  justify-content: center;
  margin-top: 16px;
}

.sf-inst__field {
  background: var(--brand-color-surface, #ffffff);
  border: 1px solid var(--brand-color-line-mid, #d8d6d0);
  border-radius: var(--radius-control);
  box-sizing: border-box;
  color: var(--brand-color-ink, #17181a);
  font-family: inherit;
  /*
   * 18 IS CHOSEN, NOT INHERITED (BTN-UNIFY handoff item 4). The board sets it
   * explicitly — a zip is five characters in a 150px box and reads small at the
   * body size — and 18px IS on the type scale, so the value is unchanged and now
   * says which rung it is. Verified against the board rather than assumed: the
   * drawing's own `.input` override carries `font-size: 18px`.
   */
  font-size: var(--text-card-header, 18px);
  /* THE BUTTON'S OWN TOKEN (W13): two names for one number is how a field and
   * the button beside it drift apart by a pixel. */
  min-height: var(--control-height, 48px);
  padding: 0 14px;
  text-align: center;
  width: 150px;
}

/*
 * W13: THE BUTTON IS EXACTLY AS TALL AS THE FIELD BESIDE IT.
 *
 * It was 15px of padding around a 16px line — a rendered 49 next to the field's
 * 48, which is the kind of one-pixel disagreement that reads as sloppiness at
 * phone size and is invisible on a desktop board. Both are now 48, which is the
 * platform's existing control height (`.sf-flow-btn`'s own `min-height`), and
 * the button's vertical padding is gone in favour of centring inside it — so a
 * label that ever wraps grows the button rather than bursting it.
 */
.sf-inst__submit {
  align-items: center;
  background: var(--brand-color-accent, #c4530a);
  border: 0;
  border-radius: var(--radius-control);
  color: var(--brand-color-surface, #ffffff);
  cursor: pointer;
  display: inline-flex;
  font-family: inherit;
  /* THE PRIMARY RUNG, BY NAME. `--control-height` is the same token the field
   * beside it takes, which is exactly what W13 asks for and exactly why
   * globals.css shares one number between buttons and inputs rather than giving
   * buttons a private height. */
  font-size: var(--button-text, 16px);
  font-weight: var(--button-weight, 700);
  justify-content: center;
  min-height: var(--control-height, 48px);
  padding: 0 24px;
}

.sf-inst__submit[disabled] {
  cursor: progress;
  opacity: 0.7;
}

.sf-inst__error {
  color: var(--brand-color-accent-strong, #9c420a);
  font-size: 13.5px;
  margin: 10px 0 0;
}

/* ------------------------------------------------------------ the body -- */

.sf-inst__body {
  padding: 26px 36px 0;
}

.sf-inst__eyebrow {
  color: var(--brand-color-muted, #6c6a62);
  font-family: var(--brand-font-mono, ui-monospace, monospace);
  font-size: 10.5px;
  letter-spacing: 0.12em;
  margin: 0 0 10px;
  text-transform: uppercase;
}

/* In a row that already spaces itself (the price table's head and its column
 * heads), the eyebrow's own bottom margin is the thing that mis-centres it. */
.sf-inst__eyebrow--flush {
  margin: 0;
}

/* The board's 34px lead over the ranked list. */
.sf-inst__eyebrow--lead {
  margin-top: 34px;
}

/* The board's 30px between the widget's three in-zone blocks. */
.sf-inst__section {
  padding-top: 30px;
}

/*
 * The three things the widget says when it has no answer, drawn identically
 * because they are the same kind of statement: a sentence where a result would
 * be. They are NOT styled as errors — "we could not reach the directory" is not
 * the shopper's mistake, and a red box in front of them implies it is.
 */
.sf-inst__status {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 15px;
  line-height: 1.55;
  margin: 0;
  padding: 26px 0;
  text-align: center;
}

/*
 * W14: THE LOADING LINE, while the ranking call fans out.
 *
 * A MARK AND A SENTENCE, not a skeleton of the cards: the number of cards is not
 * known until the answer lands, so a skeleton would be guessing at the shape of
 * the thing it is standing in for — and three grey card-shaped blocks that
 * resolve into one is a worse moment than one honest line.
 */
.sf-inst__status--busy {
  align-items: center;
  display: flex;
  gap: 12px;
  justify-content: center;
}

.sf-inst__spinner {
  animation: sf-inst-spin 900ms linear infinite;
  block-size: 16px;
  border: 2px solid var(--brand-color-line, #e8e7e3);
  border-block-start-color: var(--brand-color-accent, #c4530a);
  border-radius: 50%;
  flex: none;
  inline-size: 16px;
}

/*
 * REDUCED MOTION GETS A STILL MARK, not a slower spin: a ring that does not turn
 * still reads as "something is here", and the sentence beside it is doing the
 * actual telling.
 */
@media (prefers-reduced-motion: reduce) {
  .sf-inst__spinner {
    animation: none;
  }
}

@keyframes sf-inst-spin {
  to {
    transform: rotate(360deg);
  }
}

.sf-inst__fine {
  background: var(--brand-color-paper, #fbfbfa);
  border-radius: var(--radius-control);
  color: var(--brand-color-muted, #6c6a62);
  font-size: 13.5px;
  line-height: 1.55;
  /* NO SIDE MARGIN: the board's 36px is the panel inset, and this block already
   * sits inside `__body`, which owns that inset. The board's own `margin: 34px
   * 36px 0` is therefore transplanted as 34px of LEAD only. */
  margin: 34px 0 0;
  padding: 18px 20px;
}

/*
 * W25 — MORE AIR ABOVE THE WAY OUT, and only above it.
 *
 * Ryan (2026-09-02): "more air gap between the last installer card and the close
 * button"; and, of the gap under it, "the gap below close vs fine print is
 * already good". Measured at 390 before this rule: last card → button 26px,
 * button → fine print 64px.
 *
 * SO ONLY THE LEAD MOVES. The band itself is `.sf-modal__footer`'s 30/36/30 —
 * the delivery modal's, which is the shell's rhythm and which `dlvgate.mjs` has
 * a recorded ceiling on — and this one declaration takes the lead to 44 without
 * touching the trail, so the 64 below stays exactly 64 (30 of band + the
 * disclaimer's own 34 of margin).
 *
 * IT IS A LEAD, NOT A MARGIN, deliberately: the band is inside `.sf-inst__body`,
 * whose children are a plain block flow, and a margin here would collapse
 * against whatever the last answer state happened to end with.
 *
 * AND IT IS THE SHELL'S CUSTOM PROPERTY, NOT A `padding-top` — see the block's
 * own note in modal.css: a longhand here would race the shell's `padding`
 * shorthand on stylesheet load order, which is React's business and not ours.
 */
.sf-inst__foot {
  --sf-modal-foot-lead: 44px;
  /*
   * AND NO INLINE INSET OF ITS OWN, at any width.
   *
   * The shell's band assumes it is a child of the PANEL, which is where the
   * delivery modal puts it — so its 36 is the panel inset. This one is inside
   * `.sf-inst__body` (W18: the way out comes before the small print, and the
   * small print is the widget's), and the body already owns that inset. The old
   * `padding: 26px 36px 30px` was therefore a DOUBLE inset — measured at 390 it
   * held the full-width close to 280 inside a 320 measure, two steps in from the
   * cards above it. Zero here is what puts the button on the cards' own edges.
   */
  --sf-modal-foot-inline: 0;
}

.sf-inst__foot-close {
  /* THE COMPACT RUNG, BY NAME (BTN-UNIFY handoff). It rendered 42 — the two
   * pixels padding-plus-line-box always costs over a declared height. */
  align-items: center;
  background: none;
  border: 1px solid var(--brand-color-ink, #17181a);
  border-radius: var(--radius-control);
  color: var(--brand-color-ink, #17181a);
  cursor: pointer;
  display: inline-flex;
  font-family: inherit;
  font-size: var(--button-text-sm, 14.5px);
  font-weight: var(--button-weight-sm, 600);
  justify-content: center;
  min-height: var(--control-height-sm, 40px);
  padding: 0 var(--button-pad-x-sm, 18px);
}

/* --------------------------------------------------- the service centre -- */

.sf-inst-zone__head {
  align-items: center;
  display: flex;
  gap: 12px;
}

.sf-inst-zone__mark {
  align-items: center;
  background: var(--brand-color-accent-soft, #fbefe5);
  border-radius: 50%;
  color: var(--brand-color-accent-strong, #9c420a);
  display: flex;
  flex: none;
  height: 30px;
  justify-content: center;
  width: 30px;
}

.sf-inst-zone__name {
  font-size: 18px;
  font-weight: 700;
  margin: 0;
  line-height: normal;
}

.sf-inst-zone__toggle {
  background: none;
  border: 0;
  color: var(--brand-color-muted, #6c6a62);
  cursor: pointer;
  font-family: inherit;
  font-size: 13.5px;
  padding: 0;
  text-decoration: underline;
}

.sf-inst-zone__table {
  border: 1px solid var(--brand-color-line, #e8e7e3);
  border-radius: var(--radius-control);
  margin: 14px 0 0;
  overflow: hidden;
}

.sf-inst-zone__row {
  border-bottom: 1px solid var(--brand-color-line, #e8e7e3);
  display: flex;
  padding: 10px 16px;
}

.sf-inst-zone__row:last-child {
  border-bottom: 0;
}

.sf-inst-zone__label {
  color: var(--brand-color-muted, #6c6a62);
  flex: none;
  font-size: 13.5px;
  margin: 0;
  width: 130px;
}

.sf-inst-zone__value {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 14.5px;
  line-height: 1.5;
  margin: 0;
}

/* ------------------------------------------------------ the price table -- */

.sf-inst-price__head {
  align-items: center;
  display: flex;
  gap: 16px;
  justify-content: space-between;
}

.sf-inst-price__filters {
  align-items: center;
  display: inline-flex;
  gap: 10px;
  /* A FLEX ITEM'S FLOOR IS ITS CONTENT unless it is told otherwise, and this
   * one's content is a `<select>` sized to its longest option ("In-Ground ›
   * Adjustable › HERCULES"). Measured on `tools/overflow.mjs`: without this the
   * document scrolled sideways to 465px at every width below 465 — the select
   * simply refused to shrink and pushed the page open. Inert above 560, where
   * the head row has slack to spare. */
  min-width: 0;
}

.sf-inst-price__filter-label {
  color: var(--brand-color-muted, #6c6a62);
  font-size: 13.5px;
}

/*
 * ═══ FLAG (a) — THE FILTER JOINS THE LADDER (Ryan, 2026-09-02) ═══
 *
 * It drew 36px at 13.5, which is a private third size on a site with exactly
 * two rungs — and it was flagged from inside this lane rather than caught by a
 * gate, because `btngate` does not sweep it and BTN-UNIFY's own rule is that a
 * FIELD is a button's height (W13). Ryan ruled it: fix it now.
 *
 * 40/14.5/600, the compact rung by name. The 10px side padding stays — padding
 * is explicitly not part of the rung (globals.css §3), and a `<select>` needs
 * room for its own drawn arrow.
 *
 * IT MOVES A RECORDED GATE, and that is the honest cost: `instgate`'s `pricing`
 * region is re-recorded in the same pass, carrying this ruling's attribution.
 */
.sf-inst-price__filter {
  background: var(--brand-color-surface, #ffffff);
  max-width: 100%;
  min-width: 0;
  border: 1px solid var(--brand-color-line-mid, #d8d6d0);
  border-radius: var(--radius-control);
  color: var(--brand-color-ink, #17181a);
  font-family: inherit;
  font-size: var(--button-text-sm, 14.5px);
  font-weight: var(--button-weight-sm, 600);
  min-height: var(--control-height-sm, 40px);
  padding: 0 10px;
}

.sf-inst-price__table {
  border: 1px solid var(--brand-color-line, #e8e7e3);
  border-radius: var(--radius-control);
  margin-top: 12px;
  overflow: hidden;
}

.sf-inst-price__row {
  align-items: center;
  border-bottom: 1px solid var(--brand-color-line, #e8e7e3);
  display: flex;
  gap: 16px;
  justify-content: space-between;
  padding: 11px 22px;
}

.sf-inst-price__row:last-child {
  border-bottom: 0;
}

.sf-inst-price__row--head {
  background: var(--brand-color-well, #f1f0ec);
  padding-block: 9px;
}

/*
 * THE PAGE-CONTEXT HIGHLIGHT. Legacy tints the row `--blue-highlight` with blue
 * rules top and bottom; the storefront has no blue, so the same emphasis is the
 * accent's soft tint plus a 3px accent rule on the leading edge. The padding is
 * reduced by the rule's width so the model name does not shift by 3px between a
 * highlighted row and its neighbours.
 */
.sf-inst-price__row--selected {
  background: var(--brand-color-accent-soft, #fbefe5);
  border-inline-start: 3px solid var(--brand-color-accent, #c4530a);
  padding-inline-start: 19px;
}

.sf-inst-price__model {
  align-items: baseline;
  display: inline-flex;
  gap: 10px;
}

.sf-inst-price__name {
  color: var(--brand-color-ink, #17181a);
  font-size: 14.5px;
}

.sf-inst-price__row--selected .sf-inst-price__name {
  font-weight: 700;
}

.sf-inst-price__ctx {
  color: var(--brand-color-accent-strong, #9c420a);
  font-family: var(--brand-font-mono, ui-monospace, monospace);
  font-size: 10.5px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.sf-inst-price__figures {
  align-items: center;
  display: inline-flex;
  gap: 12px;
}

.sf-inst-price__sale {
  align-items: center;
  background: var(--brand-color-accent-soft, #fbefe5);
  border-radius: var(--radius-inner);
  color: var(--brand-color-accent-strong, #9c420a);
  display: inline-flex;
  font-size: 12.5px;
  font-weight: 600;
  padding: 2px 8px;
}

/* On the tinted row the badge takes the surface so it does not vanish into it. */
.sf-inst-price__row--selected .sf-inst-price__sale {
  background: var(--brand-color-surface, #ffffff);
}

.sf-inst-price__was {
  color: var(--brand-color-muted, #6c6a62);
  font-size: 14.5px;
  text-decoration: line-through;
}

.sf-inst-price__now {
  color: var(--brand-color-ink, #17181a);
  font-family: var(--brand-font-mono, ui-monospace, monospace);
  font-size: 15px;
}

.sf-inst-price__now--sale {
  color: var(--brand-color-accent, #c4530a);
}

.sf-inst-price__row--selected .sf-inst-price__now--sale {
  font-weight: 700;
}

.sf-inst-price__order {
  /*
   * THE COMPACT RUNG, BY NAME (BTN-UNIFY handoff). It rendered 28 at 13.5/700 —
   * the shortest button anywhere on the storefront, nine rows of it, and a
   * touch target well under the 40px floor the rung exists to hold.
   *
   * THE ACCENT FILL STAYS: the fill is the treatment and carries the emphasis;
   * the weight comes down to the rung's 600 with everything else, which is the
   * same call BTN-UNIFY made on the flow's own in-card primary.
   */
  align-items: center;
  background: var(--brand-color-accent, #c4530a);
  border: 0;
  border-radius: var(--radius-control);
  color: var(--brand-color-surface, #ffffff);
  cursor: pointer;
  display: inline-flex;
  font-family: inherit;
  font-size: var(--button-text-sm, 14.5px);
  font-weight: var(--button-weight-sm, 600);
  justify-content: center;
  min-height: var(--control-height-sm, 40px);
  padding: 0 16px;
  text-decoration: none;
}

.sf-inst-price__note {
  color: var(--brand-color-muted, #6c6a62);
  font-size: 13.5px;
  line-height: 1.5;
  margin: 10px 0 0;
}

/*
 * BELOW 560 THE ROW BECOMES TWO LINES. The board draws one width; a model name
 * and four figures cannot share a 320px row, and the thing that must never wrap
 * mid-cluster is the price-and-Order pair.
 */
@media (max-width: 559.98px) {
  /* The eyebrow and the filter stop sharing a line. SCOPED BEHIND THIS RUNG on
   * purpose (playbook §5): a `flex-wrap` on a row with no slack is not a
   * harmless fix, and at 1440 this row has plenty. */
  .sf-inst-price__head {
    flex-wrap: wrap;
  }

  .sf-inst-price__row {
    align-items: flex-start;
    flex-direction: column;
    gap: 8px;
  }

  .sf-inst-price__row--head {
    flex-direction: row;
  }

  .sf-inst-price__figures {
    width: 100%;
  }
}

/* ----------------------------------------------------- the FAQ, in-zone -- */

.sf-inst-faq {
  border-top: 1px solid var(--brand-color-line, #e8e7e3);
}

.sf-inst-faq__item {
  border-bottom: 1px solid var(--brand-color-line, #e8e7e3);
  padding: 16px 0;
}

.sf-inst-faq__item:last-child {
  border-bottom: 0;
}

.sf-inst-faq__q {
  color: var(--brand-color-ink, #17181a);
  font-size: 14.5px;
  font-weight: 700;
  margin: 0 0 6px;
  line-height: normal;
}

.sf-inst-faq__a {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 14.5px;
  line-height: 1.5;
  margin: 0;
}

/* -------------------------------------------- the out-of-zone estimate -- */

.sf-inst-est {
  background: var(--brand-color-surface, #ffffff);
  border: 1px solid var(--brand-color-line, #e8e7e3);
  border-radius: var(--radius-control);
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 22px 26px;
}

/*
 * THE ICON SPAN IS A FLEX BOX, and that is the playbook's own glyph trap (§5):
 * an `<svg>` inside an inline or block span stays INLINE and picks up its line
 * box's ~3px descender, making the heading two pixels taller than the board's.
 * A flex wrapper blockifies it.
 */
.sf-inst-est__head > span {
  display: flex;
}

.sf-inst-est__head {
  align-items: center;
  color: var(--brand-color-ink, #17181a);
  display: flex;
  font-size: 16px;
  font-weight: 700;
  gap: 10px;
  margin: 0;
  line-height: normal;
}

.sf-inst-est__body {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 15px;
  line-height: 1.55;
  margin: 0;
}

.sf-inst-est__figure {
  color: var(--brand-color-ink, #17181a);
  font-family: var(--brand-font-mono, ui-monospace, monospace);
}

.sf-inst-est__bullets {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 13.5px;
  line-height: 1.6;
  /* globals.css sets `list-style: none` and zeroes the padding on EVERY list, so
   * a list that wants markers has to ask for them back — the same correction
   * `.sf-flow-bullets` carries, and the reason these two bullets were rendering
   * as bare indented lines on the phone (caught in the 390 walkthrough shot). */
  list-style: disc;
  margin: 0;
  padding-inline-start: 18px;
}

/*
 * `.sf-inst-est__go` AND `.sf-inst-est__go[disabled]` STOOD HERE AND ARE GONE
 * (2026-09-01).
 *
 * They styled the "View N pros in your area" button INSIDE the panel, and W12
 * removed that button: the cards load themselves on open, because a shopper who
 * pressed "View N pros" on step 1 has already asked. The rules outlived their
 * markup by one pass — BTN-UNIFY's audit found them off-rung and handed them
 * back to be re-tokenised, which is what an audit reading STYLESHEETS rather
 * than pages will always do with a dead rule. Tokenising a button nobody renders
 * is a tidier corpse, so they are deleted instead.
 *
 * The orphan sweep in `tests/installer-widget.test.ts` is the durable fix: it is
 * the guard flow.css has had all along, and installers.css did not.
 */

/* ------------------------------------------------------ the ranked card -- */

/*
 * 62px of lead, because the photo circle hangs 47px above the card and needs
 * air over it. Legacy's own sheet does the same with `margin-top: 100px` and
 * `top: -50px`; the board tightened both.
 */
.sf-inst-cards {
  display: flex;
  flex-direction: column;
  gap: 62px;
  padding-top: 62px;
}

/*
 * ═══ W39 — THE CARDS STAND UP (Ryan, 2026-09-02) ═══
 *
 * "Cards don't stand out — white on white with a whisper border. Pull them up:
 * drop shadow." The card is `surface` (#FFFFFF) inside a panel that is also
 * `surface`, so the only thing separating them was a one-pixel `line` hairline
 * at a four-level delta — the same tint trap the playbook records, seen by eye
 * instead of by a gate.
 *
 * ═══ W89 — AND NOW IT IS THE HOUSE'S CARD, NOT THIS SHEET'S ═══
 *
 * Ryan (2026-09-03, on the accessories step): the accessory cards get this same
 * lift, and the card look should be STANDARDISED — border, radius, shadow — as
 * one shared definition. So the four frame properties that stood here have
 * moved to `public/storefront/card.css` (`.sf-card`), which carries W39's whole
 * rationale and its numbers verbatim, and this element wears BOTH classes.
 *
 * THE VALUES DID NOT CHANGE, which is the point: the standard was written ONTO
 * this card rather than averaged across two, so every computed border, radius,
 * ground and shadow on an installer card is the number `instgate` and
 * `instmodal` already recorded. What is left below is what is genuinely THIS
 * card's — its padding, its positioning context, and its own named inset.
 *
 * A FRAME PROPERTY MUST NOT COME BACK HERE. `.sf-card` and `.sf-inst-card` have
 * the same specificity in two different sheets, so a border redeclared here
 * would be a cascade race decided by hoist order. Nudge through card.css's
 * custom properties instead — the dual-class rule the modal shell already
 * carries.
 */
.sf-inst-card {
  padding: 0 var(--sf-inst-card-pad) 22px;
  position: relative;
  /*
   * THE CARD'S OWN INSET, NAMED — W35's whole mechanism.
   *
   * The rows below bleed to the card's edge by cancelling this number and then
   * paying it back as their own inline padding, so the two have to BE one
   * number: a row that bled 26 and paid back 16 would sit a hair off-centre at
   * every width, and the phone rung changes it (16 below 560).
   */
  --sf-inst-card-pad: 26px;
}

/*
 * The rank number. Legacy floats it above the card at `left: 20px; top: -30px`,
 * where it collides with the photo's shadow on a narrow card; the board seats it
 * IN the corner, which also means it cannot be clipped by the scrim's edge.
 */
.sf-inst-card__rank {
  align-items: center;
  background: var(--brand-color-ink, #17181a);
  border-radius: var(--radius-control) 0 var(--radius-control) 0;
  color: var(--brand-color-surface, #ffffff);
  display: flex;
  font-size: 13.5px;
  font-weight: 700;
  height: 30px;
  inset-block-start: -1px;
  inset-inline-start: -1px;
  justify-content: center;
  position: absolute;
  width: 30px;
}

/*
 * ═══ W40 — THE PROFILE CROP GETS AN EDGE (Ryan, 2026-09-02) ═══
 *
 * "The circular installer profile crop looks a little raw — border or shadow,
 * use your design expertise."
 *
 * WHAT WAS ACTUALLY RAW, measured rather than guessed: the circle already wore
 * a 3px ring, and that ring is `surface` — WHITE, on a card that is also white.
 * So the ring did the one job it was drawn for (punching a hole in the card's
 * top border, which the circle hangs across) and did nothing at all for the
 * photograph: a hard-cut bitmap ended in mid-air with no edge of its own. On a
 * headshot with a pale background — which is most of this directory — the crop
 * did not read as a circle at all.
 *
 * TWO RINGS AND A LIFT, and each of the three earns its place:
 *
 *   · the 3px WHITE ring stays. It is the gap that separates the photograph
 *     from the card edge it overlaps, and W39 just gave that edge a shadow to
 *     be separated from.
 *   · a 1px `line-mid` ring OUTSIDE it closes the circle. `line-mid` (#D8D6D0)
 *     rather than `line` (#E8E7E3) because this hairline has to read against
 *     the card's own `line` border a few pixels away — the same reason the
 *     card's first row wears `line-mid` and the rest wear `line`.
 *   · the same ambient lift the card takes, halved in reach (`0 4px 12px`, the
 *     circle is a quarter of the card's size and an 18px blur under a 100px
 *     disc reads as a smudge). Together they say the disc sits ON the card
 *     rather than being a hole cut in it.
 *
 * IT IS ALL `box-shadow`, WHICH IS THE POINT. The board's 100px outer circle is
 * a measured number that the identity block's 58px of lead is derived from; a
 * fourth pixel of real border would have moved the name, the stars and every
 * row under them. Shadows paint outside the border box and change no geometry.
 */
.sf-inst-card__photo {
  align-items: center;
  background: var(--brand-color-well, #f1f0ec);
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
  border: 3px solid var(--brand-color-surface, #ffffff);
  border-radius: 50%;
  box-shadow:
    0 0 0 1px var(--brand-color-line-mid, #d8d6d0),
    0 4px 12px rgb(23 24 26 / 10%);
  display: flex;
  /* 100 INCLUDING THE RING, which is the board's own outer circle: the board
   * draws a 94px box with a 3px border and no `box-sizing`, so it renders 100.
   * globals.css sets `border-box` everywhere, so 94 here would be a 6px-smaller
   * circle than the drawing. */
  height: 100px;
  inset-inline: 0;
  justify-content: center;
  margin: auto;
  position: absolute;
  top: -47px;
  width: 100px;
}

.sf-inst-card__identity {
  padding-top: 58px;
  text-align: center;
}

.sf-inst-card__name {
  font-size: 18px;
  font-weight: 700;
  margin: 0;
  line-height: normal;
}

.sf-inst-card__rating {
  align-items: center;
  display: flex;
  gap: 14px;
  justify-content: center;
  margin-top: 10px;
}

.sf-inst-card__top {
  background: var(--brand-color-ink, #17181a);
  border-radius: var(--radius-inner);
  color: var(--brand-color-surface, #ffffff);
  font-size: 12.5px;
  font-weight: 600;
  padding: 2px 8px;
}

.sf-inst-card__stars {
  color: var(--brand-color-accent, #c4530a);
  display: inline-flex;
  gap: 3px;
  line-height: 0;
}

.sf-inst-card__star--off {
  color: var(--brand-color-line, #e8e7e3);
}

.sf-inst-card__reviews {
  background: none;
  border: 0;
  color: var(--brand-color-muted, #6c6a62);
  cursor: pointer;
  font-family: inherit;
  font-size: 13.5px;
  padding: 0;
  text-decoration: underline;
}

/*
 * ═══ W35 — THE ROWS BLEED, THE CONTENT DOES NOT (Ryan, 2026-09-02) ═══
 *
 * "Better look with the table going all the way to the edge of the card.
 * Content stays in the center."
 *
 * TWO FACTS THAT HAD TO STAY TRUE TOGETHER, which is the whole design of this
 * pair of rules:
 *
 *   (a) the RULES — every row's hairline, and the 2px `line-mid` one that opens
 *       the table — run the full width of the card, corner to corner;
 *   (b) the WORDS in those rows do not move by a pixel. 520px is the board's key
 *       measure ("the card is 768px of content at 1440 and a five-line key
 *       running its full width reads as a table rather than a profile") and
 *       Ryan's ruling explicitly keeps it.
 *
 * SO THE BLEED IS THE CONTAINER'S AND THE MEASURE IS THE ROW'S. `__lines`
 * cancels the card's inset with a negative inline margin, and each row pays it
 * back as PADDING — which a border-top does not sit inside. The rule is drawn on
 * the row's border box (full card width); the flex content is drawn inside its
 * padding box (520, centred).
 *
 * `max()` IS WHAT MAKES IT ONE RULE INSTEAD OF A BREAKPOINT. The padding is the
 * larger of the card's own inset and half the slack over 520 — so:
 *
 *   card content 696  →  lines 748  →  padding 114  →  content 520  (centred)
 *   card content 520  →  lines 572  →  padding  26  →  content 520
 *   card content 288  →  lines 340  →  padding  26  →  content 288  (phone)
 *
 * The two arms meet exactly at 520 with no discontinuity, and BELOW it the
 * arithmetic reproduces the old `max-width: 520px` rendering to the pixel: a
 * card narrower than the key measure drew its full content width before and
 * draws its full content width now. That equivalence is why this ruling moves
 * the rules and nothing else.
 *
 * THE STRIP AND THE ACTIONS ARE IN THIS CONTAINER TOO, and both are deliberate:
 * the photo strip WANTS the bleed (W36, below) and takes no padding rule at all,
 * and the actions row is `text-align: center` — the container's centre and the
 * card's centre are the same point, so the button does not move.
 */
.sf-inst-card__lines {
  margin-block: 18px 0;
  margin-inline: calc(-1 * var(--sf-inst-card-pad));
}

/** The card's key measure — the width the words in a row are held to. */
.sf-inst-card__lines,
.sf-inst-rev {
  --sf-inst-measure: 520px;
  /*
   * HOW MUCH OF THE NEXT REVIEW SHOWS PAST THE CURRENT ONE (W50/W60).
   *
   * 34px, and it is a legibility number rather than a fraction: the first build
   * used 12% of the box, which is 38px on a 390 screen and 26 on a 320 — and at
   * 26 the sliver stops reading as a card and starts reading as a border. A
   * fixed peek says the same thing at every width, which is the whole job.
   */
  --sf-inst-rev-peek: 34px;
  /*
   * THE ONE GUTTER (W64/W66, Ryan 2026-09-03). Fifteen, declared once, and used
   * by the carousel's padding, its snap padding, its gap, the card-width
   * arithmetic and the card's own block inset — so "the gutter" is a single
   * number rather than an agreement between four rules.
   */
  --sf-inst-rev-gutter: 15px;
}

.sf-inst-card__line {
  border-top: 1px solid var(--brand-color-line, #e8e7e3);
  color: var(--brand-color-ink-soft, #44464a);
  display: flex;
  font-size: 14.5px;
  gap: 12px;
  padding-block: 12px;
  /* The bleed's other half — see the block comment above. */
  padding-inline: max(
    var(--sf-inst-card-pad),
    calc((100% - var(--sf-inst-measure)) / 2)
  );
}

.sf-inst-card__line--first {
  border-top: 2px solid var(--brand-color-line-mid, #d8d6d0);
}

.sf-inst-card__line--last {
  border-bottom: 1px solid var(--brand-color-line, #e8e7e3);
}

.sf-inst-card__mark {
  color: var(--brand-color-muted, #6c6a62);
  flex: none;
  height: 18px;
  margin-top: 1px;
  width: 18px;
}

.sf-inst-card__link {
  color: var(--brand-color-muted, #6c6a62);
}

.sf-inst-card__area-toggle {
  background: none;
  border: 0;
  color: var(--brand-color-muted, #6c6a62);
  cursor: pointer;
  font-family: inherit;
  font-size: inherit;
  padding: 0;
  text-decoration: underline;
}

/*
 * The installer's own words, and the ONE place this stylesheet styles markup it
 * did not author. The HTML arrives allowlist-sanitised from the ERP (a hard cap
 * of a dozen inline tags), so the rules below are deliberately about RHYTHM
 * only — nothing here can be defeated by an attribute, because there are none.
 */
/*
 * ═══ W34 — IT IS A BLOCK, AND IT ALWAYS MEANT TO BE ═══
 *
 * Ryan, 2026-09-02: "'View service area' text RUNS TOGETHER; it should
 * line-break."
 *
 * MEASURED, at 1280 on `/installers?zip=80021`: the revealed blurb's left edge
 * and the toggle's right edge were the SAME number — 612.656 — on the same line
 * box. The blurb started in the character position immediately after the words
 * "View service area", with nothing between them.
 *
 * THE CAUSE WAS THE ONE DECLARATION THAT WAS NOT HERE. This is a `<span>` and
 * nothing gave it a `display`, so it stayed INLINE — and `margin-top` on an
 * inline box does nothing at all. The 10px below has been inert since the rule
 * was written; the only reason it ever looked right is that at narrow widths the
 * blurb happened to wrap onto its own line by accident of measure. At the panel's
 * own 820 it does not.
 *
 * `block` is the whole fix: the margin becomes real, the blurb starts on its own
 * line under the control that revealed it, and the installer's own `<br>`s and
 * `<p>`s inside it go on doing what they always did.
 *
 * NOT A SANITISER PROBLEM, and that is worth writing down because it was the
 * first suspicion: `sanitize_service_area` allows `br`, `p`, `ul`, `ol` and `li`,
 * and the real Colorado/Texas blurb — "Nationwide\n<br><br>\n<strong><a …>Book
 * Your Pro Dunk Installation Online Direct</a></strong>", the "link hack" from
 * docs/adding-installers.md — comes through it with both breaks and the anchor
 * intact (verified against the live legacy state pages). It had breaks; it had
 * nowhere to put them.
 */
.sf-inst-card__area {
  display: block;
  font-size: 14.5px;
  line-height: 1.5;
  margin-top: 10px;
}

/*
 * WHEN THE BLURB IS THE ROW rather than a disclosure under a drive time, it
 * takes no lead: the 10px above belongs BETWEEN a distance line and the area it
 * reveals, and applying it to a row that has nothing above it just makes the
 * card 10px taller than the board.
 */
.sf-inst-card__area--only {
  margin-top: 0;
}

.sf-inst-card__area p {
  margin: 0 0 8px;
}

.sf-inst-card__area p:last-child,
.sf-inst-card__area ul:last-child,
.sf-inst-card__area ol:last-child {
  margin-bottom: 0;
}

.sf-inst-card__area ul,
.sf-inst-card__area ol {
  margin: 0 0 8px;
  padding-inline-start: 18px;
}

.sf-inst-card__area a {
  color: var(--brand-color-muted, #6c6a62);
}

.sf-inst-card__chip {
  background: var(--brand-color-accent-soft, #fbefe5);
  border-radius: var(--radius-inner);
  color: var(--brand-color-accent-strong, #9c420a);
  display: inline-block;
  font-size: 12.5px;
  font-weight: 600;
  margin-inline-start: 10px;
  padding: 2px 8px;
}

/*
 * W20 — THE CUSTOMER-PHOTO STRIP.
 *
 * Legacy's own treatment (`css/installers.css`: `.card_photos { white-space:
 * nowrap; overflow-x: auto }`), because the count is 0–12 and unknown until the
 * answer lands — a grid would reflow every card in the list by a different
 * amount. A row that scrolls is the same height whether it holds two
 * photographs or twelve.
 *
 * `position: relative` because it is an `overflow != visible` container: any
 * absolutely-positioned descendant whose ancestors are all static resolves
 * against the INITIAL containing block and extends the DOCUMENT's scrollable
 * overflow instead of this box's (the sr-span overflow bug, playbook §5).
 * Nothing here would escape today; the rule is unconditional so nothing added
 * later can.
 *
 * IT BREAKS THE 520px KEY MEASURE ON PURPOSE. The lines above it are a profile
 * and read best narrow; a photo strip reads best wide, and stopping it at 520
 * would waste a third of the card on a phone and look like a mistake on a
 * desktop.
 */
/*
 * ═══ W42 — THE STRIP GETS A CONTROL A MOUSE CAN USE (Ryan, 2026-09-02) ═══
 *
 * "NO WAY TO SCROLL THE PHOTOS ON DESKTOP — they cut off at the card's right
 * edge."
 *
 * BOTH HALVES OF THAT ARE OUR OWN DOING AND BOTH ARE CORRECT: W32 hid the
 * scrollbar (an overlay bar fading in under the thumb mid-drag is chrome on top
 * of the content it is about) and W36 made the row deliberately wider than its
 * box. What neither supplied is a way for a POINTER to move it — a trackpad can
 * swipe and a keyboard can arrow, and a mouse can do neither.
 *
 * SO THIS BLOCK IS POSITIONED and the two discs hang at its inline edges. It is
 * the WRAPPER that carries them rather than the scroller, because a control
 * inside an `overflow-x` container scrolls away with the content it scrolls.
 */
.sf-inst-card__photos {
  margin-top: 24px;
  position: relative;
}

/*
 * ONE OF THE TWO EDGE DISCS.
 *
 * IT IS THE PRODUCT GALLERY'S OWN DISC (`.sf-pdp-stage__nav` in pdp.css),
 * declaration for declaration: 40px, `surface` ground, `border-control`,
 * `border-radius: 50%`, centred on the thing it acts on. That is this site's
 * existing shape for "an icon-only control that moves you", and reusing its
 * numbers is why this does not become a fourth button treatment nobody swept.
 *
 * NOT IN `btngate`'s ROSTER, for the same reason `.sf-pdp-stage__nav` is not:
 * that gate asserts `border-radius === --radius-control`, and a circle cannot
 * satisfy it. The exception is recorded in `tools/btngate.mjs`'s header, and the
 * height and treatment are asserted by `installer-widget.test.ts` and measured
 * live by `instmodal.mjs` instead of being taken on trust.
 *
 * DRAWN ONLY WHEN THERE IS SOMEWHERE TO GO. The component sets `hidden` on the
 * disc at each end of the travel, so a strip of three photographs on a wide card
 * shows neither and a strip mid-scroll shows both — an affordance that says
 * something true rather than one that is permanently on.
 *
 * AND ONLY WHERE A POINTER NEEDS IT: `@media (hover: none)` removes both, so a
 * touch screen keeps the native swipe it already had and loses two discs sitting
 * on top of the photographs. Ryan's "mobile untested but touch-scrolls" stays
 * exactly true.
 */
.sf-inst-card__strip-nav {
  align-items: center;
  appearance: none;
  background: var(--brand-color-surface, #ffffff);
  border: var(--border-control);
  border-radius: 50%;
  /* IT SITS OVER PHOTOGRAPHS, so it takes the card's own lift to separate it
   * from them — the same two-layer restraint W39 gave the card. */
  box-shadow:
    0 1px 2px rgb(23 24 26 / 6%),
    0 6px 18px rgb(23 24 26 / 8%);
  color: var(--brand-color-ink, #17181a);
  cursor: pointer;
  display: flex;
  height: var(--control-height-sm, 40px);
  justify-content: center;
  padding: 0;
  position: absolute;
  /* THE MIDDLE OF THE TILES, NOT OF THE BOX. The strip carries 8px of bottom
   * padding (W32's old scrollbar gutter, kept as breathing room), so a bare 50%
   * would hang both discs four pixels low against the photographs they act on. */
  top: calc((100% - 8px) / 2);
  transform: translateY(-50%);
  width: var(--control-height-sm, 40px);
  z-index: 1;
}

/*
 * FLUSH WITH THE CARD'S EDGE, which is where the photographs are cut off and
 * therefore where a reader looks for the way to see more. The strip is
 * full-bleed (W36), so the disc's own inline edge is the card's.
 */
/*
 * `display: flex` BEATS THE UA'S `[hidden]`, and that is a defect this pass
 * shipped for one screenshot: the component sets `hidden` on the disc at each
 * end of the travel, the UA sheet answers `[hidden] { display: none }` at
 * specificity 0-1-0, and the rule above answers `display: flex` at 0-1-0 from a
 * later sheet — so BOTH discs were drawn over a strip with nothing to scroll.
 *
 * The platform's own advice is exactly this rule; it is written here rather than
 * globally because this sheet is the one that broke the attribute.
 */
.sf-inst-card__strip-nav[hidden] {
  display: none;
}

.sf-inst-card__strip-nav--prev {
  inset-inline-start: 0;
}

.sf-inst-card__strip-nav--next {
  inset-inline-end: 0;
}

/*
 * TOUCH SCROLLS ITSELF. Two 40px discs sitting on the first and last photograph
 * of a strip a finger can already flick is chrome in the way of the content, and
 * `hover: none` is the honest test for "this pointer cannot hover".
 */
@media (hover: none) {
  .sf-inst-card__strip-nav {
    display: none;
  }
}

/*
 * W32 — THE SCROLLBAR GOES, THE SCROLLING STAYS (Ryan, 2026-09-02): "the
 * horizontal scrollbar appears while scrubbing the thumb strip."
 *
 * A row of 96px photographs is its own affordance — the strip is visibly cut off
 * at the card's edge — and an overlay scrollbar that fades in under the thumb
 * mid-drag is chrome appearing on top of the content it is about. Legacy's own
 * strip hides it the same way (`css/installers.css`: `.card_photos::-webkit-
 * scrollbar { display: none }`).
 *
 * NOTHING ABOUT REACHABILITY CHANGES, and that is the whole care here: the
 * element keeps `overflow-x: auto` and its `tabindex="0"`, so it is still a
 * scroll container a keyboard can focus and arrow through, still swipeable, and
 * still announced. `scrollbar-width: none` is the standard property (Firefox and
 * Chromium ≥121); the `::-webkit-scrollbar` rule is what the older WebKit and
 * iOS Safari read. BOTH are needed — neither covers every engine this site is
 * opened in — and neither removes the scrolling, only its drawn bar.
 *
 * THE 8px OF BOTTOM PADDING STAYS. It was the gutter the bar sat in, and it is
 * now the strip's own breathing room against the actions row under it; taking it
 * away would shift the card's whole tail by 8px for no reason anybody asked for.
 */
.sf-inst-card__strip {
  display: flex;
  gap: 10px;
  list-style: none;
  margin: 0;
  overflow-x: auto;
  /*
   * ═══ W41 — THE FIRST PHOTOGRAPH GETS THE SAME BUFFER AS THE GAP ═══
   *
   * Ryan: "the FIRST photo sits hard against the card's left edge — give it the
   * same buffer as the inter-photo gap (and the symmetric right edge on the
   * last)." It did: W36 made the strip full-bleed, and full bleed with no
   * padding means the leading tile's edge IS the card's edge.
   *
   * 10px, WHICH IS THE `gap` ABOVE — the same number, so the space before the
   * first photograph and the space between any two are one measurement rather
   * than two that happen to agree today.
   *
   * ON THE CONTAINER, NOT ON THE FIRST CHILD, and this is the half that had to
   * survive W36's mechanics: the leading tile's `margin-inline-start` is `auto`
   * (that is what centres a short row), and an `auto` margin resolves to ZERO
   * the moment the row overflows — so a buffer written as a margin would exist
   * in exactly the arm that does not need it and vanish in the arm that does.
   * Padding on the scroll container is honoured in BOTH: it adds to a centred
   * row's slack, and it is the scroll ORIGIN when the row overflows.
   *
   * THE TRAILING SIDE IS THE SAME DECLARATION, with one honest caveat: a flex
   * scroll container's `padding-inline-end` is honoured at the scroll end by
   * Chromium and Gecko and has historically been dropped by WebKit. The fix
   * everyone reaches for is an `::after` spacer, and it is NOT available here —
   * a pseudo-element is a flex ITEM, so it would become `:last-child` and steal
   * the auto margin that centres the row. Correct in two engines and one gap
   * short in the third beats breaking the centring in all three.
   */
  padding: 0 10px 8px;
  position: relative;
  /*
   * THE SNAPPORT HAS TO KNOW ABOUT THE BUFFER, or W41 undoes itself the first
   * time anybody scrolls. MEASURED, and it is why this declaration exists:
   * `scroll-snap-align: start` aligns a tile's leading edge with the SNAPPORT's,
   * and the snapport defaults to the padding box — so the first tile, which
   * begins 10px in, snapped to `scrollLeft: 10` and the browser sat there at
   * rest. Two things went wrong at once: the buffer was scrolled out of sight,
   * and the strip reported itself 10px from its own origin, which lit the
   * "scroll back" disc over a strip already at its beginning.
   *
   * `scroll-padding-inline` insets the snapport by exactly the buffer, so the
   * first tile snaps at zero WITH its 10px still drawn.
   */
  scroll-padding-inline: 10px;
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  /* iOS momentum, and a thumb-sized scroll target on a touch screen. */
  -webkit-overflow-scrolling: touch;
}

.sf-inst-card__strip::-webkit-scrollbar {
  display: none;
}

.sf-inst-card__strip > li {
  flex: none;
  scroll-snap-align: start;
}

/*
 * ═══ W36 — CENTRED UNTIL IT CANNOT BE (Ryan, 2026-09-02) ═══
 *
 * "The photo strip is THE EXCEPTION to the content constraint: full bleed to the
 * card edge, CENTRED to start, grows from the centre out as photo count rises;
 * scrubber/scroll only when necessary — one photo sits dead centre, ten overflow
 * and scroll."
 *
 * THE BLEED IS ALREADY DONE by W35's container: the strip is the one child of
 * `__lines` that takes no `padding-inline`, so it runs corner to corner while
 * every row above it holds the 520 measure. That is the exception, spelled as
 * the absence of a rule rather than as an override.
 *
 * AUTO MARGINS, NOT `justify-content: center` — and this is the load-bearing
 * choice on the whole item. A centred flex row that OVERFLOWS its scroll
 * container pushes content past its own scroll origin: the overflow is split
 * across both ends and the leading photographs land at negative offsets that a
 * scroller cannot reach (the classic centred-overflow trap; `safe center` fixes
 * it, and is a 2022-era keyword this site does not otherwise depend on).
 *
 * Auto margins on the first and last items do the same centring by the oldest
 * rule in flexbox, and the spec settles the overflow case for us: when the free
 * space is negative, an `auto` margin resolves to ZERO. So the same two
 * declarations give both halves of Ryan's sentence —
 *
 *   one photo   →  first AND last child, both margins auto, dead centre;
 *   four photos →  free space split evenly, the row grows from the centre out;
 *   ten photos  →  free space negative, both margins zero, the row starts at
 *                  the card's left edge and scrolls.
 *
 * — with no query, no measurement and no JavaScript, in every engine that has
 * ever shipped flexbox.
 *
 * NOTHING ABOUT SCRUBBING OR KEYBOARD ACCESS CHANGES. The strip keeps its
 * `overflow-x: auto`, its hidden bar (W32), its `tabindex="0"` and its snap
 * points; what moves is where the row sits inside a box it does not fill.
 */
.sf-inst-card__strip > li:first-child {
  margin-inline-start: auto;
}

.sf-inst-card__strip > li:last-child {
  margin-inline-end: auto;
}

.sf-inst-card__photo-btn {
  background: none;
  border: 0;
  border-radius: var(--radius-inner);
  cursor: pointer;
  display: block;
  /* A BARE BUTTON AROUND AN IMAGE still gets the UA's padding and a line box; both
   * would put a grey rim inside the thumbnail's corners. */
  line-height: 0;
  padding: 0;
}

.sf-inst-card__photo-img {
  /*
   * 96 SQUARE, COVERED. The uploads are whatever aspect a phone camera produced
   * — portrait, landscape, the occasional square — and a strip of mixed aspects
   * is a ragged row. `cover` crops to a common tile; the VIEWER shows the whole
   * photograph, which is what the thumbnail is a door to.
   */
  block-size: 96px;
  border-radius: var(--radius-inner);
  display: block;
  inline-size: 96px;
  object-fit: cover;
  /* The ground under a photograph that has not loaded, so the row does not
   * flash white-on-white while twelve thumbnails arrive. */
  background: var(--brand-color-well, #f1f0ec);
}

.sf-inst-card__actions {
  /*
   * `line-height: 0` SO THE ROW IS THE BUTTON, and nothing else. An inline-flex
   * control in a line box is sat on a STRUT whose height comes from whatever
   * font-size this paragraph happened to inherit, plus the descender under it —
   * so the row measured two pixels short of the board, whose own container
   * inherits a 16px base where this one inherits 15. Zeroing the leading takes
   * the strut out of the arithmetic entirely: the row is 40 because the button
   * is 40, at any inherited size. The playbook's glyph trap, one layer up.
   */
  line-height: 0;
  margin-top: 20px;
  text-align: center;
}

.sf-inst-card__add-mark {
  /* FLEX, so the inline SVG cannot pick up its line box's descender and push the
   * label off the button's optical centre (playbook §5, the glyph trap). */
  color: var(--brand-color-accent, #c4530a);
  display: flex;
}

.sf-inst-card__add {
  /* THE COMPACT RUNG, BY NAME (BTN-UNIFY handoff). 12.5/600 inside 8px of
   * padding rendered 33 — a fifth height and a sixth type size, on a control
   * that is the same ghost as the modal's Close. The narrower 14px side padding
   * stays: padding is explicitly NOT part of the rung (globals.css), and this
   * one sits centred in a card rather than at the end of a row. */
  align-items: center;
  background: none;
  border: 1px solid var(--brand-color-ink, #17181a);
  border-radius: var(--radius-control);
  color: var(--brand-color-ink, #17181a);
  display: inline-flex;
  gap: 8px;
  font-family: inherit;
  font-size: var(--button-text-sm, 14.5px);
  font-weight: var(--button-weight-sm, 600);
  justify-content: center;
  min-height: var(--control-height-sm, 40px);
  padding: 0 14px;
  text-decoration: none;
}

/* ------------------------------------------- W38: the reviews, in the card -- */

/*
 * ═══ WHAT RYAN ASKED FOR (2026-09-02, two desktop walkthroughs) ═══
 *
 * W38: "'203 reviews' must SHOW the reviews… NOT a pop-up on a pop-up: swap the
 * card's content section IN PLACE with review cards… a lightweight version of
 * the product detail page review section. FOLLOW PDP PATTERNS EXACTLY, share
 * everything shareable — a review is a review, so let's format these the same."
 *
 * Then, walking the built thing (W43–W47 + the flag rulings): the top section is
 * fully STATIC across the swap and the back control moves BELOW the dark rule as
 * a circular arrow; the count and a fixed-order line join it in a sticky control
 * BAR; the pager dies for infinite scroll; the sort dropdown dies outright; and
 * the review photographs go back to whatever the product page really does.
 *
 * ═══ WHAT IS SHARED, AND WHAT IS TRANSPLANTED ═══
 *
 * THE MARKUP IS LITERALLY SHARED: `review-card.tsx` draws both this list and the
 * product page's, with the block prefix as a prop. Nothing about a review's
 * structure is written twice.
 *
 * THE DRAWING IS TRANSPLANTED — the declarations below are lifted from pdp.css's
 * `.sf-pdp-rev__item*` rules, value for value — and that is a deliberate stop
 * short of full sharing, with a reason. Moving those rules into a sheet BOTH
 * surfaces load would mean either shipping 270 KB of pdp.css into the installer
 * panel to reuse 200 lines of it, or lifting the product page's review rules out
 * of pdp.css into sections.css, which puts them in a second same-precedence
 * `<link>` on the flagship page and re-opens every recorded pdpgate ceiling for
 * a refactor nobody asked for.
 *
 * FLAGGED, NOT HIDDEN: if the two drawings are ever ruled to converge, the move
 * is a shared `sf-rev-` block in sections.css and a pdpgate re-record — one
 * pass, deliberately scheduled, rather than a side effect of this one.
 *
 * ═══ AND IT INHERITS W35's BLEED ═══
 *
 * The review view stands where the detail rows stood, so it takes their shape:
 * the block cancels the card's inset and each ROW pays it back as padding, so
 * every hairline between two reviews runs corner to corner while the words hold
 * the 520 key measure. One rule for both views is what stops the card looking
 * like two different cards depending on which one is open.
 */
.sf-inst-rev {
  margin-block: 18px 0;
  margin-inline: calc(-1 * var(--sf-inst-card-pad));
}

/*
 * EVERY ROW IN THE REVIEW VIEW TAKES THE MEASURE — W35's arithmetic, applied to
 * the second view of the same card. See `.sf-inst-card__lines`' block comment
 * for why `max()` reproduces the old rendering exactly below 520.
 *
 * THE PHOTO STRIPS ARE NOT IN THIS LIST, and that is not an omission: a review's
 * strip is INSIDE its row, so it is already inside the measure. W36's exception
 * is the CARD's gallery strip, which is a sibling of the rows rather than a
 * child of one.
 */
.sf-inst-rev__bar,
.sf-inst-rev__item,
.sf-inst-rev__more,
.sf-inst-rev__list > .sf-inst__status {
  padding-inline: max(
    var(--sf-inst-card-pad),
    calc((100% - var(--sf-inst-measure)) / 2)
  );
}

/*
 * ═══ W43 + W44 + W46 — THE CONTROL BAR ═══
 *
 * Ryan, walking W38's first build: the back control belongs BELOW the dark grey
 * bar, because "the top section — profile, name, stars, count, rank number — is
 * fully STATIC across the swap"; the count sits inline with the order line
 * (W44); and the whole row gets "a BACKGROUND as a control bar" with the reviews
 * scrolling UNDERNEATH it (W46).
 *
 * IT CARRIES THE DARK RULE ITSELF, and that is what makes W43 true rather than
 * approximately true: `border-top: 2px solid line-mid` is
 * `.sf-inst-card__line--first`'s own declaration, so the line that opens the
 * detail view's table opens the review view's list, in the same place, and the
 * back control is drawn INSIDE the bar — i.e. below that line. Nothing above it
 * belongs to this component at all.
 *
 * `position: sticky` RATHER THAN A SCROLL LISTENER: the browser owns the
 * arithmetic, there is no frame budget to blow, and it behaves correctly against
 * whichever ancestor is actually scrolling — the shell's scrolling content
 * container (`.sf-modal__content`, W70 — a real header row sits above it and is
 * never part of this scroll) in the pop-up instantiation, the document in the
 * other. `top: 0` because the bar's job is to stay reachable within that
 * scroller; a pinned-stack offset would be right for a page section and wrong
 * inside a pop-up.
 *
 * THE GROUND IS `paper` (#FBFBFA) ON `surface` (#FFFFFF), which is a
 * four-per-channel delta — invisible at the acceptance threshold and total at
 * threshold 2 (playbook §4, the tint trap). It is deliberately that quiet: the
 * bar has to read as chrome the reviews slide under, not as a second card.
 * The bottom hairline is what actually separates it from the first review, and
 * is why the tint alone does not have to carry the job.
 */
.sf-inst-rev__bar {
  align-items: center;
  background: var(--brand-color-paper, #fbfbfa);
  border-block: 2px solid var(--brand-color-line-mid, #d8d6d0);
  border-bottom-width: 1px;
  border-bottom-color: var(--brand-color-line, #e8e7e3);
  display: flex;
  gap: 14px;
  padding-block: 10px;
  position: sticky;
  top: 0;
  /* ABOVE THE ROWS THAT SLIDE UNDER IT. The reviews draw hairlines and
   * photographs; without a stacking order the bar's own ground would be painted
   * under a tile that happens to come later in the document. */
  z-index: 2;
}

/*
 * ═══ W59 — THE ESCAPE HATCH IS A REGULAR BUTTON AGAIN ═══
 *
 * W43 (2026-09-02) made this an arrow-only 40px DISC, on Ryan's "the labelled
 * pill is taking up too much visual space". He walked the result the same day
 * and reversed the shape: "a REGULAR (smaller) button — the arrow and the word
 * Back."
 *
 * WHAT THE ROUND TRIP SETTLED, worth writing down because it is the useful part:
 * the pill was too big because of its RUNG-WIDE padding, not because it had
 * words. A disc fixed the size and cost the meaning — a bare arrow at the head
 * of a review list reads as chrome rather than as the way out. This is the
 * middle: the compact rung's height and type, the card's own narrower 12px side
 * padding, and the word.
 *
 * IT IS BACK IN `btngate`'s WORLD, too. The disc was legitimately excepted (that
 * gate asserts `border-radius === --radius-control`, which a circle cannot
 * satisfy); a ghost with the control radius is an ordinary member, and the
 * exception note in `tools/btngate.mjs` now covers only the strip's discs.
 */
.sf-inst-rev__back {
  align-items: center;
  appearance: none;
  background: var(--brand-color-surface, #ffffff);
  border: var(--border-control);
  border-radius: var(--radius-control);
  color: var(--brand-color-ink, #17181a);
  cursor: pointer;
  display: inline-flex;
  flex: none;
  font-family: inherit;
  font-size: var(--button-text-sm, 14.5px);
  font-weight: var(--button-weight-sm, 600);
  gap: 7px;
  justify-content: center;
  min-height: var(--control-height-sm, 40px);
  /* The card's own narrower side padding, as `.sf-inst-card__add` takes: padding
   * is explicitly not part of the rung, and this control sits at the start of a
   * bar rather than at the end of a row. */
  padding: 0 12px;
}

.sf-inst-rev__back-mark {
  /* FLEX, so the inline SVG cannot pick up its line box's descender and push the
   * word off the button's optical centre (playbook §5, the glyph trap). */
  display: flex;
}

/*
 * `.sf-inst-rev__bar-count` IS RETIRED BY NAME (Ryan's W48, 2026-09-02) — W44's
 * count, one walkthrough later. "The count is on the link they pressed", and it
 * is: the card's own "203 Reviews" control opens this view and stays drawn above
 * it, so the bar was repeating a number two inches from where it already was.
 * The order line takes the `margin-inline-start: auto` that used to push the
 * pair, so the bar is still a disc at one end and a fact at the other.
 */

/*
 * FLAG RULING b+c — THE ORDER IS A STATEMENT, NOT A CONTROL.
 *
 * Ryan "really doesn't like two different drop downs on the site". The panel's
 * `<select>` is retired by name along with `.sf-inst-rev__sort-label`, and what
 * stands here is text: the list is newest-first, fixed, and it says so — because
 * a reader looking at 624 reviews still has to know whether the top one is the
 * newest or the best.
 *
 * MUTED, because it is a fact about the list rather than a thing to press. Had
 * it kept the ink of a control it would read as one, which is the failure mode
 * of drawn controls this house has a rule about.
 */
.sf-inst-rev__bar-sort {
  color: var(--brand-color-muted, #6c6a62);
  font-size: 13.5px;
  /* `auto` HOLDS THE FAR END of the bar, which W44's count used to do. */
  margin: 0 0 0 auto;
}

/*
 * `position: relative` because the list hosts the rows' photo strips: every
 * `overflow != visible` container that can host an absolutely-positioned
 * descendant gets one, unconditionally, so the sr-span overflow trap (playbook
 * §5) cannot be reintroduced by a later addition.
 */
.sf-inst-rev__list {
  position: relative;
}

/* ─── one review, transplanted from pdp.css's `.sf-pdp-rev__item*` ────────── */

.sf-inst-rev__item {
  border-top: 1px solid var(--brand-color-line, #e8e7e3);
  display: flex;
  gap: 20px;
  padding-block: 22px;
}

/*
 * THE FIRST REVIEW DRAWS NO RULE OF ITS OWN. The control bar above it already
 * closes with a hairline, and two lines a pixel apart is the seam a sticky bar
 * makes visible the moment it lifts off the first row.
 */
.sf-inst-rev__item:first-child {
  border-top: 0;
}

.sf-inst-rev__item-main {
  flex: 1;
  min-width: 0;
}

/*
 * ═══ W51 — THE STARS TAKE THEIR OWN LINE (Ryan, 2026-09-02) ═══
 *
 * "Long review titles wrap ugly next to the stars — stars on their own line,
 * line break, title below."
 *
 * WHY IT READS BADLY AND THE PRODUCT PAGE'S DOES NOT: the two are the same row
 * at very different measures. The PDP gives a review 565px of text column; this
 * panel gives it 380 (the 520 key measure less the 120 photo column and its
 * gap). Five stars and a 12px gap take 95 of that, so a real title — "A clean
 * install, start to finish" — wraps to a second line that starts under the STARS
 * rather than under itself, which is the ragged shape Ryan is describing.
 *
 * A COLUMN, NOT A `flex-wrap`. Wrapping would put a SHORT title beside the stars
 * and a long one under them — the same row rendering two different layouts
 * depending on the words in it, which is worse than either.
 *
 * 6px, NOT THE ROW'S 12: that gap separated two things reading left to right;
 * this one separates a rating from its own heading, and the pair should read as
 * one block.
 *
 * THE PRODUCT PAGE IS UNTOUCHED — this is the `sf-inst-rev` block, and pdp.css
 * draws `.sf-pdp-rev__item-head` with its own centred row at its own measure.
 */
.sf-inst-rev__item-head {
  align-items: flex-start;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/*
 * THE STARS ARE THE CARD'S OWN GLYPHS, not the product page's `★` text run:
 * this widget draws `ReviewStar` (an SVG on `STAR_PATH`) everywhere it draws a
 * rating, and a review row inside an installer card wearing a different star
 * from the rating four rows above it would be the visible seam.
 */
.sf-inst-rev__stars {
  color: var(--brand-color-accent, #c4530a);
  display: inline-flex;
  flex: none;
  gap: 3px;
  line-height: 0;
}

.sf-inst-rev__item-title {
  /*
   * NO TRACKING. globals.css gives every h1/h2/h3 `letter-spacing: -0.015em`
   * and the review title is drawn with none — the same declaration, and the same
   * measured reason, as `.sf-pdp-rev__item-title`'s.
   */
  letter-spacing: normal;

  color: var(--brand-color-ink, #17181a);
  font-family: var(--brand-font-body, system-ui, sans-serif);
  font-size: 16px;
  font-weight: 700;
  line-height: normal;
  margin: 0;
  text-wrap: wrap;
}

.sf-inst-rev__item-meta {
  color: var(--brand-color-muted, #6c6a62);
  font-size: 13.5px;
  margin: 4px 0 0;
}

/*
 * `pre-line` IS THE ONE DECLARATION THE PRODUCT PAGE DOES NOT NEED.
 *
 * Its bodies are authored as RUNS, so their paragraphing is markup. These are
 * one legacy column, and the ERP turns the 13 rows that carry `<br>` into
 * NEWLINES rather than dropping them (`_plain_review_text`) — which is only
 * worth doing if something draws them. `pre-line` collapses runs of spaces the
 * way normal text does and keeps the line breaks, which is exactly the pair of
 * behaviours a pasted review wants.
 */
.sf-inst-rev__item-body {
  color: var(--brand-color-ink-soft, #44464a);
  font-size: 14.5px;
  line-height: 1.6;
  margin: 10px 0 0;
  white-space: pre-line;
}

/*
 * ═══ W47 — THE PHOTOGRAPHS, AS THE PRODUCT PAGE ACTUALLY DRAWS THEM ═══
 *
 * Ryan: "are you sure you followed what we're doing on the product detail page?"
 * — his recollection being photographs on their own line BELOW the review text.
 *
 * MEASURED IN THE BROWSER RATHER THAN RECALLED (2026-09-02, threshold-free CDP
 * read of the live PDP with `?reviews-photos=1`):
 *
 *   at 1440  `.sf-pdp-rev__item` is `flex-direction: row, gap: 20px`; the text
 *            column is 565 wide at x=294 and the strip is 120 wide at x=879 —
 *            a RIGHT-HAND COLUMN, beside the text, not below it.
 *   at 390   the same item is `flex-direction: column, gap: 12px`; the strip is
 *            360 wide (full measure) and sits BELOW the body.
 *
 * SO RYAN'S RECOLLECTION IS THE PDP's PHONE RUNG, and its desktop rung is a
 * column. That divergence is the PRODUCT PAGE's to rule on, and it is reported
 * rather than silently resolved here.
 *
 * WHAT THIS SHEET DOES IS CONFORM: 120px, at the PDP's own 767.98 cut, with the
 * PDP's own gap and tile size. The 200px column of the first build is RETIRED BY
 * NAME — it was a judgement call ("make room") that Ryan measured as "squishing
 * the text with a huge gap", and the instruction is to follow the product page
 * exactly rather than to improve on it.
 *
 * THE TILES ARE 120 SQUARE HERE TOO, where the card's gallery strip below is 96.
 * That is the one place the two strips in this card legitimately differ, and it
 * is the same reason: this one is the PDP's row and that one is this widget's.
 */
.sf-inst-rev__item-photos {
  display: flex;
  flex: none;
  gap: 8px;
  overflow-x: auto;
  /* A clipping box with no positioned ancestor is how an abs-positioned
   * descendant escapes into the DOCUMENT's overflow (playbook §5). */
  position: relative;
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  width: 120px;
}

.sf-inst-rev__item-photos::-webkit-scrollbar {
  display: none;
}

.sf-inst-rev__item-photo {
  align-items: center;
  background: var(--brand-color-well, #f1f0ec);
  border: 0;
  border-radius: var(--radius-inner);
  cursor: pointer;
  display: flex;
  flex: none;
  height: 120px;
  justify-content: center;
  padding: 0;
  /* THE PHOTOGRAPH'S CONTAINING BLOCK. The `<img>` below is absolutely
   * positioned so the well stays underneath it unconditionally, and a strip
   * whose files have not loaded draws the ground rather than white-on-white. */
  position: relative;
  scroll-snap-align: start;
  width: 120px;
}

.sf-inst-rev__item-photo-img {
  border-radius: inherit;
  display: block;
  height: 100%;
  inset: 0;
  object-fit: cover;
  position: absolute;
  width: 100%;
}

.sf-inst-rev__well-label {
  color: var(--brand-color-muted, #6c6a62);
  font-family: var(--brand-font-mono, monospace);
  /* The placeholder-caption exemption (tokens §2). */
  font-size: 9px;
  /* THE LABEL IS THE FALLBACK, and it must not push the tile open when a long
   * one lands in the box — it is under a photograph in every case that has one,
   * and legible on the ground in the case that does not. */
  overflow: hidden;
  padding: 0 4px;
  text-align: center;
}

/*
 * THE STATUS LINES SIT WHERE THE FIRST REVIEW WOULD, so a panel that is
 * loading, empty or failed is the same shape as one that is full — nothing
 * jumps when the answer lands.
 */
.sf-inst-rev__list > .sf-inst__status {
  margin: 0;
  padding-block: 22px;
}

/*
 * ═══ W45 — THE SENTINEL, WHICH IS ALSO THE FALLBACK ═══
 *
 * The pager is retired by name (`.sf-inst-rev__list > .sf-sec-pager` goes with
 * it) and the panel scrolls infinitely: a batch of fifty, appended, with an
 * `IntersectionObserver` asking for the next one 400px before the reader
 * arrives. `.sf-inst-rev__total` retires too — the count moved into the control
 * bar (W44), where it is visible from anywhere in the list rather than only at
 * the bottom.
 *
 * IT IS A REAL BUTTON AND IT IS DRAWN, quietly, because that is the whole
 * accessibility floor under infinite scroll: an observer fires on a scroll a
 * keyboard user may never make. It is almost never pressed — scrolling reaches
 * it first — which is exactly why it is quiet rather than a call to action.
 *
 * FULL WIDTH, SO THE OBSERVER SEES IT. A shrink-wrapped control at the end of a
 * list is a small target for a root margin to intersect; a full-measure row
 * cannot be scrolled past without crossing it.
 */
.sf-inst-rev__more {
  align-items: center;
  background: none;
  border: 0;
  border-top: 1px solid var(--brand-color-line, #e8e7e3);
  color: var(--brand-color-muted, #6c6a62);
  cursor: pointer;
  display: flex;
  font-family: inherit;
  font-size: 13.5px;
  gap: 8px;
  justify-content: center;
  /* THE COMPACT RUNG'S HEIGHT, so a row of chrome in a list of content is the
   * same 40 every other control on this card is. It is not in `btngate`'s
   * roster: it is a quiet link in a button's clothing (no border, no ground),
   * which is the treatment `.sf-pdp-quiet` wears and the gate does not sweep. */
  min-height: var(--control-height-sm, 40px);
  width: 100%;
}

.sf-inst-rev__more[disabled] {
  cursor: default;
}

/* ------------------------------- W49 + W50: the box, and the carousel -- */

/*
 * W49 (DESKTOP) — A BOUNDED BOX, NOT THE WHOLE POP-UP.
 *
 * Ryan: "once you get down far enough there is no way to get back — you are
 * stranded." Reading twenty reviews used to scroll the pop-up itself, carrying
 * the card's identity, the control bar and every other installer out of reach.
 *
 * THE HEIGHT IS NOT DECLARED HERE. It is measured — the distance between the
 * current installer's profile photo, nudged to the top of the view, and the top
 * of the NEXT installer's — and written as an inline `max-height` by
 * `installer-reviews.tsx`. A number in this sheet would be a guess about a
 * viewport; the component's is arithmetic about two things already on screen.
 *
 * WHAT THIS SHEET OWNS is what happens once there is a height: the box scrolls,
 * its bar does not (the bar is the box's SIBLING, so it cannot scroll away), and
 * the scrollbar is left DRAWN. That last is deliberately the opposite of W32's
 * ruling on the photo strip: a strip of photographs is visibly cut off and needs
 * no bar, but a wall of text with a hidden bar is a wall of text nobody knows is
 * scrollable — and being lost in it was the complaint.
 */
.sf-inst-rev__list {
  overflow-y: auto;
  /* `overscroll-behavior` KEEPS THE POP-UP STILL when the box hits its end.
   * Without it the scroll chains to the surrounding scroller — the shell's
   * `.sf-modal__content` in the pop-up instantiation (W70), the document on the
   * standalone page — and carries the reader away from the reviews they were
   * reading, which is the stranding this box exists to stop. */
  overscroll-behavior-y: contain;
}

/*
 * ═══ W50 + W60 + W61 + W62 (PHONE) — ONE REVIEW AT A TIME, IN A FIXED BOX ═══
 *
 * Ryan built this up over two walkthroughs and the four rulings are one design:
 *
 *   W50  one review at a time, swiped, with the reader able to tell that more
 *        exist and that swiping is the gesture.
 *   W60  every card must land in the SAME place. The first build snapped to
 *        `center`, so the first card sat flush at 0 and every later one sat 6%
 *        in — "too far right, and it gets rid of the hint".
 *   W61  every card was the height of the LONGEST review: acres of white space
 *        under the short ones, photographs below a fold, and a card the finger
 *        could not scroll. The height must be the same measured box the desktop
 *        gets (this installer's photo topped out, the next installer's peeking)
 *        and a long review scrolls INSIDE it.
 *   W62  and within a card, "the photo is more important than the text, so that
 *        should always be shown".
 *
 * ─── W60: `start`, NOT `center` ─────────────────────────────────────────────
 *
 * `scroll-snap-align: center` cannot put every card in the same place, and that
 * is arithmetic rather than tuning: a centred card sits half the leftover
 * measure from each edge, but the FIRST card cannot scroll left of the origin,
 * so it lands flush while every other lands inset. Two different x's for the
 * same thing, which is exactly what Ryan saw.
 *
 * `start` plus a `scroll-padding-inline` equal to the list's own inset gives one
 * answer for all of them: every card's leading edge snaps to the same x, the
 * first one included, and what is left over on the right is the peek — by
 * construction, on every card, rather than as a leftover of centring.
 *
 * ─── W61 + W62: THE CARD IS A FIXED BOX WITH ONE SCROLLER IN IT ─────────────
 *
 * The list takes the measured height (set inline by `installer-reviews.tsx`) and
 * every card fills it, so a two-line review and a two-hundred-word one are the
 * same size and the carousel stops jumping. Inside the card:
 *
 *     .sf-inst-rev__item          column, exactly the box's height
 *       .sf-inst-rev__item-main   flex: 1, THE SCROLLER  (the text)
 *       .sf-inst-rev__item-photos flex: none, PINNED     (the photographs)
 *
 * which is W62 written as a layout: the photographs cannot be scrolled away
 * because they are not in the thing that scrolls.
 *
 * ─── THE NESTED-SCROLL STORY, IN ONE PLACE ─────────────────────────────────
 *
 * FOUR scrollers can be under one finger here, and getting this wrong is silent,
 * so it is written down rather than discovered again:
 *
 *   the content      VERTICAL   (the pop-up's own scroll, `.sf-modal__content`
 *                                — `modal.css`; W70 retired the header band
 *                                that used to sit sticky INSIDE this box, so
 *                                "the panel" below now names a real header row
 *                                that is never part of what scrolls)
 *   the list         HORIZONTAL (this carousel)
 *   a card's text    VERTICAL   (W61's internal scroll)
 *   the photo strip  HORIZONTAL (inside the card, when a review has several)
 *
 * ═══ THE ROUTING TABLE (W63, Ryan live-tested — this is the second pass) ═══
 *
 *   FINGER STARTS ON     AXIS         WHAT MOVES
 *   ------------------   ----------   ---------------------------------------
 *   the review's text    horizontal   the carousel (next/previous review)
 *   the review's text    vertical     the text, IF it overflows; otherwise —
 *                                     and at the text's own end — the pop-up
 *   the photo strip      horizontal   the carousel   (the strip scrolls only
 *                                     when it has more photos than fit)
 *   the photo strip      vertical     the pop-up
 *   the gutter/chrome    vertical     the pop-up
 *
 * ═══ WHAT WENT WRONG THE FIRST TIME, TWICE ═══
 *
 * Batch 6 gave the list `touch-action: pan-x` and the text `pan-y`, reasoning
 * that each box should be told which axis it owns. That is not what the property
 * does, and Ryan found the result immediately: the review content was a DEAD
 * ZONE on both axes while the photographs still swiped and the gutter still
 * scrolled.
 *
 *   (1) `touch-action` IS INTERSECTED DOWN THE ANCESTOR CHAIN. It is a
 *       permission, not a routing instruction — `pan-y` does not mean "vertical
 *       here, horizontal bubbles", it means horizontal is CANCELLED. Intersected
 *       with the list's `pan-x` it left the empty set, so a gesture starting on
 *       the words was never recognised at all. The photographs kept working
 *       precisely because that strip declared nothing: `auto ∩ pan-x = pan-x`.
 *   (2) `overscroll-behavior-y: contain` BLOCKED THE CHAIN ON A BOX THAT DOES
 *       NOT SCROLL. The text region contains it, and most reviews are short — so
 *       most cards were a non-scrollable container quietly eating the gesture.
 *       And one element up, the DESKTOP rule's own `overscroll-behavior-y:
 *       contain` on the list (correct there, where the list IS the bounded box)
 *       was still in force here, where the list is a horizontal carousel that
 *       does not scroll vertically at all.
 *
 * ═══ WHAT REPLACES IT ═══
 *
 * PERMIT BOTH AXES EVERYWHERE INSIDE THE CAROUSEL, and let the browser do the
 * routing it already does well: it disambiguates on the initial direction and
 * sends each axis to the nearest ancestor that actually scrolls in it. The list
 * is the only x-scroller, so horizontal reaches the carousel from anywhere; the
 * text region is a y-scroller only while it overflows, so vertical reaches the
 * text when there is text to move and the pop-up when there is not.
 *
 * THE CAROUSEL DOES NOT EAT VERTICAL DRAGS — the batch-6 fear that produced the
 * `pan-x` — because it is `overflow-y: hidden` and therefore not a vertical
 * scroll container. Permission is not capture.
 *
 * ONE `contain` SURVIVES, on the axis the list really scrolls: swiping past the
 * last card must not pull the page behind the pop-up.
 */
@media (max-width: 767.98px) {
  .sf-inst-rev__list {
    display: flex;
    /*
     * W61: THE MEASURED BOX, AS A HEIGHT RATHER THAN A CEILING. `--sf-inst-rev-box`
     * is published by the component beside the inline `max-height` the desktop
     * uses; a carousel wants the fixed form, because every card being the same
     * size whatever its review says is the whole ruling. `auto` is the honest
     * fallback for a window too short to measure a box in — the cards then size
     * to their content, which is what they did before any of this.
     */
    height: var(--sf-inst-rev-box, auto);
    /* And the desktop's ceiling must not also clip the carousel. */
    max-height: none;
    /*
     * ═══ W64 + W66 — ONE INSET, AND IT IS THE LIST'S ═══
     *
     * Ryan: the review gutters are "doubled up" at about 30px where 15 is
     * wanted, top and bottom as well as the sides — and more text room means
     * less vertical scrolling, which is the point of the ruling rather than a
     * side effect.
     *
     * MEASURED, AND IT REALLY WAS TWO INSETS STACKED: the list pays 16px so
     * every card lands in the same place (W60), and each card was ALSO paying
     * `.sf-inst-rev__item`'s own `padding-inline` — the desktop's 520 key
     * measure rule, which on a phone collapses to the same 16. 16 + 16 = the 32
     * Ryan measured by eye as 30.
     *
     * THE LIST KEEPS IT AND THE CARD GIVES IT UP. It has to be that way round:
     * the inset is what the snapport is aligned to, so moving it into the cards
     * would put W60's uniform landing back at the mercy of each card's own
     * padding. One number, declared once, used by the padding, the snap padding
     * and the card-width arithmetic together.
     */
    gap: var(--sf-inst-rev-gutter);
    padding-inline: var(--sf-inst-rev-gutter);
    scroll-padding-inline: var(--sf-inst-rev-gutter);
    overflow-x: auto;
    overflow-y: hidden;
    /*
     * BOTH AXES ARE PERMITTED HERE — see the routing table above. `pan-x` alone
     * was W63's bug: `touch-action` is INTERSECTED down the ancestor chain, so a
     * carousel that permits only the x axis makes every descendant dead on y,
     * whatever the descendant declares.
     *
     * PERMITTING pan-y DOES NOT MAKE THE CAROUSEL EAT VERTICAL DRAGS, which is
     * the batch-6 fear this replaces: the list is `overflow-y: hidden`, so it is
     * not a vertical scroll container and the browser routes a vertical pan past
     * it to the nearest ancestor that is.
     */
    touch-action: pan-x pan-y;
    /* Swiping past the last card must not pull the page behind the pop-up. This
     * one stays — it is about the axis the list DOES scroll. */
    overscroll-behavior-x: contain;
    /*
     * AND THE OTHER AXIS IS EXPLICITLY RELEASED, which is the second half of
     * W63 and the one that survived the first fix.
     *
     * The DESKTOP rule above gives this element `overscroll-behavior-y: contain`
     * — correct there, where the list is the bounded review box and a flick that
     * reaches its end must not carry the pop-up away. That declaration is not in
     * a media query, so it was still in force here, where the list is a
     * HORIZONTAL carousel with `overflow-y: hidden`: an element containing a
     * chain on an axis it does not scroll, sitting between the card's text and
     * the pop-up. Measured, with no gesture driver involved at all — walking the
     * ancestors from the text region to the panel and asking which of them
     * contain the y chain returned exactly this one.
     *
     * So a vertical drag on a short review had nowhere to go: the text is not a
     * scroll container when it does not overflow, and the first ancestor that
     * IS one refused to pass the gesture on.
     */
    overscroll-behavior-y: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
  }

  .sf-inst-rev__list::-webkit-scrollbar {
    display: none;
  }

  .sf-inst-rev__item {
    border-top: 0;
    display: flex;
    flex-direction: column;
    /*
     * W60: ONE WIDTH, ONE LANDING PLACE. The card is the box less its two insets
     * less the peek, so its leading edge snaps to the inset every time and the
     * peek is what remains on the right — the same on the first card and the
     * last.
     */
    flex: 0 0 calc(100% - var(--sf-inst-rev-gutter) * 2 - var(--sf-inst-rev-peek));
    /*
     * W64: NO INSET OF ITS OWN. The desktop's `padding-inline` — the 520 key
     * measure — is what was doubling the gutter here; the list pays the whole
     * inset on this rung, so the card pays none and the words get the room.
     */
    padding-inline: 0;
    /*
     * ═══ `min-width: 0`, AND IT IS NOT DECORATION ═══
     *
     * MEASURED: without it the review that carries photographs came out 280px
     * wide where every other card was 220 — so that one card landed 60px further
     * along than the rest and the peek behind it collapsed from 66px to 6. Which
     * is Ryan's W60 complaint arriving by a second route, after `scroll-snap-
     * align` had been fixed.
     *
     * THE CAUSE IS FLEXBOX'S AUTOMATIC MINIMUM SIZE. A flex item's `min-width`
     * defaults to `auto`, which resolves to its CONTENT's minimum — and this
     * card's minimum is its photo strip: two 120px tiles and their 8px gap
     * inside the card's 16px insets is exactly 280. `flex-basis` cannot win
     * against it, so the basis was silently ignored on precisely the cards that
     * have something to show.
     *
     * `0` LETS THE BASIS DECIDE, and nothing is lost: the strip is a scroller of
     * its own (`overflow-x: auto`), so it scrolls inside the card instead of
     * widening it — which is what a strip is for.
     */
    min-width: 0;
    /* W61: the measured box, not the longest review. `100%` of a list that has
     * been given a height; `auto` when it has not, which is the honest fallback
     * for a window too short to measure a box in. */
    height: 100%;
    gap: 12px;
    /* W66: the same single inset vertically — 15 top and bottom rather than the
     * 14 that stacked with the list's own to read as 30. */
    padding-block: var(--sf-inst-rev-gutter);
    scroll-snap-align: start;
    scroll-snap-stop: always;
  }

  /* W62: THE TEXT IS THE SCROLLER AND THE PHOTOGRAPHS ARE NOT IN IT. */
  .sf-inst-rev__item-main {
    flex: 1;
    min-height: 0;
    /*
     * NO `touch-action` AND NO `overscroll-behavior` — both were W63's bug, and
     * both are removed rather than re-tuned:
     *
     *   `touch-action: pan-y` did not mean "vertical here, horizontal bubbles".
     *     It CANCELS horizontal, so a sideways drag on the words never became a
     *     gesture at all and the carousel never heard it. Intersected with the
     *     list's old `pan-x` it left the empty set, which is why Ryan found the
     *     content dead on both axes at once.
     *   `overscroll-behavior-y: contain` blocks chaining even when this box does
     *     NOT overflow — and most reviews are short, so most cards were a
     *     non-scrollable container quietly eating the gesture that should have
     *     reached the pop-up.
     *
     * The default for both is exactly the behaviour Ryan described: a long
     * review scrolls its own text and then chains to the pop-up at the edge; a
     * short one has nothing to scroll and chains immediately.
     */
    overflow-y: auto;
    /* A scroller a keyboard can reach — the tab stop is declared because this
     * one has no focusable children of its own. */
    scrollbar-width: none;
  }

  .sf-inst-rev__item-main::-webkit-scrollbar {
    display: none;
  }

  /*
   * ═══ W65b — THE SCROLL HINT IS A MASK, NOT AN OVERLAY (Ryan's live report) ═══
   *
   * The W65 overlay ("still see a hard cut off on the text") was two linear
   * problems compounding, both confirmed against how production fade edges are
   * actually built (Chrome's own soft-edge-content-fade guidance; the "easing
   * gradients" write-ups on the CSS-Tricks/Larsen non-linear-gradient bug):
   *
   *   (1) TWO STOPS, LINEAR ALPHA, OVER WHITE READS AS A CUT. Perceived
   *       lightness is not linear in alpha — a straight 0%→100% ramp LOOKS
   *       nearly solid for most of its own height and only visibly moves in the
   *       last stretch, so a short two-stop ramp reads as "fine, then a wall".
   *       This is the documented bug the whole "easing gradients" genre exists
   *       to fix, not a tuning miss.
   *   (2) 26px IS UNDER ONE LINE. At this card's 14.5px/1.6 body type
   *       (23.2px line height) a 26px ramp cannot carry a full line THROUGH the
   *       fade — the eye reads the sudden appearance of a whole fresh line at
   *       the ramp's top edge as its own little cut.
   *
   * SWITCHED FROM A WHITE OVERLAY TO A MASK ON THE SCROLLER ITSELF, and that is
   * not a style preference — it removes the one part of the overlay design that
   * REQUIRED "fully opaque at the exact clip edge or a mid-glyph cut shows
   * through": an overlay hides the clipped line by PAINTING OVER it with the
   * card's own background color, so it must track that color and must reach
   * true 100% opacity by the box's bottom pixel or the paint itself has a seam.
   * A mask does not paint anything — it erases the text's own pixels — so
   * "hidden" is transparent (alpha 0) rather than "some solid color", and there
   * is no background to match should this card ever sit on a tinted surface
   * (the photo-strip placement already proved the overlay's "match the
   * surface" premise was fragile: it happens to work on white today). Support
   * is not a concern at the rung this ships on: `mask-image` is Baseline since
   * end of 2023 on every evergreen engine, and `-webkit-mask-image` — carried
   * here for older WebKit — has existed for over a decade, so both prefixes
   * together cover the full phone install base this section is scoped to.
   *
   * THE MASK IS SIZED IN PIXELS OFF THE BOX'S OWN BOTTOM (`calc(100% - Npx)`
   * stops), not a percentage of the box height: the box's height is W61's
   * dynamic measurement (whatever flex space the panel leaves), and a
   * percentage ramp would balloon on a tall box and vanish on a short one.
   * 48px is ~2.1 of this card's own line-heights — inside Ryan's "a full line
   * should pass through it" ask — and holds regardless of how tall the
   * scroller ends up on a given phone/installer.
   *
   * THE CURVE ITSELF is the widely-cited 16-stop "ease-in-quad" easing-gradient
   * table (Andreas Larsen's easing-gradients tool; reproduced across the
   * production fade-edge write-ups) rather than hand-picked stops: alpha stays
   * ABOVE 0.8 for the first ~30% of the ramp (the line passing through it reads
   * as still-there, not already dissolving) and only visibly gives way in the
   * back half, landing at EXACT 0 on the box's last pixel — which is what
   * satisfies "opaque enough to hide a mid-glyph cut" for a mask: alpha 0 at
   * 100% erases the clipped sliver completely rather than leaving it at a
   * residual channel value.
   *
   * TOGGLED THE SAME WAY THE OVERLAY WAS: `data-more`, set by the panel's one
   * delegated scroll listener + ResizeObserver (unchanged). No mask at all
   * when a review fits or has been scrolled to its end — swapping
   * `mask-image` for `none` un-erases the last line exactly the way `display:
   * none` used to hide the overlay, so a card that has nothing more to show
   * never dims its own last words. `mask-image` does not touch hit-testing —
   * the drag that scrolls the text underneath it (W63) is untouched, and there
   * is no `pointer-events` rule to carry over because a mask has no box of its
   * own to intercept anything with.
   *
   * PLACEMENT FOR FREE: this used to be a sibling element pulled back over the
   * text with a negative margin specifically so it could sit at the text's own
   * next edge (card bottom without photos, strip top with them). A mask on the
   * scroller's OWN bottom edge is that same edge by construction — there is no
   * separate element left to place, so W65's "one placement rule, not two"
   * still holds and is now simply true rather than engineered.
   */
  .sf-inst-rev__item[data-more="true"] .sf-inst-rev__item-main {
    -webkit-mask-image: linear-gradient(
      to bottom,
      rgb(0 0 0 / 100%) calc(100% - 48px),
      rgb(0 0 0 / 98.7%) calc(100% - 44.11px),
      rgb(0 0 0 / 95.1%) calc(100% - 40.56px),
      rgb(0 0 0 / 89.6%) calc(100% - 37.2px),
      rgb(0 0 0 / 82.5%) calc(100% - 34.08px),
      rgb(0 0 0 / 74.1%) calc(100% - 31.06px),
      rgb(0 0 0 / 64.8%) calc(100% - 28.22px),
      rgb(0 0 0 / 55%) calc(100% - 25.39px),
      rgb(0 0 0 / 45%) calc(100% - 22.61px),
      rgb(0 0 0 / 35.2%) calc(100% - 19.78px),
      rgb(0 0 0 / 25.9%) calc(100% - 16.94px),
      rgb(0 0 0 / 17.5%) calc(100% - 13.97px),
      rgb(0 0 0 / 10.4%) calc(100% - 10.8px),
      rgb(0 0 0 / 4.9%) calc(100% - 7.44px),
      rgb(0 0 0 / 1.3%) calc(100% - 3.89px),
      rgb(0 0 0 / 0%) 100%
    );
    mask-image: linear-gradient(
      to bottom,
      rgb(0 0 0 / 100%) calc(100% - 48px),
      rgb(0 0 0 / 98.7%) calc(100% - 44.11px),
      rgb(0 0 0 / 95.1%) calc(100% - 40.56px),
      rgb(0 0 0 / 89.6%) calc(100% - 37.2px),
      rgb(0 0 0 / 82.5%) calc(100% - 34.08px),
      rgb(0 0 0 / 74.1%) calc(100% - 31.06px),
      rgb(0 0 0 / 64.8%) calc(100% - 28.22px),
      rgb(0 0 0 / 55%) calc(100% - 25.39px),
      rgb(0 0 0 / 45%) calc(100% - 22.61px),
      rgb(0 0 0 / 35.2%) calc(100% - 19.78px),
      rgb(0 0 0 / 25.9%) calc(100% - 16.94px),
      rgb(0 0 0 / 17.5%) calc(100% - 13.97px),
      rgb(0 0 0 / 10.4%) calc(100% - 10.8px),
      rgb(0 0 0 / 4.9%) calc(100% - 7.44px),
      rgb(0 0 0 / 1.3%) calc(100% - 3.89px),
      rgb(0 0 0 / 0%) 100%
    );
  }

  /* PINNED. `flex: none` in a column that is exactly the box's height means the
   * strip keeps its own row whatever the text does — which is the whole of W62. */
  .sf-inst-rev__item-photos {
    flex: none;
    /* The same automatic-minimum trap one level down: this is itself a flex item
     * of the card's column, and its tiles must not set the card's width. */
    min-width: 0;
    width: 100%;
  }

  /* THE SENTINEL IS A CARD IN THE ROW on this rung, so infinite scroll still
   * works: the observer sees it when the reader swipes to the end, exactly as it
   * sees it at the bottom of the desktop column. */
  .sf-inst-rev__more {
    border-top: 0;
    flex: 0 0 calc(100% - var(--sf-inst-rev-gutter) * 2 - var(--sf-inst-rev-peek));
    scroll-snap-align: start;
  }

  /* A status line is the row's only child while it is drawn. */
  .sf-inst-rev__list > .sf-inst__status {
    flex: 0 0 100%;
  }
}

/*
 * THE WORDS THAT NAME THE GESTURE (W50). The peek says there is more; this says
 * how to reach it — once, quietly, above the row, and only where the gesture
 * exists. `aria-hidden` because a swipe is a POINTER affordance: a screen-reader
 * user reaches the next review by moving through the list, and telling them to
 * swipe would describe a gesture they are not making.
 */
.sf-inst-rev__swipe-hint {
  display: none;
}

@media (max-width: 767.98px) {
  .sf-inst-rev__swipe-hint {
    color: var(--brand-color-muted, #6c6a62);
    display: block;
    font-size: 12.5px;
    margin: 0;
    padding: 10px var(--sf-inst-card-pad) 0;
  }
}

/*
 * THE PHONE RUNG IS THE PRODUCT PAGE'S OWN CUT, transplanted with the rule.
 *
 * pdp.css: "The photo strip beside a review is a 120px column on a 390px screen:
 * it goes under the review rather than taking a third of its measure." MEASURED
 * on the live PDP at 390 (W47): `flex-direction: column`, `gap: 12px`, strip at
 * the full 360 measure, below the body. Both declarations are that reading.
 *
 * FULL MEASURE, AND STILL A STRIP: the tiles keep their 120px square, so a phone
 * shows two and a half and the third is the thing the reader swipes to — which
 * is what makes it legible as a strip rather than as a row that happens to fit.
 */
@media (max-width: 767.98px) {
  .sf-inst-rev__item {
    flex-direction: column;
    gap: 12px;
  }

  .sf-inst-rev__item-photos {
    width: 100%;
  }
}

/*
 * Below 560 the panel loses its side padding and the cards run edge to edge —
 * legacy does the same at 768 (`.ws_pro_card{border-radius:0}`), for the same
 * reason: 26px of padding either side of a 320px screen leaves no measure.
 */
/*
 * W11 / W19 STOOD HERE, AND W24 TOOK BACK ITS SECOND HALF (2026-09-02).
 *
 * W11/W19 (Ryan, 2026-09-01) was: "'View N Pros' = full-screen takeover → make
 * it a POP-UP like the delivery timeline (never feel taken away); on mobile 100%
 * WIDTH, not full screen." What that pass removed — a `min-height: 100%` that
 * made the panel read as a new page — is STILL GONE and that ruling stands.
 *
 * What it ALSO did was go past its own reference: `padding: 20px 0` on the scrim
 * and `border-radius: 0` on the panel put the panel at x=0, w=390 on a 390
 * phone, with no side edge to read at all, while the delivery timeline it was
 * told to be like keeps a 15px inset and its corners. Ryan, walking the two side
 * by side (W24): "not even close to being the same… border visible top/left/
 * right, X at top right."
 *
 * SO THE FRAME'S PHONE RUNG IS THE SHELL'S NOW, at the site's own 767.98 cut
 * rather than this modal's private 559.98 — see `modal.css`. What stays here is
 * the CONTENT rhythm, which is genuinely this widget's: 560 is where its price
 * table and its cards stop having a measure at 36px of inset, and that has
 * nothing to do with where the frame reflows.
 */
@media (max-width: 559.98px) {
  .sf-inst__body {
    padding-inline: 20px;
  }

  /*
   * A SECOND COPY OF THE W14 LOADING BLOCK STOOD HERE AND IS GONE (2026-09-02).
   *
   * `.sf-inst__status--busy`, `.sf-inst__spinner`, its `prefers-reduced-motion`
   * override and `@keyframes sf-inst-spin` were pasted into this media query,
   * byte-identical to the copies that live at their proper place further up this
   * sheet — and the paste landed in the middle of `.sf-inst__fine`'s own rule,
   * which is why that rule's indentation was two levels out from its siblings.
   *
   * IT CHANGED NOTHING AND THAT IS WHY IT SURVIVED. The rules are identical, so
   * below 560 they resolved to the same values twice, and the duplicated
   * `@keyframes` redefined an animation to exactly what it already was. Nesting
   * plain rules and an `@media` inside an `@media` is valid CSS, so the brace
   * count balanced and the sweep stayed green — an invisible defect that reads
   * as intent to the next person to edit this block.
   *
   * REMOVED, NOT MOVED: the originals are untouched at their own place above,
   * where they belong (they are not phone-only — a slow directory call spins at
   * every width).
   */
  .sf-inst__fine {
    margin-inline: 20px;
  }

  .sf-inst__head {
    padding-inline: 20px;
  }

  /*
   * THE PHONE RUNG SETS THE VARIABLE, NOT THE PADDING (W35, 2026-09-02).
   *
   * It used to say `padding-inline: 16px`, which is still exactly what this
   * does — the card's own `padding` shorthand reads this property. It has to be
   * the VARIABLE now because the rows below cancel the card's inset and pay it
   * back as their own padding, and a card that inset 16 while its rows paid back
   * 26 would draw every line ten pixels off centre on a phone.
   */
  .sf-inst-card {
    --sf-inst-card-pad: 16px;
  }

  /*
   * ═══ HEADROOM FOR THE STICKY BAND (W61, measured) ═══
   *
   * W61 asks for the current installer's profile photo to be scrolled to the top
   * of the pop-up. For every card but the FIRST that works, because there is
   * content above to scroll against. For the first there is not: measured, the
   * nudge asked for 89px and the panel had 44 — so it clamped at the top with 81
   * of the 100px circle still behind the header band.
   *
   * THE FIX IS ROOM, NOT ARITHMETIC. The band is 52px on this rung, so the list
   * carries the 62px lead it always had PLUS that — and the first card can then
   * be scrolled clear of the chrome exactly like every other one. The whitespace
   * only exists at the very top of the pop-up, which is where a sticky header
   * needs it anyway.
   *
   * IT IS NOT A TOKEN because the band's height is `.sf-inst__sticky-title`'s
   * own padding and type, both declared in this sheet a few rules above; a
   * custom property would be a second place to keep them in step.
   */
  .sf-inst-cards {
    padding-top: 114px;
  }
}
