/*
 * Pesos — one screen, read one-handed, in an aisle, in ten seconds.
 *
 * Palette "Andesita": cool mineral greys (Santiago light on concrete and
 * eucalyptus) with three earned accents, and nothing else. Pine green means
 * money saved, burnt ember means a deadline, lapis means a record. A colour
 * on this page is a claim, so a card with no claim to make has no colour on
 * it at all — which is what makes the two-day-old watchlist below look calm
 * rather than broken.
 *
 * Type: a system serif for names and headings, the platform grotesque for
 * every number. No webfont, no CDN, no build step — the pairing is built
 * from what the phone already has, so the page paints on the first frame
 * inside a supermarket's dead spot. Prices carry tabular figures everywhere,
 * so a column of them lines up on the decimal that Chilean pesos do not have.
 *
 * LIGHT IS THE DEFAULT, whatever the operating system says. The full light
 * palette is defined on bare :root and dark applies only under
 * [data-theme="dark"], which the header switch writes and a script in <head>
 * restores before the first paint. `prefers-color-scheme` deliberately
 * selects nothing: a phone left on "dark" all day would otherwise override a
 * choice the person made here, and a token defined ONLY inside that media
 * query is invisible to the switch — which is the exact bug that renders
 * grey ink on a grey card and makes the whole page unreadable.
 */

:root {
  color-scheme: light;

  --paper: #eceeec;
  --surface: #ffffff;
  --surface-2: #f5f7f6;
  --ink: #131917;
  --ink-2: #4d5754;
  --ink-3: #7b8582;
  --rule: #dadedc;
  --rule-soft: #e8ebe9;

  /* Savings. */
  --pine: #0f5f47;
  --pine-wash: #deeee7;
  /* Deadlines. */
  --ember: #9d420c;
  --ember-wash: #f9e5d7;
  /* Records and superlatives. */
  --lapis: #1d4e78;
  --lapis-wash: #dee9f2;

  --shadow: 0 1px 1px rgba(19, 25, 23, .04), 0 8px 20px -14px rgba(19, 25, 23, .30);

  --font-display: ui-serif, "Iowan Old Style", "Palatino Linotype", Palatino,
    Georgia, "Times New Roman", serif;
  --font-text: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;

  --radius: 14px;
  --tap: 44px;
}

/* The same palette that has always been here; only its trigger changed. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --paper: #0e1211;
  --surface: #171d1b;
  --surface-2: #1e2523;
  --ink: #e6ebe8;
  --ink-2: #a3aeaa;
  --ink-3: #7d8884;
  --rule: #2a322f;
  --rule-soft: #212825;

  --pine: #6ed3aa;
  --pine-wash: #0f302a;
  --ember: #f0a06a;
  --ember-wash: #38200f;
  --lapis: #8cc0ea;
  --lapis-wash: #12293c;

  --shadow: none;
}

* { box-sizing: border-box; }

/* The live filter hides whole cards and rows with the `hidden` attribute, and
 * every element it can land on has a display of its own further down this
 * file. Without this they would keep their place in the grid — filtered, and
 * still occupying the page. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-text);
  font-size: 16px;
  line-height: 1.45;
  /* Clears the fixed bottom bar, plus whatever the phone's chin needs. */
  padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
  -webkit-font-smoothing: antialiased;
}

/* Every number on this page is a price, a count or a date, and all three are
 * read down a column rather than along a line. */
.num, .price-hero, .price, td, th, time { font-variant-numeric: tabular-nums; }

a { color: inherit; }

.wrap {
  max-width: 30rem;
  margin: 0 auto;
  padding: 0 1rem;
}

/*
 * The two screens that are a LIST OF MANY THINGS rather than one thing to
 * read. 30rem is the right measure for a price chart or a settings form and
 * the wrong one for 79 products: above a phone it leaves two thirds of the
 * desk empty and turns the page into a ribbon. Everything else keeps the
 * narrow column on purpose.
 */
.wrap.wide { max-width: 66rem; }

/* ---- the theme switch ------------------------------------------------- */

.topbar {
  display: flex;
  justify-content: flex-end;
  padding-top: 0.5rem;
  /* Sits above the page title without pushing it down a whole row. */
  margin-bottom: -1.1rem;
}

/*
 * Hidden until the script that makes it work has run. A switch that is
 * painted but dead is worse than no switch: it teaches the reader that the
 * controls on this page are decorative.
 */
.theme-toggle {
  width: auto;
  min-height: var(--tap);
  padding: 0 0.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ink-3);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 999px;
  cursor: pointer;
}

.theme-toggle:hover { border-color: var(--rule); color: var(--ink-2); }

/* The label names the theme it switches TO, and it is set in CSS rather than
 * by the script so it is already right on the first frame. */
.theme-toggle .to-dark { display: inline; }
.theme-toggle .to-light { display: none; }
:root[data-theme="dark"] .theme-toggle .to-dark { display: none; }
:root[data-theme="dark"] .theme-toggle .to-light { display: inline; }

/* ---- page header ------------------------------------------------------ */

.page-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.35rem 0 0.9rem;
}

.page-head h1 {
  font-family: var(--font-display);
  font-size: 1.55rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  margin: 0;
}

/* A product name needs a smaller setting than a page title: chain feeds run
 * long, and at 1.55rem they wrap to three lines and push the price off screen. */
.page-head.product {
  padding-top: 0.35rem;
  padding-bottom: 0.7rem;
}

.page-head.product h1 {
  font-size: 1.22rem;
  line-height: 1.28;
}

.product-top {
  display: flex;
  gap: 0.9rem;
  align-items: flex-start;
}

.product-heading {
  flex: 1 1 auto;
  min-width: 0;
  /* Centres the name against the taller thumbnail beside it, rather than
   * pinning both to the top and leaving the name looking stranded above an
   * empty gap under a short one-line product name. */
  align-self: center;
}

.product-heading .card-brand { margin: 0 0 0.2rem; }
.product-heading h1 { margin: 0; }

.page-head .stamp {
  font-size: 0.75rem;
  color: var(--ink-3);
  letter-spacing: 0.02em;
  white-space: nowrap;
}

.section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  margin: 1.75rem 0 0.6rem;
  padding-top: 1.1rem;
  border-top: 1px solid var(--rule);
}

.section-head h2 {
  font-family: var(--font-display);
  font-size: 0.95rem;
  font-weight: 600;
  margin: 0;
  color: var(--ink-2);
}

.section-head .count { font-size: 0.78rem; color: var(--ink-3); }

/* ---- the live filter -------------------------------------------------- */

/*
 * Narrows what is already on the page, as somebody types. It sits ABOVE the
 * sort chips because it is the control people reach for first, and because
 * the two compose: the chips choose which products, this narrows them.
 */
.search { padding-bottom: 0.5rem; }

.search-input {
  width: 100%;
  min-height: var(--tap);
  padding: 0 1rem;
  font: inherit;
  font-size: 0.92rem;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 999px;
  /* iOS draws its own rounded box over the top otherwise. */
  -webkit-appearance: none;
  appearance: none;
}

.search-input::placeholder { color: var(--ink-3); }

.search-input:focus-visible {
  outline: 2px solid var(--ink-2);
  outline-offset: 1px;
}

/* What is hidden, and what was typed. Never optional: a list that shrinks
 * from 79 rows to 12 with no explanation reads as data loss. */
.search-count {
  margin: 0 0 0.6rem;
  font-size: 0.78rem;
  color: var(--ink-3);
}

/* ---- the filter bar --------------------------------------------------- */

/*
 * Links, not tabs: each one is a real URL, so the back button undoes a filter
 * and a phone can bookmark "% off". They scroll sideways rather than
 * wrapping to a second row — on a 360px screen five wrapped chips push the
 * first price below the fold, which is the one thing this screen is for.
 */
.filters {
  display: flex;
  gap: 0.35rem;
  overflow-x: auto;
  padding-bottom: 0.7rem;
  scrollbar-width: none;
}

.filters::-webkit-scrollbar { display: none; }

.filters a {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  min-height: var(--tap);
  padding: 0 0.85rem;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: var(--surface);
  color: var(--ink-3);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  text-decoration: none;
  white-space: nowrap;
}

/* The one in force is the only filled shape on the bar. It is a statement of
 * state, not a claim about a price, so it takes ink rather than an accent. */
.filters a[aria-current="true"] {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--paper);
}

/* What a ranking could not rank, and why. Never optional: a list of 3 where
 * the reader expects 79 has to account for the other 76. */
.filter-note { margin-bottom: 0.8rem; }

/* The first heading inside a form follows the page header directly, so it
 * does not need the full separation a mid-page section break gets. */
form > .section-head:first-child { margin-top: 0.5rem; padding-top: 0.9rem; }

ul.note {
  margin: 0.45rem 0 0;
  padding-left: 1.05rem;
}

ul.note li { margin-bottom: 0.25rem; }

/* The campaign's own clean name (campaignLabel(), in views/signal.ts), never
 * Cencosud's feed string. Small and set apart from the facts beside it: it
 * is provenance, not a claim, so it does not compete with the numbers. */
.campaign-label {
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: 0.7rem;
  font-weight: 700;
  color: var(--ink-3);
}

/* ---- cards ------------------------------------------------------------ */

.card {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 0.95rem 1rem 1rem;
  margin-bottom: 0.7rem;
}

/*
 * One column on a phone, two from about 43rem, three from about 63rem.
 *
 * The 20rem floor comes from the content, not from a scale: it is what the
 * longest real product name on this account ("Papel Higiénico Confort Doble
 * Hoja 25 m 24 un.") needs to set inside the card's padding without reaching
 * a third line, with the evidence sentence under it wrapping rather than
 * being cut. The evidence line is the one thing on a card that may never be
 * truncated, so the card is sized to it.
 *
 * `auto-fill`, not `auto-fit`: on a day when one product is flagged, auto-fit
 * would stretch that single card across the whole desk and set its price at
 * three inches tall. `min()` keeps the track from exceeding a 320px phone,
 * where a bare 20rem minimum would overflow the viewport sideways.
 */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
  gap: 0.7rem;
}

.grid > .card { margin-bottom: 0; }

.card > a.card-link {
  display: block;
  text-decoration: none;
  color: inherit;
}

.card-name {
  font-family: var(--font-display);
  font-size: 1.02rem;
  font-weight: 600;
  line-height: 1.3;
  margin: 0;
  /* Chain feeds emit 90-character names; two lines then an ellipsis keeps a
   * card the same height whether the product is "Leche" or a paragraph. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.card-brand {
  font-size: 0.74rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin: 0 0 0.3rem;
}

/* ---- the thumbnail: a photo beside the name, or a deliberate empty tile */

/*
 * The thumbnail sits beside the name and price; everything below it on a
 * card (badges, evidence, staleness, paid history) stays full width and
 * outside this row, unchanged by this task — a photo is not evidence.
 */
.card-top {
  display: flex;
  gap: 0.75rem;
  align-items: flex-start;
}

.card-main {
  flex: 1 1 auto;
  min-width: 0;
}

.card-main .card-brand { margin-top: 0; }

/*
 * Shared by the card grid and the product page (`.thumb-lg` there). A fixed
 * square, never the source image's own aspect ratio: chain photos are not
 * reliably square, and a tile that changed shape per product would break
 * the grid rhythm the rest of this page is built on.
 */
.thumb {
  flex: 0 0 auto;
  width: 4.5rem;
  height: 4.5rem;
  border-radius: 10px;
  overflow: hidden;
  background: var(--surface-2);
}

/* The product page's larger version — see renderThumb()'s "product" size,
 * which sets the matching width/height attributes on the <img> itself. */
.thumb-lg {
  width: 9rem;
  height: 9rem;
  border-radius: var(--radius);
}

/* The Add screen's list tile. Smaller because a search result is a candidate,
 * not something being read: the name is what is being scanned, and most rows
 * have no photo at all until the product is tracked and fetched once. */
.thumb-sm {
  width: 3rem;
  height: 3rem;
  border-radius: 8px;
}

.thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/*
 * The common state, not the edge case — most products have no cached photo
 * the first time this ships, and some never will. A plain diagonal fill,
 * not an icon: it has to read as "no photo, on purpose" rather than as a
 * failure, and a wrong or fussy icon risks reading as the latter. Both
 * tones are already theme-aware tokens, so this needs no dark-mode rule of
 * its own.
 */
.thumb-empty {
  background-image: repeating-linear-gradient(
    135deg,
    var(--surface-2) 0 9px,
    var(--rule-soft) 9px 18px
  );
}

/* ---- the price, which is the whole point ------------------------------ */

.price-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.55rem;
}

.price-hero {
  font-size: 2rem;
  font-weight: 650;
  letter-spacing: -0.025em;
  line-height: 1.05;
}

.price-hero.unknown {
  font-size: 1.1rem;
  font-weight: 500;
  color: var(--ink-3);
  letter-spacing: 0;
}

/*
 * The reference a saving is measured from: the chain's stated list price, or
 * a shelf price this user's own price undercuts. Struck through because it is
 * not what they pay — and named `price-ref`, not `price-was`, because the
 * name in the markup is what the next person reaches for when they add a
 * screen, and "was" would claim we watched the shelf at it. We did not.
 */
.price-ref {
  font-size: 0.95rem;
  color: var(--ink-3);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

.price-note {
  font-size: 0.78rem;
  color: var(--pine);
  font-weight: 600;
}

/* Money saved, which is the one thing on this page pine is for. */
.price-save {
  font-size: 0.8rem;
  font-weight: 650;
  color: var(--pine);
  white-space: nowrap;
}

/*
 * Price, reference and saving are ONE claim, so they wrap as one block. On a
 * row the alternative is a saving that squeezes the product name beside it
 * down to an ellipsis, which trades the thing being discounted for the
 * discount. Right-aligned: on a list, the prices are the column.
 */
.price-group {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: flex-end;
  gap: 0.1rem 0.45rem;
  flex: 0 1 auto;
  min-width: 0;
}

/* Set down a step in a row, where the price beside it is already small. */
.price-group .price-ref { font-size: 0.8rem; }
.price-group .price-save,
.price-group .price-note { font-size: 0.72rem; }

/* ---- the verdict: the one line that answers "should I buy this" -------- */

/*
 * Colour carries the verdict, and the palette already had the right two
 * words for it: pine is money saved, and ember is the colour this page uses
 * when something needs a second look before acting. A price ABOVE what this
 * person usually pays must never render in the same green as a genuine low —
 * that is the specific failure this rule exists to prevent, on a card whose
 * "Save $800 (16%)" is simultaneously true and beside the point.
 *
 * It also has to out-weigh that saving on the page, because they can
 * disagree: set a step larger than `.price-save`, bolder, on its own line
 * directly under the price, above the badges. A discount is a claim about
 * the shop's reference price; this is a claim about the reader's own money.
 */
.verdict {
  margin: 0.5rem 0 0;
  font-size: 0.88rem;
  font-weight: 550;
  line-height: 1.35;
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}

.verdict b { font-weight: 700; }

/* Cheaper than usual: the one thing pine means on this page. */
.verdict-below { color: var(--pine); }

/* Above usual — look again before you put it in the basket. */
.verdict-above { color: var(--ember); }

/*
 * No accent. "About usual" is a real, measured answer and not a warning, and
 * a colour on it would make every unremarkable card shout as loudly as the
 * two that have something to say.
 */
.verdict-usual { color: var(--ink-2); }

/* The count and the span. Present on every verdict, quieter than the claim. */
.verdict-evidence {
  color: var(--ink-3);
  font-weight: 450;
}

/* The arithmetic behind the sentence — product page only. */
.verdict-range { margin-top: 0.55rem; }

/*
 * The marker's halo is `--paper` where the tracker draws it, straight on the
 * page. Inside a card the ground is `--surface`, and the wrong halo shows as
 * a grey smudge around the one mark the reader is meant to read precisely.
 */
.verdict-range .track-marker { box-shadow: 0 0 0 2px var(--surface); }

/* ---- badges: only ever a "yes" ---------------------------------------- */

.badges {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin-top: 0.6rem;
}

.badge {
  font-size: 0.73rem;
  font-weight: 650;
  letter-spacing: 0.005em;
  padding: 0.22rem 0.5rem;
  border-radius: 999px;
  white-space: nowrap;
}

.badge-good { background: var(--pine-wash); color: var(--pine); }
.badge-low { background: var(--lapis-wash); color: var(--lapis); }
.badge-ends { background: var(--ember-wash); color: var(--ember); }
.badge-bundle {
  background: transparent;
  color: var(--ink-2);
  border: 1px solid var(--rule);
}

/* ---- evidence: quiet, always attached to a number --------------------- */

.evidence {
  font-size: 0.775rem;
  color: var(--ink-3);
  margin-top: 0.5rem;
  line-height: 1.4;
}

.evidence + .evidence { margin-top: 0.2rem; }

.evidence b { color: var(--ink-2); font-weight: 600; }

/* "We have not watched long enough to say." Deliberately NOT a pill, not
 * bordered, not grey-on-grey in a box — anything box-shaped here reads as a
 * measured negative, which is the one thing this state is not. */
.unknown-note { font-style: italic; }

.stock-out {
  font-size: 0.775rem;
  color: var(--ember);
  font-weight: 600;
  margin-top: 0.5rem;
}

/* ---- the bundle, which is never the headline -------------------------- */

.bundle {
  margin-top: 0.7rem;
  padding: 0.6rem 0.7rem;
  background: var(--surface-2);
  border: 1px solid var(--rule-soft);
  border-radius: 10px;
}

.bundle-line {
  font-size: 0.95rem;
  font-weight: 600;
}

.bundle-each { color: var(--ink-2); font-weight: 500; }

/* The sentence that stops someone reaching the till with the wrong number. */
.bundle-single {
  font-size: 0.775rem;
  color: var(--ink-3);
  margin-top: 0.25rem;
}

/* ---- paid history: a different question, so a different shape --------- */

.paid {
  margin-top: 0.7rem;
  padding-left: 0.7rem;
  border-left: 3px solid var(--rule);
}

.paid-label {
  font-size: 0.68rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-3);
  font-weight: 700;
}

.paid-line { font-size: 0.85rem; margin-top: 0.1rem; }

.paid-beat { color: var(--pine); font-weight: 600; }

/* ---- compact rows (watchlist, and Today's tail) ----------------------- */

.rows { border-top: 1px solid var(--rule); }

/*
 * A long list is the other half of "use the width". Multi-column, not a
 * grid: `columns` fills each column top to bottom, so the list still reads
 * in its own order down one column and then the next. A grid's row-major
 * flow would put item two to the RIGHT of item one, which quietly makes the
 * up/down arrows on the watchlist mean "left" and "right".
 */
.wrap.wide .rows { columns: 26rem; column-gap: 2.25rem; }

.wrap.wide .row { break-inside: avoid; }

.row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  min-height: var(--tap);
  padding: 0.6rem 0;
  border-bottom: 1px solid var(--rule);
}

.row-main { flex: 1 1 auto; min-width: 0; }

.row-name {
  font-family: var(--font-display);
  font-size: 0.93rem;
  font-weight: 600;
  line-height: 1.25;
  text-decoration: none;
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.row-meta { font-size: 0.74rem; color: var(--ink-3); }

.row-meta .oos { color: var(--ember); font-weight: 600; }

.row-price {
  font-size: 1.02rem;
  font-weight: 650;
  letter-spacing: -0.015em;
  white-space: nowrap;
}

.row-price.unknown { font-size: 0.8rem; font-weight: 500; color: var(--ink-3); }

.row-btn {
  flex: 0 0 auto;
  width: var(--tap);
  height: var(--tap);
  min-height: var(--tap);
  padding: 0;
  display: grid;
  place-items: center;
  border: 1px solid var(--rule);
  border-radius: 10px;
  background: transparent;
  color: var(--ink-3);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}

.row-btn:active { background: var(--surface-2); }

/* An arrow at the end of the list is disabled rather than inert: a control
 * that looks live and does nothing is how people learn to distrust the rest. */
.row-btn:disabled { opacity: 0.3; cursor: default; }

/* ---- the watchlist row, which can be rearranged ----------------------- */

/*
 * Two lines, not one. Three 44px controls and a price leave about 100px for
 * a product name on a phone, and the arrows are the primary ordering control
 * on exactly that device. So the name and the price take the first line and
 * the provenance shares the second with the buttons.
 */
.row-ordered {
  display: block;
  padding: 0.5rem 0 0.45rem;
}

.row-top {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  min-height: 1.4rem;
}

.row-top .row-name { flex: 1 1 auto; min-width: 0; }

.row-bottom {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.1rem;
}

.row-bottom .row-meta {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.row-actions { display: flex; gap: 0.3rem; flex: 0 0 auto; }

.row-actions form { margin: 0; }

/* Only once the script has confirmed dragging works does the cursor claim it. */
.reorder-live .row-ordered { cursor: grab; }

.reorder-live .row-ordered.dragging {
  opacity: 0.45;
  cursor: grabbing;
}

/* ---- the tracker row: where a price sits in its own range ------------- */

/*
 * A bar is a strong claim — it says a range exists, that today's price is
 * inside it, and that the position means something. Nothing here decides any
 * of that: band.ts does, and hands over `--at` already computed, so the
 * arithmetic behind "near its low" is testable as arithmetic. Rows with no
 * bar get a sentence in the same `.evidence.unknown-note` shape the rest of
 * the site uses for "we have not watched long enough to say", which on this
 * data is most of them.
 */
.row-track { display: block; padding: 0.55rem 0 0.65rem; }

.track-link { display: block; text-decoration: none; color: inherit; }

.track-head { display: flex; align-items: center; gap: 0.6rem; }

.track-name {
  flex: 1 1 auto;
  min-width: 0;
  font-family: var(--font-display);
  font-size: 0.93rem;
  font-weight: 600;
  line-height: 1.25;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The price of ONE unit, and — on a shelf bar whose marker is a different
 * number — the marker's own price struck through beside it. */
.track-price {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: 0.35rem;
}

/*
 * The track is the whole known range, low at the left. `--rule` rather than
 * `--rule-soft`: at 6px on the page's own paper, the softer tone disappears
 * in both themes, and an invisible track leaves a mark floating in space.
 */
.track-bar {
  position: relative;
  height: 6px;
  margin: 0.5rem 0 0.3rem;
  border-radius: 999px;
  background: var(--rule);
}

/*
 * A tick, not a dot: it has to read as "here, exactly", and a dot on a 6px
 * track reads as a blob. The halo is the page's own background, so a marker
 * pinned to either end stays legible against the track's rounded cap.
 */
.track-marker {
  position: absolute;
  left: var(--at, 50%);
  top: 50%;
  width: 3px;
  height: 15px;
  transform: translate(-50%, -50%);
  border-radius: 2px;
  background: var(--ink);
  box-shadow: 0 0 0 2px var(--paper);
}

.track-ends {
  display: flex;
  justify-content: space-between;
  font-size: 0.72rem;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}

.track-caption {
  font-size: 0.75rem;
  color: var(--ink-3);
  line-height: 1.4;
  margin: 0.2rem 0 0;
}

.track-zone { color: var(--ink-2); font-weight: 650; }

/*
 * One accent on this screen, and it is lapis — the palette's colour for a
 * record, already spent on "Lowest in 90 days". The low end is the only end
 * worth a colour: it is the actionable one and the screen's default order.
 * "Near its high" deliberately gets none, because a colour here is a claim
 * and "this is expensive" is not one this screen is making.
 */
.track-zone-low { color: var(--lapis); }

.row-track .evidence { margin-top: 0.45rem; }

/* ---- chart ------------------------------------------------------------ */

.chart {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: 0.9rem 0.9rem 0.7rem;
  box-shadow: var(--shadow);

  /*
   * Where the plot starts, and so where the price scale to its left ends.
   * The same number as CHART_GUTTER in views/product.ts, which draws the
   * rules from 140 of 640 viewBox units — a test pins the two together,
   * because if they drift every price label hangs off the rule it names.
   */
  --plot-left: 21.875%;
}

/* The plot: the drawing, and the price labels hung over its left-hand strip.
 * They are HTML rather than <text> inside the viewBox because this SVG scales
 * with the column and text inside it would scale too — a label sized to read
 * on a desk is five pixels tall on a phone. */
.chart-plot { position: relative; }

/* The chart itself, and only it: the legend's glyphs below are SVGs too and
 * must stay the size of a bullet. */
.chart-plot > svg { display: block; width: 100%; height: auto; }

/*
 * --rule and not --rule-soft. At one hairline on --surface the softer token
 * is under a 1.15 contrast ratio in the dark palette and simply is not there,
 * which is the same thing .track-bar found at 6px on --paper; a gridline
 * nobody can see is worse than none, because the reader is left believing
 * there is a ruler. It still sits well behind the 2px --ink-2 step line and
 * the filled marks, which is the point of a gridline.
 */
.chart-grid {
  stroke: var(--rule);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.chart-scale { position: absolute; inset: 0; pointer-events: none; }

.chart-tick {
  position: absolute;
  /* Right-aligned against the plot's edge, so a longer price grows leftwards
   * into the card's own padding instead of pushing the drawing over. */
  right: calc(100% - var(--plot-left));
  top: var(--at);
  transform: translateY(-50%);
  /* Measured against the widest mark: a triangle centred on the plot's first
   * date hangs 7.5 viewBox units to the LEFT of it, about 3px on a 320px
   * screen, and at a smaller gap than this the oldest boleta sits on top of
   * the label naming the rule beneath it. */
  padding-right: 0.5rem;
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-3);
  white-space: nowrap;
}

.chart-axis {
  display: flex;
  justify-content: space-between;
  font-size: 0.72rem;
  color: var(--ink-3);
  margin-top: 0.35rem;
  /* The dates belong to the plot, not to the card: without this the first one
   * sits under the price scale and names a day the drawing starts a fifth of
   * a chart later. */
  padding-left: var(--plot-left);
}

/*
 * Below this the plot is under 100px tall, and five labelled rules across it
 * stop being a ruler and become texture.
 *
 * The RULES go, not the type. A price label set smaller than 0.68rem is not
 * something anyone reads off a phone in an aisle, and a ruler that cannot be
 * read has gained the chart nothing over the bare marks it was added to
 * explain. Every other rule is dropped, so what is left is still evenly
 * spaced — three rules at twice the step, a coarser ruler rather than a
 * gap-toothed one — and the renderer marks which ones may go, because that
 * is a property of the scale rather than of the screen.
 */
@media (max-width: 24rem) {
  .chart-grid-fine { display: none; }
}

/*
 * Two marks on one axis mean two things, so both are named — with the chart's
 * own glyphs rather than a coloured square, because the key has to be the
 * mark. Set in --ink-2 rather than the caption's --ink-3: it is the sentence
 * that makes the drawing above it readable at all.
 */
.chart-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.15rem 0.85rem;
  font-size: 0.74rem;
  color: var(--ink-2);
  margin: 0.5rem 0 0;
}
.chart-legend span { display: inline-flex; align-items: center; gap: 0.32rem; }
.chart-legend .key-mark {
  display: inline-block;
  width: 0.75rem;
  height: 0.75rem;
  flex: none;
}

.chart-key {
  font-size: 0.74rem;
  color: var(--ink-3);
  margin-top: 0.45rem;
  line-height: 1.4;
}

/* The inflation caveat, in the caption's own voice — it is a limit on how the
 * chart may be read, not a second caption. */
.chart-note {
  font-size: 0.74rem;
  color: var(--ink-3);
  margin: 0.4rem 0 0;
  line-height: 1.4;
}

/* ---- tables (purchases) ----------------------------------------------- */

.table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.table th {
  text-align: left;
  font-size: 0.68rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-3);
  font-weight: 700;
  padding: 0.35rem 0;
  border-bottom: 1px solid var(--rule);
}
.table td { padding: 0.45rem 0; border-bottom: 1px solid var(--rule-soft); }
.table td:last-child, .table th:last-child { text-align: right; }
.table .promo { font-size: 0.74rem; color: var(--ink-3); }

/* Numbers align on their right edge, or two money columns cannot be read down
 * the page against each other — which is the entire point of putting TICKET
 * beside PAID. */
.table td.num, .table th.num { text-align: right; padding-left: 0.6rem; }

/* The paid price is the column the headline range quotes, so it carries the
 * weight; the ticket beside it is the reference, struck through by `price-ref`
 * exactly as on a price row. Pulled back to the table's own size: at its card
 * size it would outweigh the number it is a reference for. */
.table td.paid { font-weight: 650; }
.table .price-ref { font-size: inherit; }
.table .unknown { color: var(--ink-3); font-weight: 400; }

/* ---- forms ------------------------------------------------------------ */

.field { margin-bottom: 1.1rem; }

.field > label, .field > .legend {
  display: block;
  font-size: 0.8rem;
  font-weight: 650;
  margin-bottom: 0.35rem;
}

select, button {
  font: inherit;
  font-size: 0.95rem;
  min-height: var(--tap);
  width: 100%;
  padding: 0.6rem 0.8rem;
  border-radius: 10px;
  border: 1px solid var(--rule);
  background: var(--surface);
  color: var(--ink);
}

.btn-primary {
  background: var(--ink);
  color: var(--paper);
  border-color: var(--ink);
  font-weight: 600;
}

.choices { display: grid; gap: 0.4rem; }

.choice {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  min-height: var(--tap);
  padding: 0.45rem 0.75rem;
  border: 1px solid var(--rule);
  border-radius: 10px;
  background: var(--surface);
  cursor: pointer;
}

.choice input { width: auto; min-height: 0; margin: 0; accent-color: var(--pine); }

.choice-body { flex: 1 1 auto; }
/* Both are spans so the whole row stays one <label>; block is what stacks
 * the name over its explanation instead of running them into one sentence. */
.choice-name { display: block; font-size: 0.9rem; font-weight: 600; }
.choice-note { display: block; font-size: 0.75rem; color: var(--ink-3); }

fieldset { border: 0; margin: 0; padding: 0; }

/* ---- notes and empty states ------------------------------------------- */

.note {
  font-size: 0.79rem;
  color: var(--ink-3);
  line-height: 1.5;
  margin: 0.45rem 0 0;
}

.empty {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: 1.4rem 1.1rem;
  text-align: center;
  box-shadow: var(--shadow);
}

.empty h2 {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 600;
  margin: 0 0 0.35rem;
}

.empty p { margin: 0; font-size: 0.86rem; color: var(--ink-2); line-height: 1.5; }

.back {
  display: inline-block;
  font-size: 0.8rem;
  color: var(--ink-3);
  text-decoration: none;
  padding: 0.9rem 0 0.2rem;
}

/* ---- the Add screen --------------------------------------------------- */

/*
 * Box and button on one line, because the button is the whole interaction on
 * a phone: this is a real search that costs an outbound request, so there is
 * no live filtering here and pressing it is the deliberate act.
 */
.add-search, .add-link {
  display: flex;
  gap: 0.5rem;
  align-items: stretch;
  margin: 0 0 0.9rem;
}

.add-search .search-input, .add-link .search-input {
  flex: 1 1 auto;
  min-width: 0;
}

/* Overrides the full-width default every other button on this site wants. */
.btn-search {
  flex: 0 0 auto;
  width: auto;
  padding-inline: 1rem;
}

.row-add { gap: 0.65rem; }

/*
 * A pill, not one of the square 44px controls: those are the watchlist's
 * arrows and its remove, where a glyph carries the meaning. This one says a
 * word, and the word is what makes it unambiguous on first use.
 */
.btn-track {
  width: auto;
  min-width: 4.75rem;
  padding: 0 0.85rem;
  border-radius: 999px;
  font-size: 0.8rem;
  font-weight: 650;
  color: var(--ink);
  text-decoration: none;
}

/*
 * Already on the list: quiet, and not a button. It leads to the product's own
 * page, because the only thing left to want here is what it costs — and that
 * number lives where its evidence is, never beside a search result.
 */
.btn-tracked {
  color: var(--ink-3);
  background: var(--surface-2);
  border-color: transparent;
  font-weight: 600;
}

/*
 * What just happened, said once, above the results. `role="status"` so a
 * screen reader hears it after a redirect; no dismiss control, because the
 * next navigation clears it and a control that only removes text is a control
 * to no purpose.
 */
.notice {
  margin: 0 0 0.9rem;
  padding: 0.6rem 0.8rem;
  border-radius: 10px;
  font-size: 0.85rem;
  line-height: 1.4;
}

.notice-good { background: var(--pine-wash); color: var(--pine); }
.notice-bad { background: var(--ember-wash); color: var(--ember); }

/* ---- bottom navigation: where a thumb already is ---------------------- */

.tabbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 10;
  display: flex;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  backdrop-filter: saturate(180%) blur(12px);
  border-top: 1px solid var(--rule);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

.tabbar a {
  flex: 1 1 0;
  min-height: var(--tap);
  display: grid;
  place-items: center;
  padding: 0.55rem 0.25rem;
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--ink-3);
  text-decoration: none;
}

.tabbar a[aria-current="page"] {
  color: var(--ink);
  box-shadow: inset 0 2px 0 0 var(--pine);
}

/* Five tabs on a 320px screen leave 64px each, and "Watchlist" does not fit
 * that at the size above. The label shrinks rather than wrapping under its
 * neighbour, which is what a fixed size would do here. */
@media (max-width: 22.5rem) {
  .tabbar a {
    font-size: 0.7rem;
    padding-inline: 0.15rem;
    letter-spacing: 0;
    white-space: nowrap;
  }
}

/* Signed-out pages (sign-in, 404) have no tab bar to clear. */
body.bare { padding-bottom: 2rem; }

.signin-lede {
  font-size: 1rem;
  color: var(--ink-2);
  line-height: 1.5;
  max-width: 22rem;
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/* ---- browse ----------------------------------------------------------- */

/*
 * A denser grid than `.grid`. That one is sized to the longest real product
 * name plus an evidence sentence, because a watchlist card must never truncate
 * its evidence. A browse tile carries no evidence — name, brand, price — so it
 * can be half the width and show a shelf rather than a reading list.
 */
.tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(10.5rem, 100%), 1fr));
  gap: 0.7rem;
}

/* Equal heights, so a row of prices sits on one line. The grid already makes
 * every tile the height of the tallest; without this the CARD stretches and
 * its contents do not, leaving each price wherever its name stopped — one and
 * two-line names alternating down a row, which reads as misalignment rather
 * than as variety. */
.tile { padding: 0.7rem; margin-bottom: 0; height: 100%; }
.tile .card-link { display: flex; flex-direction: column; gap: 0.5rem; height: 100%; }
.tile-body { min-width: 0; display: flex; flex-direction: column; flex: 1 1 auto; }
.tile .thumb { width: 100%; height: 7rem; }
/*
 * CONTAIN, not the shared `cover`.
 *
 * A tile's photo box is full-width and 7rem tall while the cached file is a
 * square 250x250, so `cover` crops roughly a third off a product shot — and
 * Jumbo composites a price flag into the frame of many of them, so what gets
 * cut is usually the half that says what the thing is. The other thumbs are
 * square and never notice the difference; this one is the only non-square box
 * on the site.
 */
.tile .thumb img { object-fit: contain; padding: 0.2rem; }
/* Two lines' worth of space reserved whether the name needs one or two —
 * `line-height: 1.3` from `.card-name`, at the tile's own size. */
.tile .card-name { font-size: 0.92rem; min-height: calc(2 * 1.3 * 0.92rem); }
.tile-price {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.3rem;
  /* `auto` on top pins the row to the bottom of the tile rather than to
   * wherever the name happened to stop. */
  margin: auto 0 0;
  padding-top: 0.35rem;
  font-size: 0.95rem;
}
.tile-price s { color: var(--ink-3); font-size: 0.85em; }
.tile-off {
  color: var(--pine);
  background: var(--pine-wash);
  border-radius: 6px;
  padding: 0.05rem 0.3rem;
  font-size: 0.78rem;
  font-weight: 600;
}

.dept-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(13rem, 100%), 1fr));
  gap: 0.7rem;
}
.dept {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.5rem;
  min-height: var(--tap);
  padding: 0.85rem 1rem;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  text-decoration: none;
  color: inherit;
}
.dept-name { font-family: var(--font-display); font-weight: 600; }
.dept-count { color: var(--ink-3); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.dept:hover { border-color: var(--ink-3); }

/* A department the crawler has not reached. Still a door, and still labelled,
 * but not dressed like the seventeen that open onto something. */
.dept-empty { background: transparent; border-style: dashed; }
.dept-empty .dept-name { font-weight: 500; color: var(--ink-2); }

.crumbs { font-size: 0.88rem; color: var(--ink-2); margin: 0 0 0.8rem; }
.crumbs a { color: var(--ink-2); }

/*
 * THREE ROWS OF PILLS THAT DO THREE DIFFERENT THINGS.
 *
 * Subcategories go somewhere else, sorts reorder what is here, brands remove
 * some of it. Rendered identically — which they were — the top of a category
 * page reads as forty settings of one control, and the eye has no way to find
 * the one it wants. What separates them now is grouping and a label, not
 * colour: a label costs one quiet line and survives being read in an aisle,
 * where a fourth accent would just be a fourth thing to decode.
 *
 * The accents stay spent the way the palette note at the top of this file
 * says they are. `--pine` means MONEY SAVED, so it marks the one control that
 * makes a savings claim ("On offer only") and nothing else. A chosen sort or
 * a chosen brand is a preference, not a claim, so it is marked in ink: filled,
 * legible, obviously current, and making no promise about a price.
 */
.subcats {
  display: flex; flex-wrap: wrap; gap: 0.4rem;
  margin: 0 0 1.1rem;
}
/* Navigation, and weighted to say so: these are the only pills on the page
 * that leave it. Filled rather than outlined, and the count sits inside as a
 * quiet second value rather than as another word. */
.subcats a {
  display: inline-flex; gap: 0.4rem; align-items: baseline;
  min-height: 2.2rem;
  padding: 0.4rem 0.75rem;
  border: 1px solid transparent; border-radius: 999px;
  background: var(--surface-2); text-decoration: none; color: var(--ink);
  font-size: 0.88rem; font-weight: 600;
}
.subcats a:hover { border-color: var(--rule); background: var(--surface); }
.subcats a span { color: var(--ink-3); font-size: 0.78rem; font-weight: 400; }

/*
 * The filter region, as one block instead of four loose rows.
 *
 * It is bounded and tinted so the products below it start somewhere visible.
 * On the screen that prompted this, forty brand pills pushed the first row of
 * products past the fold — the controls looked like the page and the page
 * looked like the controls.
 */
.filters {
  display: flex; flex-direction: column; gap: 0.7rem;
  padding: 0.85rem;
  margin: 0 0 1rem;
  background: var(--surface-2);
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius);
}

.filter-group { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.55rem; }

/* The word that tells three identical rows apart. Small, uppercase and
 * ink-3: present when looked for, silent when not. */
.filter-label {
  flex: 0 0 3.2rem;
  font-size: 0.7rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  font-weight: 650;
  color: var(--ink-3);
}

.chips, .facets { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0; }
.chip, .facet {
  display: inline-flex; align-items: center; gap: 0.35rem;
  min-height: 2.2rem;
  padding: 0.35rem 0.7rem;
  border: 1px solid var(--rule); border-radius: 999px;
  background: var(--surface); text-decoration: none; color: var(--ink-2);
  font-size: 0.84rem;
}
.chip:hover, .facet:hover { border-color: var(--ink-3); color: var(--ink); }

/* A preference, in ink. See the note above on why this is not pine. */
.chip.on, .facet.on {
  background: var(--ink); border-color: var(--ink); color: var(--paper);
  font-weight: 600;
}
.facet span { color: var(--ink-3); font-size: 0.78rem; }
.facet.on span { color: var(--paper); opacity: 0.72; }

/* The one control here that IS a claim about money, so the one that gets the
 * savings colour. */
.chip-toggle.on {
  background: var(--pine-wash); border-color: var(--pine); color: var(--pine);
}

/*
 * The tail of the brand list, folded away.
 *
 * A category returns up to forty brands and the first eight cover almost every
 * reach for one. `<details>` rather than a script: this page ships no
 * JavaScript of its own, and every filter is a fresh document anyway, so there
 * is no open state worth preserving between them.
 */
.more-brands { display: inline-block; }
.more-brands summary {
  display: inline-flex; align-items: center;
  min-height: 2.2rem;
  padding: 0.35rem 0.7rem;
  border: 1px dashed var(--rule); border-radius: 999px;
  background: transparent; color: var(--ink-3);
  font-size: 0.84rem; cursor: pointer;
  list-style: none;
}
.more-brands summary::-webkit-details-marker { display: none; }
.more-brands summary:hover { border-color: var(--ink-3); color: var(--ink-2); }
.more-brands[open] summary { margin-bottom: 0.4rem; }
/* A close affordance once the tail is open, spaced off the count so it reads
 * as a control rather than as a character in the label. */
.more-brands[open] summary::after {
  content: "\2715";
  margin-left: 0.45rem;
  font-size: 0.78em;
  opacity: 0.65;
}
.more-brands > .facets { margin-top: 0.1rem; }

/*
 * Box and button on one line. `.search-input` and `.btn-search` are the Add
 * screen's pair, borrowed whole: a bare <button> here inherits the
 * `width: 100%` that every other button on this site wants, which turned
 * Search into a full-width slab beside a box crushed to its minimum width.
 */
.browse-search { display: flex; gap: 0.5rem; align-items: stretch; }
.browse-search .search-input { flex: 1 1 auto; min-width: 0; }

.price-range { display: flex; flex-wrap: wrap; gap: 0.4rem 0.55rem; align-items: center; }
.price-range label { display: inline-flex; }
.price-input {
  width: 6rem;
  min-height: 2.2rem;
  padding: 0 0.7rem;
  font: inherit; font-size: 0.86rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule); border-radius: 999px;
  -webkit-appearance: none; appearance: none;
}
.price-input::placeholder { color: var(--ink-3); }
.range-dash { color: var(--ink-3); }
/* Shorter than the 44px tap floor `.btn-search` inherits, so it matches the
 * pills it sits beside rather than towering over them. */
.price-range .btn-search { min-height: 2.2rem; padding-inline: 0.9rem; font-size: 0.86rem; }

.count { color: var(--ink-3); font-size: 0.85rem; margin: 0 0 0.6rem; }

/* Browse's own count line, which carries a "Clear filters" link on the other
 * end and so has to be a flex row. Its own class rather than `.count`: that
 * one is an inline <span> on Today, Add and the filter bar, and turning it
 * into a block-level flex container reflows all three. */
.browse-count {
  display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
}
.clear-filters { color: var(--ink-2); font-size: 0.82rem; }

/*
 * ON A PHONE, EVERY CHIP ROW IS ONE LINE THAT SCROLLS SIDEWAYS.
 *
 * Wrapped, they are not a header, they are the page: measured on a 375px
 * screen, six subcategories and four filter groups stacked to 781px and put
 * the first product at y=911 — a whole screen of controls before a single
 * thing they control. Sideways, the same rows cost about 40px each and the
 * grid starts above the fold.
 *
 * The chips are links, so tabbing to one scrolls it into view; nothing here
 * is reachable only by dragging. `.facets-tail` is left wrapping on purpose —
 * it is already behind a deliberate tap, and twenty-two brands on one
 * scrolling line would be worse than the four rows it costs.
 */
@media (max-width: 40rem) {
  .subcats,
  .filter-group > .chips,
  .filter-group > .facets {
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    /* Room for a focus ring on the pill that is flush with the edge. */
    padding: 2px;
    margin: -2px;
  }
  .subcats::-webkit-scrollbar,
  .filter-group > .chips::-webkit-scrollbar,
  .filter-group > .facets::-webkit-scrollbar { display: none; }

  /* Pills keep their own width instead of being squeezed to fit the line. */
  .subcats a, .chips > .chip, .filter-group > .facets > .facet { flex: 0 0 auto; }

  .facets-tail { flex-wrap: wrap; overflow: visible; }

  /*
   * Two up, stated rather than derived. `auto-fill` with a 10.5rem minimum
   * wants 347px for two columns and a 375px phone offers 343 — it misses by
   * four pixels and drops to ONE product per row, which is the widest, least
   * informative way to show a shelf. A phone gets two.
   */
  .tile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
}

/*
 * A label for a control whose meaning is already obvious on screen and not at
 * all obvious to a screen reader — the two price boxes, which sighted readers
 * tell apart by the dash between them and nothing else.
 */
.vh {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/*
 * One visible focus ring for everything a keyboard can land on.
 *
 * `.search-input` carried the only one on the site, which meant tabbing
 * through a category page — dozens of pills, two forms, a pager — moved an
 * invisible cursor. `:focus-visible` and not `:focus`, so a mouse click on a
 * pill does not leave a ring behind it.
 */
.chip:focus-visible,
.facet:focus-visible,
.subcats a:focus-visible,
.more-brands summary:focus-visible,
.price-input:focus-visible,
.clear-filters:focus-visible,
.card-link:focus-visible,
.dept:focus-visible,
.pager a:focus-visible,
.btn-link:focus-visible {
  outline: 2px solid var(--ink-2);
  outline-offset: 2px;
}

/* The way off a read-only product page, and the only button on it. Sized to
 * its words rather than to the column, like every other deliberate action. */
.track-cta { margin: 0.6rem 0 0; }
.btn-link {
  display: inline-flex; align-items: center;
  min-height: var(--tap);
  padding: 0 1.1rem;
  border-radius: 999px;
  background: var(--ink); color: var(--paper);
  font-size: 0.9rem; font-weight: 600;
  text-decoration: none;
}
.btn-link:hover { opacity: 0.9; }

.pager {
  display: flex; justify-content: space-between; align-items: center;
  gap: 0.5rem; margin-top: 1rem; font-size: 0.9rem;
}
