/* 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. */
@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;
  --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;
}

* { 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; }

.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; height: 34px; padding-inline: 12px;
  border: 0; border-radius: 17px; cursor: pointer; font: inherit; font-size: 13px; 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; }
.swatches { flex: 1; min-height: 0; overflow-y: scroll; padding: 6px 10px 10px 6px; scrollbar-width: thin; }
/* 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; }
.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;
}
.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); }
/* 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; }
/* 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 */
@media (max-width: 959px) {
  /* 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 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; }
  .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; }
  .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; }
  .feature-toggle { left: 12px; bottom: 12px; height: 30px; padding-inline: 10px; font-size: 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);
}
