/* Source Sans 3, self-hosted (D-106). This was a third-party stylesheet, and
   beta's `style-src 'self'` blocked it along with the font files it would have
   pulled, so every page rendered in the fallback stack. Serving the font from
   this origin fixes it without widening the policy, which is the point.

   Latin subset, font version 3.052, 28,792 bytes, sha256
   ac057a5593cbe3df0d2585da5dd5f33b8efa84aa30550c710fe061b37fc5c54b.
   SIL Open Font License 1.1; the text ships beside it as fonts/OFL.txt.
   Where the file came from and how to refresh it: docs/seo.md section 9.

   ONE file for three weights, because it is a VARIABLE font: a single `wght`
   axis, 200 to 900. Three static instances would be 46,960 bytes and three
   requests instead of 28,792 and one.

   Two things about the descriptors below, both of which bite if changed.
   They are pinned to single values rather than written as one
   `font-weight: 400 700` range: `.nav` asks for 500, and against pinned faces
   CSS font matching drops that to 400, which is what the type has always
   looked like. A range would give it a real 500 and quietly restyle the nav.
   And the axis default is 200, ExtraLight — a face that reaches the browser
   with no weight descriptor renders far too light. */
/* Londrina Solid, self-hosted like Source Sans 3 and for the same reason: the
   Content-Security-Policy is `style-src 'self'`, so a Google Fonts stylesheet
   is blocked outright and every label silently falls back (D-106). Used only
   by the combination builder's dimension lettering (D-252), so no other page
   pays for it - an unused @font-face downloads nothing. SIL Open Font
   License, like the face already here; see fonts/OFL-Londrina-Solid.txt. */
@font-face {
  font-family: "Londrina Solid";
  font-style: normal;
  font-weight: 100;
  font-display: swap;
  src: url(fonts/londrina-solid-v19-latin-100.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Londrina Solid";
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url(fonts/londrina-solid-v19-latin-300.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(fonts/source-sans-3-v19-latin-var.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(fonts/source-sans-3-v19-latin-var.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(fonts/source-sans-3-v19-latin-var.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  --ink: #111111;
  --muted: #555555;
  --line: #e6e6e6;
  /* The slider rail while the control has focus. Darker than --line so the
     state change is carried by color as well as by thickness. */
  --rail-on: #a9aeb4;
  --brand: #255b8a;
  --brand-hover: #2377b0;
  --panel: rgba(255, 255, 255, 0.92);
  --band-aspect: 2.4;   /* matches the 3840x1600 master */
  --radius: 6px;
  /* One size for every pill-shaped control, here and on the builder, which
     loads this sheet. Martin, 2026-11-01: "Can you use the same size
     pill-buttons on the main site that we have on the configurator for 'Plan
     view' and 'in/mm'?" The numbers are the builder's, and 44px is the reason
     they are worth copying rather than averaging: it is the phone tap target,
     and the configurator's own pill was 34px, dropping to 30px on exactly the
     screens where it matters most. These do NOT shrink on a phone (D-287).
     The builder's `.units button` reads them too, so the two cannot drift. */
  --pill-h: 44px;
  --pill-pad: 18px;
  --pill-font: 15px;
  --surround: #e8e5e1;   /* only visible past 1920px */
  /* The page's side gutter, in one place because the pre-rendered heading
     block has to line up with it and is not a .pad element. */
  --gutter: 48px;
  /* The social cuts' accent, ACC = (255, 196, 61) in social_scripts/overlay.py:
     the short-link pill on every end card, with its own dark ink. The coupon
     banner wears it so the DM, the video and the landing page read as one
     campaign. */
  --campaign: #ffc43d;
  --campaign-ink: #141418;
}

* { box-sizing: border-box; }

/* Section order, at every width. The size control sits ABOVE the scene it
   changes: a buyer picks the size first and then looks at the result, and
   having it below meant the thing you were about to change was off-screen
   while you changed it. This used to apply only below 959px, which let the
   phone and the desktop drift apart — one DOM, one order, reordered here
   rather than in the markup so the tab order stays sensible in both. */
.wrap { display: flex; flex-direction: column; }
.masthead     { order: 0; }
/* The family index's own heading, above the picker. Same order group as the
   masthead rather than a renumbering of every rule below: within one order
   group flexbox falls back to DOM order, and this sits after </header>. Only
   an index page has one -- a layout page's heading is `#spec`, which reads as
   the product name and is already in the purchase panel (D-138). */
.family-title { order: 0; font-size: 20px; padding-block: 20px 0; }
/* The page's own title, under the breadcrumb. `.family-title` shares the
   rule so an index and a product page set a heading the same way.
   Type scales as a SET: as it grows, leading tightens and tracking goes
   negative - large text reads too loose at the letter and too open at the
   line if both stay where small text wants them. */
.page-title, .family-title {
  order: 0;
  font-size: clamp(22px, 2.6vw, 28px);
  line-height: 1.15;
  letter-spacing: -0.01em;
  padding-block: 10px 0;
}
.sizes        { order: 2; }
.stage-wrap   { order: 3; }
.feature-list { order: 4; }
.disclaimer   { order: 5; }
.about        { order: 6; }
footer        { order: 7; }

body {
  margin: 0;
  /* Past the page's max width the surround goes a soft neutral gray, so the
     page reads as a sheet rather than dissolving into the viewport. */
  background: var(--surround);
  color: var(--ink);
  font-family: "Source Sans 3", "Myriad Pro", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}

a { color: var(--brand); text-decoration: none; }
a:hover { color: var(--brand-hover); }
h1, h2 { margin: 0; font-weight: 600; }

.wrap {
  max-width: 1920px;
  margin: 0 auto;
  min-height: 100vh;
  background: #ffffff;
  box-shadow: 0 0 60px rgba(0, 0, 0, 0.17);
}
.pad { padding-inline: var(--gutter); }

/* ---------- header ---------- */
.masthead {
  display: flex; align-items: center; justify-content: space-between;
  height: 88px; border-bottom: 1px solid var(--line);
}
/* 66px in a 94px bar. The logo grew 50% and the bar grew with it, keeping the
   28px of vertical breathing room it had at 44/72 - growing the mark inside the
   old bar would have left it 4px off the rule beneath.
   `width: auto` means the SVG's own viewBox sets the width: at 4.04:1 that is
   267px, so a re-export at a different ratio changes the footprint here. The
   narrow masthead is where that would show, though the nav is display:none
   below 960px, so the logo shares that bar with nothing. */
/* `display: block` is load-bearing, not tidying. As an inline element the img
   sits on the text baseline inside its <a>, which measured 45px against the
   image's 38px - 7px of descender space below it. align-items centres the
   ANCHOR, so the mark rode about 4px high and the bar read 10px above / 18px
   below. Block removes the phantom space, and the bar height then means what
   it says. */
.masthead img { display: block; height: 66px; width: auto; }
.nav { display: flex; align-items: center; gap: 36px; font-size: 14px; font-weight: 500; }
.nav a { color: var(--ink); padding-block: 8px; }
.nav a[aria-current="page"] { color: var(--brand); border-bottom: 2px solid var(--brand); }
.nav .etsy { color: var(--brand); font-weight: 600; display: flex; align-items: center; gap: 8px; }

/* ---------- share ---------- */
/* Deliberately NOT inside `.nav`: that element is display:none below 959px,
   which takes "Shop on Etsy" with it and would take this too. A phone is
   where `navigator.share` is the only route to Instagram, TikTok and
   Messages, so the one control that must survive a narrow screen is this one
   (D-360). It sits to the left of the Etsy link, as Martin asked. */
.share-wrap { position: relative; margin-left: auto; display: flex; align-items: center; gap: 20px; }
/* The cart link (cartlink.js), shown only while the cart has something in it.
   Its own class rather than .nav, which phones hide: a cart has to stay
   reachable at every width. */
/* Set exactly like .share-btn beside it (gap, size, weight, 15 px icon), so
   the masthead's controls read as one family (Martin, 2026-11-05). */
.cart-link { position: relative; display: inline-flex; align-items: center; gap: 8px;
             font-size: 14px; font-weight: 500; color: var(--ink); white-space: nowrap; padding: 8px 0; }
.cart-link:hover, .cart-link[aria-expanded="true"] { color: var(--brand); }
.cart-badge { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; display: none;
              background: var(--brand); color: #fff; font-size: 11px; line-height: 18px; text-align: center; }
.cart-link.has-items .cart-badge { display: inline-block; }
/* A phone's masthead has no room for the word; the icon and badge say it,
   and the link's aria-label still reads "Cart, n items". */
@media (max-width: 479px) { .cart-word { display: none; } }

/* ------------------------------------------------------------------ cart
   Shared by the header's cart sheet and the /cart/ page (cartview.js,
   PAY-D-011). The page's own layout is in cart.css. */
.cart-sheet {
  position: absolute; top: calc(100% + 10px); right: 0; z-index: 41;
  width: min(440px, calc(100vw - 32px));
  background: #fff; border: 1px solid var(--line); border-radius: 12px;
  box-shadow: 0 10px 30px rgba(16, 24, 32, .14);
}
.cart-sheet[hidden] { display: none; }
/* Focused on open only so Tab and Escape start inside; never drawn. */
.cart-sheet:focus, .share-menu:focus { outline: none; }
.cart-sheet-body { max-height: min(78vh, 720px); overflow-y: auto; padding: 14px; border-radius: inherit; }
.cart-lines { display: flex; flex-direction: column; gap: 12px; }
.line { display: grid; grid-template-columns: 120px minmax(0, 1fr) auto; gap: 16px; align-items: center;
        padding: 12px; border-radius: var(--radius); background: #fff;
        box-shadow: 0 1px 3px rgba(0, 0, 0, .08); }
.line .thumb { width: 100%; aspect-ratio: 1200 / 630; object-fit: cover; border-radius: 6px;
               background: #0e3f63; display: block; }
.line h3 { font-size: 16px; margin: 0 0 4px; line-height: 1.3; }
.line .meta { font-size: 13px; color: var(--muted); margin: 0 0 8px; }
.line .actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 14px; }
.line-price { font-weight: 700; font-size: 18px; white-space: nowrap; text-align: right; }
.line.gone { opacity: .7; }
s.was, .line-price .was { display: block; font-size: 13px; font-weight: 600; color: var(--muted); }
.sum-row s.was { display: inline; margin-left: 8px; }
.qty { display: inline-flex; align-items: center; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
.qty button { width: 32px; height: 30px; border: 0; background: transparent; font: inherit;
              font-size: 18px; cursor: pointer; color: inherit; }
.qty button:disabled { opacity: .35; cursor: default; }
.qty output { min-width: 24px; text-align: center; font-variant-numeric: tabular-nums; }
.link-btn { border: 0; background: none; padding: 0; font: inherit; color: var(--brand);
            text-decoration: underline; cursor: pointer; }
.cart-summary-body { display: flex; flex-direction: column; gap: 10px; }
.sum-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.sum-total { border-top: 1px solid var(--line); padding-top: 10px; }
.sum-coupon { margin: 0; color: #2e7d32; font-weight: 600; }
.coupon-box { display: flex; flex-direction: column; gap: 6px; }
.coupon-box p { margin: 0; }
.coupon-ask { font-size: 13px; color: var(--muted); }
.coupon-form { display: flex; gap: 8px; }
.coupon-form input { flex: 1; min-width: 0; height: 36px; padding: 0 10px; font: inherit; font-size: 15px;
                     text-transform: uppercase; border: 1px solid var(--line); border-radius: 8px; }
.coupon-apply { height: 36px; padding: 0 14px; font: inherit; font-weight: 600; color: var(--brand);
                background: #fff; border: 2px solid var(--brand); border-radius: 8px; cursor: pointer; }
.coupon-msg { color: #c0392b; }
.coupon-msg.ok { color: #2e7d32; font-weight: 600; }
.ship-goal { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px;
             border-radius: 6px; background: rgba(14, 63, 99, .06); }
.ship-goal p { margin: 0; }
.ship-rule { font-size: 14px; font-weight: 600; }
.ship-bar { height: 6px; border-radius: 999px; background: rgba(14, 63, 99, .15); overflow: hidden; }
.ship-bar span { display: block; height: 100%; background: var(--brand); border-radius: inherit;
                 transition: width .25s ease; }
.ship-goal.met .ship-bar span { background: #2e7d32; }
.ship-goal.met .ship-status { color: #2e7d32; font-weight: 600; }
@media (prefers-reduced-motion: reduce) { .ship-bar span { transition: none; } }
.cart-summary-body .buy { width: 100%; }
.sheet-view { text-align: center; font-size: 14px; }
.cart-empty { padding: 12px 0; }
.cart-error { color: #c0392b; font-weight: 600; }
/* In the sheet: a narrower line, the summary below the lines. */
.cart-sheet .cart-lines { gap: 8px; margin-bottom: 14px; }
.cart-sheet .line { grid-template-columns: 72px minmax(0, 1fr) auto; gap: 10px; padding: 8px; box-shadow: none;
                    border: 1px solid var(--line); }
.cart-sheet .line h3 { font-size: 14px; }
.cart-sheet .line-price { font-size: 15px; }
/* The phone's confirmation after an add (cartlink.js). */
.cart-toast { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 60;
              display: flex; align-items: center; justify-content: space-between; gap: 12px;
              padding: 12px 16px; border-radius: 10px; background: #1d2a36; color: #fff;
              font-size: 15px; box-shadow: 0 8px 24px rgba(0, 0, 0, .25); }
.cart-toast a { color: #fff; font-weight: 700; text-decoration: underline; white-space: nowrap; }
.share-btn {
  display: flex; align-items: center; gap: 8px;
  font: inherit; font-size: 14px; font-weight: 500; color: var(--ink);
  background: none; border: 0; padding: 8px 0; cursor: pointer;
}
.share-btn:hover { color: var(--brand); }
.share-btn[aria-expanded="true"] { color: var(--brand); }

.share-menu {
  position: absolute; top: calc(100% + 10px); right: 0; z-index: 40;
  min-width: 220px; padding: 6px;
  background: #fff; border: 1px solid var(--line); border-radius: 12px;
  box-shadow: 0 10px 30px rgba(16, 24, 32, .14);
  display: flex; flex-direction: column;
}
.share-menu[hidden] { display: none; }
/* The pointer (caret.js): a square turned 45 degrees with only its top half
   showing, so a 90-degree corner sticks up at the button that opened the
   panel. Same fill and border as the panel, drawn over the panel's top edge
   so the border runs into the corner without a seam. */
.has-caret::before {
  content: ""; position: absolute; top: -8px; right: calc(var(--caret-x, 24px) - 7px);
  width: 14px; height: 14px; background: #fff; transform: rotate(45deg);
  /* A shade darker than --line: on a white page the panel is found by its
     shadow, which this small corner is too small to cast. */
  border-left: 1px solid #cdd3d9; border-top: 1px solid #cdd3d9;
  border-top-left-radius: 2px;
  box-shadow: -3px -3px 6px rgba(16, 24, 32, .06);
}
.share-rule { height: 1px; margin: 6px 4px; background: var(--line); }
/* One rule for the <a> targets and the <button> actions together, so a link
   and an action cannot drift apart visually. */
.share-item {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px; border-radius: 8px;
  font: inherit; font-size: 14px; color: var(--ink); text-align: left;
  background: none; border: 0; width: 100%; cursor: pointer;
}
.share-item:hover { background: #f1f5f8; }
.share-item:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
.share-item svg { flex: none; opacity: .55; }
.share-item.done { color: var(--brand); }
.share-item.done svg { opacity: 1; }
/* The field the legacy copy selects. Visible on purpose when the
   clipboard API is refused: the text has to be on screen and selected
   for "press the key yourself" to mean anything. */
.share-copy-field {
  width: 100%; margin: 2px 0 4px; padding: 7px 10px;
  font: inherit; font-size: 12px; color: var(--ink);
  border: 1px solid var(--line); border-radius: 8px; background: #f7fafc;
}

.strip { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; }
.strip::-webkit-scrollbar { display: none; }
.chip {
  flex: none; height: 36px; padding-inline: 14px; border: 1px solid #cccccc;
  border-radius: 18px; display: flex; align-items: center; font-size: 14px;
  background: none; color: var(--ink); cursor: pointer; white-space: nowrap; font: inherit;
}
.chip[aria-pressed="true"] { border-color: var(--brand); background: var(--brand); color: #fff; font-weight: 600; }

/* ---------- the scene band ---------- */
/* Height is set by the script from the product at the fixed scale; the aspect
   ratio is only the fallback before the first layout pass. */
.band { position: relative; width: 100%; height: var(--band-h, calc(100vw / var(--band-aspect)));
        overflow: hidden; background: #3b2a1c; }
/* Hidden until the first layout pass has cropped it. Before that the plate
   would paint at its natural 3840px anchored top-left, showing whatever
   happens to be in that corner of the scene rather than the product. */
.stage { position: absolute; inset: 0; transform-origin: top left; will-change: transform;
         opacity: 0; transition: opacity 220ms ease; }
.band.ready .stage { opacity: 1; }
.stage.animate { transition: transform 460ms cubic-bezier(0.22, 0.61, 0.36, 1); }
/* Dragging the size slider: short and linear, so the product tracks the
   finger instead of easing in behind it. */
.stage.live { transition: transform 150ms linear; }
.stage > * { position: absolute; left: 0; top: 0; display: block; }
#plate, #remotes { user-select: none; -webkit-user-drag: none; }
#pedestal { pointer-events: none; }
/* On a size change the product layers cross-fade while the plate keeps
   zooming, so the swap is never visible at the wrong scale. */
#pedestal, #remotes { transition: opacity 190ms ease; }
.band.swapping #pedestal, .band.swapping #remotes,
.band.swapping .leaders, .band.swapping #callouts { opacity: 0; }

.leaders { position: absolute; inset: 0; pointer-events: none; }
/* Callouts fade while the band zooms, otherwise they would snap to their new
   places against a still-moving image. */
.leaders, #callouts { transition: opacity 190ms ease; }

.feature-toggle {
  position: absolute; left: 20px; bottom: 20px; z-index: 4;
  display: flex; align-items: center; gap: 7px;
  min-height: var(--pill-h); padding-inline: var(--pill-pad);
  border: 0; border-radius: 999px; cursor: pointer; font: inherit;
  font-size: var(--pill-font); font-weight: 600;
  background: rgba(255, 255, 255, 0.84); backdrop-filter: blur(6px); color: var(--ink);
}
.feature-toggle[aria-pressed="false"] { background: rgba(20, 20, 20, 0.5); color: #fff; }
.feature-toggle[aria-pressed="false"] svg { opacity: 0.65; }
/* The halo is a wider dark stroke drawn under the white one rather than a blur
   filter: a filter region is a percentage of the bounding box, so a perfectly
   horizontal leader (zero-height box) would render nothing at all. */
.leaders path, .leaders circle { fill: none; stroke-linecap: round; }
.leaders .halo { stroke: rgba(0, 0, 0, 0.55); stroke-width: 4.5; }
.leaders .ink { stroke: #ffffff; stroke-width: 1.6; }
.leaders circle.target { fill: #ffffff; stroke: rgba(0, 0, 0, 0.65); stroke-width: 1.5; }

/* Below 960 the boxes cannot fit beside the product, so the callouts become
   numbered markers on the image with a list underneath. */
.feature-list {
  display: none; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px 20px; margin: 0; padding-top: 16px; list-style: none;
  font-size: 13px; line-height: 1.35;
}
.feature-list li { display: flex; gap: 9px; }
/* The bullet carries a number only while matching numbers are on the product.
   With the toggle off it is a solid dot: same size, so nothing reflows, and
   no figure pointing at something the visitor cannot see. */
.feature-list b { flex: none; width: 22px; height: 22px; border-radius: 50%;
  border: 1.5px solid var(--ink); background: var(--ink); color: transparent;
  font-size: 12px; display: flex; align-items: center; justify-content: center; }
body.features-on .feature-list b { background: none; color: var(--ink); }
.feature-list span { color: var(--muted); }
/* Which set is showing is a mode on the element, not a rebuild of its
   children: the leaders and the compact markers are both in the DOM from the
   moment the layout is built, so a zoom only moves them. See buildCallouts. */
/* Until the first positioning pass sets a mode, nothing shows: a circle with
   no cx/cy would otherwise paint at 0,0 for a frame. */
.leaders:not([data-mode]) > * { display: none; }
.leaders .mark { display: none; }
.leaders[data-mode="compact"] .halo,
.leaders[data-mode="compact"] .ink,
.leaders[data-mode="compact"] .target { display: none; }
.leaders[data-mode="compact"] .mark { display: inline; }
.leaders[data-mode="off"] > * { display: none; }

.leaders .marker { fill: #ffffff; stroke: rgba(0, 0, 0, 0.65); stroke-width: 1.5; }
.leaders .marker-num { fill: #111111; font-size: 13px; font-weight: 700;
  text-anchor: middle; dominant-baseline: central;
  font-family: "Source Sans 3", system-ui, sans-serif; }

.callout {
  position: absolute; left: 0; top: 0; width: 212px; will-change: transform; padding: 10px 14px; border-radius: var(--radius);
  background: rgba(255, 255, 255, 0.84); backdrop-filter: blur(6px);
  font-size: 12px; line-height: 1.35;
}
.callout strong { display: block; font-size: 13px; }

/* ---------- floating panels ---------- */
/* One column so the purchase box sizes to its content and the palette takes
   whatever is left, instead of both guessing at fixed offsets. */
.stage-wrap { position: relative; }
.panel-stack {
  position: absolute; right: 24px; top: 24px; bottom: 24px; width: 380px;
  display: flex; flex-direction: column; gap: 12px; min-height: 0;
}
.panel {
  padding: 18px; background: var(--panel); backdrop-filter: blur(10px);
  border-radius: 8px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
  display: flex; flex-direction: column; gap: 14px;
}
#palette { flex: 1; min-height: 0; }
#purchase { flex: none; gap: 10px; }

.panel-head { display: flex; justify-content: space-between; align-items: baseline; }
.panel-head h2 { font-size: 16px; }
/* The selected size's name, beside the heading. Hidden: the slider's labels
   name every size directly below this, with the current one in bold, so this
   said the same thing twice. Kept in the DOM because build_seo.py fills it and
   app.js keeps it current — it costs nothing and a future layout may want it. */
.panel-head .count { font-size: 12px; color: var(--muted); display: none; }

/* The fades belong to the scroll area, not the panel: anchoring them to the
   panel put the lower one over the note once the panels became one column. */
.swatch-wrap { position: relative; flex: 1; min-height: 0; display: flex; margin: -6px; }
  /* `auto`, NOT `thin`, and the difference is the whole affordance (D-369).
     Chrome and Safari IGNORE every ::-webkit-scrollbar rule below the moment
     `scrollbar-width` is set to anything but `auto`, so `thin` silently threw
     away the styled bar and left macOS's overlay scrollbar - which reserves
     zero pixels and is invisible until you are already scrolling, i.e. after
     the moment it was supposed to tell you about. Measured 2026-11-03:
     `thin` reserved 0 px on a scroller that genuinely overflowed; `auto`
     reserved the declared height.

     The cost is Firefox, which has no ::-webkit-scrollbar and now draws its
     own full-width bar rather than a thin one. Visible either way, which is
     the point; `scrollbar-color` cannot buy it back, because setting THAT
     disables the pseudo-elements too. */
.swatches { flex: 1; min-height: 0; overflow-y: scroll; padding: 6px 10px 10px 6px; scrollbar-width: auto; }
/* Height as well as width: without it the horizontal scrollbar the mobile
   layout needs collapses, and a mouse user has no way to move the row. */
.swatches::-webkit-scrollbar { width: 8px; height: 8px; }
.swatches::-webkit-scrollbar-thumb { background: #9a9a9a; border-radius: 4px; }
.swatches::-webkit-scrollbar-track { background: var(--line); border-radius: 4px; }
/* dragToScroll() sets this in either arrangement: down in the floating panel,
   sideways below 960. */
.swatches.dragging { cursor: grabbing; scroll-behavior: auto; }
/* A drag must not leave the swatch under the cursor looking pressed. */
.swatches.dragging .swatch { pointer-events: none; }
/* While a drag is under way, nothing on the page is selectable. The browser
   reads a traveling pointerdown as a text selection, which runs away across
   the page as soon as the pointer leaves the list or the viewport and leaves
   the whole page highlighted when the drag ends (D-323). The rule is on the
   ROOT and uses !important because the selection it suppresses is the
   page's, not any one element's, and a later rule that re-enables selection
   on a child would otherwise reopen it mid-drag. Both pages' drags set it. */
html.dragging, html.dragging * { -webkit-user-select: none !important;
                                 user-select: none !important; }
.swatch-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 10px; }
.swatch { display: flex; flex-direction: column; gap: 4px; border: 0; background: none; padding: 0; cursor: pointer; font: inherit; text-align: left; }
.swatch i { display: block; height: 48px; border-radius: 4px; }
/* A rendered material close-up. The magnification lives in the render now:
   the swatch is 13mm of real surface rather than 26, so the flecks are twice
   the size and still sharp, where a CSS zoom cropped and upscaled. Left as a
   variable because it is the natural place to nudge it. */
.swatch i.shot {
  --swatch-zoom: 100%;
  background-size: var(--swatch-zoom);
  background-position: center;
  background-repeat: no-repeat;
}
/* The focus ring goes on the CHIP, not around the tile.
   The browser's own ring boxes the whole button - chip, name and finish,
   107x83 against the chip's 107x48 - so choosing a color with the arrow keys
   drew a second, larger rectangle around the one the pressed state had
   already marked. Two rings for one color (Martin, 2026-11-02; D-349).
   `outline: none` is safe here only because the line under it puts an equally
   visible ring back on the chip; the ink ring is what a visitor who TABS into
   the grid sees, before anything is chosen.
   Source order carries the rest: this rule and the pressed rule below have
   the same specificity, so a chip that is both focused and chosen - which is
   every chip reached by clicking or arrowing, since both move the focus with
   the selection (D-344) - wears the brand ring and only that. */
.swatch:focus-visible { outline: none; }
.swatch:focus-visible i { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--ink); }
.swatch[aria-pressed="true"] i { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--brand); }
.swatch .name { font-size: 11px; line-height: 1.2; color: var(--ink); }
.swatch[aria-pressed="true"] .name { font-weight: 600; }
.swatch .finish { font-size: 10px; color: var(--muted); }
.swatch .finish:not(:empty) { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fade { position: absolute; left: 0; right: 12px; height: 30px; pointer-events: none; opacity: 0; transition: opacity 160ms; }
.fade.top { top: 0; background: linear-gradient(to bottom, var(--panel), transparent); }
.fade.bottom { bottom: 0; background: linear-gradient(to top, var(--panel), transparent); }
.fade.on { opacity: 1; }

.price-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.price { font-size: 30px; font-weight: 700; line-height: 1; }
/* The list price beside the discounted one. Sized in `em` so it tracks .price
   through both of the smaller breakpoints below without restating either. */
.price-was {
  font-size: 0.65em; font-weight: 600; color: var(--muted);
  text-decoration: line-through; margin-left: 8px;
}
/* An <h1>, so the browser's heading margins and weight have to go: this is
   a product line in a purchase panel, not a page title in the visual sense. */
.spec { font-size: 13px; color: var(--muted); line-height: 1.3;
        margin: 0; font-weight: inherit; }
.buy {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  height: 48px; padding-inline: 22px; background: var(--brand); color: #fff;
  border-radius: var(--radius); font-size: 16px; font-weight: 600; border: 0; cursor: pointer; font-family: inherit;
}
/* The buy control is an anchor now, so `a:hover` would otherwise repaint its
   label brand blue on a brand blue field. */
.buy, .buy:hover { color: #ffffff; }
.buy:hover { background: var(--brand-hover); }
/* Add to cart is the main action, a full .buy button (PAY-D-013); Buy on
   Etsy is a small link under it, on product pages and on builder rows that
   Etsy sells. */
.buy:disabled { opacity: .55; cursor: default; }
.buy:disabled:hover { background: var(--brand); }
.etsy-alt { margin: 0; font-size: 14px; text-align: right; }
.etsy-link { display: inline-flex; align-items: center; gap: 4px; color: var(--muted);
             text-decoration: underline; text-underline-offset: 2px; }
.etsy-link:hover { color: var(--brand); }
.etsy-alt[hidden] { display: none; }
/* Both of these are selling points, not fine print: free US shipping and a
   live coupon are reasons to buy, and 11px muted gray is the typographic
   signal for "disclaimer, skip me".
   `margin: 0` matters more than it looks: .panel is a flex column, and flex
   items' margins do NOT collapse. The UA's 1em on each <p> stacked with the
   panel's 10px gap and put 32px between two lines that belong together. */
.note { font-size: 13px; color: var(--muted); line-height: 1.45; margin: 0; }
/* Full-contrast ink for the offer. Muted is right for the Etsy handover note
   below it, which is reassurance; it is wrong for the one line on the page
   actively offering money off. */
#offer { color: var(--ink); }
/* Two unrelated facts - a shipping threshold and a coupon - so they get their
   own lines. Run together they read as "10% off your shipping". */
.offer-line { display: block; }
.offer-line + .offer-line { margin-top: 5px; }
/* The two offers carry the weight. Inside the coupon line the how-to is a bare
   text node beside .offer-deal, so it simply does not inherit the bold. */
.offer-deal { font-weight: 600; }
/* Every coupon line, marked as if with a highlighter in the banner's yellow,
   so the instruction to type the code at Etsy cannot be missed (D-206). Inline, so the color hugs the words; `clone` gives every wrapped
   line its own padded, rounded mark instead of one box split across lines.
   The band sits slightly inside the line box, leaving air above and below the
   way a real highlighter stroke does. */
.offer-mark {
  color: var(--campaign-ink);
  background: linear-gradient(transparent 6%, var(--campaign) 6%, var(--campaign) 94%, transparent 94%);
  padding: 1px 4px;
  border-radius: 3px;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

/* The copy button after a coupon code. The icon sits in the text like a
   character; the button's padding gives it a thumb-sized target without
   pushing the line apart. */
.offer-copy {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin: -6px 0 -6px 2px;
  padding: 6px;
  border: 0;
  background: transparent;
  color: inherit;
  vertical-align: middle;
  cursor: pointer;
  border-radius: 4px;
}
.offer-copy svg { width: 15px; height: 15px; display: block; }
.offer-copy:hover { background: rgba(0, 0, 0, 0.08); }
.offer-copy:focus-visible { outline: 2px solid currentColor; outline-offset: 0; }

/* The campaign banner: one line above the masthead, shown by offer.js on every
   page while a code from a link is live (D-206). No close button: it is one
   slim line, and it is the reason they came. */
.offer-banner {
  margin: 0;
  padding: 10px var(--gutter);
  background: var(--campaign);
  color: var(--campaign-ink);
  font-weight: 600;
  font-size: 15px;
  line-height: 1.35;
  text-align: center;
}
/* Only the docked mobile layout uses this; the desktop panel shows the real
   #checkout-note instead. */
.note-echo { display: none; order: 6; }

/* ---------- size slider ---------- */
/* A real slider: drag along a rail that snaps to the sizes that actually
   exist. The whole track is the control, so a tap anywhere on it jumps to the
   nearest size, and the numbered chip on the current size is the thumb. */
.sizes {
  --track-h: 44px;
  /* Half a detent, which is the only inset the end detents need to sit fully
     on the track. It was half the TRACK height while a focus ring surrounded
     the control and the end detents had to sit at the centers of that ring's
     end caps; the ring is gone (see `.track:focus-visible` below), so that
     reserve was buying nothing and costing about 36px of usable track. */
  --pad: 12px;
  display: flex; flex-direction: column; gap: 8px;
  padding-top: 22px; padding-bottom: 8px;
}
.track {
  --t: 0;               /* current position, 0 to 1 */
  position: relative; height: var(--track-h); cursor: pointer;
  touch-action: none;   /* the drag is ours; the page must not scroll under it */
  outline: none;
}
/* No outline. A ring around a 44px track is a large piece of furniture that
   appears on touch as well as on the keyboard - WebKit treats the focus() on
   pointerdown as focus-visible - and it boxes in a control whose whole width
   is already the target. The rail itself carries the state instead: it
   thickens from 4px to 6px and darkens from --line to --rail-on. Growing from
   the center, so the line does not appear to jump.
   This is a deliberate departure from the outline used everywhere else on the
   page; it is the only control whose focus affordance would be bigger than
   most of the things around it. */
.track:focus-visible { outline: none; }

.rail, .fill {
  position: absolute; top: 20px; height: 4px; border-radius: 2px;
  transition: top 140ms, height 140ms, background-color 140ms;
}
.rail { left: var(--pad); right: var(--pad); background: var(--line); }
.track:focus-visible .rail, .track.dragging .rail { background: var(--rail-on); }
.track:focus-visible .rail, .track:focus-visible .fill,
.track.dragging .rail, .track.dragging .fill { top: 19px; height: 6px; border-radius: 3px; }
.fill {
  left: var(--pad); width: calc(var(--t) * (100% - 2 * var(--pad)));
  background: var(--brand);
  /* Width eases on a selection; the focus thickening is its own, faster beat.
     Both have to be named here, because this shorthand replaces the one on
     the shared `.rail, .fill` rule above. */
  transition: width 300ms cubic-bezier(0.22, 0.61, 0.36, 1),
              top 140ms, height 140ms;
}

.detents { position: absolute; inset: 0; pointer-events: none; }
/* Every detent carries its slot count, at every width. The number used to
   appear only under 960px, so the same control counted 1-2-3 on a phone and
   showed bare dots on a desktop; the count is the thing being chosen, and
   which screen you are on has no bearing on that.
   The chip is also the position indicator - see the thumb note below. */
.detent {
  position: absolute; top: 10px; width: 24px; height: 24px; margin-left: -12px;
  left: calc(var(--pad) + var(--p) * (100% - 2 * var(--pad)));
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: #ffffff; border: 2px solid var(--line);
  font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--muted);
  /* The element is an <i> for brevity, which would italicize the count it
     carries. It is a chip, not an emphasis. */
  font-style: normal;
  transition: border-color 200ms, background-color 200ms, color 200ms;
}
.detent .dn { display: block; line-height: 1; }
/* Passed sizes read as traveled, not as chosen: the rail's color on the
   border, the page's on the face. Only the current one inverts. */
.detent[data-on="true"] { background: #ffffff; border-color: var(--brand); color: var(--brand); }
.detent[data-cur="true"] { background: var(--brand); border-color: var(--brand); color: #ffffff; }

/* There is no separate thumb. A 26px disc centered on a 24px chip covers the
   digit it is pointing at, so the filled chip IS the thumb - as it already was
   on a phone. The markup no longer carries the element.
   The rail's fill still shows travel, and `--t` still drives it. */
.track.dragging .fill { transition: top 140ms, height 140ms; }  /* no width easing: it tracks the finger */

.labels {
  position: relative; height: 34px; font-size: 12px; line-height: 1.25;
  text-align: center; color: var(--muted);
}
/* The labels are <a> elements with real hrefs to the sibling size pages, so a
   crawler (and a visitor without JavaScript) can reach every size. app.js
   intercepts the click for the in-page swap. They are focusable, which reverses
   D-035's "nine more tab stops would be duplicates" — that was the right call
   for spans, but these are genuine destinations and a keyboard user should be
   able to reach them. The slider still answers the arrow keys. */
.labels a {
  color: inherit; text-decoration: none;
  position: absolute; top: 0; white-space: nowrap; transform: translateX(-50%);
  left: calc(var(--pad) + var(--p) * (100% - 2 * var(--pad)));
  /* Clickable, and not selectable: a click that follows a text drag should
     still pick the size rather than leave a highlight behind. */
  cursor: pointer; user-select: none; padding: 2px 6px;
  /* No negative margin to "cancel" that padding. This box is absolutely
     positioned and shrink-to-fit, so `margin-left: -6px` moves it left and
     `margin-right: -6px` pulls nothing back: every label sat 6px - half its own
     side padding - to the left of the detent it names. Measured at 1440px
     before the fix: "Single" centered on 53.5, its chip on 59.5. The padding
     alone is symmetric, so translateX(-50%) centers the text on the stop. */
  border-radius: 4px; transition: color 120ms, background-color 120ms;
}
.labels a:hover { color: var(--ink); background: var(--line); }
.labels a:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
/* Every label centers on its own detent, the ends included. They used to align
   to their end instead, which kept them inside the track but left the first and
   last labels visibly off the chip they name - and a label that points at the
   wrong stop is worse than one that overhangs by a few pixels into the gutter.
   `data-edge` stays: `fitLabels` still uses it to keep the ends. */
.labels a[data-on="true"] { color: var(--ink); font-weight: 600; }
/* When the labels would collide, only the ends survive (see fitLabels). */
.labels[data-fit="ends"] a:not([data-edge]) { display: none; }

/* ---------- description and footer ---------- */
.about { display: flex; flex-direction: column; gap: 12px; padding-top: 32px; }
.about .cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; font-size: 15px; line-height: 1.5; }
/* Until the listing description is carried in the manifest this column is
   empty. An empty grid child still takes a row and a gap, which reads as a
   hole under the heading, so drop it and let the facts have the width. */
.about #about-body:empty { display: none; }
.about .cols:has(#about-body:empty) { grid-template-columns: minmax(0, 1fr); max-width: 680px; }
/* The facts are paragraphs, so their default block margins stack on top of
   the flex gap and space them about four times further apart than intended. */
/* Both columns space their paragraphs the same way and start on the same
   line. A default paragraph margin on the first one pushes that column down
   relative to the other. */
.about .cols > div { display: flex; flex-direction: column; gap: 10px; }
.about .cols p { margin: 0; }
.disclaimer { padding-top: 10px; font-size: 11px; color: var(--muted); }
#color-caveat:not(:empty) { color: var(--ink); }
/* Two spans of very different length; equal columns keep them from sitting at
   the edges with a hole between. */
footer span { flex: 1 1 0; min-width: 0; }
/* Its own row under the two columns, because it belongs to the whole page
   rather than sitting beside one column of it. */
.copyright { flex: 1 0 100%; order: 1; font-size: 12px; }
/* ---------- breadcrumb ---------- */
/* Wayfinding: one line that answers "where am I" and "where can I go".
   The trail stops before the current page and ends on a separator - the
   <h1> beneath it is the last crumb. Everything here stays muted so it
   does not compete with that heading.

   Tracking is slightly POSITIVE. Small type reads tighter than it is, so it
   wants the opposite of the negative tracking a display heading wants - one
   fixed letter-spacing across a type scale is wrong at one end of it.

   Sized in em/rem rather than px so the row grows with a reader's own text
   size instead of staying put while the text around it grows. */
.crumbs {
  order: 0;
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 0 0.5em;
  padding-block: 0.75rem 0;
  font-size: 13px; line-height: 1.4; letter-spacing: 0.01em;
  color: var(--muted);
}
/* The catalog is the root of the trail, so its own crumb list is empty.
   Without this it would still claim the vertical space. */
.crumbs:empty { display: none; }
.crumbs a {
  color: var(--muted);
  /* Hit area, not visual size: 10px of padding pulled back by an equal
     negative margin turns an 18px line of text into a 38px touch target
     and moves nothing on the page. */
  padding-block: 10px; margin-block: -10px;
  transition: color 120ms ease-out;
}
.crumbs a:hover { color: var(--brand); }
/* Feedback on the press itself, not on release. */
.crumbs a:active { color: var(--brand-hover); }
.crumbs .home { padding-inline: 2px; }
.crumbs .home svg { width: 15px; height: 15px; fill: currentColor; display: block; }
.crumbs .sep { opacity: 0.45; }

/* ---------- the "which one do you have" test ---------- */
/* Only a group page configures it; empty everywhere else, and an empty
   container must not claim the vertical space. */
#identify:empty { display: none; }
#identify { margin-block: 1.75rem 0; }
#identify h2 { font-size: 18px; margin: 0 0 0.6rem; }
.identify-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.identify-list li { display: flex; align-items: baseline; gap: 12px; }
/* The button label set as the remote sets it, so the reader is matching a
   shape on the page against a shape in their hand rather than reading a
   description of one. That is why there is no `font-variant-caps` and no
   `text-transform` here: the earlier remote prints OFF and the current one
   prints a lowercase `hue` wordmark, and small caps would render both as
   capitals and erase the only thing this test asks the reader to look at. */
.identify-list .btn-label {
  flex: none; min-width: 52px; text-align: center;
  padding: 4px 10px; border: 1px solid var(--line); border-radius: 6px;
  background: #fff; font-size: 13px; font-weight: 600; color: var(--ink);
  letter-spacing: 0.04em;
}

/* ---------- footer social ---------- */
/* Its own full-width row, for the same reason .copyright has one: the
   accounts belong to the shop, not to either column of footer text. Sits
   above the copyright because it is content rather than a legal notice.
   20px is the smallest these glyphs stay legible at; the 8px padding makes
   the tap target 36px, which is what a thumb needs on the mobile dock. */
.social { flex: 1 0 100%; order: 1; display: flex; gap: 10px; margin: -8px 0; }
/* Brand colors, not monochrome. The usual argument for gray is that a
   footer should recede - but these marks are recognised by colour before
   they are recognised by shape, and the compromise everyone reaches for
   (gray at rest, colour on hover) is invisible on a phone, which is 95% of
   this site's traffic. There is no dark theme here for them to fight.
   TikTok and X really are black marks; that is their brand, not an
   oversight (D-172). */
.social a { display: inline-flex; padding: 8px; }
.social a:hover { opacity: 0.72; }
.social .s-etsy      { color: #f16521; }
.social .s-pinterest { color: #bd081c; }
.social .s-instagram { color: #e4405f; }
.social .s-tiktok    { color: #111111; }
.social .s-x         { color: #111111; }
.social .s-facebook  { color: #0866ff; }
.social svg { width: 20px; height: 20px; fill: currentColor; display: block; }

footer {
  display: flex; justify-content: space-between; align-items: flex-start; gap: 32px;
  padding-block: 24px; margin-top: 36px; border-top: 1px solid var(--line);
  font-size: 12px; color: var(--muted); flex-wrap: wrap;
}

/* ---------- tiers ---------- */
@media (max-width: 1100px) { :root { --gutter: 24px; } .nav { gap: 22px; } }

/* The script decides when a callout box can no longer sit beside its target
   and sets this class; the list is not keyed to a width. */
body.compact-callouts .feature-list { display: grid; }

/* ---------- under 960: full mobile mode */
/* ---- the scroll dots, shared by every palette (D-375) ---- */
/* The dot geometry, declared once and used by both rows. They were
   fallbacks inside var() until they needed changing, at which point the
   same number lived in six places.

   KEEP THE TWO SIZES THE SAME PARITY. Both rows are an even number of
   pixels tall - 26 for the strip, 16 for the palette - so an odd dot
   centers on a half pixel and an even one does not:

       7px   (26-7)/2 = 9.5     (16-7)/2 = 4.5
       4px   (26-4)/2 = 11      (16-4)/2 = 6
       3px   (26-3)/2 = 11.5    (16-3)/2 = 6.5

   At 7 and 4 the two sizes round opposite ways and the small dots sit a
   device pixel off the large ones, which Martin saw immediately. 7 and 3
   round the same way and line up. Changing one size means checking the
   other against this table, not just picking a number that looks right
   on its own. */
:root { --dot-pitch:12px; --dot-size:7px; --dot-size-off:3px; }
/* The row is OFF by default and switched on only inside the narrow block
   below, where the palette becomes a horizontal scroller.
 *
 * This is not belt and braces, it is the whole of what keeps the wide layout
 * intact. Every other rule for these elements lives inside that media query,
 * so above 959px the row had NO styles - it lost `position:absolute`, fell
 * into normal flow, and `.swatch-wrap` being a flex row made it a second
 * flex item 376px tall sitting beside the swatches. That squeezed
 * `.swatches` until its grid overflowed sideways, which made syncDots
 * compute `over` as true, which kept the dots: a loop that sustained itself
 * and only ever appeared after a resize, never on a fresh load at that width
 * (Martin, in Safari; it reproduces everywhere).
 *
 * The lesson generalizes past this element: styling something only inside a
 * media query leaves it UNSTYLED outside, and an absolutely positioned box
 * that loses its positioning does not disappear - it lands in the layout. */
.swatch-dots { display: none; }


@media (max-width: 959px) {

  /* The native bar is hidden ONLY here. Above 960px the palette is a tall
     grid inside the panel and genuinely needs its vertical scrollbar; the
     dots never appear there, because syncDots asks about SIDEWAYS overflow
     and a tall grid has none. */
  #palette .swatches { scrollbar-width:none; }
  #palette .swatches::-webkit-scrollbar { display:none; }
  /* The 8px the native bar took, given back only while the dots show -
     the same conditional reservation as the strip's, for the same reason
     (D-373). The horizontal palette's own padding is 6px 5px 8px. */
  #palette .swatches.has-dots { padding-bottom:16px; }

  /* Centered under the row, in the band the bar used to occupy. The wrap
     is the positioned ancestor the fades already rely on, and it shares the
     scroller's edges, so the row spans the same box the swatches do. */
  /* `stretch`, NOT `center` - the strip's row gets this right and this one
     did not. With `center` each button shrinks to its DOT's height: 7px for
     an in-view dot and 3px for an out-of-view one. A click a pixel or two
     off the dot then missed the button entirely, fell through this row
     (pointer-events:none) to the swatch underneath, and SELECTED that color
     - and pickColor scrolls the new ring into view without centering, which
     is exactly the "it scrolls but does not center" Martin saw.
     Stretched, every button is the full height of the band, so the row is
     one unbroken strip of hit area with nothing to fall through. */
  .swatch-dots { position:absolute; left:0; right:0; bottom:0; height:16px;
                 display:flex; justify-content:center; align-items:stretch;
                 pointer-events:none; }
  .swatch-dots[hidden] { display:none; }
  /* Structurally identical to .tray-dot: the button is the hit area and
     nothing else, and the dot is a pseudo-element of --dot-size, so it is a
     CIRCLE rather than whatever rectangle the button happens to be. The
     first version put the color on the button itself and got 12x10 ovals
     for it (Martin). Same pitch, same size, same shape as the strip's -
     which is the consistency that is the whole reason these are here. */
  .swatch-dot { appearance:none; -webkit-appearance:none; border:0; padding:0;
                margin:0; background:none; font:inherit;
                width:var(--dot-pitch, 12px); flex:0 0 auto; cursor:pointer;
                pointer-events:auto; display:flex; align-items:center;
                justify-content:center; border-radius:0; }
  /* The color rides on a custom property set by paletteDots(), because the
     element that carries it is the pseudo-element and JS cannot reach one. */
  .swatch-dot::before { content:''; width:var(--dot-size-off, 4px);
                        height:var(--dot-size-off, 4px); border-radius:50%;
                        background:var(--dot-color, #999);
                        /* SIZE carries the in-view state here, not opacity.
                           Opacity was tried both ways and neither worked: at
                           .38 the pale half of the palette washed out to
                           nothing and Jet Black read as gray, and at .72 -
                           raised to protect the colors - the signal
                           disappeared and the row stopped saying that
                           anything was off-screen, which is the one thing it
                           is for (Martin). Color is spoken for by the jump
                           bar, so the state went to the channel that was
                           still free. The small dots stay legible enough to
                           aim at, which is all the jump bar needs. */
                        opacity:.75;
                        transition:opacity 120ms linear, width 120ms linear,
                                   height 120ms linear; }
  .swatch-dot.on::before { width:var(--dot-size, 6px);
                           height:var(--dot-size, 6px); opacity:1; }
  /* The same test swatch() uses for a pale chip, so a dot and its chip never
     disagree about needing an edge to be seen against white. */
  .swatch-dot.light::before { box-shadow:inset 0 0 0 1px rgba(0,0,0,.22); }
  /* The chosen color is always FULL SIZE, in view or not. It is the one dot
     someone looks for deliberately, and shrinking it off-screen would hide
     exactly the thing the ring exists to point at. */
  .swatch-dot.picked::before { width:var(--dot-size, 6px);
                               height:var(--dot-size, 6px); opacity:1;
                               box-shadow:0 0 0 1.5px var(--bg, #fff),
                                          0 0 0 3px var(--ink, #222); }
  .swatch-dot.light.picked::before { box-shadow:inset 0 0 0 1px rgba(0,0,0,.22),
                                                0 0 0 1.5px var(--bg, #fff),
                                                0 0 0 3px var(--ink, #222); }
  .swatch-dot:focus-visible { outline:2px solid var(--ink, #222);
                              outline-offset:2px; }

  /* The slider moves above the band on mobile. Source order stays as it is,
     because on desktop the size control belongs under the scene it changes;
     reordering here rather than in the markup keeps one DOM for both and keeps
     the tab order sensible in each. */
  /* The order itself now lives outside this query — see `.wrap` near the top.
     Keeping a second copy here meant desktop and phone could drift apart. */
  /* Above the scene the block needs real white space under it, not just the
     focus ring's clearance: 8px of reserve reads as the ring touching the
     band. Match the side gutter so the gap looks deliberate. */
  .sizes { padding-top: 14px; padding-bottom: 24px; }

  :root { --gutter: 24px; }
  .about .cols { grid-template-columns: 1fr; }
  .nav { display: none; }
  /* The nav and the Etsy link go on a phone; Share stays, because
     the OS share sheet is the whole point of it there. */
  .share-wrap { margin-left: auto; }
  /* The prompt stays on a phone. Hiding it was what left the chooser looking
     like decoration; it is the only thing that says what the control is for. */
  /* Full width on a phone: a pill sized to "Amazon Smart Wireless Dimmer
     Switch" and a pill sized to "Hue V1" look like different controls when
     they are the only thing on their line. */
  /* The name row goes: eleven of "Straight Quintuple" do not fit across a
     phone, and the numbered chips already say what each stop is. Everything
     else about the chips is the same as it is on a desktop, and lives with
     `.detent` above rather than being restated here. */
  .labels { display: none; }
  .band { border-radius: var(--radius); margin-inline: 24px; width: auto; }
  /* In mobile mode the copy always sits in the page body, whether or not the
     boxes also fit beside the product, and whether or not the toggle is on. */
  .feature-list { display: grid; }
  .feature-list { grid-template-columns: 1fr; max-width: 560px; }
  footer { flex-direction: column; align-items: flex-start; gap: 8px; }

  .panel-stack { position: static; width: auto; padding: 20px 0 0 0; gap: 0; }
  .panel { background: none; box-shadow: none; backdrop-filter: none; padding: 0; }
  #palette { flex: none; padding-inline: 24px; }

  /* One scrolling row: a grid spread across a wide page puts the swatches too
     far apart to compare. */
  /* Pulled out by the scroller's side padding, so the row still starts on
     the gutter while the scroller has room for the selection ring. The
     fades are positioned against this element, so it must share the
     scroller's edges exactly. */
  .swatch-wrap { margin: 0 -5px; min-height: 0; }
  /* Side padding so the selected chip's ring is not clipped by the scroll
     container: it is drawn 4px outside the chip, and this row scrolls, so
     zero padding cuts the first and last selections in half. The negative
     margin goes on the WRAPPER, not the scroller, so the two keep the same
     edges - the fades are positioned against the wrapper, and if the scroller
     overhangs it they sit inside the row and wash a chip that is still fully
     visible. */
  .swatches { overflow-x: auto; overflow-y: visible; padding: 6px 5px 8px 5px;
              cursor: grab; }
  .swatch-grid { display: flex; gap: 10px; }
  .swatch { flex: none; width: 84px; }
  .fade { top: 0; bottom: 0; height: auto; width: 30px; }
  .fade.top { left: 0; right: auto; background: linear-gradient(to right, #fff, transparent); }
  .fade.bottom { right: 0; left: auto; background: linear-gradient(to left, #fff, transparent); }

  /* Docked to the viewport, not sticky: sticky only engages when the
     container is taller than the viewport, and this one is not. */
  #purchase {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 6;
    flex-direction: column; align-items: stretch;
    gap: 8px; padding: 10px 24px calc(10px + env(safe-area-inset-bottom, 0px)) 24px;
    background: #ffffff; border-top: 1px solid var(--line);
    box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.10);
  }
  /* The offer stays. Free shipping and a live coupon are the two reasons to
     press Buy, and hiding every .note here hid them from the ~95% of visitors
     who arrive on a phone - the surface the social campaigns feed.
     The Etsy handover note is the one that goes: three more lines would make
     the dock a fifth of the screen, and it is reassurance rather than an
     offer. It reappears in the page body below (.note-echo). */
  #purchase .note { display: none; }
  #purchase #offer { display: block; font-size: 12px; line-height: 1.3; }
  .note-echo { display: block; margin-top: 18px; }
  .wrap { padding-bottom: calc(var(--dock-h, 88px) + 16px); }
}

@media (max-width: 719px) {
  :root { --gutter: 16px; }
  #palette { padding-inline: 16px; }
  .band { margin-inline: 16px; }
  #purchase { padding-inline: 16px; }
  .price { font-size: 24px; }
}

@media (max-width: 479px) {
  .swatch { width: 76px; }
}

/* ---------- 360 to 420: the last place anything has to give ---------- */
/* Gutters stay at 16px all the way down: the band's height is capped by what
   the master can cover at that width, so narrowing the gutter only buys a few
   pixels of band and costs a consistent page edge. */
@media (max-width: 419px) {
  .masthead { height: 78px; }
  /* Scaled with the bar rather than left behind: 44 of 72 is 61%, and 36 of 60
     is the same fraction, so the mark keeps its weight on a phone. There is
     room - the nav is display:none below 960px, so the only thing the logo
     shares this bar with is empty space. */
  .masthead img { height: 54px; }
  .buy { height: 44px; padding-inline: 16px; font-size: 15px; }
  .price { font-size: 22px; }
  .spec { font-size: 12px; }
  /* Position only. The size is deliberately the same on a phone: shrinking a
     44px target to 30px undoes the one thing it is sized for (D-287). */
  .feature-toggle { left: 12px; bottom: 12px; }
  .swatch { width: 68px; }
  .swatch i { height: 42px; }
  .sizes { padding-top: 16px; }
}

/* ---------- catalog ----------
   The catalog and the brand-group pages. Their own stylesheet rules rather
   than the configurator's, because catalog.html carries no script: every tile
   is a link and nothing here waits on a manifest to arrive (D-111). */
/* The breadcrumb is the trail TO this heading, so it sits close to it -
   proximity is what says they belong together. The air goes underneath
   instead, between the heading block and the grid it introduces. It used to
   be the other way round: 32px above the h1 and 4px below, which read as a
   heading that had drifted away from its trail and landed on its prompt.
   The zero above matches the product pages, which measured 0 between the
   crumb box and the heading box and read correctly. In rem below, so the
   block grows with a reader's text size (D-172). */
.catalog { padding-block: 0 8px; }
.catalog h1 { margin: 0 0 0.6rem; font-size: 28px; font-weight: 600; }
.catalog .prompt { margin: 0 0 1.75rem; color: var(--muted); }

.catalog-grid {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 20px;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}

.catalog-grid a {
  display: flex; flex-direction: column; height: 100%;
  border: 1px solid var(--line); border-radius: var(--radius);
  text-decoration: none; color: inherit; background: #fff;
  transition: border-color .15s ease, transform .15s ease;
}
.catalog-grid a:hover,
.catalog-grid a:focus-visible { border-color: var(--brand); transform: translateY(-2px); }

/* The remote sits on the scene plate, cropped to the tile. A fixed aspect
   keeps the grid even before the images decode, so the page does not reflow
   under the pointer. */
.catalog-grid .shot {
  position: relative; aspect-ratio: 4 / 3; overflow: hidden;
  border-radius: var(--radius) var(--radius) 0 0; background: var(--surround);
}
/* Three pre-cropped layers from `web/tiles/`, stacked: scene plate, pedestal,
   remotes. Same composition the configurator draws, same source renders, but
   cropped and downscaled at build time - 403 KB for the page against 4.9 MB
   for the originals, and no inline `style` for `style-src 'self'` to block
   (D-117). All three share the tile box exactly, so they cannot drift. */
.catalog-grid .shot img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; display: block;
}
.catalog-grid .label { padding: 14px 16px 16px; }
.catalog-grid .label strong { display: block; font-size: 17px; font-weight: 600; }
.catalog-grid .label span { display: block; margin-top: 3px; color: var(--muted); font-size: 14px; }

@media (max-width: 720px) {
  :root { --gutter: 20px; }
  .catalog-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
}

/* ---------- no-script fallbacks ----------
   The pre-rendered size links. They are the only navigation on a layout page
   when app.js does not run, and a duplicate of the slider when it does, so
   they are shown by default and hidden the moment `has-js` lands (D-111).
   Default-visible, not default-hidden: a stylesheet that loads while a script
   does not must fail toward the links being there. */
.seo-sizes { margin: 0 0 12px; padding-inline: var(--gutter); }
.seo-sizes ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 8px;
}
.seo-sizes a {
  display: inline-block; padding: 6px 12px;
  border: 1px solid var(--line); border-radius: var(--radius);
  text-decoration: none; color: var(--ink); font-size: 14px;
}
.seo-sizes a[aria-current="page"] { border-color: var(--brand); color: var(--brand); }
.has-js .seo-sizes { display: none; }

/* Everything below is drawn or populated by app.js and is an empty shell
   without it: the stage shows a bare table with no product on it, the family
   <select> has no options, and the slider has no detents. Hidden rather than
   shown broken - what is left is a real heading, the price, the facts, the
   size links above and a working buy button, which is a page that still sells.
   The product SHOT is the gap: there is no composite still per layout, which
   is the same missing asset build_seo.py warns about for og:image. One render
   pass fixes both. */
html:not(.has-js) .band,
html:not(.has-js) .sizes,
html:not(.has-js) #palette { display: none; }

/* The purchase panel is an absolutely-positioned overlay ON the stage, so
   hiding the stage wrapper takes the price and the buy button with it.
   Drop it back into normal flow instead: it carries the only way to buy
   anything. The <h1> is no longer in here - it moved up under the
   breadcrumb (D-169) - so a no-JS page keeps its heading either way. */
/* Only above 959px. Below that the responsive rules already drop the panel
   into normal flow, and overriding them again just fights them. */
@media (min-width: 960px) {
  html:not(.has-js) .stage-wrap { position: static; height: auto; min-height: 0; }
  html:not(.has-js) .panel-stack {
    position: static; width: auto; max-width: 420px;
    margin: 0 var(--gutter) 28px; inset: auto;
  }
}
html:not(.has-js) .panel {
  background: #fff; backdrop-filter: none;
  box-shadow: none; border: 1px solid var(--line);
}
