/* ==========================================================================
   Scoute — design system
   Thai-first: loopless Noto Sans Thai with Thai-native fallbacks, generous
   line-height for upper/lower vowel marks, no layout assumptions about
   spaces between words.
   ========================================================================== */

:root {
  --font: 'Noto Sans Thai', 'IBM Plex Sans Thai', 'Sukhumvit Set', 'Thonburi',
    'Leelawadee UI', 'Helvetica Neue', system-ui, -apple-system, sans-serif;
  --mono: 'IBM Plex Mono', 'SF Mono', 'Sarabun', ui-monospace, monospace;

  --bg: #f4f5f7;
  --surface: #ffffff;
  --surface-2: #fafbfc;
  --surface-3: #eef0f4;
  --border: #e3e6ec;
  --border-strong: #cfd5df;

  --ink: #14181f;
  --ink-2: #48505e;
  --ink-3: #7b8494;

  --accent: #ef3b24;      /* chilli red — savings, discounts */
  --accent-soft: #fdecea;
  --accent-ink: #a01f10;
  --go: #0e9f6e;          /* green — best price, success */
  --go-soft: #e6f7f0;
  --info: #2563eb;
  --info-soft: #eaf0fe;
  --warn: #b5822f;
  --warn-soft: #fdf4e3;
  --line: #06c755;        /* LINE brand green (nominative use) */

  --radius: 14px;
  --radius-sm: 9px;
  --radius-lg: 22px;
  --shadow-1: 0 1px 2px rgba(16, 22, 34, 0.05), 0 1px 3px rgba(16, 22, 34, 0.04);
  --shadow-2: 0 4px 12px rgba(16, 22, 34, 0.07), 0 2px 4px rgba(16, 22, 34, 0.04);
  --shadow-3: 0 18px 44px rgba(16, 22, 34, 0.18);
  --header-h: 60px;
  --nav-h: 62px;
  --maxw: 1180px;
}

[data-theme='dark'] {
  --bg: #0e1116;
  --surface: #171b22;
  --surface-2: #1c2129;
  --surface-3: #232a33;
  --border: #2a313b;
  --border-strong: #3a424e;
  --ink: #eef1f5;
  --ink-2: #a9b3c1;
  --ink-3: #7c8695;
  --accent-soft: #3a1a15;
  --accent-ink: #ff9d8e;
  --go-soft: #10322a;
  --info-soft: #16233d;
  --warn-soft: #32270f;
  --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-2: 0 4px 14px rgba(0, 0, 0, 0.45);
  --shadow-3: 0 18px 44px rgba(0, 0, 0, 0.6);
}

* { box-sizing: border-box; }

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  /* Thai stacks marks above/below the baseline — 1.7 keeps them legible. */
  line-height: 1.7;
  font-size: 15.5px;
  -webkit-font-smoothing: antialiased;
  overflow-wrap: break-word;
}

h1, h2, h3, h4 { line-height: 1.45; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
p { margin: 0; }
a { color: inherit; }
button { font: inherit; color: inherit; cursor: pointer; }
input, select, textarea { font: inherit; color: inherit; }
::selection { background: var(--accent); color: #fff; }

:focus-visible {
  outline: 2.5px solid var(--info);
  outline-offset: 2px;
  border-radius: 6px;
}

.num { font-variant-numeric: tabular-nums; }

/* ---------- app frame ---------- */

.app { min-height: 100vh; display: flex; flex-direction: column; }

.topbar {
  position: sticky; top: 0; z-index: 40;
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: saturate(180%) blur(14px);
  border-bottom: 1px solid var(--border);
}
.topbar-inner {
  max-width: var(--maxw); margin: 0 auto; padding: 8px 14px;
  min-height: var(--header-h); display: flex; align-items: center; gap: 10px;
}

.brand { display: flex; align-items: center; gap: 9px; text-decoration: none; flex: none; }
/* The app's mark, in the header: `icon.svg` itself, drawn in a tile the same
   shape as the app's other tiles. It used to be the letter `ถ` on a gradient of
   `--accent` — which made the app's own mark a red-orange accent surface while
   the icon it stood for was purple, and left two drawings of one thing to keep
   in step by hand.

   The tile keeps a ground of the file's own colour (`#9d2ec0`), because that is
   what shows through the corner the file's own radius (11.3% of its width) can
   leave inside the tile's 10px one: **the file's ground and this one have to
   stay the same colour**, or those corners will show both. The accent itself is
   untouched — prices, savings, badges and every action button are still the
   app's red-orange. */
.brand-mark {
  width: 34px; height: 34px; border-radius: 10px; flex: none; overflow: hidden;
  background: #9d2ec0; display: grid; place-items: center;
  box-shadow: var(--shadow-1);
}
.brand-mark img { width: 100%; height: 100%; display: block; }
.brand-name { font-weight: 800; font-size: 1.05rem; letter-spacing: -0.02em; }
.brand-sub { font-size: 10.5px; color: var(--ink-3); line-height: 1.2; display: block; margin-top: -3px; }

.searchbar {
  flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px;
  background: var(--surface-3); border: 1px solid transparent;
  border-radius: 999px; padding: 7px 6px 7px 14px;
  transition: border-color .15s, background .15s;
}
.searchbar:focus-within { border-color: var(--border-strong); background: var(--surface); }
.searchbar input {
  flex: 1; min-width: 0; border: 0; background: transparent; padding: 2px 0; font-size: 15px;
}
.searchbar input::placeholder { color: var(--ink-3); }
.searchbar input:focus { outline: none; }

.iconbtn {
  border: 1px solid var(--border); background: var(--surface); width: 36px; height: 36px;
  border-radius: 10px; display: grid; place-items: center; flex: none;
  transition: background .15s, border-color .15s, transform .1s;
}
.iconbtn:hover { background: var(--surface-3); }
.iconbtn:active { transform: scale(.95); }
.iconbtn.subtle { border-color: transparent; background: transparent; }
.iconbtn.subtle:hover { background: var(--surface-3); }

.pill-toggle {
  display: inline-flex; border: 1px solid var(--border); border-radius: 999px;
  overflow: hidden; background: var(--surface); flex: none;
}
.pill-toggle button {
  border: 0; background: transparent; padding: 6px 11px; font-size: 12.5px;
  font-weight: 700; color: var(--ink-3); line-height: 1.5;
}
.pill-toggle button[aria-pressed='true'] { background: var(--ink); color: var(--bg); }

/* The favourite — the header's door into the chains the reader follows, in the
   slot a light/dark switch used to hold.

   Nothing is drawn around the mark: no box and no count on its corner. The bar
   is a header, and a square with a filled circle on it was two shapes the reader
   had to read before reaching the one thing the control is — the heart. It is
   the shape the leaflet bar's own icon buttons have, which is where this heart
   also appears larger: bare at rest, a tint under the finger.

   The 36 px is the *target*, not a drawing. A finger needs the size even when
   nothing is painted there, and the tint on hover, on press and on the screen it
   leads to is what shows the touch area exists.

   It is the mark alone at every width. The word beside it was tried and is
   gone: a labelled control in this bar belongs to the language switch, and the
   room belongs to the search field — on a phone that is the whole of it, and on
   a wide window the label only widened a row that already reads as a row of
   marks. The name travels on the <a> instead, so the control is still
   `Favourite` / `รายการโปรด` to a reader who cannot see the mark at all, and it
   is the only place the number of chains followed is stated now that the count
   has left the corner. */
.favbtn {
  position: relative; width: 36px; height: 36px; flex: none;
  border-radius: 10px;
  display: grid; place-items: center; color: var(--ink-3);
  transition: background .15s, color .15s, transform .1s;
}
.favbtn:hover { background: var(--surface-3); color: var(--ink-2); }
.favbtn:active { transform: scale(.95); }
/* The mark is the app's own heart, filled — the glyph the leaflet bar draws for
   the shopping list beside it and the feed card draws on every chain — so the
   header's icon set is one hand rather than two. Following is then said the way
   that bar says it for the chain's own heart: colour, not a second drawing
   (`.bbar .iconbtn.on`). */
.favbtn.on { color: var(--accent); }
/* On the store screen itself, which is where it leads: the same tint a bare icon
   button takes under the finger. */
.favbtn[aria-current='page'] { background: var(--surface-3); }

/* The language switch is a primary control, so it stays in the header in every
   format. On narrow phones it only tightens up, to leave the search field room. */
#langToggle { display: inline-flex; }
@media (max-width: 640px) {
  #langToggle button { padding: 5px 7px; font-size: 11.5px; }
  /* Buying the language switch its space back on a phone: the inline submit is
     redundant (Enter and the search page both submit), so the field keeps room. */
  .searchbar { padding: 7px 8px 7px 12px; }
  .searchbar button[type='submit'] { display: none; }
}
/* Below ~360px there is genuinely no room for the brand mark as well as the
   language switch and a usable search field. The app name is in the document
   title and on every page, so the decorative mark yields; the switch stays.
   Common phone widths (375px and up) keep their branding. */
@media (max-width: 359px) { .brand { display: none; } }
@media (max-width: 470px) { .brand-text { display: none; } }

.pad-h { padding-left: 15px; padding-right: 15px; }

/* ---------- layout ---------- */

/* `minmax(0, 1fr)` rather than `1fr`: a bare `1fr` has an `auto` minimum, so the
   horizontal rails (`.hscroll`) — whose content is intrinsically ~1500px wide —
   passed that width up to the track and made the whole page scroll sideways on a
   phone. The minimum has to be 0 for the rails to stay scrollable themselves. */
.shell {
  flex: 1; width: 100%; max-width: var(--maxw); margin: 0 auto;
  display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; padding: 18px 14px 110px;
}
@media (min-width: 980px) {
  .shell { grid-template-columns: 232px minmax(0, 1fr); padding: 24px 20px 60px; }
  .main { min-width: 0; }
}

.sidenav { display: none; }
@media (min-width: 980px) {
  .sidenav { display: block; position: sticky; top: 84px; align-self: start; }
}
.sidenav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.navlink {
  display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 11px;
  text-decoration: none; color: var(--ink-2); font-weight: 600; font-size: 14.5px;
}
.navlink:hover { background: var(--surface-3); color: var(--ink); }
.navlink.active { background: var(--ink); color: var(--bg); }
.navlink.active svg { opacity: 1; }
.navlink svg { opacity: .75; flex: none; }
.navcount {
  margin-left: auto; background: var(--accent-soft); color: var(--accent-ink);
  font-size: 11px; font-weight: 800; padding: 1px 7px; border-radius: 999px;
}
.navlink.active .navcount { background: rgba(255,255,255,.22); color: inherit; }

.bottomnav {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
  background: color-mix(in srgb, var(--surface) 94%, transparent);
  backdrop-filter: blur(14px);
  border-top: 1px solid var(--border);
  padding-bottom: env(safe-area-inset-bottom);
  /* One equal column per tab, however many tabs there are. A hard-coded count
     (it was repeat(5, …) for five) left four tabs using only 80% of the bar and
     an empty track sitting at the right edge, so the items bunched to the left
     instead of spreading across the screen. Auto columns cannot drift like that
     again when a tab is added or removed. */
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
}
@media (min-width: 980px) { .bottomnav { display: none; } }
/* Two states wear no chrome at all, and each says so itself.

   The full-screen page viewer is one: that screen is the leaflet page and
   nothing else — a dark overlay over the whole app, with its own bar at the top
   and its own prev/next at the foot — so while it is open the tab bar leads
   nowhere it does not already cover. It marks itself on `<body>` (`pv-open`, set
   and cleared by `openPageViewer` in app.js), in the same function that shows
   and hides the overlay, so the bar is back in the same paint that closes it.

   The chain's own screen is the other, and that one is a decision about the
   screen rather than about a state: a chain's brochure screen is the store, its
   two tabs and its pages, so it wears neither the header nor the tab bar and the
   leaflet is the whole of it. It marks the body with its screen name for this
   (`syncChrome` in app.js), beside `pv-open`. It is, in fact, the route keying
   that the viewer's own rule replaced: that one took the bar off the brochure
   screen as a side effect while the viewer was being fixed, which is why the
   comment here used to say the brochure stack keeps the bar like every other
   screen. Taking the bar off it is the point now, so the keying is back — and it
   takes the header with it, which is what leaves the screen nothing but its own
   store bar and its leaflet. Everywhere else — the circulars feed, a flyer, a
   single deal, a product, the store directory, search, the lists, Data &
   settings — keeps both.

   The attribute is `data-screen`, not `data-route`: the sidebar's own links
   carry `data-route`, and `syncChrome` lights every element a bare
   `[data-route]` matches, so a second meaning for that name put `class="active"`
   and `aria-current="page"` on the document body. Both selectors here also
   carry their own element (`body.pv-open …`, `body[data-screen=…] …`) rather
   than being bare attribute or class selectors, so that they outweigh the
   forced-phone-frame rules further down, which would otherwise turn the controls
   back on in the mobile viewport.

   `--nav-h` — the room the ring's bar and the toasts leave for the tab bar —
   goes to zero on both, so nothing keeps a tab bar's worth of clearance while
   the bar is not there. The header's height goes to zero with the header, which
   is why `syncTopbarHeight` is measured per screen rather than once per load
   (see `render` in app.js): the chain header sticks to whatever that number is.

   What the screen is left with is its edges, so the shell's room for the bar
   goes back to being room for the page: the phone's 110 px and the wide
   layout's 60 px were the fixed bar's clearance to begin with. The top inset is
   the display cutout's, which the header used to be covering. */
body.pv-open { --nav-h: 0px; }
body.pv-open .bottomnav { display: none; }
body.pv-open { --nav-h: 0px; }
body[data-screen='brochure'] { --nav-h: 0px; }
body[data-screen='brochure'] .bottomnav { display: none; }
body[data-screen='brochure'] .topbar { display: none; }
body[data-screen='brochure'] .shell {
  padding-top: calc(18px + env(safe-area-inset-top));
  padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
@media (min-width: 980px) {
  body[data-screen='brochure'] .shell { padding-top: 24px; }
  /* The sidebar sticks below the header on every other screen; with no header on
     this one it would drift 60 px and then hang below the top edge. */
  body[data-screen='brochure'] .sidenav { top: 24px; }
}
.bnav-item {
  border: 0; background: transparent; text-decoration: none; color: var(--ink-3);
  padding: 8px 2px 9px; display: flex; flex-direction: column; align-items: center; gap: 2px;
  font-size: 10.5px; font-weight: 700; position: relative; line-height: 1.4;
}
.bnav-item.active { color: var(--accent); }
.bnav-badge {
  position: absolute; top: 4px; left: 50%; margin-left: 6px;
  background: var(--accent); color: #fff; font-size: 9.5px; font-weight: 800;
  min-width: 16px; height: 16px; border-radius: 999px; display: grid; place-items: center;
  padding: 0 4px;
}

/* ---------- primitives ---------- */

.card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow-1);
}
.pad { padding: 15px; }
.stack { display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; align-items: center; gap: 9px; }
.row.wrap { flex-wrap: wrap; }
.spread { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.grow { flex: 1; min-width: 0; }
.muted { color: var(--ink-3); }
.small { font-size: 12.5px; }
.tiny { font-size: 11.5px; }
.strong { font-weight: 700; }
.strike { text-decoration: line-through; color: var(--ink-3); }
.nowrap { white-space: nowrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Off screen but still read aloud: the flyer's tap feedback is the ink circle
   itself, so the sentence that says what happened goes here rather than into a
   toast card covering the page it is talking about. */
.sr-only {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 11px; }
.section-head h2 { display: flex; align-items: center; gap: 8px; }
.linkbtn {
  border: 0; background: transparent; color: var(--accent); font-weight: 700;
  font-size: 13px; padding: 2px 0; text-decoration: none;
}
.linkbtn:hover { text-decoration: underline; }

.btn {
  border: 1px solid var(--border); background: var(--surface); border-radius: 11px;
  padding: 9px 14px; font-weight: 700; font-size: 14px; line-height: 1.5;
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  transition: transform .1s, background .15s, border-color .15s;
}
.btn:hover { background: var(--surface-3); }
.btn:active { transform: scale(.98); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: #d9300f; }
.btn.dark { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.btn.go { background: var(--go); border-color: var(--go); color: #fff; }
.btn.go:hover { background: #0b8a5f; }
.btn.line { background: var(--line); border-color: var(--line); color: #fff; }
.btn.line:hover { background: #05b34c; }
.btn.sm { padding: 6px 11px; font-size: 12.5px; border-radius: 9px; }
.btn.block { width: 100%; }
.btn[disabled] { opacity: .5; cursor: not-allowed; }

.chip {
  border: 1px solid var(--border); background: var(--surface); color: var(--ink-2);
  border-radius: 999px; padding: 6px 12px; font-size: 13px; font-weight: 600;
  display: inline-flex; align-items: center; gap: 6px; line-height: 1.6;
  transition: background .15s, color .15s, border-color .15s;
}
.chip:hover { border-color: var(--border-strong); }
.chip[aria-pressed='true'] { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.chip.mini { padding: 3px 9px; font-size: 11.5px; }

.badge {
  display: inline-flex; align-items: center; gap: 4px; border-radius: 7px;
  padding: 2px 7px; font-size: 11.5px; font-weight: 800; line-height: 1.6;
  background: var(--surface-3); color: var(--ink-2); white-space: nowrap;
}
.badge.save { background: var(--accent); color: #fff; }
.badge.best { background: var(--go); color: #fff; }
.badge.member { background: var(--info-soft); color: var(--info); }
.badge.coupon { background: var(--warn-soft); color: var(--warn); }
.badge.hot { background: #fff1e0; color: #b45309; }
[data-theme='dark'] .badge.hot { background: #33240d; color: #fbbf24; }
.badge.ghost { background: transparent; border: 1px solid var(--border); color: var(--ink-3); }

.storetag {
  display: inline-flex; align-items: center; gap: 6px; font-weight: 700; font-size: 12.5px;
  color: var(--ink-2);
}

/* A store's group in the shopping list is headed by the store itself: its own
   mark and its name, in the place a shopper scans for when they are standing in
   one shop and want only that shop's rows. The hairline under it closes the
   heading so the rows below read as one stop. */
.storegroup {
  display: flex; flex-wrap: wrap; align-items: center; gap: 9px;
  padding-bottom: 9px; margin-bottom: 8px; border-bottom: 1px solid var(--border);
}
.storegroup-mark { display: flex; flex: none; }
/* The store's name is the one thing in this row that must not be shortened to
   fit: a heading that reads "ที…" names nothing, and it did before this was
   measured. It wraps instead, and the group's own controls wrap under it. */
.storegroup-name { margin: 0; min-width: 0; font-size: 16.5px; font-weight: 800; letter-spacing: -.01em; }
.storegroup .badge { flex: none; }
/* A store's box holds that store's priced rows and, under this smaller heading,
   the rings the shopper drew on that same store's leaflet: one box is one stop
   on the trip. The dashed rule separates the two kinds of row without reading as
   another store's heading, which the old pooled section left a shopper to work
   out from a sub-heading. */
.ringhead {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: 8px; margin: 14px 0 6px; padding-top: 12px; border-top: 1px dashed var(--border);
  font-size: 13.5px; font-weight: 800; color: var(--ink-2);
}
.storetag .dot { width: 9px; height: 9px; border-radius: 3px; flex: none; }

.hscroll { display: flex; gap: 12px; overflow-x: auto; padding: 2px 2px 12px; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
.hscroll::-webkit-scrollbar { height: 7px; }
.hscroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9px; }
.hscroll > * { scroll-snap-align: start; flex: 0 0 auto; }

.grid { display: grid; gap: 13px; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); }
.grid.wide { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.grid.tiles { grid-template-columns: repeat(auto-fill, minmax(146px, 1fr)); }

/* ---------- product glyph ---------- */

.thumb {
  position: relative; aspect-ratio: 4 / 3; border-radius: 11px; overflow: hidden;
  display: grid; place-items: center; flex: none;
}
.thumb svg { width: 46%; height: 46%; opacity: .95; }
/* The same photograph in the small square tile: the deal feed, search results,
 * the shopping list and the store screen all draw this one tile. */
.thumb-photo { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: contain; }
.thumb::after {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(120% 90% at 78% 12%, rgba(255,255,255,.5), transparent 62%);
}
.thumb .thumb-save {
  position: absolute; top: 7px; left: 7px; z-index: 2;
}

/* A row's picture is a link back to the brochure it came from: the tile itself
   is what you tap, so the thing you recognise is the thing that takes you there.
   One class for both kinds of row — a ring's crop opens the chain's own page at
   the spot it was circled, and an offer's tile opens the store's brochure on the
   page the item is printed on, with that page then opened full size zoomed to the
   item itself (see VIEWS.list and `openSpottedItem` in app.js; a chain whose
   pages are its own artwork, where no page can be named, keeps the brochure as
   the fallback). Both are `<a>`s, so the destination works as a copyable link
   and the tile keeps its own size. */
.thumblink {
  display: block; flex: none; width: 46px; padding: 0; border: 0;
  border-radius: 11px; background: none; color: inherit; line-height: 0;
  cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent;
}
.thumblink:active { transform: scale(.96); }
.thumblink:focus-visible { outline: 2px solid var(--go); outline-offset: 2px; }

/* A row's tile is 46px wide, which is narrower than either chip it can carry:
   the promo badge ("ซื้อเป็นแพ็ก", "Bundle") is anchored to the right edge, so
   the tile's rounded crop cut it down to its last two letters and left them
   lying over the photograph, and the saving chip sat on top of the picture as
   well. The row already prints the discount beside its own price, so on this
   tile the picture is the whole of it. */
.thumblink .thumb .badge { display: none; }

/* ---------- deal card ---------- */

.deal {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; display: flex; flex-direction: column; text-align: left;
  transition: box-shadow .16s, transform .16s, border-color .16s;
}
.deal:hover { box-shadow: var(--shadow-2); transform: translateY(-2px); border-color: var(--border-strong); }
.deal-body { padding: 10px 11px 12px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.deal-name { font-weight: 700; font-size: 13.5px; line-height: 1.6; }
.deal-brand { font-size: 11px; color: var(--ink-3); }
.deal-price { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.price-now { font-size: 19px; font-weight: 800; color: var(--accent); letter-spacing: -0.02em; }
.price-was { font-size: 12px; color: var(--ink-3); text-decoration: line-through; }
.deal-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: auto; }

/* ---------- circular book ---------- */

/* ---------- a store in the browse feed ---------- */

/* The feed's own grid. A column is never narrower than a card's two preview
   panels side by side (so they are never cropped by their own column), and a
   lone column is never left stretched across a desk (the card caps itself). */
.feed { display: grid; gap: 13px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); }

/* The card a leaflet app shows a store in: who the chain is, this issue, and
   the first pages of its leaflet — two of them, side by side. Every chain gets
   this card, so no store is represented by its logo while a neighbour is
   represented by its circular. */
.fcard {
  display: flex; flex-direction: column; overflow: hidden;
  width: 100%; max-width: 420px; justify-self: center;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow-1);
}
.fcard-head { display: flex; align-items: center; gap: 10px; padding: 12px 12px 9px; }
.fcard-who { display: flex; align-items: center; gap: 11px; flex: 1; min-width: 0; text-decoration: none; color: inherit; }
.fcard-title { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.fcard-name { font-weight: 800; font-size: 1.07rem; line-height: 1.2; }
.fcard-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
/* The favourite is the app's existing follow toggle: a card is both the way in
   to a leaflet and the way to stop seeing a chain you never shop at. */
.fcard-fav {
  flex: none; width: 40px; height: 40px; display: grid; place-items: center;
  border: 0; border-radius: 50%; cursor: pointer; color: var(--ink-3); background: none;
}
.fcard-fav:hover { background: var(--surface-3); }
.fcard-fav[aria-pressed="true"] { color: var(--accent); }
.fcard-fav[aria-pressed="true"] svg { fill: currentColor; }
.fcard-pages { display: flex; align-items: flex-start; justify-content: center; gap: 8px; padding: 2px 12px 0; overflow: hidden; }
.fcard-note { padding: 2px 12px 0; margin: 0; line-height: 1.6; }
.fcard-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px 12px; margin: 0; }

/* A leaflet page shown in a feed tile. The page is laid out at its own reading
   width and scaled down into the tile, so what a preview shows is the page
   itself and not a second, smaller drawing of it that could drift from the
   real thing. The tile is the window it is seen through. */
.pv {
  /* Fluid within the card: the pair share the row, so a wide card shows two
     bigger pages and a narrow one shows two smaller, and neither pushes the
     card out of its own grid. `fitPreviews` scales each page to its tile. */
  position: relative; flex: 1 1 146px; min-width: 0; aspect-ratio: 146 / 207;
  overflow: hidden; background: var(--surface-3);
  border: 1px solid var(--border); border-radius: 10px;
}
.pv-scale { display: block; transform-origin: 0 0; }
/* Inside a tile the page is a picture: no drop shadow (the tile already has an
   edge) and no rounded corner of its own, which would show the tile behind it. */
.pv-scale .flyer-page { box-shadow: none; border-radius: 0; }
.pv-img, .circ-pv img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center top; }

/* Circular shortcut on the home feed — the same two ideas as the card above in
   one page instead of two: the leaflet's own first page, and the chain's name
   over it. */
.circ-card {
  width: 146px; height: 207px; flex: none; position: relative; overflow: hidden;
  display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch;
  text-decoration: none; color: inherit;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  transition: box-shadow .16s, transform .16s, border-color .16s;
}
.circ-card:hover { box-shadow: var(--shadow-1); border-color: var(--border-strong); transform: translateY(-1px); }
.circ-pv { position: absolute; inset: 0; overflow: hidden; }
.circ-name { font-size: 11.5px; font-weight: 800; line-height: 1.35; }
.circ-count { font-size: 10px; color: rgba(255, 255, 255, .84); }
.circ-band {
  position: relative; width: 100%; padding: 22px 8px 8px; text-align: left;
  color: #fff; display: flex; flex-direction: column; gap: 1px;
  background: linear-gradient(transparent, rgba(6, 9, 14, .86));
}

/* The circulars feed's cover banner (`.chero` and its band) is gone: the card it
   stood in front of opens the same brochure, so the rules it needed have no
   callers left. */

/* The card's rows are gone: every store is a `.fcard` now, so the row-shaped
   list and its cover slot have no callers left. */

/* ---------- list / rows ---------- */

.rows { display: flex; flex-direction: column; }
.rowitem {
  display: flex; align-items: center; gap: 11px; padding: 11px 4px;
  border-bottom: 1px solid var(--border);
}
.rowitem:last-child { border-bottom: 0; }
.checkbox {
  width: 24px; height: 24px; border-radius: 7px; border: 1.8px solid var(--border-strong);
  background: var(--surface); flex: none; display: grid; place-items: center; padding: 0;
}
.checkbox[aria-checked='true'] { background: var(--go); border-color: var(--go); color: #fff; }
.rowitem.done .rowlabel { text-decoration: line-through; color: var(--ink-3); }

/* A row whose leaflet window has ended. It stays on the list with its quantity
   and its tick — the shopper still put it there — but it is a record of a shop
   rather than a price to shop with: dimmed whole, its picture is no longer a
   link into a page that prints that price, and the chip where the discount was
   says when it ended. `opacity` rather than a colour on each part, so the row
   reads as one thing in both themes and the checkbox stays as clickable as it
   was (it is only less prominent, never disabled). */
.rowitem.expired { opacity: .55; }
.rowitem.expired .rowlabel { color: var(--ink-3); }
/* The end of the window, said in the meta line rather than as a chip: a pill here
   reads as another control, and this row has no controls to offer beyond the
   quantity and the bin. */
.endednote { color: var(--ink-3); font-weight: 700; }
.thumblink.off { cursor: default; }
.thumblink.off:active { transform: none; }

/* A ring drawn on a leaflet page, as a list row: the picture is the region
   inside the ring, cut out of the chain's own page. `.plain` is for a ring whose
   crop is missing (it was drawn before its page had loaded and the picture never
   arrived), and it carries the pen mark instead. It is not called `.empty`, which
   this stylesheet already uses for a whole empty screen — 44 px of padding on
   each side, which measured 88 px tall on a 46 px tile. */
.markthumb {
  position: relative; display: grid; place-items: center; width: 46px; height: 46px;
  border-radius: 10px; overflow: hidden; flex: none; color: var(--ink-3);
  background: var(--surface-3); box-shadow: inset 0 0 0 1px var(--border);
}
.markthumb img { width: 100%; height: 100%; object-fit: cover; }
/* The pen frame, on the card's larger tile and nowhere else. It used to be drawn
   over every ring's thumbnail, the shopping list's 46 px rows included — where it
   was a second mark for something each row already says: the rows sit under a
   heading that names them (*Circled on a brochure page*) and each carries its own
   page line. A 40 px red ellipse drawn over a 46 px picture is also drawn over the
   picture, and the crop is the one thing on that row the shopper recognises the
   product by. */
.markthumb.lg:not(.plain)::after {
  content: ''; position: absolute; inset: 5px; border-radius: 50%;
  border: 2.5px solid rgba(217, 45, 32, .85);
}
/* A ring's own two lines: what it is, and where it was circled. The leaflet prints
   a name and a price and no source here can read either, so the row carries them
   only where the app has them, and says the store and the page otherwise. */
.markfoot { display: flex; align-items: center; gap: 8px; margin-top: 4px; min-width: 0; }

/* The picture a selection was made out of: the product's own photograph, or the
   crop the ring took. */
.pick { display: flex; gap: 12px; align-items: flex-start; }
.markthumb.lg { width: 78px; height: 78px; border-radius: 13px; }

/* The bar a selection raises: a strip that slides up over the foot of the page
   with what was picked and the button that shares it.

   Deliberately not a sheet. There is no scrim and nothing is dimmed: the page it
   is about stays readable and scrollable behind it, because the circle is still
   the whole confirmation and the bar is only what can be done with it. It clears
   the bottom nav on a phone and clears nothing on a wide screen, where that nav
   is gone, and it is one short row however much is inside it. */
.pickbar {
  position: fixed; z-index: 55; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--nav-h) + 10px); width: min(540px, calc(100vw - 20px));
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-3);
  padding: 12px; animation: slideup .22s cubic-bezier(.22,.9,.3,1);
}
@media (min-width: 980px) { .pickbar { bottom: 18px; } }
[data-viewport='mobile'] .pickbar { bottom: calc(var(--nav-h) + 10px); }
[data-viewport='desktop'] .pickbar { bottom: 18px; }
/* The pen works on the page opened full size as well as on the stack, so the bar
   a ring raises there has to be above the viewer rather than behind it (the
   viewer is 70, the sheet 75) — and clear of the viewer's own page controls,
   which stay reachable underneath it. */
body.pv-open .pickbar { z-index: 76; bottom: calc(66px + env(safe-area-inset-bottom)); }
/* The `.pbread` rule that stood here is gone with the reading row it styled: the
   line under the card that said a read was running, or that it had failed, or
   that the block held no name and no price. The app no longer reads a leaflet's
   print (see `selectionBar` in app.js), so no markup carries that class. */
.pickbar .pb-x { position: absolute; top: 4px; right: 4px; }
.pickbar .price-now { font-size: 1.15rem; }
.pb-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.pb-actions .pb-share { flex: 1; justify-content: center; }
.sheet:focus { outline: none; }

.qtybox { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; flex: none; }
.qtybox button { border: 0; background: transparent; width: 28px; height: 28px; display: grid; place-items: center; font-weight: 800; }
.qtybox button:hover { background: var(--surface-3); }
.qtybox span { min-width: 22px; text-align: center; font-weight: 700; font-size: 13px; }


.toolbar {
  display: flex; gap: 9px; align-items: center; flex-wrap: wrap;
  padding: 11px 12px; background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); position: sticky; top: calc(var(--header-h) + 6px); z-index: 20;
}
select.control, input.control {
  border: 1px solid var(--border); background: var(--surface); border-radius: 10px;
  padding: 7px 10px; font-size: 13.5px; font-weight: 600;
}
input[type='range'] { accent-color: var(--accent); width: 132px; }

/* ---------- sheet / modal ---------- */

.scrim {
  position: fixed; inset: 0; background: rgba(12, 15, 22, .5); z-index: 60;
  backdrop-filter: blur(3px); animation: fade .16s ease-out;
}
.sheet {
  position: fixed; z-index: 61; background: var(--surface); overflow-y: auto;
  left: 0; right: 0; bottom: 0; max-height: 92vh;
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  box-shadow: var(--shadow-3); animation: slideup .22s cubic-bezier(.22,.9,.3,1);
  padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
@media (min-width: 760px) {
  .sheet {
    left: 50%; right: auto; bottom: auto; top: 50%;
    transform: translate(-50%, -50%); width: min(620px, 94vw);
    max-height: 88vh; border-radius: var(--radius-lg);
    animation: pop .18s ease-out;
  }
}
.sheet-head {
  position: sticky; top: 0; z-index: 2; background: var(--surface);
  border-bottom: 1px solid var(--border); padding: 13px 15px;
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.sheet-body { padding: 15px; }
.sheet-grab { width: 38px; height: 4px; border-radius: 9px; background: var(--border-strong); margin: 8px auto 0; }
@media (min-width: 760px) { .sheet-grab { display: none; } }

/* A sheet's menu: one row per choice, the whole row tappable, the way out
   pinned right so a column of them reads as a list of what can be done. */
.menu { display: flex; flex-direction: column; gap: 2px; }
.menu-row {
  display: flex; align-items: center; gap: 11px;
  padding: 12px 10px; border-radius: var(--radius-sm);
  color: var(--ink); text-decoration: none; font-weight: 600; font-size: 14px;
}
.menu-row > span { flex: 1; min-width: 0; }
.menu-row svg { color: var(--ink-3); flex: none; }
.menu-row:hover { background: var(--surface-3); }

@keyframes fade { from { opacity: 0; } }
@keyframes slideup { from { transform: translateY(16px); opacity: .4; } }
@keyframes pop { from { transform: translate(-50%, -48%) scale(.97); opacity: 0; } }

/* ---------- toast ---------- */

.toasts {
  position: fixed; z-index: 80; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--nav-h) + 14px); display: flex; flex-direction: column; gap: 8px; align-items: center;
  pointer-events: none; width: max-content; max-width: 92vw;
}
@media (min-width: 980px) { .toasts { bottom: 22px; } }
.toast {
  background: var(--ink); color: var(--bg); border-radius: 12px; padding: 10px 15px;
  font-size: 13.5px; font-weight: 600; box-shadow: var(--shadow-3);
  display: flex; align-items: center; gap: 9px; animation: slideup .18s ease-out;
  pointer-events: auto;
}
.toast .t-action { color: #ffb4a7; font-weight: 800; background: none; border: 0; padding: 0; }

.ptr { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 700; color: var(--ink-3); }

.notice {
  border-radius: var(--radius); padding: 12px 14px; font-size: 13px; line-height: 1.7;
  border: 1px solid var(--border); background: var(--surface-2);
}
.notice.info { background: var(--info-soft); border-color: transparent; }
.notice.warn { background: var(--warn-soft); border-color: transparent; }

.empty { text-align: center; padding: 44px 20px; color: var(--ink-3); }
.empty .art { font-size: 34px; margin-bottom: 8px; }

.skeleton {
  background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 37%, var(--surface-3) 63%);
  background-size: 400% 100%; animation: shimmer 1.3s ease-in-out infinite; border-radius: 9px;
}
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }

.bars { display: flex; flex-direction: column; gap: 9px; }
.bar-row { display: grid; grid-template-columns: 1fr auto; gap: 6px 10px; align-items: center; }
.bar-track { grid-column: 1 / -1; height: 8px; border-radius: 9px; background: var(--surface-3); overflow: hidden; }
.bar-fill { height: 100%; border-radius: 9px; }

.footnote { font-size: 11.5px; color: var(--ink-3); line-height: 1.75; }

.sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* ==========================================================================
   Viewport mode — force the mobile or desktop layout regardless of the real
   window size. Attribute selectors outweigh the media queries above, so these
   win at any width. `auto` (the default) keeps the responsive behaviour.
   ========================================================================== */

[data-viewport='mobile'] body { background: var(--surface-3); }
[data-viewport='mobile'] .app {
  max-width: 430px; margin: 0 auto; min-height: 100vh; background: var(--bg);
  border-left: 1px solid var(--border); border-right: 1px solid var(--border);
}
[data-viewport='mobile'] .topbar-inner { max-width: 430px; }
[data-viewport='mobile'] .shell { grid-template-columns: minmax(0, 1fr); padding: 18px 14px 110px; }
[data-viewport='mobile'] .sidenav { display: none; }
[data-viewport='mobile'] .brand-text { display: none; }
/* Same control, same place, in the forced phone frame. */
[data-viewport='mobile'] #langToggle { display: inline-flex; }
[data-viewport='mobile'] #langToggle button { padding: 5px 8px; font-size: 11.5px; }
[data-viewport='mobile'] .bottomnav {
  display: grid; left: 50%; right: auto; transform: translateX(-50%);
  width: 100%; max-width: 430px; border-left: 1px solid var(--border); border-right: 1px solid var(--border);
}
[data-viewport='mobile'] .toasts { bottom: calc(var(--nav-h) + 14px); }
[data-viewport='mobile'] .flyer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* Sheets stay as bottom sheets on a phone-shaped frame. */
[data-viewport='mobile'] .sheet {
  left: 50%; right: auto; bottom: auto; top: 50%; transform: translate(-50%, -50%);
  width: min(410px, 94vw); max-height: 88vh; border-radius: var(--radius-lg);
}
[data-viewport='mobile'] .sheet-grab { display: none; }

[data-viewport='desktop'] .app { min-width: 940px; }
[data-viewport='desktop'] .shell { grid-template-columns: 232px minmax(0, 1fr); padding: 24px 20px 60px; }
[data-viewport='desktop'] .sidenav { display: block; position: sticky; top: 84px; align-self: start; }
[data-viewport='desktop'] .bottomnav { display: none; }
[data-viewport='desktop'] .brand-text { display: inline; }
[data-viewport='desktop'] #langToggle { display: inline-flex; }
[data-viewport='desktop'] .toasts { bottom: 22px; }
[data-viewport='desktop'] .sheet {
  left: 50%; right: auto; bottom: auto; top: 50%; transform: translate(-50%, -50%);
  width: min(620px, 94vw); max-height: 88vh; border-radius: var(--radius-lg);
}
[data-viewport='desktop'] .sheet-grab { display: none; }

/* ==========================================================================
   Store marks — the retailer's own logo, with no box drawn behind it
   ========================================================================== */

.storelogo {
  position: relative; display: inline-grid; place-items: center; flex: none;
  border-radius: 8px; overflow: hidden; color: #fff; font-weight: 800;
  line-height: 1; letter-spacing: .02em; background: transparent;
}
.storelogo > span { position: relative; z-index: 1; }
/* The monogram is the tile's fallback, never a backdrop. The moment the artwork
   lands (`load` in `bind()` adds the class) it is hidden, because a file with
   transparency would otherwise show both — the mark, and the letters behind the
   parts of it the file leaves clear. That accident is only invisible while the
   monogram is white on a pale surface, which is not something to rely on: on the
   one mark the app plates it doubled the wordmark on the brand's own red. */
.storelogo.has-mark > span { visibility: hidden; }
/* The coloured tile is a fallback, never decoration. It is needed in two cases:
   a mark whose artwork has no ground of its own (flagged `onColor` in LOGO_FILE),
   which nothing but a coloured surface can show at all — the colour comes from
   `--mark-bg`, which is the ground the brand itself gives the mark when the app
   knows one (Tops Daily's is the chain's own red, the colour its wordmark is
   painted on in white) and the retailer's approximated colour otherwise; and a
   failed image, so that the monogram revealed underneath has something to sit on.
   Where the file is a tile of its own, the plate squares it off: the artwork is
   drawn `contain`, so without the plate a rounded tile would float in the row as
   a coloured band rather than the chip the brand shows. */
.storelogo.oncolor,
.storelogo.fallback {
  background: var(--mark-bg, #78808f);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12);
}
/* Where a mark's own ink would otherwise melt into the surface, its silhouette is
   traced with a soft contact shadow instead of being put back in a box: pale
   artwork on the light theme, dark artwork on the dark theme. Each mark's tone
   was measured from the file, and plated marks are excluded — their plate already
   separates them. */
.storelogo.ink-light:not(.oncolor):not(.fallback) {
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, .5)) drop-shadow(0 0 2.5px rgba(0, 0, 0, .26));
}
[data-theme='dark'] .storelogo.ink-light:not(.oncolor):not(.fallback) { filter: none; }
[data-theme='dark'] .storelogo.ink-dark:not(.oncolor):not(.fallback) {
  filter: drop-shadow(0 0 1px rgba(255, 255, 255, .6)) drop-shadow(0 0 2.5px rgba(255, 255, 255, .3));
}
/* The flyer masthead is the leaflet's own coloured header, and it stays coloured
   in both themes, so a dark mark standing on it is outlined in every theme. */
.flyer-masthead .storelogo.ink-dark:not(.oncolor):not(.fallback) {
  filter: drop-shadow(0 0 1px rgba(255, 255, 255, .6)) drop-shadow(0 0 2.5px rgba(255, 255, 255, .3));
}
/* Marks are a mix of square icons and wide wordmarks, so they are fitted rather
   than cropped. No plate of our own is painted behind them: any light edge you
   see comes from the file itself (some are supplied only as JPEG or PNG with a
   baked-in background). */
.storelogo img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: contain; z-index: 2; display: block; padding: 4%;
}
.storelogo.oncolor img { padding: 9%; }

/* ==========================================================================
   Interactive flyer — a printed leaflet you can write on
   Fixed colours on purpose: this represents paper, and stays "paper" in dark
   mode rather than inverting into something no leaflet ever looks like.
   ========================================================================== */

.flyer-stage { max-width: 100%; }

.flyer-page {
  position: relative; background: #fffdf6; color: #1b1b1b;
  border-radius: var(--radius); box-shadow: var(--shadow-2);
  overflow: hidden; isolation: isolate;
  background-image:
    repeating-linear-gradient(0deg, rgba(0, 0, 0, .014) 0 2px, transparent 2px 4px),
    radial-gradient(120% 60% at 92% 4%, rgba(226, 35, 26, .05), transparent 60%);
  font-family: var(--font);
}

.flyer-masthead {
  display: flex; align-items: center; gap: 11px; padding: 13px 15px; color: #fff;
  position: relative;
}
.flyer-masthead .fm-titles { min-width: 0; flex: 1; }
.flyer-masthead h1 { font-size: 1.05rem; color: #fff; }
.flyer-masthead .fm-meta { font-size: 11.5px; opacity: .92; line-height: 1.6; }
.flyer-masthead .fm-page {
  background: rgba(255, 255, 255, .22); border-radius: 8px; padding: 3px 9px;
  font-size: 12px; font-weight: 800; white-space: nowrap;
}

.flyer-strap {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding: 9px 15px; background: #f6efdc; border-bottom: 2px dashed #e2d6ba;
  font-size: 12.5px; color: #6b5a35; font-weight: 600;
}

.flyer-grid {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px; padding: 13px;
}

.flyer-item {
  position: relative; display: flex; flex-direction: column; gap: 5px;
  /* Both printings of an offer have to lay out identically. A flex <button>
     takes `align-items: flex-start` from the UA stylesheet where a <span> gets
     `normal`, and an aspect-ratio box with no intrinsic width collapses to
     nothing under flex-start — which is how the art box came to measure 0x0. */
  align-items: stretch;
  padding: 8px 9px 10px; text-align: left; background: #fff;
  border: 1px solid #ebe3d0; border-radius: 11px; color: #1b1b1b;
  box-shadow: 0 1px 2px rgba(60, 45, 10, .07);
  transition: transform .14s, box-shadow .14s, border-color .14s;
  transform: rotate(var(--tilt, 0deg));
}
.flyer-item:hover { transform: rotate(0deg) translateY(-2px); box-shadow: 0 8px 18px rgba(60, 45, 10, .16); z-index: 2; }
/* A circled item, whichever way the page was printed: the control marks itself
   with aria-pressed, and a read-only print — which must not be a button — marks
   itself with `.on`. Both have to look circled. */
.flyer-item[aria-pressed='true'], .flyer-item.on {
  border-color: #f0b4a8; box-shadow: 0 0 0 2px rgba(226, 35, 26, .16), 0 1px 2px rgba(60, 45, 10, .08);
}
.flyer-item[aria-pressed='true']::after, .flyer-item.on::after {
  content: '✓ ';
  position: absolute; left: 8px; top: 8px; z-index: 4;
  background: var(--accent); color: #fff; font-size: 10px; font-weight: 800;
  border-radius: 999px; width: 17px; height: 17px; display: grid; place-items: center;
  box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
}

.fi-art {
  position: relative; display: block; aspect-ratio: 1 / .82; border-radius: 9px; overflow: hidden;
  background:
    radial-gradient(90% 80% at 28% 16%, color-mix(in srgb, var(--c) 26%, #fff), color-mix(in srgb, var(--c) 9%, #fff));
}
.fi-glyph { position: absolute; inset: 0; margin: auto; width: 50%; height: 50%; color: var(--c); }
.fi-art::after {
  content: ''; position: absolute; inset: auto 0 0 0; height: 26%;
  background: linear-gradient(transparent, color-mix(in srgb, var(--c) 14%, transparent));
}
/*
 * The chain's own product photograph, printed over the category mark drawn for
 * it: `contain`, not `cover`, because these are catalogue cut-outs on a square
 * canvas and a cover-fit would crop the product into a box of another shape.
 * The photo brings its own white background, so it reads as a cut-out in both
 * themes. A load that fails removes the image (see the `error` handler in
 * app.js), leaving the mark underneath rather than a broken-image icon.
 */
.fi-photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.fi-art.has-photo::after { display: none; }
.fi-note { font-size: 10.5px; font-weight: 700; color: #9a6a12; }

.fi-brand { font-size: 10px; color: #8a8171; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.fi-name { font-size: 12.8px; font-weight: 700; line-height: 1.55; }
.fi-pack { font-size: 10.5px; color: #8a8171; }
.fi-prices { display: flex; align-items: baseline; gap: 6px; margin-top: auto; padding-top: 3px; }
.fi-now { font-size: 20px; font-weight: 800; color: #d92d20; letter-spacing: -.02em; }
.fi-was { font-size: 11.5px; color: #9a917f; text-decoration: line-through; }
.fi-per { font-size: 10px; color: #9a917f; }

.fi-burst {
  position: absolute; top: -9px; right: -9px; width: 44px; height: 44px; z-index: 3;
  display: grid; place-items: center; background: #e2231a; color: #fff;
  font-size: 11.5px; font-weight: 800; line-height: 1; transform: rotate(-14deg);
  clip-path: polygon(50% 0%, 62% 20%, 84% 13%, 80% 35%, 100% 44%, 82% 56%, 94% 76%, 71% 75%, 68% 98%, 50% 84%, 32% 98%, 29% 75%, 6% 76%, 18% 56%, 0% 44%, 20% 35%, 16% 13%, 38% 20%);
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .22));
}
.flyer-item[aria-pressed='true'] .fi-burst, .flyer-item.on .fi-burst { background: #b31b12; }
/* A read-only print is not a control: no pointer, no lift on hover. */
.pv-paper .flyer-item { cursor: default; }
.pv-paper .flyer-item:hover {
  transform: rotate(var(--tilt, 0deg)); box-shadow: 0 1px 2px rgba(60, 45, 10, .07);
}

.flyer-foot {
  display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
  padding: 11px 15px; border-top: 2px dashed #e2d6ba; font-size: 11.5px; color: #7d7358;
}

/* The ink layer sits above the page content but never eats clicks. There are
   two of them — one over a page of our own circular, one over a page of the
   chain's own artwork (`.art-ink`) — and they are the same pen, so an item
   circled on either kind of page is marked the same way. */
.flyer-ink, .art-ink { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; }
.flyer-ink ellipse, .art-ink ellipse {
  fill: none; stroke: #d92d20; stroke-width: 3.4; stroke-linecap: round; opacity: .9;
}
.flyer-ink ellipse.sketch, .art-ink ellipse.sketch { stroke-width: 2.2; opacity: .42; }
.flyer-ink ellipse.fresh, .art-ink ellipse.fresh {
  stroke-dasharray: var(--len, 1200); stroke-dashoffset: var(--len, 1200);
  animation: inkin .55s cubic-bezier(.45, 0, .35, 1) forwards;
}
/* A tap where the page shows nothing to circle: the same ring, grey and dashed,
   fading out where the finger landed. The leaflet's own answer, rather than a
   card over it — and a screen reader gets the sentence (see `refuseRing`). */
.art-ink ellipse.refused {
  stroke: #6b7280; stroke-width: 2.6; stroke-dasharray: 9 7; opacity: 0;
  animation: refuse .9s ease-out forwards;
}
@keyframes refuse { 12% { opacity: .85; } 100% { opacity: 0; } }
@keyframes inkin { to { stroke-dashoffset: 0; } }

.flyer-nav { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.flyer-dots { display: flex; gap: 5px; flex-wrap: wrap; }
.flyer-dots button {
  width: 26px; height: 26px; border-radius: 8px; border: 1px solid var(--border);
  background: var(--surface); font-size: 11.5px; font-weight: 700; color: var(--ink-2); padding: 0;
}
.flyer-dots button[aria-current='true'] { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.flyer-dots button:hover { border-color: var(--border-strong); }

.circled-count {
  display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 700;
  color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 3px 11px;
}

@media (max-width: 620px) {
  .flyer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; padding: 11px; }
  .fi-name { font-size: 12.2px; }
  .fi-burst { width: 39px; height: 39px; font-size: 10.5px; }
}

/* ==========================================================================
   The retailer's own brochure — their pages, read inside the app.
   The whole point of the screen: the chain's header and tabs stay put, and the
   leaflet itself is a stack of full-width pages you scroll, one after another.
   ========================================================================== */

.brochure-mark { display: inline-flex; flex: 0 0 auto; }

/* The campaign slot on a brochure screen, at both of the places it is sold — the
   head of the screen and the foot of the leaflet, after the last page: the space
   a store buys, 702 × 396, holding the store's own artwork and opening the
   store's own campaign page in a new tab. One rule for both placements, because
   they are one buy: the shape is what the store pays for, so nothing about it
   may depend on where on the screen it stands.

   `.campaign` is rendered only when the store has supplied artwork, so in the
   app it exists exactly when there is a picture to go in it: an unsold
   placement is not part of the screen (see `campaignSlot` in app.js).

   `aspect-ratio` and not a height, because the slot has to be the same shape at
   every width — the size the artwork is cut for is part of what the store buys —
   and `max-width` stops it growing past that size on a wide screen, where it is
   centred in the column. `object-fit: cover` is what keeps the artwork filling
   it: the slot is the store's, so no part of it is ever the surface colour
   behind the panel.

   The panel underneath — `.campaign-house` and its four lines — is the *empty*
   slot: the space named, measured and linked. The app no longer draws it; it is
   kept for the uploader's own page, which links this stylesheet and previews the
   space it is filling (`tools/admin/`, “Preview the 702 × 396 slot”). */
.campaign {
  position: relative; display: block; width: 100%; max-width: 702px; margin-inline: auto;
  aspect-ratio: 702 / 396; overflow: hidden; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-3);
  color: var(--ink); text-decoration: none; box-shadow: var(--shadow-1);
}
.campaign:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.campaign-house {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: flex-start; justify-content: center; gap: 5px; padding: 15px 17px;
}
.campaign-kicker { font-size: 11.5px; font-weight: 700; letter-spacing: 0.02em; color: var(--ink-3); }
.campaign-title { font-size: 17px; font-weight: 800; letter-spacing: -0.01em; }
.campaign-line { font-size: 12.5px; color: var(--ink-2); }
.campaign-cta {
  display: inline-flex; align-items: center; gap: 6px;
  margin-top: 4px; font-size: 12.5px; font-weight: 700; color: var(--accent-ink);
}
.campaign-photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* A paid placement says so, on the placement: over the artwork when there is
   some, over the empty slot when there is not. */
.campaign-tag {
  position: absolute; top: 8px; right: 8px; z-index: 2;
  padding: 2px 8px; border-radius: 999px; letter-spacing: 0.02em;
  font-size: 10.5px; font-weight: 700; color: #fff; background: rgba(12, 15, 22, 0.6);
}

/* Header and tabs travel as one sticky block, so the chain and the issue stay
   readable nine pages into a leaflet and the tabs are always one tap away. */
.bsticky {
  position: sticky; top: var(--topbar-h, var(--header-h)); z-index: 25;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow-1); overflow: hidden;
}
/* On the chain's own screen, and on a phone, that block runs the full width of
   the screen — the same move the pages make (`@media (max-width: 720px)`, see
   `.bpages` below), at the same breakpoint and for the same reason.

   This is the one screen that is the leaflet and its edges (see "Two states
   wear no chrome at all" above): the header and the tab bar are gone, so the
   block over the pages is the screen's whole chrome, and a card floating inset
   from the edges read as one more card in a stack that is not there — the
   pages under it already reach both edges, so the bar was the one thing on the
   screen that did not. The room is the shell's own padding, taken back here
   rather than by narrowing the shell, which would move the sidebar and the
   column with it. Square, and without its side borders, because a band that
   reaches the edges is not a card: the top and bottom rules and the shadow
   stay, and they are what separate it from the pages that scroll under it.
   Above this width the block keeps the card it has always been, because the
   pages stop being edge to edge there too — a print page is capped and centred
   instead — and the two stay in step either way. */
@media (max-width: 720px) {
  body[data-screen='brochure'] .bsticky {
    margin-inline: -14px;
    border-left: 0; border-right: 0; border-radius: 0;
  }
}
/* The store screen's control bar, above the store itself: back, a search, the
   list with its count, the favourite and the overflow. Every control is an
   icon and none of them is labelled, which is what keeps the leaflet — not the
   app's chrome — the thing the screen is about. */
.bbar {
  display: flex; align-items: center; gap: 2px; padding: 4px 6px;
  border-bottom: 1px solid var(--border);
}
.bbar .iconbtn { position: relative; }
/* Following a chain is the one control here that is a state rather than a move,
   so it is the one that keeps a colour when it is on. */
.bbar .iconbtn.on { color: var(--accent); }
.bbar .iconbtn.on:hover { background: var(--accent-soft); }

/* The count rides on the list icon, ringed in the surface colour so it reads
   over the card in either theme. */
.ixcount {
  position: absolute; top: -3px; right: -3px;
  background: var(--accent); color: #fff; font-size: 9.5px; font-weight: 800;
  min-width: 16px; height: 16px; border-radius: 999px;
  display: grid; place-items: center; padding: 0 4px;
  box-shadow: 0 0 0 2px var(--surface);
}

.bhead { display: flex; align-items: center; gap: 11px; padding: 11px 13px; }
.bhead-name { font-size: 1.16rem; }
.bhead-meta {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  font-size: 12px; color: var(--ink-3); margin-top: 3px;
}
.bhead-meta > span { display: inline-flex; align-items: center; gap: 5px; }
.badge.new { background: var(--info-soft); color: var(--info); }

.btabs { display: flex; border-top: 1px solid var(--border); }
.btab {
  flex: 1; text-align: center; text-decoration: none; padding: 11px 8px 12px;
  font-weight: 700; font-size: 14px; color: var(--ink-3);
  border-bottom: 2.5px solid transparent;
}
/* The active tab is marked the way a leaflet app marks it: one coloured bar
   under the word you are reading, not a filled pill. */
.btab[aria-selected='true'] { color: var(--info); border-bottom-color: var(--info); }
.btab:hover { color: var(--ink-2); }

.bpages { display: flex; flex-direction: column; gap: 10px; }
.bpage { margin: 0; position: relative; }
.bpage-view {
  display: block; width: 100%; padding: 0; line-height: 0;
  border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; background: var(--surface); box-shadow: var(--shadow-1);
}
.bpage-view img { display: block; width: 100%; height: auto; }
.bpage-view:active { transform: scale(.996); }
/* A page of the chain's own artwork is a drawing surface: tap a product and a
   ring is drawn around it, the way a pen marks a paper leaflet. The page itself
   is the target rather than anything in it, because a picture has no products in
   its markup to hit — and a tap there must never open anything, so full screen
   is the page's own corner button. `touch-action: manipulation` keeps the finger
   scrolling the stack rather than being captured by the page. */
.bpage-art {
  position: relative; cursor: crosshair; touch-action: manipulation;
  -webkit-user-select: none; user-select: none;
}
.bpage-art img { -webkit-user-drag: none; }
.bpage-art:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.bpage-zoom {
  position: absolute; right: 9px; bottom: 9px; width: 30px; height: 30px;
  border-radius: 9px; display: grid; place-items: center;
  background: rgba(10, 14, 20, .6); color: #fff; backdrop-filter: blur(4px);
  border: 0; cursor: pointer;
}

/* A page of our own circular, printed into the stack. It carries the same frame
   as a served page, so a stack of ours and a stack of the chain's own artwork
   read as the same thing — which is the point of the screen. */
/* A page of our own circular, printed into the stack. It carries the same frame
   as a served page, so a stack of ours and a stack of the chain's own artwork
   read as the same thing — which is the point of the screen. */
.bpage-paper {
  border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; box-shadow: var(--shadow-1);
}
.bpage-paper .flyer-page { border-radius: 0; box-shadow: none; }
.bpage-num {
  position: absolute; left: 10px; bottom: 10px; border-radius: 999px;
  padding: 2px 9px; font-size: 11.5px; font-weight: 700;
  background: rgba(10, 14, 20, .6); color: #fff; backdrop-filter: blur(4px);
}
.bpage-num .muted { color: rgba(255, 255, 255, .74); }

/* ---------- one page, full screen ---------- */

body.pv-open { overflow: hidden; }
.pageview {
  position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column;
  background: #0b0d11; color: #eef1f5;
}
.pv-bar, .pv-foot { display: flex; align-items: center; gap: 10px; padding: 8px 12px; flex: none; }
.pv-count, .pv-name { flex: 1; text-align: center; font-size: 13px; font-weight: 700; opacity: .88; }
.pv-btn {
  border: 1px solid rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .08);
  color: #fff; width: 42px; height: 42px; border-radius: 12px;
  display: grid; place-items: center; flex: none;
}
.pv-btn[aria-pressed='true'] { background: var(--accent); border-color: var(--accent); }
.pv-btn:disabled { opacity: .32; }
/* `safe center`, not plain `center`, on both axes: a page that fits is centred
   in the pane, but a page larger than it (the zoomed one) falls back to the
   pane's own edge so it scrolls from its first line and its first column instead
   of being cut off with no way back. Measured in the browser: 552 px page in a
   728 px pane centres at 88 px, and the same page zoomed to 1061 px sits at 0
   and scrolls.
   The horizontal half is not a nicety: centred *overflow* is unreachable, so
   before this a zoomed page put half of itself off the left edge where no scroll
   could reach it — measured on a 394 px pane, a 758 px page scrolled 0–190 px,
   which is the right half only. That is where a shopping-list link's product sat
   whenever it was in the page's first or second column (see `centreOnItem` in
   app.js), which is why the two are fixed together. */
.pv-stage {
  flex: 1; overflow: auto; display: grid;
  /* One explicit track, so a page is sized by the pane rather than by its own
     widest content: a printed page's grid would otherwise report max-content
     and blow past the screen it is meant to be read at. */
  grid-template-columns: minmax(0, 1fr);
  align-content: safe center; justify-items: safe center;
  -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
.pv-img { display: block; width: 100%; max-width: 760px; height: auto; }
/* The wrapper the full-screen page is drawn on: it carries the ink layer, so the
   rings a page has are on it when the page is opened here — which is where a
   ring's own row on the shopping list leads (see `drawFlyerInk`). */
.pv-art { display: block; width: 100%; max-width: 760px; position: relative; line-height: 0; }
.pv-art .pv-img { width: 100%; max-width: none; height: auto; }
/* Doubled, the served 1130 px page is still wider than the pixels it lands on,
   so zoomed text stays sharp instead of being magnified into blur. */
.pv-img.zoomed, .pv-art.zoomed { width: 200%; max-width: none; }
/* One of our pages in the same viewer. It is vector text, so doubling it is
   lossless — the zoom control means the same thing on both kinds of page. */
.pv-paper { display: block; width: 100%; max-width: 760px; }
.pv-paper .flyer-page { border-radius: 0; box-shadow: none; }
.pv-paper.zoomed { width: 200%; max-width: none; }

/* In the phone frame the viewer stays inside the frame, like the bottom nav. */
[data-viewport='mobile'] .pageview {
  left: 50%; right: auto; transform: translateX(-50%); width: 100%; max-width: 430px;
  border-left: 1px solid var(--border); border-right: 1px solid var(--border);
}

@media (max-width: 720px) {
  /* Edge to edge: on a phone the leaflet gets the full width of the screen,
     which is what makes a print page readable at all. */
  .bpages { gap: 6px; margin-left: -14px; margin-right: -14px; }
  .bpage-view { border-radius: 0; border-left: 0; border-right: 0; }
  /* No aspect ratio here on purpose. Holding a page of ours to the shape of a
     served page was tried and reverted: `aspect-ratio` with an auto height sets
     a definite height rather than a minimum, so the six-offer page came out
     893 px tall inside a 531 px figure and `overflow: hidden` cut 362 px off
     the bottom of it. A page takes its own height; the tiers differ in length
     because a chain's own page is dense and ours is six offers. */
  .bpage-paper { border-radius: 0; border-left: 0; border-right: 0; }
  .pv-img { max-width: none; }
  .pv-art { max-width: none; }
  .pv-paper { max-width: none; }
}

@media (min-width: 721px) {
  /* A print page stretched across a desktop column is worse to read, not
     better. It is capped at the same width the full-screen viewer uses, so
     tapping a page opens it at the size it was already being read at. */
  .bpages { max-width: 760px; margin-inline: auto; }
}

.brochure-frame {
  border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; background: var(--surface-3); box-shadow: var(--shadow-2);
}

.bf-bar {
  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
  padding: 8px 12px; background: var(--surface); border-bottom: 1px solid var(--border);
}
.bf-bar .small { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
.bf-bar .btn { margin-left: auto; }

/* Tall enough to read a leaflet page, bounded so the app's own controls stay
   reachable underneath a third-party page we do not control. */
.brochure-frame iframe { display: block; width: 100%; height: min(78vh, 1040px); border: 0; background: #fff; }

@media (max-width: 720px) {
  .brochure-frame iframe { height: 68vh; }
}

/* ---------- product hotspots ----------
   The brochure is the interface: a page of the chain's own artwork with a layer
   of product-sized buttons over it. The buttons themselves paint nothing, so
   the leaflet looks exactly as the retailer printed it until a product is
   chosen. Their boxes are fractions of the page (see `hotspotLayer`), which is
   what keeps them on their products through a resize and through the viewer's
   zoom without re-measuring anything.
   The layer ignores the pointer; only its buttons take one. That is what leaves
   the page underneath scrollable, and what makes a tap on the paper a tap on the
   page rather than on a full-page invisible sheet. */
.hotspot-layer {
  position: absolute; inset: 0; z-index: 3; pointer-events: none;
  overflow: hidden; border-radius: inherit;
}
.hotspot {
  position: absolute; padding: 0; border: 0; background: transparent;
  pointer-events: auto; cursor: pointer; border-radius: 10px;
  color: inherit; -webkit-tap-highlight-color: transparent;
}
.hotspot:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.hotspot .hs-ring {
  position: absolute; display: none; border-radius: 50%;
  border: 3px solid var(--accent);
  box-shadow: 0 0 0 3px rgba(255, 255, 255, .55), 0 0 22px rgba(0, 0, 0, .25);
  pointer-events: none; animation: hspop .28s cubic-bezier(.2, .9, .3, 1.2);
}
.hotspot.on .hs-ring { display: block; }
@keyframes hspop {
  from { transform: scale(.55); opacity: 0; }
  70% { transform: scale(1.04); opacity: 1; }
  to { transform: scale(1); opacity: 1; }
}
/* The same choice on one of our own printed cards: the card is already a
   button, so the highlight is its edge and a soft wash, not a second ring. */
.flyer-item.hs-on {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
  box-shadow: 0 0 0 6px rgba(255, 255, 255, .35), 0 10px 24px rgba(20, 40, 80, .2);
}

/* ---------- the product sheet ----------
   A bottom sheet, not a dialog: there is no scrim, nothing is dimmed and the
   brochure stays readable behind it, because the circle on the product and the
   sheet are two halves of the same answer. It clears the phone's bottom bar and
   appears above the full-screen viewer (z-index 75 against the viewer's 70). */
.hs-wrap { position: fixed; inset: 0; z-index: 75; pointer-events: none; }
.hs-sheet {
  position: absolute; left: 50%; bottom: 0; transform: translate(-50%, 105%);
  width: min(540px, 100%); max-height: min(72vh, 560px);
  display: flex; flex-direction: column; pointer-events: auto;
  background: var(--surface); color: var(--ink);
  border-radius: 20px 20px 0 0; box-shadow: 0 -18px 50px rgba(8, 12, 20, .24);
  transition: transform .24s cubic-bezier(.22, .9, .3, 1);
  padding-bottom: calc(10px + env(safe-area-inset-bottom));
}
.hs-wrap.hs-open .hs-sheet { transform: translate(-50%, 0); }
.hs-wrap.hs-closing .hs-sheet { transform: translate(-50%, 105%); }
.hs-sheet.dragging { transition: none; }
.hs-head { position: relative; flex: none; height: 28px; cursor: grab; touch-action: none; }
.hs-head:active { cursor: grabbing; }
.hs-grab {
  position: absolute; left: 50%; top: 8px; transform: translateX(-50%);
  width: 38px; height: 4px; border-radius: 99px; background: var(--border-strong);
}
.hs-close { position: absolute; right: 8px; top: 2px; }
.hs-body {
  display: flex; gap: 14px; align-items: flex-start;
  padding: 2px 18px 4px; overflow-y: auto;
}
.hs-crop {
  width: 104px; height: auto; flex: none; border-radius: 14px;
  border: 1px solid var(--border); background-color: var(--surface-3);
  background-repeat: no-repeat; overflow: hidden;
}
.hs-crop img { width: 100%; height: 100%; object-fit: contain; display: block; padding: 8px; box-sizing: border-box; }
.hs-crop-none { display: grid; place-items: center; color: var(--ink-3); }
.hs-info { min-width: 0; flex: 1; }
.hs-store { margin-bottom: 3px; }
.hs-name { font-size: 1.05rem; line-height: 1.35; margin: 0 0 6px; }
.hs-prices { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.hs-price { font-size: 1.5rem; font-weight: 800; color: var(--go); }
.hs-was { color: var(--ink-3); text-decoration: line-through; font-weight: 700; }
.hs-save { align-self: center; }
.hs-meta {
  display: flex; align-items: flex-start; gap: 6px; margin-top: 6px;
  font-size: 12.5px; color: var(--ink-2); line-height: 1.45;
}
.hs-meta svg { flex: none; margin-top: 1px; color: var(--ink-3); }
.hs-actions {
  display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 18px 6px;
}
.hs-actions .btn { flex: 1 1 150px; justify-content: center; }
.hs-source {
  display: flex; align-items: flex-start; gap: 6px; padding: 2px 18px 4px;
  font-size: 11px; line-height: 1.45; color: var(--ink-3);
}
.hs-source svg { flex: none; margin-top: 1px; }
@media (min-width: 760px) {
  .hs-sheet { border-radius: 20px; bottom: 18px; max-height: 72vh; }
  .hs-wrap.hs-open .hs-sheet { transform: translate(-50%, 0); }
}

/* ---------- the page viewer's own zoom ----------
   The page is one canvas; the picture and the hotspot layer are inside it, so a
   zoom changes both together. `width` is the scale, not a transform, which keeps
   the browser's text and photographs sharp and leaves the stage's scrollbars
   honest about what is on screen. */
.pv-canvas { display: block; line-height: 0; }
.pv-canvas .pv-art, .pv-canvas .pv-paper { width: 100%; max-width: none; margin: 0; }
.pv-canvas .pv-art .pv-img { max-width: none; width: 100%; }
.pv-stage { cursor: grab; }
.pv-stage:active { cursor: grabbing; }
.pv-canvas .flyer-item { cursor: pointer; }
@media (max-width: 560px) {
  .hs-body { padding-inline: 14px; }
  .hs-crop { width: 86px; }
  .hs-name { font-size: 1rem; }
  .hs-price { font-size: 1.35rem; }
}
