/* =============================================================================
   Toytastic → Qorx · component CSS
   Class names are the contract. Values come from tokens.css - no literal hex
   below except #fff and rgba() overlays.
   ============================================================================= */

*{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{font-family:var(--font-body);color:var(--tt-navy);background:#fff;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;line-height:var(--lh-body)}
.wrap{max-width:var(--wrap);margin:0 auto;padding:0 32px}
a{text-decoration:none;color:inherit}
img{max-width:100%;display:block}
button{font:inherit;cursor:pointer}
[hidden]{display:none !important}

/* ---- accessibility primitives --------------------------------------------- */
.vh{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* The size lives on .skip and not on .skip:focus. This is the first keyboard
   stop and the first control on every page, so its target belongs to the
   element rather than to a state of it: the focused box already rendered
   47px from padding and line-height, and stating the minimum means a change
   of type scale cannot quietly take it back. The focused appearance is the
   one that shipped. */
.skip{position:absolute;left:-9999px;display:flex;align-items:center;min-height:var(--tap-min);padding:12px 18px}
.skip:focus{left:16px;top:16px;z-index:100;background:#fff;color:var(--tt-navy);border-radius:8px;outline:var(--focus-ring)}
:where(a,button,input,select,textarea,summary,[tabindex]):focus-visible{outline:var(--focus-ring);outline-offset:2px;border-radius:4px}
.util :focus-visible,.hero :focus-visible,.ftr :focus-visible,.gf :focus-visible{outline:var(--focus-ring-light)}

/* ---- utility bar ---------------------------------------------------------- */
.util{background:var(--tt-navy);color:var(--pale);font-size:var(--fs-xs)}
.util .wrap{display:flex;justify-content:space-between;align-items:center;min-height:38px;gap:8px 22px;flex-wrap:wrap}
.util b{color:var(--tt-yellow);font-weight:700}
.util ul{display:flex;gap:22px;list-style:none;flex-wrap:wrap}

/* ---- header --------------------------------------------------------------- */
/* THE TOP INSET, and this is the one that matters most on a phone. The header
   is sticky at top:0, so once the page scrolls it is pinned to the very top of
   the viewport - which, inside the app shell with viewport-fit:cover, is
   underneath the status bar and the notch. Without this the search field and
   the basket icon sit behind the clock. The fallback is 0px, so nothing moves
   on the web or on the mock pages, where the value is 0 anyway.
   Companions: .consentin, .filters, .lbclose and .lbhint below, and .sheetbar
   and .mobnav-panel which already had theirs. */
.hdr{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:40;
     padding-top:env(safe-area-inset-top,0px)}
.hdr .wrap{display:flex;align-items:center;gap:28px;min-height:78px}
/* min-height, not padding: the logo already centres its image, and on the
   phone header the row it sits in is 44px tall because the menu button is.
   Declaring the minimum changes no layout at any width and stops the target
   being 44px only by the accident of a neighbour. */
.logo{font-weight:800;font-size:var(--fs-3xl);letter-spacing:-.6px;color:var(--tt-navy);white-space:nowrap;display:flex;align-items:center;min-height:var(--tap-min)}
.logo i{color:var(--tt-orange-ink);font-style:normal}
.logo img{max-height:46px;width:auto}
.search{flex:1;position:relative;display:flex}
.search input{width:100%;height:46px;border:2px solid var(--line-control);border-radius:var(--r-pill);padding:0 52px 0 20px;font-size:var(--fs-sm);color:var(--ink);background:var(--surface)}
.search input::placeholder{color:var(--muted);opacity:1}
.search button{position:absolute;right:5px;top:5px;width:36px;height:36px;border:0;border-radius:50%;background:var(--tt-orange);color:var(--tt-navy);display:flex;align-items:center;justify-content:center}
.search button:hover{background:var(--tt-orange-hover)}
.hicons{display:flex;gap:22px;align-items:center;color:var(--tt-navy);list-style:none}
.hicon{display:flex;flex-direction:column;align-items:center;gap:3px;font-size:var(--fs-2xs);font-weight:600;position:relative;min-width:44px;padding:4px}
.pip{position:absolute;top:-2px;right:2px;background:var(--tt-orange);color:var(--tt-navy);font-size:var(--fs-3xs);font-weight:800;border-radius:9px;padding:1px 5px}

/* ---- nav + mega menu ------------------------------------------------------ */
.nav{background:#fff;border-bottom:1px solid var(--line)}
.nav .wrap{display:flex;gap:30px;align-items:center;min-height:52px;font-size:var(--fs-sm);font-weight:600}
.nav ul{display:flex;gap:30px;list-style:none;align-items:center}
.nav a,.nav button{padding:6px 0;border:0;border-bottom:3px solid transparent;color:var(--tt-navy);background:none;font-weight:600;font-size:var(--fs-sm)}
.nav a.on,.nav button[aria-expanded="true"]{border-color:var(--tt-orange-ink)}
.nav a.hl{color:var(--tt-orange-ink)}
.navwrap{position:relative}
.mega{position:absolute;left:0;right:0;top:100%;background:#fff;border-bottom:1px solid var(--line);box-shadow:var(--shadow-pop);z-index:30}
.mega .wrap{display:flex;gap:36px;padding-top:26px;padding-bottom:28px;flex-wrap:wrap}
.mcol{min-width:172px}
.mcol h3{font-size:var(--fs-2xs);letter-spacing:1.1px;color:var(--faint-ink);margin-bottom:12px;text-transform:uppercase}
.mcol ul{list-style:none}
.mcol a{display:block;font-size:var(--fs-sm);padding:5px 0;color:var(--tt-navy)}
.mcol a:hover{color:var(--tt-orange-ink);text-decoration:underline}
.mtiles{margin-left:auto;display:flex;gap:14px}
.mtile{width:196px;border-radius:14px;overflow:hidden;background:var(--surface);display:block}
.mtile .art{height:118px;display:flex;align-items:center;justify-content:center}
.mtile p{padding:10px 12px 13px;font-size:var(--fs-xs);font-weight:700;line-height:var(--lh-snug)}
.mtile small{display:block;font-weight:500;color:var(--faint-ink);margin-top:3px}

/* ---- hero ----------------------------------------------------------------- */
.hero{background:linear-gradient(105deg,var(--tt-navy) 0%,var(--tt-navy-2) 62%,var(--tt-navy-3) 100%);color:#fff;position:relative;overflow:hidden}
.hero .wrap{display:flex;align-items:center;gap:40px;padding-top:52px;padding-bottom:52px;position:relative;z-index:2}
.hero .txt{flex:1.05}
.eyebrow{display:inline-block;background:rgba(255,201,74,.17);color:var(--tt-yellow);font-size:var(--fs-xs);font-weight:800;letter-spacing:1.4px;padding:6px 13px;border-radius:20px;margin-bottom:16px}
.hero h1{font-size:var(--fs-8xl);line-height:var(--lh-flat);letter-spacing:-1.2px;margin-bottom:16px}
.hero h1 em{font-style:normal;color:var(--tt-yellow)}
.hero p{font-size:var(--fs-lg);line-height:var(--lh-relaxed);color:var(--pale);max-width:470px;margin-bottom:26px}
.hero .art{flex:1;display:flex;align-items:center;justify-content:center}
.blob{position:absolute;border-radius:50%;opacity:.22}

/* ---- buttons -------------------------------------------------------------- */
/* Navy on the real brand amber = 8.10:1. White on it is 1.86:1 and fails badly,
   which is why every label on an amber fill is navy. Sampling the actual logo
   made this MORE clear-cut, not less - the placeholder was 2.59:1. */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;background:var(--tt-orange);color:var(--tt-navy);font-weight:800;font-size:var(--fs-md);padding:14px 26px;border:0;border-radius:var(--r-pill);min-height:var(--tap-min)}
/* A colour swap alone reads as flat, so the hover also lifts the control and
   casts a shadow, and the press drops both again. Transform and shadow are
   compositor-friendly, so this costs nothing on a page of them. */
.btn{box-shadow:0 1px 2px rgba(22,38,79,.10);
     transition-property:background-color,box-shadow,translate,scale;
     transition-duration:var(--t-fast),var(--t-fast),var(--t-fast),var(--t-press);
     transition-timing-function:var(--ease-out)}
.btn:hover{background:var(--tt-orange-hover);translate:0 -1px;
           box-shadow:0 6px 16px rgba(22,38,79,.18)}
.btn:active{translate:0 0;box-shadow:0 1px 2px rgba(22,38,79,.10);
            scale:var(--press-scale)}
.btn.ghost:hover{translate:0 -1px}
@media (prefers-reduced-motion:reduce){
  .btn:hover,.btn:active{translate:0 0}
}
.btn.ghost{background:transparent;border:2px solid rgba(255,255,255,.55);color:#fff}
.btn.sm{padding:10px 20px;font-size:var(--fs-sm)}
.btn.navy{background:var(--tt-navy);color:#fff}
.btn.navy:hover{background:var(--tt-navy-2)}

/* ---- shop by age strip ---------------------------------------------------- */
/* Vertical only. .ages is always used as `class="wrap ages"`, so a padding
   SHORTHAND here sets the horizontal sides to 0 and wipes the gutter .wrap
   just established - which put the homepage category tiles hard against both
   screen edges at every width above 400px, where no later .wrap rule existed
   to win it back. */
.ages{padding-top:34px;padding-bottom:6px}
.ages .row{display:flex;gap:12px;list-style:none}
/* its motion - the hover colour change and the press - is declared once in the
   MOTION SYSTEM block at the foot of this file, with named properties */
.agechip{flex:1;display:block;background:var(--surface);border:2px solid var(--line-2);border-radius:15px;padding:15px 12px;text-align:center;min-height:var(--tap-min)}
.agechip .n{font-size:var(--fs-xl);font-weight:800;color:var(--tt-navy);display:block}
.agechip .l{font-size:var(--fs-2xs);color:var(--faint-ink);margin-top:2px;display:block}
.agechip:hover{border-color:var(--line-control)}
.agechip.on{background:var(--peach);border-color:var(--tt-orange-ink)}
.agechip.on .n{color:var(--tt-orange-ink)}

/* ---- section headings ----------------------------------------------------- */
.sec{padding-top:44px;padding-bottom:44px}
.sech{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:22px;gap:12px}
.sech h2{font-size:var(--fs-4xl);letter-spacing:-.6px}
.sech p{color:var(--faint-ink);font-size:var(--fs-sm);margin-top:5px}
.sech a{color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-sm)}
.sech a:hover{text-decoration:underline}

/* ---- product cards -------------------------------------------------------- */
.grid{display:grid;gap:20px;list-style:none}
.g4{grid-template-columns:repeat(4,1fr)}
.g3{grid-template-columns:repeat(3,1fr)}
/* Elevation is the shadow's job and structure is the border's, and the 1px
   --line-2 edge was doing elevation. It is now a 1px ring drawn AS a shadow
   (--line is the declared card-edge token) with --shadow-card lifting it off
   the page; hover swaps the lift for --shadow-pop and the card travels 3px.
   Named properties only - never transition:all. */
.card{border:0;border-radius:var(--r-card);overflow:hidden;background:#fff;position:relative;display:flex;flex-direction:column;height:100%;
      box-shadow:0 0 0 1px var(--line),var(--shadow-card);
      transition-property:translate,box-shadow;transition-duration:var(--t-base);transition-timing-function:var(--ease-out)}
.card:hover{translate:0 -3px;box-shadow:0 0 0 1px var(--line),var(--shadow-pop)}
/* CONCENTRIC RADII. The art panel is inset 8px inside a 16px card, so its own
   radius is 16 - 8 = 8, written as the sum rather than as the answer. Insetting
   it also gives the mixed photography one frame: a packshot on white and a
   lifestyle shot both sit on the same tinted panel at the same size, instead of
   one bleeding to the card edge and the other floating in the middle of it. */
/* The image area is WHITE, not --surface, and the reason is the catalogue
   rather than the design. The mock was built with cutout images that sit on
   a tint invisibly; the real photography is 9,468 white-background JPEGs.
   Sampling a rendered card across the art box gave exactly this:

       #F7F9FD  56px    the --surface tint
       #FFFFFF  66px    the photograph's own white background
       #F7F9FD  57px    the tint again

   - a white rectangle inside a tinted rectangle, on every card on the site.
   A box inside a box, which is what made the grid look empty and boxy.

   White makes the photograph's own background indistinguishable from its
   container, which is how every retailer displaying white-background product
   photography does it. The card keeps its edge from the hairline and the
   shadow, so the art area was never relying on the tint to be legible. A
   cutout image still renders correctly on white; a white JPEG on a tint
   could not be fixed from this end at all. */
.card .art{height:186px;margin:8px 8px 0;border-radius:calc(var(--r-card) - 8px);background:#fff;display:flex;align-items:center;justify-content:center;position:relative;padding:10px;overflow:hidden}
/* object-fit stays contain (see .art img): a product photograph is never
   cropped. The hover zoom is the interruptible half of the card's hover -
   slower than the lift, so the picture settles after the card does. */
.card .art img{transition-property:scale;transition-duration:var(--t-slow);transition-timing-function:var(--ease-out)}
.card:hover .art img{scale:1.04}
.card .body{padding:14px 15px 16px;display:flex;flex-direction:column;flex:1}
.brandline{font-size:var(--fs-2xs);font-weight:800;letter-spacing:.9px;color:var(--faint-ink);text-transform:uppercase;margin-bottom:5px}
.card h3{font-size:var(--fs-sm);line-height:var(--lh-snug);font-weight:600;margin-bottom:9px;flex:1;text-wrap:balance}
.card h3 a:hover{text-decoration:underline}
.stars{display:flex;align-items:center;gap:5px;font-size:var(--fs-2xs);color:var(--faint-ink);margin-bottom:9px}
.stars .s{color:var(--tt-amber);letter-spacing:1px}
/* PRICE HIERARCHY. The now-price is the one number on the card that matters,
   so it is the only navy thing in the body and it is set a step larger with
   the tracking tightened; the was-price steps down in size AND weight rather
   than only wearing a line through it. tabular-nums on the row so a column of
   four cards has its pounds and its pence in vertical register. */
.price{display:flex;align-items:baseline;gap:8px;margin-bottom:11px;flex-wrap:wrap;font-variant-numeric:tabular-nums}
.price .now{font-size:var(--fs-xl);font-weight:800;color:var(--tt-navy);letter-spacing:-.4px}
.price .was{font-size:var(--fs-xs);font-weight:600;color:var(--faint-ink);text-decoration:line-through;text-decoration-thickness:1px}
/* SALE FLAG. Same amber fill and navy label (8.10:1), read as a flag rather
   than a tag: uppercase and tracked so it is legible at 11px, a true pill
   radius, tabular digits so 5% and 45% are the same shape, and its own small
   shadow so it lifts off the art panel instead of lying flat on it. */
.badge{position:absolute;top:9px;left:9px;z-index:2;background:var(--tt-orange);color:var(--tt-navy);font-size:var(--fs-2xs);font-weight:800;letter-spacing:.5px;text-transform:uppercase;font-variant-numeric:tabular-nums;padding:5px 10px;border-radius:var(--r-pill);box-shadow:var(--shadow-card)}
.badge.g{background:var(--tt-green);color:#fff}
.badge.d{background:var(--tt-navy);color:#fff}
/* LAST ONE. Navy on white like .d, because it is a fact about stock rather
   than a price shout, and it must read differently from SAVE at a glance.
   A card can carry both flags at once (a reduced last-one), so a second
   badge drops below the first instead of landing on top of it: 9px top +
   ~26px flag + 6px gap. */
.badge.last{background:var(--tt-navy);color:#fff}
/* PRE-LOVED. Green on white (5.32:1, the "White text on gift-finder band" row
   in contrast-check.js), which is the third fill this component has and the
   only one left that reads as neither a price shout nor the stock flag. It is
   a fact about WHAT the item is rather than about its price or its count, and
   it is the one flag a shopper must not mistake for decoration, so it takes
   the top slot when a card carries two - see product_card.html. A pre-loved
   card never also carries .last: preloved.py holds that argument. */
/* nowrap because the stacking offset below is a fixed 41px, which assumes a
   one-line flag: this is the longest word any badge carries, and wrapping it
   on a narrow card made the Save badge under it land on top of it. */
.badge.preloved{background:var(--tt-green);color:#fff;white-space:nowrap}
.badge+.badge{top:41px}
.agetag{position:absolute;bottom:11px;left:11px;background:rgba(255,255,255,.94);color:var(--tt-navy);font-size:var(--fs-2xs);font-weight:700;padding:4px 9px;border-radius:12px}
.wish{position:absolute;top:8px;right:8px;z-index:2;width:var(--tap-min);height:var(--tap-min);border:0;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;box-shadow:var(--shadow-card);color:var(--tt-navy);
      transition-property:scale,background-color;transition-duration:var(--t-press),var(--t-fast);transition-timing-function:var(--ease-out)}
.wish:hover{background:var(--surface)}
.wish:active{scale:var(--press-scale)}
.stock{font-size:var(--fs-xs);font-weight:700;color:var(--tt-green);display:flex;align-items:center;gap:6px;margin-bottom:11px}
.stock.low{color:var(--tt-amber)}
/* A card that CANNOT answer, which is a bundle on a listing: supply is
   derived on the detail read only, so the card holds the box count and not
   whether the box can be filled. Neither green nor amber, because both are
   claims - the card carries a link to the page that can answer instead. */
.stock.check{color:var(--ink-2);font-weight:600}
.stock.check a{color:inherit;text-decoration:underline}
/* A line the shop has said is finished (endofline.py): sold out and never
   returning. Not amber, because amber is "back soon" and this is the one
   stock line that promises nothing. */
.stock.gone{color:var(--ink-2)}
.stock i{width:7px;height:7px;border-radius:50%;background:currentColor;display:block;flex:none}
.addbtn{width:100%;background:var(--tt-navy);color:#fff;border:0;border-radius:var(--r-pill);min-height:var(--tap-min);font-weight:700;font-size:var(--fs-sm);
        transition-property:scale,background-color;transition-duration:var(--t-press),var(--t-fast);transition-timing-function:var(--ease-out)}
.addbtn:hover{background:var(--tt-navy-2)}
.addbtn:active{scale:var(--press-scale)}
.addbtn.alt{background:#fff;color:var(--tt-navy);border:2px solid var(--tt-navy)}
/* The static cues - shadow, ring, background - all survive; only the travel,
   the zoom and the press go. */
@media (prefers-reduced-motion:reduce){
  .card,.card:hover{translate:none}
  .card:hover .art img{scale:none}
  .addbtn:active,.wish:active{scale:none}
}

/* ---- USP bar -------------------------------------------------------------- */
.usp{background:var(--peach-2);border-top:1px solid var(--line-peach);border-bottom:1px solid var(--line-peach)}
.usp .wrap{display:flex;justify-content:space-between;padding:22px 32px;gap:16px;list-style:none;flex-wrap:wrap}
.uspitem{display:flex;gap:12px;align-items:center}
.uspitem .ic{width:38px;height:38px;border-radius:50%;background:var(--tt-orange);color:var(--tt-navy);display:flex;align-items:center;justify-content:center;flex:none}
.uspitem b{display:block;font-size:var(--fs-sm)}
.uspitem small{color:var(--faint-ink);font-size:var(--fs-xs)}

/* ---- gift finder band ----------------------------------------------------- */
.gf{background:var(--tt-green);color:#fff;border-radius:22px;padding:34px 38px;display:flex;align-items:center;gap:34px;position:relative;overflow:hidden;flex-wrap:wrap}
.gf h2{font-size:var(--fs-5xl);letter-spacing:-.6px;margin-bottom:9px}
.gf p{font-size:var(--fs-md);color:#fff;max-width:440px;line-height:var(--lh-body)}
.gfsteps{display:flex;gap:11px;margin-left:auto;position:relative;z-index:2;list-style:none;flex-wrap:wrap}
.gfstep{background:rgba(255,255,255,.15);border:1.5px solid rgba(255,255,255,.45);border-radius:14px;padding:14px 17px;min-width:118px}
.gfstep .k{font-size:var(--fs-3xs);letter-spacing:1px;color:#fff;font-weight:700;margin-bottom:6px}
.gfstep .v{font-size:var(--fs-sm);font-weight:700}

/* ---- brand rail ----------------------------------------------------------- */
.brands{display:flex;gap:14px;list-style:none;flex-wrap:wrap}
.brandcard{flex:1;border:1px solid var(--line-2);border-radius:15px;padding:19px 14px;text-align:center;display:block;min-width:140px}
.brandcard:hover{border-color:var(--line-control)}
.brandcard .bn{font-size:var(--fs-md);font-weight:800;letter-spacing:-.3px;display:block}
.brandcard .bc{font-size:var(--fs-2xs);color:var(--faint-ink);margin-top:3px;display:block}

/* ---- footer --------------------------------------------------------------- */
.ftr{background:var(--tt-navy);color:var(--pale);margin-top:14px}
/* On navy, the BRAND orange is the correct choice: 5.81:1. --tt-orange-ink is
   darkened for light backgrounds and drops to 2.83:1 here - the mirror image of
   the problem it was created to solve. Caught by the contrast audit, not by eye. */
.ftr .logo i{color:var(--tt-orange)}
.ftr .wrap{padding:40px 32px 30px;display:flex;gap:52px;flex-wrap:wrap}
.ftr h2{color:#fff;font-size:var(--fs-xs);margin-bottom:13px;letter-spacing:.4px}
.ftr ul{list-style:none}
.ftr a{display:block;font-size:var(--fs-xs);padding:4px 0;color:var(--pale)}
.ftr a:hover{color:#fff;text-decoration:underline}
.ftr .about{max-width:280px;font-size:var(--fs-xs);line-height:var(--lh-relaxed)}

/* ---- PLP ------------------------------------------------------------------ */
.crumb{font-size:var(--fs-xs);color:var(--faint-ink);padding:16px 0}
.crumb ol{list-style:none;display:flex;gap:6px;flex-wrap:wrap}
.crumb a:hover{text-decoration:underline}
.crumb [aria-current]{color:var(--tt-navy);font-weight:700}
.plphead{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px;gap:14px;flex-wrap:wrap}
.plphead h1{font-size:var(--fs-5xl);letter-spacing:-.7px}
.plphead .cnt{color:var(--faint-ink);font-size:var(--fs-sm);margin-top:6px}
.sortbox{display:flex;gap:9px;align-items:center;font-size:var(--fs-sm);font-weight:600}
.sortbox select{border:2px solid var(--line-control);border-radius:var(--r-pill);padding:9px 17px;font-size:var(--fs-sm);font-weight:600;background:#fff;color:var(--tt-navy);min-height:var(--tap-min)}
.plpbody{display:flex;gap:28px;align-items:flex-start;padding-bottom:44px}
.filters{width:246px;flex:none}
.fgrp{border-bottom:1px solid var(--line-2);padding:17px 0}
.fgrp:first-of-type{padding-top:0}
.fgrp legend,.fgrp h3{font-size:var(--fs-sm);font-weight:700;margin-bottom:12px;width:100%}
.fgrp ul{list-style:none}
/* Real checkboxes, accent-coloured. Previously these were styled divs with no
   semantics - unreachable by keyboard and invisible to screen readers. */
.fopt{display:flex;align-items:center;gap:9px;font-size:var(--fs-sm);padding:5px 0;color:var(--ink-2);min-height:32px;cursor:pointer}
.fopt input[type=checkbox]{width:18px;height:18px;margin:0;flex:none;accent-color:var(--tt-orange-ink);border:2px solid var(--line-control);border-radius:5px}
.fopt .c{margin-left:auto;color:var(--faint-ink);font-size:var(--fs-xs);font-variant-numeric:tabular-nums}
.fopt:has(input:disabled){color:var(--faint);cursor:not-allowed}
.fopt:has(input:disabled) .c{color:var(--faint)}
.fmore{color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-xs);background:none;border:0;padding:6px 0;min-height:32px}
.fmore:hover{text-decoration:underline}

/* ---- price facet ---------------------------------------------------------- */
/* Two handles, plus number inputs, plus preset bands. SC 2.5.7 requires a
   non-dragging alternative for any dragging movement - the number inputs and
   the preset bands are that alternative. */
.pricefacet .bands{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:14px;list-style:none}
.pricefacet .bands button{border:1.5px solid var(--line-control);background:#fff;border-radius:var(--r-chip);padding:7px 12px;font-size:var(--fs-xs);font-weight:600;color:var(--tt-navy);min-height:34px}
.pricefacet .bands button[aria-pressed=true]{background:var(--peach);border-color:var(--tt-orange-ink);color:var(--tt-orange-ink)}
.pricefacet .sliders{position:relative;height:34px;margin:4px 0 6px}
.pricefacet input[type=range]{position:absolute;left:0;right:0;top:8px;width:100%;margin:0;background:none;pointer-events:none;-webkit-appearance:none;appearance:none;height:18px}
.pricefacet input[type=range]::-webkit-slider-thumb{pointer-events:auto;-webkit-appearance:none;width:20px;height:20px;border-radius:50%;background:#fff;border:3px solid var(--tt-orange-ink);box-shadow:var(--shadow-card)}
.pricefacet input[type=range]::-moz-range-thumb{pointer-events:auto;width:20px;height:20px;border-radius:50%;background:#fff;border:3px solid var(--tt-orange-ink)}
.pricefacet .track{position:absolute;left:0;right:0;top:15px;height:4px;background:var(--line-2);border-radius:3px}
.pricefacet .fill{position:absolute;top:0;bottom:0;background:var(--tt-orange-ink);border-radius:3px}
.pricefacet .nums{display:flex;align-items:center;gap:8px}
.pricefacet .nums label{font-size:var(--fs-xs);color:var(--faint-ink)}
.pricefacet .nums input{width:78px;border:2px solid var(--line-control);border-radius:var(--r-input);padding:7px 9px;font-size:var(--fs-sm);color:var(--ink);min-height:38px}

/* ---- applied chips -------------------------------------------------------- */
.chips{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:18px;align-items:center;list-style:none}
.chip{background:var(--peach);color:var(--tt-navy);border:1.5px solid var(--line-peach-strong);border-radius:var(--r-chip);padding:0 6px 0 12px;font-size:var(--fs-xs);font-weight:600;display:flex;gap:4px;align-items:center;min-height:34px}
.chip button{background:none;border:0;color:var(--tt-orange-ink);font-weight:800;font-size:var(--fs-sm);line-height:1;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center}
.chip button:hover{background:#fff}
.clearall{color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-xs);background:none;border:0;min-height:34px;padding:0 4px}
.clearall:hover{text-decoration:underline}

/* ---- mobile filter trigger ------------------------------------------------ */
.fbar{display:none;gap:10px;padding:0 0 14px}
.fbar button{flex:1;border:2px solid var(--line-control);background:#fff;border-radius:var(--r-pill);min-height:var(--tap-min);font-weight:700;font-size:var(--fs-sm);color:var(--tt-navy);display:flex;align-items:center;justify-content:center;gap:8px}
.fbar .count{background:var(--tt-orange);color:var(--tt-navy);border-radius:11px;padding:1px 7px;font-size:var(--fs-xs);font-weight:800}
.sheetbar{display:none}

/* ---- pagination ----------------------------------------------------------- */
.pager{display:flex;gap:8px;justify-content:center;padding:28px 0 8px;list-style:none;flex-wrap:wrap}
.pager a,.pager span{min-width:var(--tap-min);min-height:var(--tap-min);display:flex;align-items:center;justify-content:center;border:2px solid var(--line);border-radius:10px;font-weight:700;font-size:var(--fs-sm);padding:0 12px}
.pager [aria-current]{background:var(--tt-navy);color:#fff;border-color:var(--tt-navy)}
.pager a:hover{border-color:var(--line-control)}

/* ---- PDP ------------------------------------------------------------------ */
.pdp{display:flex;gap:44px;padding:8px 0 44px;align-items:flex-start}
.gal{width:540px;flex:none}
.galmain{height:430px;background:var(--surface);border-radius:18px;display:flex;align-items:center;justify-content:center;position:relative}
/* overflow-x on the BASE rule, not just <=400: five 78px thumbs plus gaps are
   434px, which overflows any viewport under ~470px - the 401-470 band had no
   escape hatch. flex:none stops them being crushed instead of scrolling. */
.thumbs{display:flex;gap:11px;margin-top:12px;list-style:none;overflow-x:auto;padding-bottom:4px}
.thumb{width:78px;height:78px;border-radius:12px;background:var(--surface);border:2px solid transparent;display:flex;align-items:center;justify-content:center;padding:0;flex:none}
.thumb[aria-current=true]{border-color:var(--tt-navy)}
.pinfo{flex:1}
.pinfo h1{font-size:var(--fs-5xl);line-height:var(--lh-tight);letter-spacing:-.7px;margin:7px 0 12px}
.pprice{display:flex;align-items:baseline;gap:12px;margin:16px 0 6px;flex-wrap:wrap}
.pprice .now{font-size:var(--fs-6xl);font-weight:800}
.pprice .was{font-size:var(--fs-lg);color:var(--faint-ink);text-decoration:line-through}
.pprice .save{background:var(--tt-orange);color:var(--tt-navy);font-size:var(--fs-xs);font-weight:800;padding:5px 11px;border-radius:14px}
.subst{font-size:var(--fs-xs);color:var(--faint-ink);margin-bottom:4px}
.keyfacts{display:grid;grid-template-columns:1fr 1fr;gap:11px;margin:20px 0;list-style:none}
.fact{background:var(--surface);border-radius:12px;padding:12px 14px}
/* display:block is load-bearing, not cosmetic. These are spans; without it the
   label and value run together as "SUITABLE FOR3 - 6 years". */
.fact .k{display:block;font-size:var(--fs-2xs);letter-spacing:.7px;color:var(--faint-ink);font-weight:700;text-transform:uppercase;line-height:var(--lh-snug)}
.fact .v{display:block;font-size:var(--fs-sm);font-weight:700;margin-top:4px;line-height:var(--lh-snug)}
.delivbox{border:2px solid var(--line);border-radius:14px;padding:16px 18px;margin:18px 0;list-style:none}
.delivrow{display:flex;gap:11px;align-items:flex-start;padding:7px 0;font-size:var(--fs-sm)}
.delivrow b{display:block}
.delivrow small{color:var(--faint-ink)}
.warn{background:var(--peach-2);border:1.5px solid var(--line-peach-strong);border-radius:12px;padding:13px 15px;font-size:var(--fs-xs);color:var(--warn-ink);display:flex;gap:10px;margin:16px 0}
.buyrow{display:flex;gap:12px;margin:18px 0 10px;align-items:stretch}
.qty{display:flex;align-items:center;border:2px solid var(--line-control);border-radius:var(--r-pill);overflow:hidden}
.qty button{width:44px;min-height:var(--tap-min);border:0;background:#fff;font-size:var(--fs-xl);font-weight:700;color:var(--tt-navy)}
.qty button:hover{background:var(--surface)}
/* The field between the two steppers was 44x18 inside a 44px-tall control,
   so the row looked right and the middle third of it was not tappable. */
.qty input{width:44px;min-height:var(--tap-min);border:0;text-align:center;font-weight:700;font-size:var(--fs-md);color:var(--ink);-moz-appearance:textfield}
.qty input::-webkit-outer-spin-button,.qty input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.big{flex:1;min-height:54px;border-radius:var(--r-pill);border:0;background:var(--tt-orange);color:var(--tt-navy);font-size:var(--fs-lg);font-weight:800}
.big:hover{background:var(--tt-orange-hover)}

/* ---- illustration fallback ------------------------------------------------ */
/* Any product key without a supplied photo falls back to the inline SVG, so a
   partial set of real photography is fine. */
/* Real photography arrives in whatever aspect ratio the supplier shot it -
   ours range from 400x307 to 678x1000. Constrain BOTH axes or the tall ones
   render as a thin sliver and the wide ones overflow. */
.art svg{width:auto;height:78%;max-width:82%}
.art img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}
.galmain img{max-width:92%;max-height:92%;width:auto;height:auto;object-fit:contain}
.thumb img{max-width:86%;max-height:86%;object-fit:contain}

/* =============================================================================
   BREAKPOINTS - FR-59 requires 320 / 768 / 1024 / 1440
   ============================================================================= */
@media (max-width:1024px){
  .wrap{padding-left:20px;padding-right:20px}
  .g4{grid-template-columns:repeat(3,1fr)}
  .pdp{gap:28px}
  .gal{width:min(440px,48%)}
  .filters{width:216px}
  .hero h1{font-size:var(--fs-6xl)}
}
@media (max-width:768px){
  .util .wrap{padding:8px 20px;font-size:var(--fs-xs)}
  .util ul{gap:14px}
  .hdr .wrap{min-height:auto;padding:12px 20px;flex-wrap:wrap;gap:12px}
  .search{order:3;flex-basis:100%}
  .nav{display:none}
  .hero .wrap{flex-direction:column;padding-top:32px;padding-bottom:32px}
  .hero h1{font-size:var(--fs-5xl);letter-spacing:-.6px}
  .hero .art{order:-1}
  .g4,.g3{grid-template-columns:repeat(2,1fr)}
  .ages .row{overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;padding-bottom:6px}
  .agechip{min-width:84px;scroll-snap-align:start}
  .sec{padding-top:28px;padding-bottom:28px}
  .sech{flex-direction:column;align-items:flex-start;gap:8px}
  .sech h2{font-size:var(--fs-2xl)}
  .usp .wrap{padding:18px 20px}
  .gf{flex-direction:column;padding:24px;border-radius:18px}
  .gfsteps{margin-left:0}
  .ftr .wrap{flex-direction:column;gap:26px;padding:28px 20px}
  .plphead{flex-direction:column;align-items:flex-start}
  .plphead h1{font-size:var(--fs-2xl)}
  .sortbox{width:100%}
  .sortbox select{width:100%}
  /* filters become a bottom sheet */
  .fbar{display:flex}
  .plpbody{flex-direction:column;gap:0}
  /* The 96px bottom clears the .sheetbar that floats over this panel; the
     inset clears the home indicator underneath that, so the last filter row
     is reachable rather than half-covered. .sheetbar carries its own inset
     already (further down this file). */
  .filters{position:fixed;inset:auto 0 0 0;overflow:auto;background:#fff;
           border-radius:var(--r-sheet) var(--r-sheet) 0 0;box-shadow:var(--shadow-sheet);
           padding:18px 20px calc(96px + env(safe-area-inset-bottom,0px));
           z-index:60;transform:translateY(101%);
           transition-property:transform;transition-duration:var(--t-base);
           transition-timing-function:var(--ease-in-out)}
  .filters[data-open]{transform:none}
  .sheetbar{display:block;position:fixed;left:0;right:0;bottom:0;padding:12px 20px 18px;background:#fff;
            border-top:1px solid var(--line);z-index:61}
  .filters:not([data-open]) ~ .sheetbar{display:none}
  .sheetbar .btn{width:100%}
  .pdp{flex-direction:column;gap:22px}
  .gal{width:100%}
  /* 4:3 capped, not 1:1: at exactly 768 a square gallery was 728x728px and
     pushed the price below two screens. 460px cap keeps the fold honest;
     object-fit:contain means no crop either way. */
  .galmain{height:auto;aspect-ratio:4/3;max-height:460px}
  .pinfo h1{font-size:var(--fs-2xl)}
  .pprice .now{font-size:var(--fs-4xl)}
  .keyfacts{grid-template-columns:1fr}
}
@media (max-width:400px){
  .g4,.g3{grid-template-columns:1fr}
  .buyrow{flex-wrap:wrap}
  .qty,.big{flex-basis:100%}
  .hero h1{font-size:var(--fs-3xl)}
  /* at 375 the 46px-tall logo renders ~189px wide; with three 44px header
     icons that is 393px of content in a 347px row and the icons wrapped to a
     second line. 36px logo (~148px) puts the whole row back on one line. */
  .logo img{max-height:36px}
  .hicons{gap:12px}
}
/* prefers-reduced-motion is handled once, for the whole sheet, in the MOTION
   SYSTEM block at the foot of this file. The blanket rule that used to sit here
   cut every transition to .01ms, opacity included; the block at the foot keeps
   opacity - a cross-fade carries no movement - and stops the transforms
   instead, which is what the preference actually asks for. */
@media (pointer:coarse){
  .fopt{min-height:var(--tap-min)}
  .fopt input[type=checkbox]{width:22px;height:22px}
}

/* Footer mark: the real logo is a single-colour amber wordmark, so the earlier
   two-tone white/amber text approximation was wrong. Amber on navy is 8.10:1. */
.ftrlogo img{width:180px;height:auto;max-width:100%}
.ftrlogo i{color:var(--tt-orange);font-style:normal}

/* ---- PDP: data-gap flag, feature bullets, mockup note ---------------------- */
/* A field with no manufacturer source is shown as absent, not guessed. FR-74a
   forbids inferring age suitability or load limits, so the honest render of a
   missing spec is the missing spec. */
.fact-gap{background:var(--peach-2);border:1px dashed var(--line-control)}
.fact-gap .v{color:var(--faint-ink);font-weight:600}
.gapflag{display:inline-block;margin-left:6px;background:var(--tt-navy);color:#fff;font-size:var(--fs-3xs);
         font-weight:800;letter-spacing:.5px;text-transform:uppercase;padding:2px 6px;border-radius:8px;
         vertical-align:1px}
.feats{list-style:none;margin:4px 0 18px}
.feats li{position:relative;padding:4px 0 4px 22px;font-size:var(--fs-sm);color:var(--ink-2);line-height:var(--lh-body)}
.feats li::before{content:"";position:absolute;left:4px;top:12px;width:7px;height:7px;border-radius:50%;
                  background:var(--tt-orange)}

/* =============================================================================
   SPACING PASS - tuned against real content, not lorem
   -----------------------------------------------------------------------------
   Real product names are far longer than placeholders. "Rolly Kid John Deere
   Pedal Tractor with Frontloader and Trailer" is 58 characters and wraps to
   three lines in a 3-up grid; the invented names were one or two. Everything
   below is a consequence of designing against the actual catalogue.
   ============================================================================= */

/* ---- product card: cope with 1-to-3 line titles without ragged card heights */
.card h3{line-height:var(--lh-body);margin-bottom:10px;min-height:calc(var(--lh-body) * 2em)}
.card .body{padding:15px 16px 17px;gap:0}
.brandline{margin-bottom:6px;line-height:var(--lh-snug)}
.stars{margin-bottom:10px;line-height:var(--lh-snug);flex-wrap:wrap;row-gap:2px}
.price{margin-bottom:12px;row-gap:2px;line-height:var(--lh-tight)}
.stock{margin-bottom:13px;line-height:var(--lh-snug)}
/* the price/stock/button block sits at the bottom so cards align on a row */
.card h3{flex:1 0 auto}

/* ---- PDP info column: one vertical rhythm instead of ad-hoc margins -------- */
.pinfo > * + *{margin-top:14px}
.pinfo .brandline{margin:0 0 2px}
.pinfo h1{margin:6px 0 0;line-height:var(--lh-tight)}
.pinfo .stars{margin-top:10px}
.pprice{margin:16px 0 0;row-gap:4px}
.subst{margin-top:6px;line-height:var(--lh-body)}
/* PRE-LOVED, on the product page, directly under the price and above the buy
   row. A tinted block rather than another line of type: it is the one thing
   on this page that changes what the item IS, and the price it sits under has
   just told the shopper what it costs. --surface with a green edge borrows
   the same fill the page's quiet fact blocks use, so it reads as information
   rather than as a warning - a second-hand toy is not a problem with the
   product. Text is --ink on --surface, which is a gated pair.

   NAMED .prelovedbox AND NOT .preloved, beside .notifybox and .delivbox. The
   card flag is .badge.preloved, and a bare .preloved here matched it too:
   same specificity as .badge, declared later, so the flag inherited this
   block's padding and 14px type, grew to two lines and the Save badge
   stacked under it landed on top of it. */
.prelovedbox{margin-top:12px;padding:11px 13px;background:var(--surface);
             border-left:3px solid var(--tt-green);
             border-radius:var(--r-input);font-size:var(--fs-sm);
             color:var(--ink);line-height:var(--lh-body)}
.prelovedbox b{color:var(--tt-navy)}
.keyfacts{margin:20px 0 0}
.feats{margin:16px 0 0}
.pinfo .stock{margin:16px 0 0;font-size:var(--fs-sm)}
.buyrow{margin:18px 0 0}
.delivbox{margin:20px 0 0}
.warn{margin:18px 0 0;line-height:var(--lh-relaxed)}
.warn small{display:block;margin-top:7px;line-height:var(--lh-body)}
.delivrow{padding:9px 0;gap:12px}
.delivrow b{margin-bottom:2px;line-height:var(--lh-snug)}
.delivrow small{display:block;line-height:var(--lh-body)}
.fact{padding:13px 15px}

/* ---- section rhythm ------------------------------------------------------- */
.sech h2{line-height:var(--lh-tight)}
.sech p{line-height:var(--lh-body)}
.hero h1{margin-bottom:18px}
.hero p{margin-bottom:28px;line-height:var(--lh-relaxed)}

/* =============================================================================
   MOBILE SPACING - the version that matters, since near-all traffic is mobile
   ============================================================================= */
@media (max-width:768px){
  /* 20px was mean once real content filled the columns */
  .wrap{padding-left:18px;padding-right:18px}
  .util .wrap{padding:9px 18px;row-gap:5px}
  .hdr .wrap{padding:14px 18px 16px;gap:14px}
  .usp .wrap{padding:20px 18px;gap:18px 20px}
  .ftr .wrap{padding:30px 18px 26px;gap:28px}

  /* breathing room between stacked sections */
  .sec{padding-top:30px;padding-bottom:30px}
  .ages{padding-top:26px;padding-bottom:4px}
  .grid{gap:16px}
  .card .body{padding:13px 14px 15px}
  .card h3{font-size:var(--fs-sm);min-height:0}          /* no forced height when 2-up */
  .card .art{height:168px;padding:10px}

  /* PDP */
  .pinfo > * + *{margin-top:13px}
  .keyfacts{gap:10px}
  .fact{padding:12px 14px}
  .delivbox{padding:15px 16px}
  .buyrow{gap:10px}

  /* chips and filters need vertical room once they wrap */
  .chips{gap:8px;margin-bottom:16px;row-gap:8px}
  .fbar{gap:9px;padding-bottom:16px}
  /* Same pairing as the 768 band above: the bar's height plus the
     device's own bottom inset. */
  .filters{padding:20px 18px calc(100px + env(safe-area-inset-bottom,0px))}
  .fgrp{padding:16px 0}
  .sheetbar{padding:12px 18px calc(14px + env(safe-area-inset-bottom,0px))}
}

/* There is a second max-width:400px band further down that also sets the
   gutter. The later one wins, so these are aligned to it rather than left
   disagreeing - two bands at the same breakpoint quietly answering
   differently is how a value gets "fixed" in the one that never applied. */
@media (max-width:400px){
  .wrap{padding-left:18px;padding-right:18px}
  .util .wrap{padding:8px 18px}
  .hdr .wrap{padding:12px 18px 14px}
  .card .art{height:200px}                        /* 1-up, so give it height back */
  .card h3{font-size:var(--fs-sm)}
  .keyfacts{gap:9px}
  .hero .wrap{padding-top:26px;padding-bottom:26px}
  .gf{padding:20px}
  .pprice .now{font-size:var(--fs-3xl)}
}

/* ---- shop-by-category strip ------------------------------------------------
   Replaces the single-year age chips on the homepage. Same row mechanics as
   .agechip (which is retained - the age pages still use it), but sized for an
   icon plus a wrapping label rather than a number. */
.catrow{display:flex;gap:12px;list-style:none}
/* as .agechip: motion lives in the MOTION SYSTEM block at the foot of the file */
.catchip{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:8px;
         background:var(--surface);border:2px solid var(--line-2);border-radius:15px;
         padding:14px 8px 12px;text-align:center;min-height:var(--tap-min)}
.catchip:hover{border-color:var(--tt-orange-ink);background:var(--peach)}
.catchip .ic{width:40px;height:30px;display:flex;align-items:center;justify-content:center;flex:none}
.catchip .ic svg{width:40px;height:30px}
.catchip .l{font-size:var(--fs-xs);font-weight:700;color:var(--tt-navy);line-height:var(--lh-snug)}
@media (max-width:1024px){ .catchip .l{font-size:var(--fs-2xs)} }
@media (max-width:768px){
  .catrow{overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;padding-bottom:6px}
  .catchip{min-width:96px;scroll-snap-align:start}
}
@media (max-width:400px){ .catchip{min-width:88px} }

/* =============================================================================
   BASKET - basket.html
   -----------------------------------------------------------------------------
   Reuses the existing contract wherever one exists: .qty from the PDP buy row,
   .card/.grid/.sech for the cross-sell rail, .stock, .brandline.
   Only what the basket page genuinely adds is new below.
   ============================================================================= */
.bhead{display:flex;align-items:baseline;gap:14px;margin-bottom:4px}
.bhead h1{font-size:var(--fs-5xl);letter-spacing:-.7px}
.bhead .cnt{color:var(--faint-ink);font-size:var(--fs-sm)}

/* summary sits BESIDE the line items on desktop, stacks UNDER them <=768 */
.basket{display:flex;gap:36px;align-items:flex-start;padding:14px 0 8px}
.bmain{flex:1;min-width:0}
.blines{list-style:none}
.bline{display:grid;grid-template-columns:112px 1fr auto;gap:18px;padding:20px 0;border-bottom:1px solid var(--line-2);align-items:start}
.bline:first-child{padding-top:6px}
.bimg{width:112px;height:112px;background:var(--surface);border-radius:12px;display:flex;align-items:center;justify-content:center;padding:10px;flex:none}
.bimg img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}
.binfo h3{font-size:var(--fs-md);line-height:var(--lh-body);font-weight:600;margin-bottom:7px;max-width:52ch}
.binfo h3 a:hover{text-decoration:underline}
.bprice{font-size:var(--fs-sm);font-weight:700;display:flex;gap:8px;align-items:baseline;margin-bottom:7px;flex-wrap:wrap}
.bprice .was{font-size:var(--fs-xs);color:var(--faint-ink);text-decoration:line-through;font-weight:600}
.binfo .stock{margin-bottom:0}
.bqtyrow{display:flex;align-items:center;gap:18px;margin-top:12px;flex-wrap:wrap}
.bremove{background:none;border:0;color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-xs);text-decoration:underline;padding:6px 2px;min-height:32px}
.bremove:hover{color:var(--tt-navy)}
.bltotal{font-size:var(--fs-md);font-weight:800;text-align:right;font-variant-numeric:tabular-nums;padding-top:4px}
.bempty{border:2px dashed var(--line-control);border-radius:var(--r-card);padding:26px;text-align:center;color:var(--faint-ink);font-size:var(--fs-sm)}
.bempty a{color:var(--tt-orange-ink);font-weight:700;text-decoration:underline}

.bsum{width:340px;flex:none;background:var(--surface);border-radius:var(--r-card);padding:22px 22px 24px;position:sticky;top:94px}
.bsum h2{font-size:var(--fs-lg);letter-spacing:-.3px;margin-bottom:14px}
.srows{list-style:none}
.srow{display:flex;justify-content:space-between;gap:14px;padding:7px 0;font-size:var(--fs-sm);align-items:baseline}
.srow small{display:block;color:var(--faint-ink);font-size:var(--fs-xs);font-weight:500;line-height:var(--lh-body);margin-top:2px}
.srow b{font-variant-numeric:tabular-nums;flex:none}
.srow.total{border-top:1.5px solid var(--line);margin-top:8px;padding-top:13px;font-size:var(--fs-md);font-weight:800}
.bsum .btn{width:100%;margin-top:16px}
.paynote{display:block;text-align:center;color:var(--faint-ink);font-size:var(--fs-xs);margin-top:9px;line-height:var(--lh-body)}
.contlink{text-align:center;margin-top:12px;font-size:var(--fs-sm)}
.contlink a{color:var(--tt-orange-ink);font-weight:700}
.contlink a:hover{text-decoration:underline}

/* ---- discount code, in the summary panel -----------------------------------
   The Apply button overrides .bsum .btn's full width, because the field and
   the button are one row: a full-width button below a full-width input reads
   as the checkout button and gets pressed as one.
   The remove control is a link rather than a second button for the same
   reason - nothing beside Checkout should look as pressable as Checkout. */
.discform{margin-top:16px;border-top:1.5px solid var(--line);padding-top:14px}
.discform label{display:block;font-size:var(--fs-xs);font-weight:700;color:var(--faint-ink);margin-bottom:6px}
.discrow{display:flex;gap:8px;align-items:stretch}
.discrow .ainput{flex:1;min-width:0}
.bsum .discrow .btn{width:auto;margin-top:0;padding:14px 18px;font-size:var(--fs-sm);flex:none}
.disclist{list-style:none;margin-top:10px;display:flex;flex-direction:column;gap:6px}
.disclist li{display:flex;justify-content:space-between;align-items:center;gap:10px;
  background:var(--peach);border-radius:10px;padding:8px 12px;font-size:var(--fs-sm);font-weight:700}
.disclist form{margin:0}
.linkbtn{background:none;border:0;padding:0;color:var(--tt-orange-ink);font-weight:700;
  font-size:var(--fs-xs);text-decoration:underline;min-height:var(--tap-min);}
.linkbtn:hover{text-decoration:none}

/* ---- free-delivery-over-£50 progress line ---------------------------------- */
.fdprog{background:#fff;border:1.5px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:12px}
.fdprog p{font-size:var(--fs-xs);color:var(--faint-ink);line-height:var(--lh-body);margin-bottom:8px}
.fdprog b{color:var(--tt-green)}
.fdbar{height:8px;background:var(--line-2);border-radius:4px;overflow:hidden}
/* width, and not the scaleX a progress bar would normally animate, because the
   fill's width is set as an inline style by the page that owns it - converting
   it to a transform means changing that script too, which is noted for the
   owning lane rather than done here. It is one element with no siblings inside
   an overflow:hidden bar, so the layout it recalculates is its own. */
.fdbar i{display:block;height:100%;background:var(--tt-green);border-radius:4px;
         transition-property:width;transition-duration:var(--t-base);
         transition-timing-function:var(--ease-out)}

/* ---- cross-sell rail ------------------------------------------------------ */
.xsell{padding:26px 0 8px}

@media (max-width:1024px){
  .basket{gap:24px}
  .bsum{width:300px}
}
@media (max-width:768px){
  .basket{flex-direction:column;gap:20px}
  .bsum{width:100%;position:static;top:auto}
  .bhead h1{font-size:var(--fs-2xl)}
  .bline{grid-template-columns:96px 1fr auto;gap:14px;padding:16px 0}
  .bimg{width:96px;height:96px}
  .xsell{padding:20px 0 4px}
}
@media (max-width:400px){
  /* line total moves under the details; the image spans both rows */
  .bline{grid-template-columns:84px 1fr}
  .bimg{width:84px;height:84px;grid-row:1/3}
  .bltotal{grid-column:2;text-align:left;padding-top:0}
  .bqtyrow{gap:12px}
}

/* =============================================================================
   SPACING PASS 2 - sections were sitting too close (client review, 1 Aug)
   ============================================================================= */
/* breathing room after the USP bar and between stacked sections */
.usp{margin-bottom:10px}
.sec{padding-top:52px;padding-bottom:52px}
.ages{padding-top:46px;padding-bottom:10px}
.ages .sech{margin-bottom:26px}
.sech{margin-bottom:26px;row-gap:10px}
.sech p{margin-top:7px;line-height:var(--lh-body)}
.catrow{gap:14px}
.catchip{padding:18px 10px 16px;gap:10px}
.catchip .l{line-height:var(--lh-snug)}
.gf{margin:10px 0}
.brands{gap:16px}

/* tablet: 768-1024 was inheriting desktop 32px wrap on some widths and 20px on
   others - settle on a proper tablet gutter and calmer section rhythm */
@media (max-width:1024px){
  .wrap{padding-left:26px;padding-right:26px}
  .sec{padding-top:44px;padding-bottom:44px}
  .ages{padding-top:38px;padding-bottom:8px}
  .usp .wrap{padding:22px 26px}
  .hero .wrap{padding-top:44px;padding-bottom:44px}
}
/* mobile: more generous gutters and vertical rhythm than the first pass */
@media (max-width:768px){
  .wrap{padding-left:20px;padding-right:20px}
  .util .wrap{padding:10px 20px}
  .hdr .wrap{padding:14px 20px 16px}
  .usp .wrap{padding:22px 20px;gap:20px 22px}
  .usp{margin-bottom:6px}
  .sec{padding-top:36px;padding-bottom:36px}
  .ages{padding-top:32px;padding-bottom:8px}
  .ages .sech,.sech{margin-bottom:20px}
  .sech h2{line-height:var(--lh-tight)}
  .catrow{gap:12px;padding-bottom:10px}
  .catchip{min-width:104px;padding:16px 10px 14px}
  .grid{gap:18px}
  .ftr .wrap{padding:32px 20px 28px}
}
/* 18px, not 16. The gutter scale runs 32 - 26 - 20 down the breakpoints, and
   dropping to 16 on the smallest screens put content closer to the edge than
   anywhere else on the site - on the one size where a thumb is already
   covering part of it. 18 keeps the step gentle without spending width a
   320px screen cannot afford. */
@media (max-width:400px){
  .wrap{padding-left:18px;padding-right:18px}
  .util .wrap{padding:9px 18px}
  .hdr .wrap{padding:12px 18px 14px}
  .usp .wrap{padding:20px 18px}
  .sec{padding-top:32px;padding-bottom:32px}
  .catchip{min-width:96px}
}

/* =============================================================================
   POLISH PASS 3 - client review, 1 Aug evening
   ============================================================================= */

/* ---- filter rail: counts were flush against the rail edge ----------------- */
.plpbody{gap:36px}
.filters{width:256px;padding-right:6px}
.fopt .c{margin-left:auto;padding-left:12px;min-width:26px;text-align:right}
.fgrp{padding:18px 0}
.fgrp legend,.fgrp h3{margin-bottom:13px}

/* ---- category tiles now carry real photos --------------------------------- */
.catchip .ic{width:64px;height:56px;border-radius:10px;overflow:hidden;background:#fff}
.catchip .ic img{width:100%;height:100%;object-fit:contain;padding:3px}
.catchip{padding:14px 10px 13px}

/* ---- hero: real lifestyle photo, framed ----------------------------------- */
.heroart img{max-height:300px;width:auto;border-radius:18px;box-shadow:0 18px 44px rgba(0,0,0,.35)}

/* ---- footer: 2x2 link columns on tablet and mobile ------------------------ */
@media (max-width:1024px){
  .ftr .wrap{display:grid;grid-template-columns:1fr 1fr;gap:28px 36px;padding:34px 26px 30px}
  .ftr .about{grid-column:1/-1;max-width:none}
}
@media (max-width:768px){
  .ftr .wrap{gap:24px 24px;padding:30px 20px 26px}
}
@media (max-width:400px){
  .ftr .wrap{grid-template-columns:1fr 1fr;gap:22px 18px;padding:26px 18px 24px}
}

/* ---- more tablet/mobile air ------------------------------------------------ */
@media (max-width:1024px){
  .plpbody{gap:30px}
  .filters{width:232px}
  .heroart img{max-height:250px}
}
@media (max-width:768px){
  .catchip .ic{width:56px;height:48px}
  .heroart img{max-height:210px;border-radius:14px}
  .hero .art{margin-bottom:4px}
  .pinfo .buyrow{margin:14px 0 4px}
  .keyfacts{margin-top:16px}
}

/* ---- fieldset reset: the browser default border was drawing vertical lines
   around every filter group. border-bottom alone does not remove the other
   three sides of the UA groove border. ---- */
.fgrp{border:0;border-bottom:1px solid var(--line-2);margin:0;min-width:0;padding-left:0;padding-right:0}
.fgrp legend{padding:0;margin-left:0}
.fgrp:last-of-type{border-bottom:0}

/* =============================================================================
   POLISH PASS 4 - hero backdrop, alignment, trim-safe shadows
   ============================================================================= */

/* ---- hero: real photo as a FULL-BLEED BACKDROP behind the copy, not a spot
   image. Navy overlay keeps white text at ~5.5:1 even over the photo's
   brightest region. Gradient remains underneath as the no-image fallback. */
.hero{background:
  linear-gradient(rgba(19,34,87,.86),rgba(19,34,87,.72)),
  url("products/hero.jpg") center 38%/cover no-repeat,
  linear-gradient(105deg,var(--tt-navy) 0%,var(--tt-navy-2) 62%,var(--tt-navy-3) 100%)}
.hero .wrap{padding-top:64px;padding-bottom:64px}
.hero .txt{flex:1;max-width:640px}
.blob{display:none}
@media (max-width:768px){
  .hero{background:
    linear-gradient(rgba(19,34,87,.88),rgba(19,34,87,.76)),
    url("products/hero.jpg") center 30%/cover no-repeat,
    linear-gradient(105deg,var(--tt-navy) 0%,var(--tt-navy-3) 100%)}
  .hero .wrap{padding-top:46px;padding-bottom:46px}
}

/* ---- USP bar: grid, so the 2x2 on mobile actually ALIGNS ------------------- */
.usp .wrap{display:grid;grid-template-columns:repeat(4,1fr);gap:18px 26px}
@media (max-width:1024px){ .usp .wrap{grid-template-columns:repeat(2,1fr)} }
@media (max-width:400px){ .usp .wrap{grid-template-columns:1fr;gap:14px} }
.uspitem{align-items:flex-start}
.uspitem .ic{margin-top:1px}

/* ---- card text breathing room --------------------------------------------- */
.card .body{padding:16px 17px 18px}
.card h3{margin-bottom:11px}
.price{margin-bottom:12px}
@media (max-width:768px){
  .card .body{padding:14px 15px 16px}
  .grid{gap:18px}
}

/* ---- the fixed bottom sheet's upward shadow was painting a faint tint at the
   very bottom of full-page captures. No shadow while closed. ---- */
.filters:not([data-open]){box-shadow:none}

/* =============================================================================
   LINK CHIPS - "Popular brands" strip (index.html) and "More ways to shop"
   internal-linking module (category.html). Text chips, defined once, tokens
   only. Chip text is --tt-orange-ink: 5.00:1 on white, 4.58:1 on --peach
   (hover). Border is --line-control at 3.61:1 (SC 1.4.11). min-height is
   --tap-min at every width, matching .btn and .catchip.
   ============================================================================= */
.linkchips{display:flex;gap:10px;flex-wrap:wrap;list-style:none}
.linkchip{display:inline-flex;align-items:center;background:#fff;border:1.5px solid var(--line-control);
          border-radius:var(--r-chip);padding:9px 16px;font-size:var(--fs-sm);font-weight:700;
          color:var(--tt-orange-ink);min-height:var(--tap-min)}
.linkchip:hover{background:var(--peach);border-color:var(--tt-orange-ink)}

/* Slim homepage strip: the label, the row and the link to the full index all
   share one wrapping row. It carried six text chips until #11335 turned it
   into supplier marks (SUPPLIER BRAND MARKS, at the foot of this file); the
   mock's index.html still uses .linkchips here, which is why that recipe
   above is unchanged. */
.pbrands{padding-top:10px;padding-bottom:6px;display:flex;align-items:center;gap:12px 18px;flex-wrap:wrap}
.pbrands h2{font-size:var(--fs-xs);letter-spacing:1.1px;text-transform:uppercase;color:var(--faint-ink);flex:none}

/* category-page module: labelled chip groups above the footer */
.mws{padding-top:10px;padding-bottom:46px}
.mws .sech{margin-bottom:8px}
.mwsgrp{margin-top:20px}
.mwsgrp h3{font-size:var(--fs-2xs);letter-spacing:1.1px;color:var(--faint-ink);margin-bottom:11px;text-transform:uppercase}

@media (max-width:768px){
  .pbrands{padding-top:6px;padding-bottom:2px}
  .mws{padding-top:6px;padding-bottom:38px}
  .mwsgrp{margin-top:18px}
}

/* =============================================================================
   PDP READ-MORE + BASKET STEPPER - server-rendered controls (tt-store)
   -----------------------------------------------------------------------------
   Two patterns the static mock never needed, both built from existing
   contracts: the read-more summary is the .linkchip recipe on a <summary>,
   and the basket stepper is the PDP .qty capsule with a read-only value
   between its buttons. Tokens only.
   ============================================================================= */

/* ---- PDP description collapse --------------------------------------------- */
/* Long migrated copy: the first description block stays visible; everything
   after it sits inside <details class="readmore">. The summary carries the
   .linkchip look (same tokens, same --tap-min target); its two spans swap so
   the open state reads "Read less". The default disclosure triangle is
   suppressed - the chip itself is the affordance. */
.readmore{margin-top:14px}
.readmore summary{display:inline-flex;align-items:center;background:#fff;border:1.5px solid var(--line-control);
                  border-radius:var(--r-chip);padding:9px 16px;font-size:var(--fs-sm);font-weight:700;
                  color:var(--tt-orange-ink);min-height:var(--tap-min);cursor:pointer;list-style:none}
.readmore summary::-webkit-details-marker{display:none}
.readmore summary:hover{background:var(--peach);border-color:var(--tt-orange-ink)}
.readmore summary .less{display:none}
.readmore[open] summary .more{display:none}
.readmore[open] summary .less{display:inline}

/* ---- basket qty stepper ---------------------------------------------------- */
/* The mock steps quantity with script; the storefront steps it with two
   submit buttons that each carry the new quantity, so every tap is one POST
   to /basket/update and a fresh page - no number input to truncate. Reuses
   the .qty capsule; only the value between the buttons is new. */
.bstep .bstepval{width:44px;text-align:center;font-weight:700;font-size:var(--fs-md);color:var(--ink);
                 font-variant-numeric:tabular-nums}

/* =============================================================================
   SEARCH SUGGEST - the typeahead listbox static/suggest.js renders under the
   header search pill. The panel hangs off .search (already position:relative)
   so it tracks the pill at every breakpoint, including the <=768 full-width
   row. Highlight follows the selected-state pattern elsewhere: --peach fill
   with navy text (15.58:1 on white per tokens.css, still comfortable on
   peach); the brandline is --faint-ink, which passes on both surfaces. Rows
   meet --tap-min. z-index sits above the sticky header's content (.hdr is 40).
   ============================================================================= */
.suggest{position:absolute;/* elevation only: a floating panel takes the shadow, not a border */top:calc(100% + 8px);left:0;right:0;background:#fff;
         border:1px solid var(--line);border-radius:var(--r-card);
         box-shadow:var(--shadow-pop);list-style:none;overflow:hidden;z-index:50}
.suggest-item{display:flex;align-items:center;gap:12px;padding:9px 14px;
              min-height:var(--tap-min);cursor:pointer}
.suggest-item[aria-selected=true]{background:var(--peach)}
.suggest-art{width:44px;height:44px;flex:none;background:var(--surface);
             border-radius:10px;display:flex;align-items:center;
             justify-content:center;overflow:hidden}
.suggest-art img{max-width:86%;max-height:86%;width:auto;height:auto;object-fit:contain}
.suggest-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.suggest-brand{font-size:var(--fs-3xs);font-weight:800;letter-spacing:.8px;
               color:var(--faint-ink);text-transform:uppercase}
.suggest-title{font-size:var(--fs-sm);font-weight:600;color:var(--tt-navy);line-height:var(--lh-snug);
               overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.suggest-price{font-size:var(--fs-sm);font-weight:800;color:var(--tt-navy);flex:none;
               font-variant-numeric:tabular-nums}
@media (max-width:768px){
  .suggest{top:calc(100% + 6px)}
  .suggest-item{padding:8px 12px;gap:10px}
  .suggest-art{width:40px;height:40px}
}

/* =============================================================================
   MEGA MENU - the curated pillars in the main nav (partials/header.html)
   -----------------------------------------------------------------------------
   CSS only, no script. A panel opens on :hover and on :focus-within, so the
   pointer and the keyboard both reach it: a closed panel is visibility:hidden,
   which keeps its links out of the tab order until the pillar link itself takes
   focus, at which point the panel becomes visible and Tab carries on into it.

   The row item stretches to the full height of the nav on purpose. The panel
   hangs off the nav's bottom edge, so a shrink-to-fit <li> would leave a dead
   strip between the link and the panel that closed it again as the pointer
   travelled down.

   Hidden below 1024px: the pillars stay as plain links there, and below 768px
   the whole nav is display:none already (breakpoint block above), so a touch
   device never meets a hover-only panel.

   Every rule below is scoped .meganav ... on purpose. The panel lives inside
   .nav, and .nav .wrap / .nav ul / .nav a already lay out the nav bar itself:
   left unscoped, the panel's own columns lost to them and each column's list
   rendered as one horizontal flex row. Scoping wins on specificity instead of
   relying on source order.
   ============================================================================= */
.meganav{position:relative}
.meganav>.wrap>ul{align-items:stretch}
/* The item stretches to the full nav height so its hover box reaches the
   nav's bottom edge, and ::after bridges any sub-pixel gap between that edge
   and the panel. Without both, the pointer crosses a dead strip on its way
   down and the panel closes under it - the single most common way a hover
   mega menu feels broken. The hide is also delayed slightly, so a brief
   diagonal slip off the item does not snap the panel shut mid-movement. */
/* align-self:stretch makes the item's box reach the nav's bottom edge, which
   is exactly where the panel starts, so there is no dead strip for the
   pointer to cross on its way down. Deliberately NOT position:relative: the
   panel is absolutely positioned and must size against .meganav to span the
   full width. Give the item its own positioning context and every panel
   shrinks to the width of its nav label - which is how this broke once. */
.megaitem{display:flex;align-items:center;align-self:stretch}
/* Sale sits apart from the pillars on purpose, so give it a shape rather
   than leaving it floating at the end of the row like a forgotten item. */
/* The pill has to sit on the same optical line as the plain nav links. Those
   carry a 3px transparent bottom border for their hover rule, so the pill
   needs the same border and vertical padding or it rides high. margin-left
   separates it from the pillars; align-self centres it in the stretched row. */
/* Pushed to the right-hand end of the bar rather than trailing the last
   pillar. margin-left:auto only reaches the far edge if the list is allowed
   to fill the row, hence flex:1 on the ul - without it the auto margin has
   no free space to consume and the pill sits a gap away from Puzzles &
   Games, which is where it was. */
.nav ul{flex:1}
.nav li:has(> a.hl){display:flex;align-items:center;margin-left:auto}
.nav a.hl{background:var(--peach);color:var(--tt-orange-ink);
          border-radius:var(--r-pill);padding:9px 20px;
          font-size:var(--fs-md);
          font-weight:700;line-height:1;border-bottom:0;align-self:center}
.nav a.hl:hover{background:var(--peach-2);border-bottom:0}
/* A campaign is running, so the dot breathes rather than blinks: a slow,
   low-contrast pulse reads as "live" without becoming the thing your eye
   keeps returning to. Keyframes are right here - this is ambient, not a
   response to anything the shopper did - and reduced-motion stops it dead
   while keeping the dot, so the state is never announced by movement alone. */
@keyframes salepulse{
  0%,100%{transform:scale(1);opacity:1}
  50%{transform:scale(1.45);opacity:.55}
}
/* 8px, up with the pill: a 7px dot beside larger type reads as a speck
   rather than a status light. */
.nav a.hl .livedot{display:inline-block;width:8px;height:8px;margin-right:8px;
                   border-radius:50%;background:var(--tt-orange-ink);
                   animation:salepulse 2.4s var(--ease-in-out) infinite}
@media (prefers-reduced-motion:reduce){
  .nav a.hl .livedot{animation:none}
}
.megapanel{position:absolute;left:0;right:0;top:100%;background:#fff;
           border-top:1px solid var(--line);border-bottom:1px solid var(--line);
           box-shadow:var(--shadow-pop);z-index:30;
           visibility:hidden;opacity:0;
           /* visibility is delayed by --t-hold rather than transitioned: for
              those 250ms the panel is transparent but still present, so it
              still answers the pointer and a diagonal slip on the way down to
              it re-opens it instead of closing it. An intent hold, not a
              duration - which is why it has its own token. */
           transition-property:opacity,visibility;
           transition-duration:var(--t-fast),0s;
           transition-timing-function:var(--ease-out),linear;
           transition-delay:0s,var(--t-hold)}
.megaitem:hover > .megapanel,
.megaitem:focus-within > .megapanel{visibility:visible;opacity:1;
           transition-property:opacity,visibility;
           transition-duration:var(--t-fast),0s;
           transition-timing-function:var(--ease-out),linear;
           transition-delay:0s,0s}
.meganav .megarow{display:flex;gap:34px;align-items:flex-start;flex-wrap:wrap;
                  min-height:0;padding-top:26px;padding-bottom:28px}
.meganav .megacol{min-width:168px}
.meganav .megacol h3{font-size:var(--fs-2xs);letter-spacing:1.1px;color:var(--faint-ink);
                     margin-bottom:12px;text-transform:uppercase}
.meganav .megacol ul{display:block;list-style:none;gap:0}
.meganav .megacol ul a{display:block;border:0;padding:5px 0;font-size:var(--fs-sm);
                       font-weight:600;color:var(--tt-navy)}
.meganav .megacol ul a:hover{color:var(--tt-orange-ink);text-decoration:underline}
/* the pillar's own categories are the wide column: two text columns, so ten
   children read as a block rather than a ladder that runs past the fold */
.meganav .megacol-wide{flex:1 1 420px;min-width:270px}
.meganav .megacol-wide ul{columns:2;column-gap:30px}
.meganav .megacol-wide li{break-inside:avoid}
/* the lede closes the row: one factual line about the pillar, then the way in */
.meganav .megalede{margin-left:auto;flex:0 1 250px;background:var(--surface);
                   border-radius:var(--r-card);padding:18px 20px}
.meganav .megalede p{font-size:var(--fs-xs);line-height:var(--lh-body);color:var(--faint-ink);
                     margin-bottom:14px}
.meganav .megalede .btn{border:0}
@media (max-width:1024px){ .megapanel{display:none} }

/* =============================================================================
   SCROLL-CONTAINED ROWS - a horizontal row keeps its overflow to itself
   -----------------------------------------------------------------------------
   The homepage category strip used to run its tiles past the viewport edge, so
   the BODY took the sideways scroll and every section on the page moved with
   it. .scrollrow puts the scroll inside the row at every width, not only on
   mobile: max-width holds the row to its container, overflow-x:auto gives the
   row its own scrollbar, and overscroll-behavior-x:contain stops a swipe that
   reaches the end of the row from becoming a page or back-navigation gesture.
   Any horizontal row on a page carries this class; a wrapping row such as
   .linkchips cannot overflow, but takes the same containment so there is one
   rule rather than two.
   ============================================================================= */
.scrollrow{display:flex;flex-wrap:nowrap;max-width:100%;overflow-x:auto;
           overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;
           scroll-snap-type:x proximity;padding-bottom:10px}
.scrollrow > li{flex:1 0 auto;min-width:140px;scroll-snap-align:start}
.linkchips{max-width:100%;overscroll-behavior-x:contain}
@media (max-width:768px){ .scrollrow > li{min-width:132px} }
@media (max-width:400px){ .scrollrow > li{min-width:124px} }

/* ---- text-only category tile ----------------------------------------------
   The curated pillars are groups rather than single catalogue rows, so they
   carry no tile photograph. The name does the whole job; the tile keeps
   .catchip's surface, border and hover so the strip still reads as one set. */
.cattile{justify-content:center;padding:20px 14px;min-height:78px}
/* The grid tiles carry a photograph, so they need the height to show it. */
/* Tile height is the GRID's business (grid-auto-rows below), not the tile's:
   a min-height taller than the row makes every tile overflow its cell and
   land on whatever follows. Height changes belong on grid-auto-rows. */
.cattile .l{font-size:var(--fs-sm);line-height:var(--lh-snug)}
@media (max-width:768px){ .cattile{padding:18px 12px;min-height:72px} }

/* =============================================================================
   ACCOUNT AND SIGN-IN - signin.html, account.html (tt-store)
   -----------------------------------------------------------------------------
   Two pages, one narrow form column and one two-column page, both built on
   contracts that already exist: .wrap/.crumb/.bhead for the frame, .btn for
   every action, .srows/.srow for the lines inside an order card, and the
   .basket/.bmain/.bsum proportions for the account's main-plus-panel layout.
   What is genuinely new is the form control itself - the mock never had one -
   so .ainput is defined once here and reused by both pages.

   Contrast: input borders are --line-control (3.61:1, SC 1.4.11), matching
   every other control. Notice text is --ink on --peach at 13.4:1; the error
   variant only swaps the border to --danger (7.09:1 on --peach), because the
   message wording, not the colour, is what carries the meaning (SC 1.4.1).
   Secondary type is --faint-ink, which passes on white and on --surface.
   Every button and text action clears --tap-min.
   ============================================================================= */

/* ---- shared form controls -------------------------------------------------- */
.afield{margin-bottom:14px}
.afield label,.afield .alabel{display:block;font-size:var(--fs-sm);font-weight:700;margin-bottom:6px}
.ainput{width:100%;background:#fff;border:2px solid var(--line-control);border-radius:var(--r-input);
        padding:10px 12px;font-size:var(--fs-sm);color:var(--ink);min-height:var(--tap-min)}
/* the 6-digit sign-in code: spaced out so it can be checked at a glance */
.ainput.code{font-size:var(--fs-xl);font-weight:800;letter-spacing:6px;text-align:center;
             font-variant-numeric:tabular-nums}
.astatic{display:block;font-size:var(--fs-sm);color:var(--ink-2);padding:2px 0 4px}
.achk{display:flex;align-items:flex-start;gap:10px;margin:2px 0 18px}
.achk input{width:20px;height:20px;flex:none;margin-top:2px;accent-color:var(--tt-navy)}
.achk label{font-size:var(--fs-sm);line-height:var(--lh-body);color:var(--ink-2)}

/* one notice pattern for both pages: neutral by default, .bad for a problem */
.anote{max-width:440px;background:var(--peach);border:1.5px solid var(--tt-orange-ink);
       border-radius:12px;padding:12px 14px;font-size:var(--fs-sm);line-height:var(--lh-body);
       color:var(--ink);margin-bottom:18px}
.anote.bad{border-color:var(--danger)}

/* secondary actions: a link and a form button that read identically */
.aalt{margin-top:16px;display:flex;align-items:center;gap:6px 18px;flex-wrap:wrap;font-size:var(--fs-sm)}
.aalt a,.alink{display:inline-flex;align-items:center;min-height:var(--tap-min);
               color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-sm)}
.alink{background:none;border:0;text-decoration:underline;padding:0}
.aalt a:hover,.alink:hover{text-decoration:underline;color:var(--tt-navy)}

/* ---- sign-in column -------------------------------------------------------- */
.acol{max-width:440px;padding:6px 0 52px}
.alead{color:var(--ink-2);font-size:var(--fs-sm);line-height:var(--lh-relaxed);margin:8px 0 20px}

/* ---- account page ---------------------------------------------------------- */
/* Same shape as the basket: content beside a panel on desktop, stacked <=768 */
.acct{display:flex;gap:36px;align-items:flex-start;padding:14px 0 52px}
.amain{flex:1;min-width:0}
.aside{width:340px;flex:none;background:var(--surface);border-radius:var(--r-card);padding:22px 22px 24px}
.aside h2{font-size:var(--fs-lg);letter-spacing:-.3px;margin-bottom:14px}
.asignout{margin-top:6px}
.ah2{font-size:var(--fs-lg);letter-spacing:-.3px;margin-bottom:14px}

.aorders{list-style:none;display:flex;flex-direction:column;gap:14px}
.aorder{border:1px solid var(--line-2);border-radius:var(--r-card);background:#fff;padding:16px 18px 18px}
.aorder h3{font-size:var(--fs-md);letter-spacing:-.2px;margin-bottom:4px}
.ameta{font-size:var(--fs-xs);color:var(--faint-ink);margin-bottom:10px}
.atrack{margin-top:12px;font-size:var(--fs-sm)}
.atrack a{color:var(--tt-orange-ink);font-weight:700}
.atrack a:hover{text-decoration:underline}
.aempty{border:2px dashed var(--line-control);border-radius:var(--r-card);padding:26px;
        color:var(--faint-ink);font-size:var(--fs-sm);line-height:var(--lh-relaxed);max-width:60ch}

@media (max-width:1024px){
  .acct{gap:24px}
  .aside{width:300px}
}
@media (max-width:768px){
  /* stacked, so both columns take the full width rather than their content's */
  .acct{flex-direction:column;gap:20px;padding-bottom:40px}
  .amain,.aside{width:100%}
  .acol{padding-bottom:40px}
}

/* =============================================================================
   POLISH PASS 5 - mega menu promo, category tile grid; client review, 2 Aug
   -----------------------------------------------------------------------------
   Three things the client asked for, in one block because they share the tile
   and panel contracts already defined above.

   1. NAV LABELS ON ONE LINE. The pillar labels are taxonomy's short field and
      the longest is fifteen characters, so they fit - but "fit" was resting on
      the browser not choosing to wrap, which is not a guarantee. white-space
      settles it, and the nav gap tightens between 1024px and 1200px where the
      bar is at its most crowded (seven pillars plus Sale in a 960px wrap).

   2. THE PANEL IS WIDER THAN THREE COLUMNS NOW. The pillar's own categories
      keep the wide column; the four ways in that this storefront adds - age,
      brand, character, sale - sit in a 2x2 grid beside it rather than as four
      more flex columns, which at this width would have wrapped into a ragged
      second line. The promo card closes the row as .megalede always did, with
      a photo above the copy.

   3. THE CATEGORY STRIP IS A GRID, NOT A SCROLLER. Seven tiles on two rows,
      all of them on screen: nothing is hidden behind a sideways swipe that a
      phone user has to discover. .scrollrow stays defined above for any other
      row that genuinely runs long; this one no longer needs it, and because
      the grid cannot overflow there is nothing left to contain.

   Colour: every value below is a token, and no new text/background pair is
   introduced - the tiles and panel keep .catchip's and .megacol's, which are
   already contrast-verified.
   ============================================================================= */

/* ---- 1. nav labels ---------------------------------------------------------- */
.meganav>.wrap>ul>li>a{white-space:nowrap}
@media (max-width:1200px){ .meganav>.wrap>ul{gap:22px} }

/* THE ROW HAS TO BE ABLE TO FALL ONTO A SECOND LINE. Between 771px, where
   .nav stops being display:none, and 865px there is no arrangement of seven
   nowrap labels, seven gaps and the Sale pill that fits on one line: the
   row's min-content is 801px against a content box of 704px at the narrowest,
   and the 97px difference is not recoverable. Closing it on the gap alone
   would need 8px between labels, which is narrower than a space and reads as
   one run-on string; closing it on the labels would need two characters off
   each of the seven, at every width including the ones with room to spare.
   Without this the row simply overflowed: the Sale pill crossed the right
   edge and every page in the range scrolled sideways.

   Wrapping cannot engage above 866px, which is inside the range where
   .megapanel is already display:none, so no pillar ever opens a panel from a
   wrapped row. The pill keeps its margin-left:auto and settles at the right
   hand end of the second row, the corner it holds at every other width. The
   22px gap above is doing double duty as the row gap here and has to: the
   link ring below (inset:-5px) and the pill ring above it (inset:-8px) spend
   13px of it, so anything tighter puts two tap targets on top of each other.

   Deliberately not a breakpoint. A media query switching the nav at 866px
   would be tuned to the current seven labels and would go stale the day one
   is renamed, in the direction that overflows again. Wrapping is measured
   against whatever the labels actually are. */
.meganav>.wrap>ul{flex-wrap:wrap}

/* ---- 2. panel: wide column, feature grid, promo card ------------------------ */
/* the wide column gives ground now that four feature columns sit beside it;
   its two text columns still hold, because 230px is two 100px-ish columns */
.meganav .megacol-wide{flex:1 1 300px;min-width:230px}
.meganav .megafeat{flex:1 1 340px;display:grid;
                   grid-template-columns:repeat(2,minmax(140px,1fr));
                   gap:22px 26px}
.meganav .megafeat .megacol{min-width:0}
.meganav .megarow{gap:28px}

/* the promo card: photo, the pillar's one factual line, then the way in */
.meganav .megapromo{flex:0 0 236px}
.meganav .megapromo .promoart{display:block;height:132px;background:#fff;
                              border-radius:12px;overflow:hidden;
                              margin-bottom:14px}
.meganav .megapromo .promoart img{width:100%;height:100%;object-fit:contain;
                                  padding:6px}
/* a pillar with nothing in stock renders the frame with nothing in it; the
   card then closes up round the copy rather than showing an empty white box */
.meganav .megapromo .promoart:empty{display:none}

/* ---- 3. category tiles: a wrapping grid with real photos -------------------- */
/* four across is 4+3 for the seven pillars, so the second row reads as the
   rest of one set rather than as a stray tile. Four holds all the way down
   to 768px - the client asked for two rows, and dropping to three columns
   any earlier makes it three - and a tile is still 231px wide at 1024px.

   grid-auto-rows fixes the row height rather than letting the tallest tile
   set it. Ride-Ons spans two columns (below), and without a fixed row height
   a taller hero would leave its row-mates stretched with dead space under
   their photographs. */
.catgrid{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:200px;
         gap:14px;list-style:none}
/* Seven pillars in a four-column grid leave a hole in the second row. The
   FIRST tile takes the spare column - 2+1+1 then 1+1+1+1 - so the row reads
   as an editorial lead rather than a grid with a gap patched at the end, and
   Ride-Ons (the flagship pillar) is the one that gets the room.

   grid-column has to sit on the LI: the LI is the grid item, the .cattile
   anchor inside it is not, so the previous :last-child rule on .cattile was
   being computed and discarded. */
.catgrid > li{min-width:0}
.catgrid > li:first-child{grid-column:span 2}

/* THE TILE IS THE PHOTOGRAPH. The name used to sit on a separate navy band
   below a framed art box, which is what the client read as flat: two stacked
   rectangles with a hairline round them. Now the picture fills the tile and
   the name sits ON it over a navy scrim.

   Border removed, shadow kept: the hairline was faking elevation next to a
   shadow already doing that job, and a 1px grey line across a photograph edge
   only ever looks like dirt.

   The tile background is --tt-navy-2 rather than white, so a pillar with no
   photograph yet - and the moment before a photograph loads - reads as a
   deliberate solid brand tile with its name on it, not as an empty white box.
   White on --tt-navy-2 is 12.39:1 (contrast-check.js). */
.catgrid .cattile{position:relative;display:block;height:100%;padding:0;
                  border:0;border-radius:var(--r-card);overflow:hidden;
                  background:var(--tt-navy-2);
                  box-shadow:var(--shadow-card);text-decoration:none;
                  transition-property:translate,scale,box-shadow;
                  transition-duration:var(--t-base),var(--t-press),var(--t-base);
                  transition-timing-function:var(--ease-out)}
.catgrid .cattile:hover{translate:0 -3px;box-shadow:var(--shadow-pop)}
.catgrid .cattile:active{scale:var(--press-scale)}
/* the page focus ring is navy, which would disappear into a navy tile, so the
   ring is offset out onto the white page behind it */
.catgrid .cattile:focus-visible{outline:var(--focus-ring);outline-offset:3px}

.catgrid .cattile-art{position:absolute;inset:0;overflow:hidden;
                      background:var(--tt-navy-2)}
.catgrid .cattile-art img{width:100%;height:100%;object-fit:cover;display:block;
                          transition-property:scale;transition-duration:var(--t-slow);
                          transition-timing-function:var(--ease-out)}
.catgrid .cattile:hover .cattile-art img{scale:1.06}

/* DUOTONE WASH. The seven photographs are a mix of lifestyle shots and
   packshots on white, and nothing else makes them read as one set: a flat
   navy wash at 8% pulls a blown-out white packshot and a busy garden shot
   into the same key. It lifts to 2% on hover, so pointing at a tile makes the
   photograph come alive - a real payoff rather than only a shadow moving.
   Opacity on a token colour, so there is no alpha colour value to maintain. */
.catgrid .cattile-art::before{content:"";position:absolute;inset:0;z-index:1;
                              pointer-events:none;background:var(--tt-navy);
                              opacity:.08;
                              transition-property:opacity;
                              transition-duration:var(--t-base);
                              transition-timing-function:var(--ease-out)}
.catgrid .cattile:hover .cattile-art::before{opacity:.02}
/* THE LABEL, AND THE SCRIM ANCHORED TO IT.

   The label carries solid --tt-navy, so the text pair is pinned at white on
   navy - 15.06:1 - however bright the photograph under it happens to be. A
   scrim that is merely dark is only ever as readable as the picture allows,
   and these pictures include packshots on pure white.

   The ramp is the label's own ::before at bottom:100%, so it starts exactly
   where the plate starts at every width and can never fall out of step with
   it. Written as a fixed offset from the tile's foot instead, it would show a
   hard seam the moment a name wrapped to two lines - which "Puzzles & Games"
   does at 480px. The picture dissolves into the name; it does not stop at a
   band, which is what the old stacked art-box-plus-band read as. */
.catgrid .cattile-label{position:absolute;left:0;right:0;bottom:0;z-index:3;
                        display:flex;align-items:center;gap:10px;
                        background:var(--tt-navy);color:#fff;
                        font-weight:700;font-size:var(--fs-sm);letter-spacing:.2px;
                        line-height:var(--lh-snug);padding:11px 13px 12px 15px;
                        text-wrap:balance}
/* The ramp is retired (client review, 9 Aug) - the same call as the
   character tiles' four cut: the photograph does the work and the
   bold keyline does the design. The plate stays solid navy, so the
   contrast never depended on the ramp here either. */
.catgrid .cattile-label{border-top:3px solid var(--tt-orange)}
/* the lead tile is twice as wide, so it takes the larger of the two label
   sizes - the hierarchy is stated twice, in width and in type */
.catgrid > li:first-child .cattile-label{font-size:var(--fs-md);padding:12px 15px 13px 17px}
.catgrid > li:first-child .cattile-go{width:34px;height:34px}

/* THE AMBER GO CHIP. The affordance is a chip, not a repeated "Shop now" -
   seven tiles each spelling it out is noise, and a chip carries the brand
   amber onto every tile, which is what makes the strip look designed rather
   than labelled. Navy on --tt-orange is 8.10:1. It is aria-hidden: the tile
   is already one link with the pillar's real name in it. */
.catgrid .cattile-go{flex:none;margin-left:auto;width:30px;height:30px;
                     border-radius:50%;background:var(--tt-orange);
                     color:var(--tt-navy);display:flex;align-items:center;
                     justify-content:center;
                     transition-property:translate,background-color;
                     transition-duration:var(--t-base);
                     transition-timing-function:var(--ease-out)}
/* optical, not geometric: an arrow's mass sits behind its point, so a
   geometrically centred one reads as sitting left in its circle */
.catgrid .cattile-go svg{margin-left:1px}
.catgrid .cattile:hover .cattile-go{translate:3px 0;background:var(--tt-orange-hover)}

@media (max-width:1024px){ .catgrid{gap:12px;grid-auto-rows:186px} }
@media (max-width:768px){
  /* below the tablet breakpoint two rows would mean four tiles across a
     phone-width screen, so the tiles get the room instead */
  .catgrid{grid-template-columns:repeat(3,1fr);grid-auto-rows:132px}
  .catgrid .cattile-label{font-size:var(--fs-xs);padding:9px 10px 10px 12px;gap:8px}
  .catgrid > li:first-child .cattile-label{font-size:var(--fs-sm);padding:10px 11px 11px 13px}
  .catgrid .cattile-go,
  .catgrid > li:first-child .cattile-go{width:28px;height:28px}
  /* three columns and a two-wide lead leaves one cell over, so the LAST tile
     takes it: 2+1 / 1+1+1 / 1+2 is nine cells in three full rows. */
  .catgrid > li:last-child{grid-column:span 2}
}
@media (max-width:480px){
  /* two columns: the lead tile takes a full row on its own and the remaining
     six pair off exactly, so the wide-tile rule from 768 stands down. */
  .catgrid{grid-template-columns:repeat(2,1fr)}
  .catgrid > li:last-child{grid-column:auto}
}
/* Not everything needs to move. Reduced motion keeps every static cue - the
   shadow, the wash lifting, the chip changing colour - and drops the travel,
   the zoom and the press. */
@media (prefers-reduced-motion:reduce){
  .catgrid .cattile,
  .catgrid .cattile:hover,
  .catgrid .cattile:active{translate:none;scale:none}
  .catgrid .cattile:hover .cattile-art img{scale:none}
  .catgrid .cattile:hover .cattile-go{translate:none}
}

/* =============================================================================
   PDP IMAGE LIGHTBOX - the zoom overlay static/lightbox.js opens (product.html)
   -----------------------------------------------------------------------------
   The trigger is a link round the gallery image, so it is a control before any
   script runs and needs no styling of its own beyond filling .galmain and
   carrying a focus ring. .zoomcue is the small magnifier badge that tells a
   shopper the image does something; it is decorative and aria-hidden, so its
   contrast is not load-bearing, but navy on white clears 15.58:1 anyway.

   The overlay is a native <dialog>. Every display rule below is scoped to
   [open]: an unscoped display on the element would beat the browser's own
   display:none for a closed dialog and leave the overlay on the page. The
   backdrop is --tt-navy at 82% opacity rather than a raw colour, so it moves
   with the brand. The close button and the stage both meet --tap-min, the
   close button sits above the image at every width, and the hint line is
   --pale on navy (9.56:1 per tokens.css).
   ============================================================================= */
/* align-self:stretch + flex:1 rather than height:100%: .galmain is a flex box
   whose height comes from an aspect-ratio below 768, where a percentage
   height has nothing definite to resolve against. */
.zoomtrig{position:relative;display:flex;align-items:center;justify-content:center;
          align-self:stretch;flex:1;min-width:0;border-radius:18px}
.zoomtrig:hover .zoomcue{background:#fff;color:var(--tt-navy)}
.zoomcue{position:absolute;right:12px;bottom:12px;width:38px;height:38px;
         border-radius:50%;background:#fff;color:var(--tt-navy);
         display:flex;align-items:center;justify-content:center;
         box-shadow:var(--shadow-card)}

.lightbox{border:0;padding:0;background:transparent;max-width:100%;max-height:100%;
          width:100%;height:100%}
.lightbox[open]{display:flex;align-items:center;justify-content:center}
.lightbox::backdrop{background:var(--tt-navy);opacity:.82}
.lbstage{border:0;background:transparent;padding:0;display:flex;
         align-items:center;justify-content:center;max-width:92vw;max-height:82vh;
         min-width:var(--tap-min);min-height:var(--tap-min);cursor:zoom-in;
         touch-action:none;overflow:hidden}
.lbimg{max-width:92vw;max-height:82vh;width:auto;height:auto;object-fit:contain;
       border-radius:12px;background:#fff}
.lightbox.zoomed .lbstage{cursor:grab}
.lightbox.zoomed .lbstage:active{cursor:grabbing}
.lightbox.zoomed .lbimg{-webkit-user-select:none;user-select:none}
/* The close button is the only way out of a full-screen overlay, so it is the
   control least able to afford sitting under a notch or a rounded corner. */
.lbclose{position:fixed;top:calc(16px + env(safe-area-inset-top,0px));
         right:calc(16px + env(safe-area-inset-right,0px));
         width:var(--tap-min);height:var(--tap-min);
         border:0;border-radius:50%;background:#fff;color:var(--tt-navy);
         display:flex;align-items:center;justify-content:center;
         box-shadow:var(--shadow-pop)}
.lbclose:hover{background:var(--surface)}
.lbhint{position:fixed;left:0;right:0;bottom:calc(18px + env(safe-area-inset-bottom,0px));
        text-align:center;font-size:var(--fs-xs);color:var(--pale)}
/* the navy focus ring would vanish into the navy backdrop, so the overlay
   takes the light ring the other dark surfaces already use */
.lightbox :focus-visible{outline:var(--focus-ring-light)}
@media (max-width:768px){
  .zoomcue{right:10px;bottom:10px;width:34px;height:34px}
  .lbstage,.lbimg{max-width:94vw;max-height:74vh}
  .lbhint{bottom:12px;padding:0 20px;line-height:var(--lh-body)}
}

/* ---- despatch countdown ---------------------------------------------------
   The delivery box gains one row carrying the arrival date and the minutes
   left to order for it. Tabular numerals so a ticking "4 hrs 20 mins" never
   changes width and nudges the sentence around it. The row is hidden by
   lightbox.js the moment the cutoff passes, and [hidden] needs stating here:
   .delivrow's display:flex would otherwise beat the browser's own rule and
   leave a stale promise on screen. */
.delivrow[hidden]{display:none}
.delivleft{font-variant-numeric:tabular-nums;font-weight:700;color:var(--ink)}
#deliv-promise time{font-variant-numeric:tabular-nums}

/* =============================================================================
   MOTION SYSTEM - the press, the focus ring, and reduced motion
   -----------------------------------------------------------------------------
   Every duration and every curve in this file resolves to a token in tokens.css.
   There is no bare millisecond value left anywhere in it, and nothing new should
   add one: if a movement will not fit --t-fast, --t-press, --t-base or --t-slow,
   the question to ask is what the movement is for.

   FOUR RULES THIS BLOCK EXISTS TO HOLD

   1. TRANSITIONS FOR STATE, KEYFRAMES FOR SEQUENCES. A transition retargets
      mid-flight, so a button released halfway through its press returns smoothly
      from wherever it had got to. A keyframe animation restarts from frame one,
      which is what "this interface feels broken" usually turns out to be. The
      two keyframe sequences in this sheet are the exceptions that prove it: the
      homepage hero entrance runs once per document load and is not a state, and
      the header elevation is scrubbed by a scroll timeline rather than played
      on a clock, so it runs backwards frame for frame when you scroll back up.
      Neither is an interactive state. No routine interaction should add a third.

   2. NAMED PROPERTIES, NEVER `all`. `transition: .15s` is `transition-property:
      all` wearing a duration: the browser watches every property on the element
      and animates things nobody asked for - a padding change at a breakpoint, a
      colour arriving with a state class. Two rules were written that way and
      both are now enumerated here (.agechip and .catchip).

   3. NOTHING ANIMATES LAYOUT WHERE A TRANSFORM WOULD DO. scale, translate and
      opacity are composited and cost the page almost nothing per frame; width,
      height, top and left put the layout engine back to work on every frame.
      One exception survives, .fdbar i, and the reason is written above it.

   4. NOT EVERYTHING NEEDS TO MOVE. Motion is a budget. It is spent on the press,
      which says the tap landed, and on the card and tile hover, which says the
      whole thing is one target. It is spent once more on the homepage hero,
      which stages its four groups --t-stagger apart on first load, and that is
      the entire budget. Nothing else stages, and that is a decision rather than
      an omission: every page here is server-rendered, so an entrance on a
      listing or a basket would replay in full on every single navigation -
      precisely the routine, high-frequency case the technique is not for.

   WHY `scale:` AND NOT `transform: scale()`
   scale is an independent transform property, so it composes with a transform
   the element already carries rather than overwriting it. The category tile
   travels with `translate` on hover and presses with `scale`, and neither rule
   needs to know the other exists. It also lets the reduced-motion block stop a
   press without touching a lift.

   NO `will-change` ANYWHERE. It is a hint for a first-frame stutter somebody has
   actually measured, not a decoration, and every one costs a compositing layer
   for the life of the element. If a stutter is found on a real device, add it
   there, to that element, and say what was measured.

   WHERE THE EXCEPTIONS LIVE
   .addbtn, .wish and .catgrid .cattile declare their own press beside their own
   component, because each carries other motion in the same rule and splitting
   that across two places would be worse than the repetition. They use the same
   tokens and the same 0.96. Everything else is below.

   storefront/tests/test_motion_tokens.py holds this contract to the file.
   ============================================================================= */

/* ---- 1. the press -----------------------------------------------------------
   Every button-like control on the site, in one list. --press-scale is 0.96
   exactly, because below 0.95 a press reads as a flinch rather than a press,
   and --t-press is shared with nothing else, so the press cannot be retuned by
   somebody retuning hovers.

   One property list serves all of them. Naming a property that a given control
   never changes costs nothing - the list is fixed and enumerated, which is the
   whole difference from `all` - and it makes the hover colour, the selected
   border and the press one contract instead of nineteen. Colour moves at
   --t-fast because these are the highest-frequency interactions on the site and
   repeated motion spends attention every time it plays; the scale keeps its own
   150ms.

   Deliberately NOT in this list: real links in running text (.sech a, .crumb a,
   .contlink a), which have no button surface for a scale to act on and answer a
   press with their underline and their colour; the sort <select>, which hands
   straight over to a native menu the moment it is pressed; the filter
   checkboxes, which have the platform's own press; .zoomtrig, which is a 430px
   gallery image where four percent is a lurch rather than a press; and .hicon,
   which is plain navigation and the highest-frequency target in the chrome, so
   it gets colour rather than choreography (see block 5 of the polish pass,
   which made that call).

   The BUTTON SYSTEM block further down extends the same contract - these tokens,
   this 0.96 - to the controls that are <button> elements wearing text styling
   (.fmore, .clearall, .bremove, .alink, .aalt a), and adds :not(:disabled) so a
   disabled control cannot sink. Those controls do have a hit area to press;
   they simply do not look like buttons. Both lists resolve to one behaviour,
   which is the only thing that has to be true. */
.btn,.big,.search button,.qty button,.linkchip,.readmore summary,
.catchip,.agechip,.brandcard,.mtile,.nav a.hl,.pager a,.thumb,
.pricefacet .bands button,.chip button,.fbar button,.suggest-item,.lbclose{
  transition-property:scale,background-color,border-color,color;
  transition-duration:var(--t-press),var(--t-fast),var(--t-fast),var(--t-fast);
  transition-timing-function:var(--ease-out)}
.btn:active,.big:active,.search button:active,.qty button:active,
.linkchip:active,.readmore summary:active,.catchip:active,.agechip:active,
.brandcard:active,.mtile:active,.nav a.hl:active,
.pager a:active,.thumb:active,.pricefacet .bands button:active,
.chip button:active,.fbar button:active,.suggest-item:active,
.lbclose:active{scale:var(--press-scale)}

/* ---- 2. focus ---------------------------------------------------------------
   Audited control by control across the six mock pages and every storefront
   template. The :where(a,button,input,select,textarea,summary,[tabindex]) rule
   at the head of this file already reaches every interactive element on the
   site - there is no div carrying a click handler anywhere for it to miss - and
   the dark surfaces that would swallow a navy ring (.util, .hero, .ftr, .gf and
   the lightbox) already swap in --focus-ring-light. The ring is also absent from
   every transition list above, on purpose: a focus ring that fades in is a focus
   ring that is not there yet at the moment the key is released.

   What the audit did turn up is three controls whose ring was being CLIPPED. An
   outline is painted outside the border box, so a control inside an
   overflow:hidden or overflow-x:auto parent loses whichever part of its ring
   falls outside that parent: the quantity capsule clips its own two buttons and
   its input, and the thumbnail rail clips the top edge of every thumb. A
   negative outline-offset paints the same 3px navy ring just inside the control
   instead, where nothing can clip it. Same token, same width, same colour; only
   which side of the edge it sits on changes. */
.qty button:focus-visible,.qty input:focus-visible{outline-offset:-3px}
.thumb:focus-visible{outline-offset:-3px}
@media (max-width:768px){
  /* the age and category strips turn into horizontal scrollers at this width,
     and a scroller clips its children's rings exactly as the thumbnail rail
     does: overflow-x:auto computes overflow-y to auto as well, so the ring is
     cut off top and bottom too, not only at the two ends of the row. Above
     768px neither row scrolls and both chips keep the outset ring. */
  .ages .row .agechip:focus-visible,
  .catrow .catchip:focus-visible{outline-offset:-3px}
}

/* ---- 3. the one cross-fade --------------------------------------------------
   nav.js swaps the mega menu's promo photograph when a child category is
   hovered: opacity to 0, wait, swap the src, opacity back to 1. With no
   transition on the element that is a hard cut to blank and back. opacity is the
   only property involved, so nothing moves, and because it is a transition and
   not a keyframe a shopper running down a column of ten categories retargets one
   fade rather than queueing ten. */
.meganav .megapromo .promoart img{transition-property:opacity;
                                  transition-duration:var(--t-fast);
                                  transition-timing-function:var(--ease-out)}

/* ---- 4. reduced motion ------------------------------------------------------
   One block for the whole sheet, and it is the reason there is a --press-scale
   token at all.

   The rule the preference actually states is: remove the movement, keep the
   information. So every transition in the file narrows to opacity - a cross-fade
   carries no movement, and it is the one channel the preference is not asking us
   to give up - and --press-scale goes to 1, which stops all nineteen presses
   above in a single declaration. The transforms that survive narrowing are held
   at rest in their own component blocks, next to the rules that introduced them.

   Nothing is lost but the movement. The press still lands, because the browser
   still fires :active and the control still changes colour. The card still
   answers a hover, with its shadow. The category tile still lifts its wash and
   colours its chip. The wishlist heart still fills. Not one state anywhere in
   this storefront is announced by movement alone, which is what makes turning
   the movement off safe.

   The bottom sheet keeps its transform, because that transform is not
   decoration: translateY(101%) is how the sheet is off-screen when closed.
   Narrowing its transition is what makes it appear rather than travel. */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{
    animation-duration:.01ms!important;
    animation-iteration-count:1!important;
    transition-property:opacity!important;
    transition-duration:var(--t-fast)!important}
  :root{--press-scale:1}
  /* the mega menu's --t-hold is an intent delay, not motion: nothing moves
     during it, and it is what lets a diagonal slip on the way down to the panel
     re-open it instead of closing it. It is restored by hand here, because the
     narrowing above would otherwise take the visibility half of the transition
     with it and make the panel close the instant the pointer leaves. */
  .megapanel{transition-property:opacity,visibility!important;
             transition-duration:var(--t-fast),0s!important;
             transition-delay:0s,var(--t-hold)!important}
  .megaitem:hover > .megapanel,
  .megaitem:focus-within > .megapanel{transition-property:opacity,visibility!important;
             transition-duration:var(--t-fast),0s!important;
             transition-delay:0s,0s!important}
}


/* =============================================================================
   PAGE POLISH - hero entrance, header craft, footer structure, radii
   -----------------------------------------------------------------------------
   Page-level craft over the chrome every page carries (header, search, footer)
   plus the homepage hero. Everything here is an ADDITIVE override appended to
   the end of the file, so nothing above moves and the six mock/*.html pages
   pick up exactly what the storefront does.

   IT SITS ON TOP OF THE MOTION SYSTEM BLOCK, NOT BESIDE IT. Every duration and
   curve below resolves to a tokens.css token. The press, the focus ring and the
   reduced-motion narrowing all belong to that block and are NOT repeated here:
   .btn, .search button and .hicon are already in its press list, so the hero
   buttons and the search submit already sink to --press-scale on tap, and
   .agechip/.catchip already have their `transition: .15s` enumerated there.

   TWO VALUES BELOW ARE NOT ON A TOKEN LADDER, AND BOTH ARE DELIBERATE:
     the 100ms stagger step is a SEQUENCING interval, not the duration of any
       movement - it is how far apart two groups start, and it comes from the
       cited source's ~100ms. It does not belong on a scale whose rungs are
       chosen "by what the movement is FOR".
     the 64px header scroll range is a scroll DISTANCE. It is a length.
   Both are flagged for the tokens.css owner rather than invented into that
   file from here.

   ONE NEW COLOUR PAIRING, no new token: --tt-navy-2 carries footer text. That
   is the token's declared purpose ("raised surfaces on navy"), and both text
   pairs are now measured by contrast-check.js -
     white on --tt-navy-2 ... 12.39:1
     --pale on --tt-navy-2 ..  7.86:1
   Everything else reuses a pair the gate already protects.
   ============================================================================= */

/* ---- 0. root rendering details ---------------------------------------------
   color-scheme is a statement, not a style. tokens.css says dark mode is NOT
   launched, and a browser left to guess will auto-darken form controls and
   scrollbars on some platforms - which would silently invalidate every ratio
   contrast-check.js asserts, because the gate reads declared hex values and
   cannot see a UA re-render. Declaring the scheme keeps the rendered page the
   page the gate measured. base.html carries the same value as a meta tag so
   the first paint agrees with the stylesheet before CSS arrives.

   Font smoothing used to be half-declared - body carried the -webkit half and
   nothing carried the Firefox-on-macOS half, so text rendered heavier there
   than in Safari or Chrome on the same machine. Both halves now sit together
   on the body rule at the top of this sheet, which is the point of the pair:
   split across two elements they are two places to change one decision. */
:root{color-scheme:light}

/* Headings balance - every heading on this site is far inside the six-line
   ceiling the balancing algorithm is limited to, so none of them is silently
   ignored. Short-to-medium prose gets pretty, which has no line limit, so no
   description, list item or caption ends on a single orphaned word. Long
   article copy is left on the browser default deliberately: the content and
   blog pages set their paragraph styles inline, so nothing here reaches them. */
h1,h2,h3,h4{text-wrap:balance}
p,li,figcaption,blockquote{text-wrap:pretty}

/* ---- 1. homepage hero: the one staged entrance -----------------------------
   The MOTION SYSTEM block above says there are no keyframes in this sheet and
   that a staggered entrance would replay on every navigation. That reasoning is
   right about a whole-page entrance and is why nothing else here staggers. It
   does not hold for this one element, and the difference is worth stating
   precisely rather than quietly overriding:

     .hero is rendered by home.html and by nothing else. Every internal
     navigation away from the homepage lands on a page that has no .hero in it
     at all, so there is nothing to replay. The entrance is reachable only by
     loading the homepage, which is the one moment an entrance is for.

   BE HONEST ABOUT THE LIMIT. This storefront is server-rendered with no client
   router, so a CSS-only rule cannot know it has run before: returning to the
   homepage later in the session plays it again. What it can guarantee, and
   does, is that nothing can re-trigger it WITHIN a page's life - there is no
   :hover, no :focus, no class toggle and no script bound to it - and that a
   back/forward restore from bfcache brings the document back with the
   animation already finished rather than restarting it. A once-per-session
   guarantee needs storage, which needs script; that is a trade the brief asked
   not to make.

   Four semantic groups - eyebrow, headline, lede, buttons - each starting
   100ms behind the last, each combining opacity, translateY(12px) and
   blur(4px). Keyframes are correct here for the reason the animations guidance
   gives: this is a one-shot sequence, not an interactive state change, so
   there is no interruption to be interruptible about.

   --ease-emphasis is used for the first time. tokens.css declares it for
   "a one-shot moment meant to be noticed" and notes nothing uses it yet; a
   hero entrance is what it was reserved for, and it resolves to exactly the
   cubic-bezier(.2,0,0,1) the cited source specifies.

   FILL-MODE IS BACKWARDS, NOT BOTH, and that is the load-bearing choice.
   Backwards holds the from-frame through the delay, which is the whole point,
   but lets each element fall back to its own unstyled state the moment the run
   ends - so no permanent blur(0) is left pinning a compositing layer on four
   elements for the life of the page, which `both` would have done. It also
   fails safe in the only direction that matters: a browser that ignores the
   animation applies no fill either, so the copy is simply visible rather than
   stuck at opacity 0.

   nth-child(n+4) rather than nth-child(4) so a fifth element added to the hero
   later joins the last group instead of trailing 100ms further behind for ever.

   The whole thing is declared INSIDE prefers-reduced-motion:no-preference, so
   under reduce it is never declared at all - there is no from-frame to get
   stuck on and nothing for the reduce block to have to override. That is a
   stronger guarantee than clamping the duration after the fact. */
@keyframes tt-hero-in{
  from{opacity:0;transform:translateY(12px);filter:blur(4px)}
  to{opacity:1;transform:translateY(0);filter:blur(0)}
}
@media (prefers-reduced-motion:no-preference){
  .hero .txt > *{animation-name:tt-hero-in;
                 animation-duration:var(--t-slow);
                 animation-timing-function:var(--ease-emphasis);
                 animation-fill-mode:backwards}
  /* A sequencing interval, not a movement duration - which is exactly why it
     has a token of its own (--t-stagger) rather than borrowing one from the
     duration scale. Stepping 0, 1x, 2x, 3x off a single value is also what
     stops the one staged entrance on this site quietly becoming two rhythms
     the next time a group is added to the hero. */
  .hero .txt > *:nth-child(1){animation-delay:0s}
  .hero .txt > *:nth-child(2){animation-delay:var(--t-stagger)}
  .hero .txt > *:nth-child(3){animation-delay:calc(var(--t-stagger) * 2)}
  .hero .txt > *:nth-child(n+4){animation-delay:calc(var(--t-stagger) * 3)}
}

/* ---- 2. header: elevation on scroll ----------------------------------------
   .hdr is sticky. At the top of the page its bottom hairline is STRUCTURE - it
   divides the header from the nav bar sitting directly beneath it. Once the
   page scrolls the nav has gone, and that same hairline is doing ELEVATION
   instead, which is a shadow's job. So the two swap: the border fades out as
   the shadow fades in, and at no point are both doing the same job.

   CSS-ONLY, chosen over the inline script the brief allowed, via a scroll-
   driven animation. The justification for reaching for keyframes on something
   that is not a one-shot sequence: a scroll timeline is SCRUBBED by scroll
   offset rather than played on a clock. Scrolling back up runs it backwards
   frame for frame, so it is interruptible in the only sense that matters and
   the reason the transitions-for-state rule exists does not apply. A script
   toggling a class would need a scroll listener on every page of the site to
   achieve less.

   THE @supports GUARD IS LOAD-BEARING, not decoration. Without it a browser
   with no scroll-timeline support falls back to the DOCUMENT timeline and
   plays the elevation once on load regardless of scroll position - the classic
   footgun of this technique. Guarded, such a browser keeps the plain hairline
   header: less polish, nothing broken, still no script.

   Gated on no-preference too, because the reduce block above sets
   animation-duration:.01ms!important, and on a progress-based timeline that
   collapses the range and would pin the header to its elevated end state at
   every scroll position, shadow and all. Under reduce the header keeps its
   hairline, which is the honest static answer. */
@keyframes tt-hdr-lift{
  from{border-bottom-color:var(--line);box-shadow:0 0 0 transparent}
  to{border-bottom-color:transparent;box-shadow:var(--shadow-card)}
}
@media (prefers-reduced-motion:no-preference){
  @supports (animation-timeline:scroll()){
    /* linear, because the timeline is the scrollbar: any easing here would
       make the shadow arrive at a different rate than the finger moves */
    .hdr{animation-name:tt-hdr-lift;animation-fill-mode:both;
         animation-timing-function:linear;
         animation-timeline:scroll(root block);
         animation-range:0px 64px}   /* scroll distance, not a duration */
  }
}

/* ---- 3. header: the search pill --------------------------------------------
   FOCUS. The resting field is --surface behind a --line-control border
   (3.61:1, SC 1.4.11). On focus the surface lifts to white and the border
   becomes --tt-navy: the field says "typing goes here", and navy is the same
   value the focus ring itself uses, so field and ring agree instead of
   competing. A border that communicates STATE is exactly the case the
   shadow-for-elevation rule keeps as a border, so this stays a border and no
   shadow is added. Named properties only, at --t-fast, which is the token for
   focus and colour. --ink on white (13.6:1) and the --muted placeholder on
   white (4.77:1) are both pairs the gate already carries, so the focus surface
   introduces nothing new to measure.

   HIT AREA AND CONCENTRIC RADIUS, solved by one set of numbers. The submit
   button was 36x36 - under the 40x40 dense-desktop floor and well under 44x44
   for touch. Growing it has to keep the circle concentric inside the pill.
   The field's border-radius is --r-pill (28px) but its height is smaller than
   56px, so the radius clamps to exactly half the height and the field is a
   true pill. That is what makes the arithmetic land on both pointer types:

     fine pointer .. field 46 tall -> radius 23 = button 40 (r20) + 3px inset
     coarse ........ field 50 tall -> radius 25 = button 44 (r22) + 3px inset

   Both exact, and --r-pill is left alone in both cases rather than hard-coding
   23px and 25px over the token. No hit area overlaps another target either:
   the button's box sits wholly inside the field it is already painted on top
   of, so there is no pixel where two targets compete. The right-hand text
   padding grows with the button so a long query never runs underneath it. */
.search input{padding-right:56px;
              transition-property:background-color,border-color;
              transition-duration:var(--t-fast);
              transition-timing-function:var(--ease-out)}
.search input:focus{background:#fff;border-color:var(--tt-navy)}
.search button{top:3px;right:3px;width:40px;height:40px}
@media (pointer:coarse){
  .search input{height:50px;padding-right:60px}
  .search button{top:3px;right:3px;width:44px;height:44px}
}

/* ---- 4. header: icon hit areas and the basket count ------------------------
   .hicon already declared min-width:44px; its HEIGHT was only ever 44px by
   accident of icon plus gap plus label plus padding happening to add up.
   Stating min-height makes the 44x44 target a rule rather than arithmetic that
   the next font-size change quietly breaks. .hicons keeps its 22px gap (12px
   at 400 and below), so no two targets touch, let alone overlap.

   The basket pip is a number that changes while the shopper watches, which is
   the tabular-nums case exactly: 1 becoming 2 must not re-centre the pip. */
.hicon{min-height:var(--tap-min);justify-content:center}
.pip{font-variant-numeric:tabular-nums}

/* ---- 5. search suggest: concentric panel -----------------------------------
   CONCENTRIC VIOLATION, FIXED. The panel is --r-card (16px) with
   overflow:hidden, and the rows ran edge to edge - so the selected row's
   --peach fill was a square surface inside a 16px corner: clipped into a wedge
   on the first and last rows, square everywhere else. 6px of panel padding
   with a 10px row radius is concentric (10 + 6 = 16) and turns the highlight
   into a proper rounded row.

   The 44px thumbnail keeps its own 10px radius rather than being forced to
   match: it sits 14px in from the row's edge and 14 is greater than the row's
   10px radius, so the two corners never share an arc and are free to be
   chosen independently. */
.suggest{padding:6px}
.suggest-item{border-radius:10px}

/* ---- 6. footer: structure --------------------------------------------------
   The footer was a flex row of five blocks with a 52px gap and no top edge, so
   it read as the page running out rather than as a section.

   TOP RULE IN A BRAND TOKEN. --tt-orange on --tt-navy is 5.81:1, already
   carried by the gate as the footer mark pair, and as a 3px non-text boundary
   it is far clear of the 3:1 that SC 1.4.11 asks of one.

   COLUMN RHYTHM. An explicit grid above 1024px instead of flex-wrap: the about
   block takes 1.6fr and the four link columns take an equal share each, so the
   columns line up on one baseline at every desktop width rather than wherever
   wrapping happened to leave them. The 1024 / 768 / 400 footer grids defined
   earlier in this file are untouched - this rule is scoped to 1025px and up
   precisely so it cannot reach them. Column headings adopt the same uppercase
   micro-label that .mcol h3 and .mwsgrp h3 already use, so the three sets of
   column heads on the site finally match.

   Sizes here use the type tokens as they are declared, i.e. var(--fs-2xs) with
   no trailing unit - the token already carries its px. */
.ftr{border-top:3px solid var(--tt-orange)}
.ftr h2{font-size:var(--fs-2xs);letter-spacing:1.1px;text-transform:uppercase;
        margin-bottom:12px}
.ftr a{padding:8px 0}
@media (min-width:1025px){
  /* One explicit track for the about block, then implicit columns for however
     many link columns the footer happens to carry, each an equal share. Column
     COUNT is not hard-coded on purpose: the storefront footer has four link
     columns and the mock pages' footers have three, and a repeat(4) template
     would have left the mock pages a dead fifth column. */
  .ftr .wrap{display:grid;align-items:start;
             grid-auto-flow:column;
             grid-template-columns:minmax(0,1.6fr);
             grid-auto-columns:minmax(0,1fr);
             gap:34px 40px}
  .ftr .about{max-width:36ch}
}
/* On a touch device the link rows become real 44px targets. They stack with no
   gap between them, so each row touches its neighbour and no two overlap -
   which is the condition the hit-area rule actually sets. */
@media (pointer:coarse){
  .ftr a{display:flex;align-items:center;min-height:var(--tap-min);padding:0}
}

/* ---- 7. footer: trust row --------------------------------------------------
   Four statements, no badges and no payment marks. Every line is either
   already stated verbatim elsewhere on this same page - the utility bar and
   the USP bar carry the delivery, returns and safety lines - or is a fact
   about how this origin is built: checkout is a full redirect to the hosted
   checkout URL the API returns, and this origin renders no payment field of
   any kind. Nothing here is a logo we would have to be entitled to use, or a
   certification we cannot produce the paperwork for. A row of card-scheme
   marks would have been the conventional thing to put here and is exactly what
   this row refuses to be.

   The band is --tt-navy-2, the token declared for raised surfaces on navy, so
   the row separates from the columns by surface rather than by yet another
   rule - the footer already has one rule doing structural work at its top edge
   and a second would be noise. Its two text pairs are new and are now in
   contrast-check.js: white 12.39:1 and --pale 7.86:1, both clear of 4.5:1. */
.ftrtrust{background:var(--tt-navy-2)}
.ftr .ftrtrust .wrap{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));
                     gap:14px 32px;padding-top:18px;padding-bottom:18px;
                     list-style:none}
.ftrtrust li{font-size:var(--fs-xs);line-height:var(--lh-body);color:var(--pale)}
.ftrtrust b{display:block;color:#fff;font-size:var(--fs-sm);margin-bottom:2px}
@media (max-width:1024px){
  .ftr .ftrtrust .wrap{grid-template-columns:repeat(2,minmax(0,1fr));
                       gap:14px 26px}
}
@media (max-width:480px){
  .ftr .ftrtrust .wrap{grid-template-columns:1fr;gap:12px}
}

/* =============================================================================
   TYPE SYSTEM
   -----------------------------------------------------------------------------
   The scale itself lives in tokens.css. This block is the part that cannot be
   expressed as a token: the rendering rules that apply across every component
   at once, and the record of what the migration moved.

   WHAT CHANGED, AND BY HOW MUCH. Every font-size in this file was a literal
   until now - 130 of them across 28 distinct values. All 130 now resolve to one
   of 14 rungs, and NO CLASS MOVED BY MORE THAN 1.0px. These are the ones that
   moved by the full pixel; everything else moved by 0.5px or not at all:

     15px  -> --fs-sm  14   .gfstep .v, the chip close glyph
     17px  -> --fs-lg  18   .hero p (lede), .big (PDP add to basket)
     17px  -> --fs-md  16   .bltotal, .srow.total, the lead category tile label
     19px  -> --fs-lg  18   .bsum h2, .aside h2, .ah2
     21px  -> --fs-xl  20   .price .now, .agechip .n
     24px  -> --fs-2xl 23   .plphead h1, .pinfo h1, .bhead h1 (all <=768)
     27px  -> --fs-3xl 26   .logo, .hero h1 (<=400), .pprice .now (<=400)
     31px  -> --fs-5xl 32   .gf h2
     36px  -> --fs-6xl 37   .pprice .now
     38px  -> --fs-6xl 37   .hero h1 (<=1024)

   17px is the only source value that split. The hero lede and the PDP buy
   button step UP so they stay clearly above body; the basket totals step DOWN
   so they stay clearly below the panel heading above them. Everything else
   maps once, which is what makes the migration checkable.

   Leading moved on the same principle. The largest single change is 1.5 -> 1.45
   on eight prose rules, worth 0.7px a line at --fs-sm, and .hero h1 went
   1.07 -> 1.1, worth 1.4px across the hero's two lines.

   NO HEADING DEFAULTS ARE SET HERE, ON PURPOSE. Every h1/h2/h3 on these pages
   is already sized by its component class, and a default font-size on the bare
   element would win in exactly the places nobody expected it to.
   ============================================================================= */

/* ---- text wrapping --------------------------------------------------------- */
/* balance equalises line lengths, and browsers only run it on short blocks -
   six lines in Chromium - which covers every heading here; the longest real one
   is a product title at three lines in a 3-up grid.

   pretty is the default for everything short-to-medium. It does not equalise
   the lines, it only stops the last one being a single orphaned word. Neither
   belongs on genuinely long copy, 10 lines and up, where the browser's own
   behaviour is already right and both cost layout work for nothing. The longest
   paragraphs on the site are the footer about line and the blog post body, and
   both sit well inside that. */
/* Declared once, above, alongside the colour-scheme rule - h1 through h4
   balance, and p/li/figcaption/blockquote get pretty. Restating them here
   would give the sheet two places to change one decision, which is the thing
   this block exists to stop, so this is a pointer and not a rule. */

/* ---- font smoothing -------------------------------------------------------- */
/* Declared once on body, at the top of this file. The -webkit half was already
   there; the -moz-osx half went in during this pass, because with only one of
   the two present Firefox on macOS rendered body copy heavier than every other
   browser on the same machine. Both are ignored off macOS, so it costs nothing
   anywhere else. Never per-element: the whole value of smoothing is that all
   the text on a page agrees with itself. */

/* ---- tabular numerals ------------------------------------------------------ */
/* Any number that CHANGES, or that ALIGNS against another number. Proportional
   digits set 1 narrower than 8, so a column of four card prices sits ragged and
   a quantity going 9 -> 10 nudges everything after it along the row.

   The list is explicit rather than a blanket rule on body, because tabular
   figures are the wrong default for prose: they are for prices, counts, totals,
   quantities and anything on a clock. Deliberately NOT here - order references
   and postcodes, which never align against each other and read better with the
   font's proportional figures.

   .price, .badge, .fopt .c, .srow b, .bltotal, .bstep .bstepval, .ainput.code,
   .suggest-price, .delivleft and #deliv-promise time already carried this at
   their own definitions. They are repeated below so the whole set is legible in
   one place instead of scattered across nine components. */
.price,.price .now,.price .was,
.pprice,.pprice .now,.pprice .was,.pprice .save,
.bprice,.bprice .was,.bltotal,.suggest-price,
.srow,.srow b,.srow.total,
.cnt,.fopt .c,.fbar .count,.pip,
.qty input,.bstep .bstepval,
.pager a,.pager span,
.agechip .n,.stars,.stock,.badge,.subst,
.fdprog b,.brandcard .bc,.ameta,.fact .v,
.pricefacet .bands button,.pricefacet .nums input,.chip,
.ainput.code,.delivleft,#deliv-promise time{font-variant-numeric:tabular-nums}


/* =============================================================================
   BUTTON SYSTEM
   -----------------------------------------------------------------------------
   Five families, no new class names. The templates already depend on .btn,
   .addbtn, .big, .ghost and .linkchip, so this reconciles what those classes
   ARE rather than replacing them. Every rule below either unifies a value that
   had drifted or adds a state that was missing; nothing is restated for the
   sake of having it here, because a second declaration of a value that already
   works is a second place to have to change it.

     PRIMARY   .btn, .big                    amber fill, navy label
     SOLID     .btn.navy, .addbtn            navy fill, white label
     OUTLINE   .btn.ghost, .addbtn.alt,      white fill, navy label, bordered
               .fbar button
     CHIP      .linkchip, .readmore summary, white fill, amber label, lighter
               .pricefacet .bands button     border, smaller box
     QUIET     .fmore, .clearall, .bremove,  label only
               .alink, .aalt a, .chip button

   CONTRAST. Every pair used below is one contrast-check.js already gates, so
   this block adds nothing to that ledger:
     navy on amber           8.10:1      navy on amber hover      6.91:1
     white on navy          15.58:1      navy on white           15.58:1
     amber-ink on white      5.00:1      amber-ink on peach       4.58:1
     line-control on white   3.61:1      line-control on surface  3.43:1
     faint-ink on surface    5.68:1      danger on white          6.40:1

   RADII ARE CONCENTRIC OR THEY ARE PILLS. Six controls carried hand-picked
   radii - 22, 24, 26, 27px - every one of which the browser was already
   clamping to half the control's height, which is to say rendering as a pill.
   They all now read var(--r-pill), which clamps to the identical value: the
   shape did not move, only the number of places you have to look to change it.

   Where a button genuinely nests inside a rounded box, outer = inner + padding
   already holds. The search submit is a 36px circle (r18) inset 5px inside a
   24px pill, and 24 - 5 = 19, so it is concentric to within a pixel. .bsum,
   .aside and .megalede each pad a --r-card box by 18-22px, which is more than
   their 16px radius, so a button inside them sits clear of the corner entirely
   and a pill is simply correct. The .qty capsule clips its square-cornered
   buttons with overflow:hidden, which gives a true concentric inner curve free.

   SHADOWS FOR ELEVATION, BORDERS FOR STRUCTURE. No button here does both. The
   outline families carry a border because the border IS the control's boundary;
   .wish, .lbclose and .zoomcue float over photography and carry a shadow with
   no border at all.
   ============================================================================= */

/* ---- 1. one transition contract for everything that reads as a button ------ */
/* Named properties, never transition:all - a blanket transition catches layout
   properties too and turns a reflow into an animation. The durations are
   positional: the three colour properties take --t-fast, scale takes --t-press,
   because a press has its own fixed timing.

   scale is used as its own property rather than through transform, so
   transition-property can name it without dragging translate or rotate along
   with it. That matters on .wish, where something else is already moving. */
.btn,.big,.addbtn,.fbar button,.sortbox select,.linkchip,.readmore summary,
.pricefacet .bands button,.chip button,.qty button,.fmore,.clearall,.bremove,
.alink,.aalt a,.pager a,.wish,.lbclose,.search button{
  transition-property:background-color,border-color,color,scale;
  transition-duration:var(--t-fast),var(--t-fast),var(--t-fast),var(--t-press);
  transition-timing-function:var(--ease-out)}

/* ---- 2. the press ---------------------------------------------------------- */
/* 0.96 exactly, from the token, and one selector per member of the transition
   contract above so the two lists can be read against each other. Below 0.95
   stops reading as a press and starts reading as a flinch.

   No :not(:disabled) guard here: a browser does not fire :active on a disabled
   control in the first place, and the disabled block further down resets scale
   anyway. A <select> is deliberately left out of the press: pressing it opens
   the native menu, and a control that shrinks while a system popup grows out of
   it is the one place the press reads as a glitch. */
.btn:active,.big:active,.addbtn:active,.fbar button:active,
.linkchip:active,.readmore summary:active,.pricefacet .bands button:active,
.chip button:active,.qty button:active,.fmore:active,.clearall:active,
.bremove:active,.alink:active,.aalt a:active,.pager a:active,.wish:active,
.lbclose:active,.search button:active{scale:var(--press-scale)}

/* ---- 3. the padding scale --------------------------------------------------- */
/* Four sizes and no fifth. .big is the PDP buy button, the only member set a
   rung larger; its 54px min-height already governed its box, so giving it the
   horizontal padding it never had changes the rendered height by 0.1px and
   gives the label somewhere to sit on the one page where the button is not
   stretched to the column. */
.btn{padding:14px 26px}          /* --fs-md label, 44px floor    */
.btn.sm{padding:10px 20px}       /* --fs-sm label                */
.big{padding:14px 30px}          /* --fs-lg label, 54px floor    */
.linkchip,.readmore summary{padding:9px 16px}
.pricefacet .bands button{padding:7px 12px}

/* ---- 4. OUTLINE: the ghost now works on light surfaces too ------------------ */
/* .ghost was defined for navy only - white label, translucent white border -
   and the storefront also uses it for the sort form's Go button on the white
   PLP pages, where that treatment had nothing to sit against. On a light
   surface it is now the standard outline: navy label at 15.58:1 with a
   --line-control boundary at 3.61:1. On a dark surface it keeps the
   translucent white it always had, and gains the hover it never had - the
   original .btn:hover was being beaten by .btn.ghost's own background, so a
   ghost button did not respond to the pointer at all.

   The dark-surface border went from 55% white to 72%, measured against the
   three dark surfaces it actually sits on:
     on --tt-navy                          5.49:1 -> 8.40:1
     on the hero photo at its brightest    3.10:1 -> 4.11:1
     on --tt-green, the gift band          2.74:1 -> 3.58:1
   The gift band is the one that needed it: at 55% the boundary of a control sat
   under the 3:1 that SC 1.4.11 asks for. */
.btn.ghost{background:#fff;color:var(--tt-navy);border:2px solid var(--line-control)}
.btn.ghost:not(:disabled):hover{background:var(--surface);border-color:var(--tt-navy)}
.hero .btn.ghost,.gf .btn.ghost,.ftr .btn.ghost,.util .btn.ghost{
  background:transparent;color:#fff;border-color:rgba(255,255,255,.72)}
.hero .btn.ghost:not(:disabled):hover,.gf .btn.ghost:not(:disabled):hover,
.ftr .btn.ghost:not(:disabled):hover,.util .btn.ghost:not(:disabled):hover{
  background:rgba(255,255,255,.12);border-color:#fff}

/* the other two outline members had no hover either. .addbtn.alt is written at
   0,4,0 on purpose: .addbtn's own navy hover would otherwise reach it and turn
   the white card button navy under the pointer. */
.addbtn.alt:not(:disabled):hover{background:var(--surface);border-color:var(--tt-navy)}
.fbar button:not(:disabled):hover{background:var(--surface);border-color:var(--tt-navy)}

/* ---- 5. CHIP: the toggling one gains a hover ------------------------------- */
/* The price bands are the only chips here that toggle, and aria-pressed already
   carries that state statically in colour and border - so the hover below is
   feedback, never the thing a shopper has to rely on. Scoped away from the
   pressed state so pointing at an applied band does not un-highlight it. */
.pricefacet .bands button:not([aria-pressed=true]):not(:disabled):hover{
  background:var(--peach);border-color:var(--tt-orange-ink)}

/* ---- 6. QUIET: the destructive one names itself ----------------------------- */
/* .bremove is the DESTRUCTIVE slot - the only control on the site that throws
   work away. Its resting state stays amber-ink, because repainting a basket
   control red at rest is a look the client has not seen and does not need to
   approve to get the benefit. Its hover is --danger, 6.40:1 on white and
   already in the contrast ledger, so the action names itself as destructive at
   the moment a shopper is about to take it and not before. */
.bremove:not(:disabled):hover{color:var(--danger)}

/* ---- 7. DISABLED ------------------------------------------------------------ */
/* One disabled look for every family, on purpose: a control that cannot be used
   should stop advertising which family it belongs to.

   Fading the live colours with opacity was the alternative and it is worse -
   navy on amber at 55% measures 2.58:1, unreadable even though SC 1.4.3 exempts
   inactive components from having to be readable. This uses two pairs the
   contrast gate already holds instead: --faint-ink on --surface at 5.68:1, and
   a --line-control boundary at 3.43:1 on that same surface. Legible, and
   unmistakably off. */
.btn:disabled,.big:disabled,.addbtn:disabled,.fbar button:disabled,
.sortbox select:disabled,.pricefacet .bands button:disabled,
.qty button:disabled,.wish:disabled,.lbclose:disabled,.search button:disabled{
  background:var(--surface);color:var(--faint-ink);
  border:2px solid var(--line-control);box-shadow:none;
  cursor:not-allowed;scale:1}
/* the quiet family has no box at rest, so it should not grow one when it is
   turned off. It loses its colour and its underline, and nothing else. */
.fmore:disabled,.clearall:disabled,.bremove:disabled,.alink:disabled,
.chip button:disabled{background:none;border:0;color:var(--faint-ink);
                      text-decoration:none;cursor:not-allowed;scale:1}

/* ---- 8. FOCUS --------------------------------------------------------------- */
/* The ring itself is global, at the top of this file, and is already the right
   colour on light and dark surfaces alike. What it needed was to stop being
   drawn as a 4px-cornered rectangle round a pill: that global rule sets
   border-radius:4px on any focused control, which is right for a text field and
   wrong for everything in this block. Restating each family's own radius under
   :focus-visible keeps the ring concentric with the shape it is ringing. */
.btn:focus-visible,.big:focus-visible,.addbtn:focus-visible,
.fbar button:focus-visible,.sortbox select:focus-visible{
  border-radius:var(--r-pill)}
.linkchip:focus-visible,.readmore summary:focus-visible,
.pricefacet .bands button:focus-visible{border-radius:var(--r-chip)}
.wish:focus-visible,.lbclose:focus-visible,.chip button:focus-visible,
.search button:focus-visible{border-radius:50%}

/* ---- 9. HIT AREAS ----------------------------------------------------------- */
/* 44x44 is the target and there are two honest ways to reach it. Where a
   control has room around it, the box itself grows. Where growing the box would
   move the layout, a transparent ::after extends the target instead - and the
   extension is only ever allowed to reach into space no other target occupies,
   which is precisely what rules the pseudo-element out for stacked link lists.

   BOX GROWS. These sat at 30-38px and had the room:
     .fmore 32 -> 44   .clearall 34 -> 44   .bremove 32 -> 44
     .chip  34 -> 44   the price bands 34 -> 44   the price inputs 38 -> 44
   The applied-filter row and the filter rail get 10-12px taller as a result.
   That is the cost of the rule and it is worth paying here: these are the
   controls a shopper uses to narrow a 900-product catalogue on a phone. */
.fmore,.clearall,.bremove{display:inline-flex;align-items:center;
                          min-height:var(--tap-min)}
.chip{min-height:var(--tap-min)}
.pricefacet .bands button,.pricefacet .nums input{min-height:var(--tap-min)}
/* .hicon hit area is set once, above in the page-polish block. */

/* PSEUDO-ELEMENT EXTENDS. The chip's close control keeps its circle and reaches
   44 through a 4px ring, which lands exactly on the chip's own new 44px height:
   it extends into the chip and never past it, so two chips stacked in a wrapped
   row cannot have overlapping targets however tight the row gap becomes. */
.chip button{width:36px;height:36px;position:relative}
.chip button::after{content:"";position:absolute;inset:-4px}

/* The nav row is 52px tall and its links are about 35px, so a ring fits inside
   the row with space to spare. inset is 0 on the horizontal axis on purpose:
   the links sit 22-30px apart and a sideways extension would run two targets
   into each other. The Sale pill is shorter still - line-height:1 and no bottom
   border - so it takes a deeper ring to reach the same 44.

   Scoped to the bar's own children with >, NOT to .nav a: the mega panel's
   link columns are inside .nav too, and they are stacked flush, so extending
   those would put every target on top of its neighbour. Those are covered in
   the note below instead. */
.nav>.wrap>ul>li>a,.nav>.wrap>ul>li>button{position:relative}
.nav>.wrap>ul>li>a::after,.nav>.wrap>ul>li>button::after{
  content:"";position:absolute;inset:-5px 0}
.nav>.wrap>ul>li>a.hl::after{inset:-8px 0}

/* The search submit is a 36px circle sitting inside the search field. Its ring
   reaches 44, and the only thing it overlaps is the field it sits in - whose
   right-hand 52px is padding with no text in it, so the worst outcome of an
   edge tap is focusing the search box the shopper was aiming at anyway. */
/* NOT position:relative here: line 38 pins this button absolutely inside the
   field, and re-declaring position at equal specificity later in the file
   wins the cascade and drops it back into the flow. The button is already a
   positioned element, so the hit-area pseudo anchors to it as it stands. */
.search button::after{content:"";position:absolute;inset:-4px}

/* STILL SHORT OF 44, AND WHY. The mega panel's link columns (.meganav .megacol
   ul a) and the footer's link columns (.ftr a) are stacked flush, so neither a
   ring nor extra padding gets them to 44 without either overlapping the link
   above or changing the height of a panel that was signed off last week. Both
   are links rather than controls, both are desktop-width surfaces - the mega
   panel is display:none below 1024px, so a touch device never meets it - and
   both want a padding decision taken with the component rather than here. */

/* ---- 10. REDUCED MOTION ------------------------------------------------------ */
/* Nothing to declare here, and that is the point. The press is the only thing
   in this block that moves, it is expressed as var(--press-scale), and the
   sheet's reduced-motion block resets that token to 1 - so every button above
   stops pressing on one declaration rather than on a list of selectors that
   goes stale the moment a family gains a member.

   Every state above survives the preference intact, because none of them was
   only ever a movement: hover changes the fill or the border, the pressed
   filter band carries aria-pressed and its own colour, and disabled changes
   the whole control. */

/* =============================================================================
   PDP GALLERY AND FAQ
   -----------------------------------------------------------------------------
   The gallery holds every stored image; only the first is visible, the rest
   are .vh so lightbox.js still finds them and the thumbnail strip can swap
   one in without a second fetch. A single-image product renders no strip at
   all rather than a row of one.

   The FAQ list is native <details>: keyboard-operable, findable by the
   browser's in-page search, and no script to go wrong. Same content feeds
   the FAQPage structured data.
   ============================================================================= */
.galthumbs{list-style:none;display:flex;gap:10px;margin-top:12px;flex-wrap:wrap}
.galthumb{width:64px;height:64px;padding:4px;background:#fff;
          border:1.5px solid var(--line);border-radius:10px;cursor:pointer;
          display:flex;align-items:center;justify-content:center;
          transition-property:border-color,box-shadow,scale;
          transition-duration:var(--t-press);transition-timing-function:var(--ease-out)}
.galthumb img{max-width:100%;max-height:100%;object-fit:contain}
.galthumb:hover{border-color:var(--line-control)}
.galthumb:active{scale:var(--press-scale)}
.galthumb.on{border-color:var(--tt-navy);box-shadow:0 0 0 2px rgba(19,34,87,.12)}

/* ---- product video facade -------------------------------------------------
   Under the gallery on the 47 products that carry a YouTube video. What is on
   the page before a click is a poster this origin serves and a button; the
   iframe arrives only when the button is pressed, which is what keeps a
   third-party cookie off the page before consent has been answered.

   The poster, the button and the frame that replaces it are all 16/9 and all
   full width, so nothing below moves when the swap happens. aspect-ratio
   rather than a padding box because the height is known and stating it is
   what stops the reflow before the poster has loaded.

   .pvfall is the scripts-off control. It is hidden here and revealed by the
   rule product.html puts in the head, which is the only place a page can
   answer "no JavaScript" without JavaScript. */
.pvid{margin:16px 0 0}
.pvplay{position:relative;display:block;width:100%;min-height:var(--tap-min);
        padding:0;border:0;border-radius:14px;overflow:hidden;cursor:pointer;
        background:var(--tt-navy)}
.pvplay img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover}
/* The badge is the affordance, so it is the thing that answers the pointer.
   Decorative and aria-hidden, exactly as .zoomcue is, and white on navy for
   the same reason. */
.pvplay .pvicon{position:absolute;left:50%;top:50%;translate:-50% -50%;
                width:62px;height:62px;padding-left:5px;border-radius:50%;
                background:#fff;color:var(--tt-navy);
                display:flex;align-items:center;justify-content:center;
                box-shadow:var(--shadow-pop);
                transition-property:scale;transition-duration:var(--t-press);
                transition-timing-function:var(--ease-out)}
.pvplay:hover .pvicon{scale:1.06}
.pvplay:active .pvicon{scale:var(--press-scale)}
.pvframe{display:block;width:100%;aspect-ratio:16/9;border:0;border-radius:14px}
.pvfall{display:none;align-items:center;gap:8px;min-height:var(--tap-min);
        color:var(--tt-navy);font-size:var(--fs-sm);font-weight:600}
.pvcap{margin-top:8px;font-size:var(--fs-xs);color:var(--ink-2);
       line-height:var(--lh-body)}
@media (prefers-reduced-motion:reduce){
  .pvplay .pvicon{transition-duration:.01ms}
}

.faqs{margin-top:26px}
.faq{border-bottom:1px solid var(--line)}
.faq summary{list-style:none;cursor:pointer;padding:14px 30px 14px 0;
             position:relative;font-weight:600;color:var(--tt-navy);
             font-size:var(--fs-md);min-height:var(--tap-min);display:flex;
             align-items:center}
.faq summary::-webkit-details-marker{display:none}
/* The marker is drawn rather than a glyph so it can rotate on open without
   the font's own baseline shifting the row. */
.faq summary::after{content:"";position:absolute;right:6px;top:50%;
                    width:10px;height:10px;margin-top:-6px;
                    border-right:2px solid var(--tt-orange-ink);
                    border-bottom:2px solid var(--tt-orange-ink);
                    transform:rotate(45deg);
                    transition-property:transform;transition-duration:var(--t-base);transition-timing-function:var(--ease-out)}
.faq[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
.faq p{padding:0 30px 16px 0;color:var(--ink-2);font-size:var(--fs-sm);line-height:var(--lh-relaxed)}
@media (prefers-reduced-motion:reduce){
  .galthumb,.faq summary::after{transition-duration:.01ms}
}

/* The utility bar's links became real destinations in this sweep, so they
   need a real target. The bar is short, so the row cannot grow to 44 without
   changing the header's proportions; the links instead take the dense-desktop
   floor with a pseudo-element carrying the rest. They sit far enough apart
   that the extended areas cannot collide. */
.util a{position:relative;display:inline-flex;align-items:center;min-height:24px}
.util a::after{content:"";position:absolute;left:-6px;right:-6px;top:-10px;bottom:-10px}

/* ---- save to wishlist, PDP ---------------------------------------------- */
/* The card hearts are a bare icon because space is tight; on the PDP there is
   room to say what the control does, and a saved state that reads at a glance
   rather than a subtly filled 18px path. */
.pdpwish{margin-top:12px}
.wishbtn{display:inline-flex;align-items:center;gap:9px;min-height:var(--tap-min);
         padding:9px 16px;border:1.5px solid var(--line-control);
         border-radius:var(--r-pill);background:#fff;color:var(--tt-navy);
         font-size:var(--fs-sm);font-weight:600;cursor:pointer;
         transition-property:scale,border-color,background-color,color;
         transition-duration:var(--t-press),var(--t-fast),var(--t-fast),var(--t-fast);
         transition-timing-function:var(--ease-out)}
.wishbtn:hover{border-color:var(--tt-navy)}
.wishbtn:active{scale:var(--press-scale)}
.wishbtn[aria-pressed="true"]{background:var(--peach);border-color:var(--tt-orange);
                              color:var(--tt-orange-ink)}
@media (prefers-reduced-motion:reduce){ .wishbtn{transition-duration:.01ms} }

/* =============================================================================
   IP AND CHARACTER TILES - the /characters grid (partials/ip_tiles.html)
   -----------------------------------------------------------------------------
   The live site merchandises its licensed properties as a wall of branded
   tiles. This is that surface, built the only way we are entitled to build it:

   NO LICENSED LOGO, WORDMARK OR CHARACTER ARTWORK APPEARS HERE OR IS LOADED
   FROM ANYWHERE. Toytastic's right to use the Barbie, Bluey, Peppa Pig,
   Frozen, Spider-Man, PAW Patrol and Playmobil marks on a new site is not
   confirmed, so every tile is OUR OWN product photography plus type, and the
   colour behind a name is a design token rather than a rights holder's brand
   colour. Nothing in this block needs to change if a licence is refused.

   The structure is deliberately the homepage category tile's, because that
   argument was already made and won: the tile IS the photograph, the name
   sits on it over a solid navy plate, and a navy scrim ramps out of the plate
   into the picture. Pinning the plate is what makes the text pair a constant -
   white on --tt-navy, 15.06:1, and --pale on --tt-navy, 9.56:1, both already
   in contrast-check.js - rather than something that depends on whichever
   packshot the catalogue happened to return this afternoon. The amber go chip
   is --tt-orange on --tt-navy at 8.10:1, also already in the ledger. This
   block introduces NO new colour pair.

   What is new is the tint: one wash colour per character, set as --ip-tint by
   the .iptile-tint-* classes below and resolved from tokens.css. It gives
   fourteen tiles some variety without fourteen bespoke treatments, and it is
   the only thing that tells a Frozen tile from a Tractor Fun one when both
   are photographs of a bicycle on grass. It carries no text at any strength,
   so it carries no contrast obligation of its own.
   ============================================================================= */

/* The intro paragraph on /characters. Was an inline 14.5px literal. */
.iplead{max-width:72ch;margin:0 0 24px;color:var(--ink-2);
        font-size:var(--fs-sm);line-height:var(--lh-relaxed);text-wrap:pretty}

/* auto-fill rather than a fixed column count: the registry is the source of
   truth for how many hubs exist, and a grid that has to be re-tuned every
   time a character is added is a grid that will be wrong within the month.
   The minimum is 156px and not a rounder 160 for a measured reason: the 1280
   wrap leaves 1201px of content, seven columns of 160 plus six 14px gaps want
   1204, and missing seven columns by three pixels drops the grid to six
   across and leaves today's fourteen hubs as 6+6+2 - a last row of two, which
   reads as a set that ran out rather than a wall. At 156 it lands on seven
   and the fourteen fill two rows exactly. */
.iptiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(156px,1fr));
         grid-auto-rows:150px;gap:14px;list-style:none;margin:0;padding:0}
.iptiles > li{min-width:0}
/* The home band's larger cut: four to a row and taller, so eight
   tiles sit as two even rows of recognisable faces rather than a
   thin strip. Same tiles, same rules - only the grid changes. */
.iptiles.four{grid-template-columns:repeat(4,1fr);grid-auto-rows:210px;
  gap:24px}
/* The four cut drops the gradient ramp and halves the tint wash: at
   this size the photograph is the point, and the fog that helped
   fourteen small tiles read as a set just muddies eight big ones.
   The label keeps its solid navy plate and gains a bold orange
   keyline - the hard edge IS the design, doing cleanly what the
   gradient did murkily. Contrast is untouched: the name always sat
   on the solid plate, never on the ramp. */
.iptiles.four .iptile-label::before{display:none}
.iptiles.four .iptile-label{border-top:3px solid var(--tt-orange)}
.iptiles.four .iptile-art::before{opacity:.15}
.iptiles.four .iptile:hover .iptile-art::before{opacity:.05}

/* THE TINT KEYS. Six themes, every one of them an existing token - the
   registry names a theme, never a colour, so a tile grid cannot smuggle a
   hex past tokens.css.

   These are the ONLY place --ip-tint is set. A default on .iptile itself
   would be the same specificity as these classes and would win on source
   order alone, which is exactly what it did the first time this was written:
   every tile rendered navy and nothing on the page said the theme had been
   dropped. The fallback lives at the point of use - var(--ip-tint,
   var(--tt-navy)) - where it cannot race anything. */
.iptile-tint-navy{--ip-tint:var(--tt-navy)}
.iptile-tint-sky{--ip-tint:var(--tt-navy-3)}
.iptile-tint-green{--ip-tint:var(--tt-green)}
.iptile-tint-amber{--ip-tint:var(--tt-orange)}
.iptile-tint-bronze{--ip-tint:var(--tt-amber)}
.iptile-tint-sun{--ip-tint:var(--tt-yellow)}

.iptile{position:relative;display:block;height:100%;min-height:var(--tap-min);
        padding:0;border:0;border-radius:var(--r-card);overflow:hidden;
        background:var(--tt-navy-2);box-shadow:var(--shadow-card);
        text-decoration:none;
        transition-property:translate,scale,box-shadow;
        transition-duration:var(--t-base),var(--t-press),var(--t-base);
        transition-timing-function:var(--ease-out)}
.iptile:hover{translate:0 -3px;box-shadow:var(--shadow-pop)}
.iptile:active{scale:var(--press-scale)}
/* the page focus ring is navy and would vanish into a navy tile, so it is
   offset out onto the white page behind it */
.iptile:focus-visible{outline:var(--focus-ring);outline-offset:3px}

.iptile-art{position:absolute;inset:0;overflow:hidden;background:var(--tt-navy-2)}
.iptile-art img{width:100%;height:100%;object-fit:cover;display:block;
                transition-property:scale;transition-duration:var(--t-slow);
                transition-timing-function:var(--ease-out)}
.iptile:hover .iptile-art img{scale:1.06}

/* THE TINT WASH. 30% over the photograph, dropping to 10% on hover so that
   pointing at a tile pulls the real picture forward - the tint is a way of
   telling fourteen tiles apart at a glance, not a filter anyone should have
   to look through once they have chosen one. Opacity on a token colour, so
   there is no alpha value to maintain and no new hex. */
.iptile-art::before{content:"";position:absolute;inset:0;z-index:1;
                    pointer-events:none;
                    background:var(--ip-tint,var(--tt-navy));opacity:.3;
                    transition-property:opacity;
                    transition-duration:var(--t-base);
                    transition-timing-function:var(--ease-out)}
.iptile:hover .iptile-art::before{opacity:.1}

/* THE TYPE-ONLY TILE. A character with no photograph to borrow renders no
   picture frame at all - the partial omits .iptile-art rather than leaving an
   empty box behind a tint - and the tile gives its whole face to the tint and
   a larger name. 55% keeps even the yellow and green tints inside the navy
   family instead of shouting across the grid at a photograph. */
.iptile-type::before{content:"";position:absolute;inset:0;z-index:1;
                     pointer-events:none;
                     background:var(--ip-tint,var(--tt-navy));opacity:.55}
.iptile-type .iptile-name{font-size:var(--fs-lg)}

/* THE LABEL, AND THE SCRIM ANCHORED TO IT. The ramp is the label's own
   ::before at bottom:100%, so it starts exactly where the plate starts at
   every width and can never fall out of step with it - a fixed offset from
   the tile's foot shows a hard seam the moment a name wraps, which "Tractor
   Fun" does at 480px. */
.iptile-label{position:absolute;left:0;right:0;bottom:0;z-index:3;
              display:flex;align-items:center;gap:10px;
              background:var(--tt-navy);color:#fff;
              padding:9px 11px 10px 13px}
.iptile-label::before{content:"";position:absolute;left:0;right:0;bottom:100%;
                      height:76px;pointer-events:none;
                      background:linear-gradient(to top,
                        var(--tt-navy),transparent)}
.iptile-text{display:flex;flex-direction:column;gap:1px;min-width:0}
.iptile-name{font-weight:700;font-size:var(--fs-sm);line-height:var(--lh-snug);
             letter-spacing:.2px;text-wrap:balance}
/* the count changes with the catalogue and sits under a name in a column of
   other counts, so it is tabular */
.iptile-count{color:var(--pale);font-size:var(--fs-2xs);
              line-height:var(--lh-snug);font-variant-numeric:tabular-nums}

/* THE AMBER GO CHIP, as on the homepage tiles: one chip carries the brand
   amber onto every tile, where fourteen repetitions of "Shop now" would only
   be noise. aria-hidden - the tile is already one link with a real name. */
.iptile-go{flex:none;margin-left:auto;width:30px;height:30px;border-radius:50%;
           background:var(--tt-orange);color:var(--tt-navy);display:flex;
           align-items:center;justify-content:center;
           transition-property:translate,background-color;
           transition-duration:var(--t-base);
           transition-timing-function:var(--ease-out)}
/* optical, not geometric: an arrow's mass sits behind its point */
.iptile-go svg{margin-left:1px}
.iptile:hover .iptile-go{translate:3px 0;background:var(--tt-orange-hover)}

@media (max-width:1024px){ .iptiles{gap:12px;grid-auto-rows:138px}
  .iptiles.four{grid-auto-rows:180px;gap:20px} }
@media (max-width:768px){
  .iptiles{grid-template-columns:repeat(3,1fr);grid-auto-rows:126px}
  .iptiles.four{grid-template-columns:repeat(2,1fr);grid-auto-rows:150px;gap:16px}
  .iptile-label{padding:8px 9px 9px 11px;gap:8px}
  .iptile-label::before{height:54px}
  .iptile-go{width:28px;height:28px}
}
@media (max-width:480px){
  /* two across on a phone: below this a tile is too small for a photograph to
     say anything at all, and a name over a thumbnail is not merchandising.
     The row is TALLER here than at 768 rather than shorter - the label plate
     is a near-constant 50px, so shrinking the row is spent entirely out of
     the picture, and at 118px the photograph was down to 68px of the tile. */
  .iptiles{grid-template-columns:repeat(2,1fr);grid-auto-rows:132px}
  .iptiles.four{grid-template-columns:repeat(2,1fr);grid-auto-rows:140px}
  .iptile-name{font-size:var(--fs-xs)}
  .iptile-type .iptile-name{font-size:var(--fs-md)}
}
/* Reduced motion keeps every static cue - the shadow, the tint lifting, the
   chip changing colour - and drops the travel, the zoom and the press. */
@media (prefers-reduced-motion:reduce){
  .iptile,
  .iptile:hover,
  .iptile:active{translate:none;scale:none}
  .iptile:hover .iptile-art img{scale:none}
  .iptile:hover .iptile-go{translate:none}
}

/* =============================================================================
   CAMPAIGN RAIL - the seasonal campaigns on the homepage (tt-store)
   -----------------------------------------------------------------------------
   THIS REPLACES THE FULL-BLEED PROMO BANDS, and the reason is worth writing
   down because the data underneath did not change at all. What was here was
   four full-width colour slabs stacked down the homepage, each a flat block
   with a headline, a button and two photographs beside it. Measured at 1280px
   that stack stood 1264px tall - four bands at 316px each - and all of it sat
   between the shopper and the first product on the page. It also read as
   advertising rather than as merchandising: four coloured slabs in a row is a
   wall of colour, and a full-width colour band is a pattern the web left
   behind about a decade ago.

   This is the same records on a rail. One row of cards, one card per live
   campaign: the photograph the target already found fills the card, the
   campaign's own theme colour washes it so a seasonal campaign still reads as
   itself from across the page, and the name, one line and the call to action
   sit on a plate at the foot. The whole card is the link.

   MEASURED IN THE BROWSER, at a 1280px viewport, with the four campaigns the
   shipped seed puts up today and each of them photographed:

     four bands          4 x 316px                        1264px
     the card row        300px card + gutter + scrollbar   325px   25.7%
     the rail in full    plus its heading and .sec padding 518px   41.0%

   So the promotional surface itself is a quarter of what it replaced, and even
   counting the section heading and the standard 44px section padding that a
   full-bleed band never had to pay, the whole thing is 518px against 1264px.
   Everything below it - "Popular right now" and both product rails - moves 746
   pixels up the page.

   (Against the development fixture two of those four campaigns find nothing in
   stock, so the old stack measures 1070px rather than 1264px there and the
   saving reads as 552px. The 1264px figure is the one that describes the live
   tenant, where all four are photographed, and it is the honest comparison.)

   NOTHING BELOW TOUCHES THE DATA. promos.py still supplies the record, the
   Europe/London schedule, the theme NAME and the photographs found behind the
   target. This block and partials/promo_band.html are the whole change.

   ONE ROW, ANY NUMBER OF CAMPAIGNS
   The row is .scrollrow - the house contract for a horizontal row that keeps
   its overflow to itself - with a card basis of 320px and flex-grow left on.
   That one rule covers every count without a special case, which is exactly
   what a wrapping grid could not do:

     1 campaign    the card grows to the full wrap and reads as a feature
     2             two halves
     3             three across, the row exactly full
     4 and up      no room left to grow, so the cards hold 320px and the row
                   scrolls. That partial fourth card IS the affordance, so
                   nothing has to announce the scroll.

   Measured at 1280px: 1265x300, 626x300, 412x300, then 320x300 with 3.79
   cards in view from four campaigns on. Nothing in the template knows which
   of those it is rendering.

   A three-column wrapping grid would have laid four campaigns out as 3+1, and
   a last row of one reads as a set that ran out rather than as a set. The rail
   has no such shape to get wrong, at any count.

   NO CAMPAIGNS, NO RAIL. home.html renders the section only when the list is
   non-empty, so an out-of-season homepage carries no empty heading.

   THE SCRIM IS PINNED, WHICH IS THE WHOLE REASON THE CONTRAST IS MEASURABLE
   Text over a photograph is only ever as readable as the photograph allows,
   and this catalogue is packshots on white as often as it is lifestyle shots.
   So the copy does NOT sit on a gradient over a picture: it sits on a plate of
   the campaign's own theme colour at full opacity, and the gradient is that
   plate's own ::before, ramping out of it into the picture above. The pair a
   shopper reads is fixed by the theme and never by whatever the catalogue
   returned this morning. Anchoring the ramp to the plate rather than to the
   foot of the card is the same fix the character tiles carry: a ramp at a
   fixed offset shows a hard seam the moment a name wraps to two lines.

   Every pair this block produces, measured, and every one of them now a named
   row in contrast-check.js under CAMPAIGN RAIL:

     navy    #fff name on --tt-navy      15.06:1   --pale line     9.55:1
     amber   navy name on --tt-orange     8.10:1   navy line       8.10:1
     green   #fff name on --tt-green      5.33:1   #fff line       5.33:1
     peach   orange-ink on --peach-2      4.68:1   faint-ink line  5.60:1

   The call to action inherits the name's colour, so it is the same pair again.
   The go chip carries a glyph rather than text, so its fill is gated at 3:1 as
   a UI boundary: three of the four chips reuse a pair the ledger already held,
   and the peach card's navy chip on --peach-2 is genuinely new at 14.11:1, so
   it is added.

   THE WASH IS DECORATIVE AND CARRIES NOTHING
   34% of the theme colour over the photograph, dropping to 14% on hover so
   that pointing at a card pulls the real picture forward - the wash is how
   four campaigns tell themselves apart at a glance, not a filter anyone should
   have to look through once they have chosen one. No text sits on it at any
   point, so it carries no contrast obligation, which is the same argument the
   character tiles' tint makes. The peach theme washes with the brand amber
   rather than with --peach-2, because a near-white tint over a photograph is
   not a wash, it is a fog; the copy plate below stays --peach-2, so a peach
   campaign still reads peach exactly where it is read.

   A CAMPAIGN WITH NO PHOTOGRAPH IS A CARD, NOT A GAP
   .campcard-type drops the picture frame entirely rather than leaving an empty
   box behind a wash, gives the whole face to the theme colour and steps the
   name up a rung. Same treatment and same reasoning as the character tiles'
   type-only tile. If a photograph 404s in the browser, onerror removes the img
   and the theme colour behind it stands in - still a card, never a hole.

   TABULAR FIGURES ON THE NAME, kept from the bands and for the reason the
   bands had: campaign names are numeric offers - "3 for 2", "20% off", "2 for
   15" - and a row of cards with digits at different widths on each one reads
   as three different typefaces.

   MOTION IS THE TILE CONTRACT, NOT A NEW ONE. The lift, the press, the photo
   zoom, the wash lifting and the chip travelling are the same five the
   category and character tiles already carry, on the same tokens. Reduced
   motion drops the travel, the zoom and the press and keeps every static cue,
   as it does for those tiles.
   ============================================================================= */

/* ---- the row ---------------------------------------------------------------
   .scrollrow carries the containment (max-width, overflow-x, overscroll) and
   is not repeated here; only the card basis is this rail's business. 320px is
   chosen against the content width of the 1280 wrap: three cards plus two
   14px gaps fit with room to grow, and four want 1322px, which is over. So
   four is the count at which the row starts to scroll, and four is what the
   shipped seed runs today. */
/* The row is a <ul>, so it needs the list reset the other rows get; without
   it the browser's markers sit outside each card. */
/* A GRID, not the .scrollrow it started as. Four cards at a fixed 320px plus
   three 14px gaps is 1,322px inside a 1,280px wrap, so the fourth campaign
   was clipped by exactly the gap total and a scrollbar appeared under the
   row - the flex basis had shrink 0, so the cards could not give up the 42px
   between them. It read as broken rather than as "scroll for more", which is
   the same fault the category tiles had before they became .catgrid.
   auto-fit means the columns divide whatever width there is: four across
   here, fewer as the viewport narrows, and never a sideways scroll. */
.camprow{display:grid;grid-template-columns:repeat(auto-fit,minmax(232px,1fr));
         gap:14px;list-style:none;margin:0;padding:0;overflow:visible;
         max-width:100%}
.camprow > li::marker{content:""}
.camprow > li{min-width:0}

/* ---- the theme keys --------------------------------------------------------
   Four names, every value an existing token. A promo record says "green"; it
   never says #1E7A4C, which is the whole reason a non-technical person can run
   this surface.

   These classes are the ONLY place the --camp-* properties are set. A default
   on .campcard itself would carry the same specificity as these and would win
   on source order alone - which is precisely what happened the first time the
   character tiles were written, and every tile rendered navy with nothing on
   the page to say the theme had been dropped. The fallback lives at each point
   of use instead, where it cannot race anything. */
.campcard-navy{--camp-scrim:var(--tt-navy);--camp-ink:#fff;
               --camp-sub:var(--pale);--camp-wash:var(--tt-navy);
               --camp-chip:var(--tt-orange);--camp-chip-ink:var(--tt-navy)}
.campcard-amber{--camp-scrim:var(--tt-orange);--camp-ink:var(--tt-navy);
                --camp-sub:var(--tt-navy);--camp-wash:var(--tt-orange);
                --camp-chip:var(--tt-navy);--camp-chip-ink:#fff}
.campcard-green{--camp-scrim:var(--tt-green);--camp-ink:#fff;
                --camp-sub:#fff;--camp-wash:var(--tt-green);
                --camp-chip:#fff;--camp-chip-ink:var(--tt-navy)}
.campcard-peach{--camp-scrim:var(--peach-2);--camp-ink:var(--tt-orange-ink);
                --camp-sub:var(--faint-ink);--camp-wash:var(--tt-orange);
                --camp-chip:var(--tt-navy);--camp-chip-ink:#fff}

/* ---- the card --------------------------------------------------------------
   height, not aspect-ratio. A ratio would make the one-campaign case absurd:
   a single card grows to the full 1216px wrap, and at 4/3 that is a 912px tall
   card. A fixed height holds the rail at one weight whether it is showing one
   campaign or six, which is the point of a rail. */
.campcard{position:relative;display:block;height:300px;overflow:hidden;
          border-radius:var(--r-card);
          background:var(--camp-scrim,var(--tt-navy));
          box-shadow:var(--shadow-card);
          transition-property:translate,scale,box-shadow;
          transition-duration:var(--t-base),var(--t-press),var(--t-base);
          transition-timing-function:var(--ease-out)}
.campcard:hover{translate:0 -3px;box-shadow:var(--shadow-pop)}
.campcard:active{scale:var(--press-scale)}
/* the page ring is navy and would vanish into a navy or green card, so it is
   offset out onto the white page behind it, as the tiles do */
.campcard:focus-visible{outline:var(--focus-ring);outline-offset:3px}

/* ---- the photograph and its wash ------------------------------------------- */
.campcard-art{position:absolute;inset:0;overflow:hidden;
              background:var(--camp-scrim,var(--tt-navy))}
.campcard-art img{width:100%;height:100%;object-fit:cover;display:block;
                  transition-property:scale;transition-duration:var(--t-slow);
                  transition-timing-function:var(--ease-out)}
.campcard:hover .campcard-art img{scale:1.06}
.campcard-art::before{content:"";position:absolute;inset:0;z-index:1;
                      pointer-events:none;
                      background:var(--camp-wash,var(--tt-navy));opacity:.17;
                      transition-property:opacity;
                      transition-duration:var(--t-base);
                      transition-timing-function:var(--ease-out)}
.campcard:hover .campcard-art::before{opacity:.06}

/* ---- the copy plate, and the ramp anchored to it --------------------------- */
.campcard-copy{position:absolute;left:0;right:0;bottom:0;z-index:3;
               display:flex;flex-direction:column;gap:5px;
               padding:14px 16px 15px;
               background:var(--camp-scrim,var(--tt-navy));
               color:var(--camp-ink,#fff)}
/* Ramp retired with the rest (client review, 9 Aug); the plate is
   themed per band but the keyline stays brand orange everywhere -
   one design language across every tile family. */
.campcard-copy{border-top:3px solid var(--tt-orange)}
.campcard-name{font-size:var(--fs-lg);font-weight:800;
               line-height:var(--lh-tight);letter-spacing:-.3px;
               font-variant-numeric:tabular-nums}
/* two lines, clamped. The seed's supporting lines run to ninety characters and
   a card is not a paragraph; clamping is what keeps the plate a predictable
   height across four cards of different copy lengths. */
.campcard-line{font-size:var(--fs-xs);line-height:var(--lh-snug);
               color:var(--camp-sub,var(--pale));
               display:-webkit-box;-webkit-box-orient:vertical;
               -webkit-line-clamp:2;overflow:hidden}

/* ---- the call to action ----------------------------------------------------
   The words a person typed into cta_label, not a repeated "Shop now": on the
   bands this was a real button, and the card cannot nest a control inside a
   link, so it becomes the card's own action line. It inherits --camp-ink, so
   it is the same measured pair as the name. */
.campcard-cta{display:flex;align-items:center;gap:9px;margin-top:3px;
              font-size:var(--fs-sm);font-weight:800;letter-spacing:.2px}
.campcard-go{flex:none;width:30px;height:30px;border-radius:50%;
             background:var(--camp-chip,var(--tt-orange));
             color:var(--camp-chip-ink,var(--tt-navy));
             display:flex;align-items:center;justify-content:center;
             transition-property:translate;transition-duration:var(--t-base);
             transition-timing-function:var(--ease-out)}
/* optical, not geometric: an arrow's mass sits behind its point */
.campcard-go svg{margin-left:1px}
.campcard:hover .campcard-go{translate:3px 0}

/* ---- the type-only card ----------------------------------------------------
   A campaign whose target found nothing in stock. The plate becomes the whole
   card face rather than a strip at the foot of an empty frame, and the ramp
   goes with the photograph it was ramping out of. The copy stays at the bottom
   rather than centring, so a rail mixing photographed and type-only cards
   still has one baseline running across it. */
.campcard-type .campcard-copy{top:0;justify-content:flex-end;
                              padding:18px 18px 17px}
.campcard-type .campcard-name{font-size:var(--fs-2xl)}
.campcard-type .campcard-line{-webkit-line-clamp:4}

/* ---- widths ---------------------------------------------------------------- */
@media (max-width:1024px){
  .camprow{grid-template-columns:repeat(auto-fit,minmax(216px,1fr))}
  .campcard{height:268px}
}
@media (max-width:768px){
  /* two across on a tablet. The old rule aimed for a third card half in
     view, which only reads as an invitation to scroll when the row scrolls;
     in a grid a clipped card is just a clipped card. */
  .camprow{grid-template-columns:repeat(2,minmax(0,1fr))}
  .campcard{height:250px}
  .campcard-copy{padding:12px 13px 13px}
  .campcard-name{font-size:var(--fs-md)}
  .campcard-type .campcard-name{font-size:var(--fs-xl)}
  .campcard-go{width:28px;height:28px}
}
@media (max-width:480px){
  /* one across on a phone: full-width cards, stacked, nothing cut off and
     nothing to swipe. */
  .camprow{grid-template-columns:minmax(0,1fr)}
  .campcard{height:236px}
}

/* Reduced motion keeps every static cue - the shadow, the wash lifting, the
   picture staying put - and drops the travel, the zoom and the press. */
@media (prefers-reduced-motion:reduce){
  .campcard,
  .campcard:hover,
  .campcard:active{translate:none;scale:none}
  .campcard:hover .campcard-art img{scale:none}
  .campcard:hover .campcard-go{translate:none}
}

/* =============================================================================
   MEGA MENU OFFERS - the campaign inside the panel (partials/header.html)
   -----------------------------------------------------------------------------
   The client's own suggestion, and the better surface for it. A campaign on the
   homepage is a poster a shopper meets before they have said what they want;
   the mega menu is where somebody browsing with intent already is. Hovering
   "Ride-Ons" IS the statement of intent, so the panel is where a campaign has
   the most to say - and the panel already ended with a card carrying a
   photograph, a line of copy and a button, which is a campaign's exact shape.

   THREE THINGS THIS BLOCK STYLES, and nothing else:

     1. .megacampaign  the promo card when a campaign is running. The same box
                       as the pillar card it replaces, in the campaign's theme.
     2. .offerpill     the badge beside "<Pillar> on sale" in the Offers
                       column, shown only for a campaign that genuinely points
                       at that pillar.
     3. .hl.live       the Sale nav item while anything is scheduled.

   NO NEW COLOUR PAIR. Every value below is a token, #fff aside, and every
   text-on-background pair the four themes produce is already in the ledger
   contrast-check.js gates - they are the same four names promos.py validates
   against, at the same weights or heavier:

     navy    white title 15.06:1  --pale subtitle 9.55:1
             button: the standard amber .btn, amber on navy 8.10:1, its navy
             label on amber 8.10:1
     amber   navy title 8.10:1    navy subtitle 8.10:1
             button: navy fill, white label 15.06:1
     green   white title 5.33:1   white subtitle 5.33:1
             button: white fill, navy label 15.06:1
     peach   orange-ink title 4.68:1   --faint-ink subtitle 5.60:1
             button: navy fill, white label 15.06:1
     pill    --tt-orange-ink on --peach, 4.58:1 - the pair the Sale pill in
             this same nav already uses
     dot     --tt-orange-ink on --peach, the pill's own ink, so the dot is the
             same amber as the word beside it

   NO TEXT OVER A PHOTOGRAPH anywhere here, so no card needs a scrim and no
   card's contrast depends on what the catalogue returned this morning: the
   copy sits on the flat themed panel and the picture sits above it. That is
   why nothing in this block needs a new row in contrast-check.js.

   NOTHING MOVES. There is no motion in this block at all - no pulse on the
   dot, no fade on the pill. The panel's own open/close transition and the
   .btn press both come from rules above and are untouched. A permanent
   animation in the site chrome runs on every page a shopper opens and is the
   first thing they learn to stop seeing, which is the opposite of what a live
   campaign wants. It is also why there is nothing here for the
   prefers-reduced-motion block to narrow.

   THE PANEL DOES NOT CHANGE HEIGHT between its two states: .mcart is the same
   132px frame .promoart is, so a campaign starting or ending never moves the
   panel's bottom edge under the pointer.
   ============================================================================= */

/* ---- 1. the campaign card -------------------------------------------------- */
/* The lede's grey surface and padding step aside for the themed card, which
   then owns the whole 236px column. :has is what lets the card restyle its own
   container without the template changing the container's classes - and the
   container's classes are what the rest of the panel's rules are written
   against. */
.meganav .megapromo:has(> .megacampaign){background:none;padding:0}

.meganav .megacampaign{--mc-bg:var(--tt-navy);--mc-ink:#fff;--mc-sub:var(--pale);
                       display:block;background:var(--mc-bg);color:var(--mc-ink);
                       border-radius:var(--r-card);padding:16px 18px 18px}
.meganav .megacampaign.amber{--mc-bg:var(--tt-orange);--mc-ink:var(--tt-navy);
                             --mc-sub:var(--tt-navy)}
.meganav .megacampaign.green{--mc-bg:var(--tt-green);--mc-ink:#fff;--mc-sub:#fff}
/* THE PILLAR CARD, alternating navy and amber down the menu. Same two token
   pairs the campaign card uses, so contrast-check.js already gates every
   text-on-background combination here and no new pair enters the ledger:
   navy carries white at 15.06:1, amber carries navy at 8.59:1.

   It is a SEPARATE class from .megacampaign rather than a reuse of it,
   because the two mean different things. A campaign card is a thing running
   right now and its colour was chosen for it; a pillar card is the menu's own
   furniture and its colour is positional. Sharing a class would make a future
   change to one silently change the other. */
.meganav .pillarcard{--mc-bg:var(--tt-navy);--mc-ink:#fff;--mc-sub:var(--pale);
                     display:block;background:var(--mc-bg);color:var(--mc-ink);
                     border-radius:var(--r-card);padding:16px 18px 18px}
.meganav .pillarcard.amber{--mc-bg:var(--tt-orange);--mc-ink:var(--tt-navy);
                           --mc-sub:var(--tt-navy)}
.meganav .pillarcard p{color:var(--mc-sub);margin:10px 0 14px}
.meganav .megapromo:has(> .pillarcard){background:none;padding:0}
/* THE BUTTON ON A PILLAR CARD IS DRAWN IN THE CARD'S OWN INK. Inside the menu
   the .btn recipe loses its amber fill (the menu's link rules win), and on a
   navy card that left a navy label on a navy ground - invisible until hover.
   The client's note, 2 Sep: "invisible buttons - until hovering over - on
   all blue boxes in the navigation ... make text white, and link box white".
   So: transparent, a 1.5px border in the card's ink, label in the card's ink
   - white on navy, navy on amber - and hover fills with the ink and flips the
   label to the card colour. --mc-ink and --mc-bg are the card's own tokens,
   so the rule is one rule for both colourways and any future one. */
.meganav .pillarcard .btn{background:transparent;color:var(--mc-ink);
                          border:1.5px solid var(--mc-ink);box-shadow:none}
.meganav .pillarcard .btn:hover{background:var(--mc-ink);color:var(--mc-bg);
                                border-color:var(--mc-ink)}
.meganav .pillarcard .btn:focus-visible{outline-color:var(--mc-ink)}

.meganav .megacampaign.peach{--mc-bg:var(--peach-2);--mc-ink:var(--tt-orange-ink);
                             --mc-sub:var(--faint-ink)}

/* The photograph: a white frame with the shot CONTAINED, matching .promoart.
   This catalogue is shot on white, so a white frame reads as the product's own
   background rather than as a card, and contain keeps a pedal tractor's wheels
   attached to it. A campaign whose target found nothing in stock renders the
   frame empty and the frame removes itself, so the card closes up round the
   copy exactly as the pillar card does. */
.meganav .megacampaign .mcart{display:block;height:132px;background:#fff;
                              border-radius:12px;overflow:hidden;
                              margin-bottom:12px}
.meganav .megacampaign .mcart img{width:100%;height:100%;object-fit:contain;
                                  padding:6px}
.meganav .megacampaign .mcart:empty{display:none}

/* Tabular figures on the title, the same exception the campaign headlines take
   elsewhere and for the same reason: campaign titles are numeric offers - "3
   for 2", "20% off" - and proportional digits make a set of them read as
   several typefaces. The subtitle is prose and keeps its own proportions. */
.meganav .megacampaign .mct{display:block;font-size:var(--fs-sm);
                            line-height:var(--lh-snug);font-weight:800;
                            font-variant-numeric:tabular-nums;
                            text-wrap:balance}
.meganav .megacampaign .mcs{display:block;margin-top:5px;font-size:var(--fs-xs);
                            line-height:var(--lh-body);color:var(--mc-sub);
                            text-wrap:pretty}
.meganav .megacampaign .btn{margin-top:13px}

/* The button changes family per theme rather than the theme bending to suit
   the button - a theme is chosen for the season, and the control has to stay
   legible on whichever one it lands on. navy keeps the standard amber .btn. */
.meganav .megacampaign.amber .btn,
.meganav .megacampaign.peach .btn{background:var(--tt-navy);color:#fff}
.meganav .megacampaign.amber .btn:hover,
.meganav .megacampaign.peach .btn:hover{background:var(--tt-navy-2)}
.meganav .megacampaign.green .btn{background:#fff;color:var(--tt-navy)}
.meganav .megacampaign.green .btn:hover{background:var(--surface)}
/* The default ring is navy, which on a navy or green card is a ring nobody can
   see. The same flip .hero, .util and .ftr already make. */
.meganav .megacampaign.navy :focus-visible,
.meganav .megacampaign.green :focus-visible{outline:var(--focus-ring-light)}

/* ---- 2. the Offers column's badge ------------------------------------------ */
/* inline-block on purpose, and not only for the padding: text-decoration
   propagates from the anchor to its inline descendants and a child cannot
   refuse it, so an inline pill would be underlined along with the link on
   hover. An inline-block starts its own decoration context and is not. */
.meganav .offerpill{display:inline-block;margin-left:7px;padding:1px 8px;
                    border-radius:var(--r-pill);background:var(--peach);
                    color:var(--tt-orange-ink);font-size:var(--fs-2xs);
                    font-weight:800;letter-spacing:.4px;text-transform:uppercase;
                    line-height:var(--lh-snug);vertical-align:middle}
.meganav .megacol ul a:hover .offerpill{color:var(--tt-orange-ink)}

/* ---- 3. Sale reads as live ------------------------------------------------- */
/* The dot itself is the .livedot span next to the pulse keyframes further up,
   which replaced the ::before that used to live here. Both shipped at once and
   the pill wore two dots. Nothing to declare here now; the note stays so the
   ::before is not reinvented. */

/* ---- sticky footer -------------------------------------------------------- */
/* A short page (a policy page, an empty search) used to leave the footer
   floating mid-screen with white space beneath it. The body becomes a column
   and <main> takes the slack, so the footer sits at the bottom of the
   viewport when the content is short and directly under the content when it
   is not. min-height:100svh rather than 100vh: on mobile browsers the vh unit
   includes the chrome that hides on scroll, which is what makes a "sticky"
   footer sit just below the fold on a phone. */
body{min-height:100svh;display:flex;flex-direction:column}
body > main{flex:1 0 auto}
body > .ftr{margin-top:auto}
/* Making the body a column flex container had a consequence that is easy to
   miss: .wrap carries margin:0 auto, and an auto margin on the CROSS axis of
   a flex container cancels the default stretch. So every page whose content
   sits in a top-level .wrap - the wishlist, listings, basket, PDP, search -
   stopped filling the width and shrank to fit its contents instead, which is
   why a wishlist holding one card drew its heading a card's width from the
   left while the header and footer above and below it stayed full width.

   width:100% restores a definite cross size, so max-width caps it and the
   auto margins go back to doing the one job they were there for: centring. */
body > .wrap{width:100%}

/* ============================================================================
   PHONE MENU
   ----------------------------------------------------------------------------
   Below 768px .nav is display:none and nothing replaced it, so a phone had no
   way to browse categories at all - two thirds of measured search clicks
   arriving at a shop they cannot navigate.

   It is a <details>, not a button and a script. Native disclosure carries the
   open state, keyboard operation and the screen-reader announcement on its
   own, and it survives JavaScript being off - which matters here because the
   mega menu it stands in for is CSS hover, so a scripted phone menu would
   have made the phone the only place navigation could fail.

   The marker goes because a burger with a triangle beside it reads as two
   controls. ::-webkit-details-marker is still needed for Safari.
   ========================================================================== */
.mobnav{display:none}
.mobnav > summary{list-style:none;cursor:pointer;min-width:var(--tap-min);
                  min-height:var(--tap-min);display:flex;align-items:center;
                  justify-content:center;color:var(--tt-navy);
                  border-radius:12px}
.mobnav > summary::-webkit-details-marker{display:none}
.mobnav > summary::marker{content:""}
/* The press settles rather than snapping, and scale is named on its own
   so the transition cannot drag anything else along with it - the same
   contract every other pressable control on this site follows. */
.mobnav > summary{transition-property:background-color,scale;
                  transition-duration:var(--t-fast),var(--t-press);
                  transition-timing-function:var(--ease-out)}
.mobnav > summary:hover{background:var(--surface)}
.mobnav > summary:active{scale:var(--press-scale)}

.mobnav-panel{position:fixed;inset:auto 0 0 0;top:auto;max-height:78svh;
              overflow-y:auto;overscroll-behavior:contain;background:#fff;
              border-radius:var(--r-sheet) var(--r-sheet) 0 0;
              box-shadow:var(--shadow-sheet);z-index:70;
              padding:10px 20px calc(24px + env(safe-area-inset-bottom,0px))}

.mobnav-list{list-style:none;margin:0;padding:0}
.mobnav-list > li{border-bottom:1px solid var(--line)}
.mobnav-list > li:last-child{border-bottom:0}

/* Every row, at either level, is a full-width 44px target. A menu is the one
   surface where a mis-tap costs the whole visit. */
.mobnav-list summary,
.mobnav-list a{display:flex;align-items:center;min-height:var(--tap-min);
               padding:10px 2px;font-weight:700;font-size:var(--fs-md);
               color:var(--tt-navy);border-bottom:0;cursor:pointer}
.mobnav-list summary{list-style:none;justify-content:space-between}
.mobnav-list summary::-webkit-details-marker{display:none}
.mobnav-list summary::marker{content:""}
/* A chevron that turns, so open and closed are distinguishable without
   relying on which rows happen to be on screen. */
.mobnav-list summary::after{content:"";width:9px;height:9px;flex:none;
                            border-right:2.5px solid var(--faint-ink);
                            border-bottom:2.5px solid var(--faint-ink);
                            rotate:45deg;margin-right:6px;
                            transition-property:rotate;
                            transition-duration:var(--t-fast);
                            transition-timing-function:var(--ease-out)}
.mobnav-list details[open] > summary::after{rotate:225deg}
.mobnav-list details ul{list-style:none;margin:0;padding:0 0 8px 14px}
/* Child rows are quieter than their pillar but not smaller: --tap-min is the
   house floor and a menu is the worst place to go under it. */
.mobnav-list details a{font-weight:600;font-size:var(--fs-sm);
                       color:var(--ink-2)}
.mobnav-list details a:hover{color:var(--tt-navy)}

.mobnav-more{margin-top:8px;border-top:1px solid var(--line)}
.mobnav-sale{color:var(--tt-orange-ink)}

@media (prefers-reduced-motion:reduce){
  .mobnav-list summary::after{transition-duration:0s}
}

@media (max-width:768px){
  .mobnav{display:block}
  /* First in the header row, so the menu sits where a thumb reaches it and
     the logo keeps its place beside it. */
  .mobnav{order:-1}
}

/* ============================================================================
   PHONE HEADER
   ----------------------------------------------------------------------------
   The header carried five things at once on a phone - a navy utility bar, the
   logo, three icon links, the search field and the menu - and read as clutter
   before a shopper had seen a single toy.

   The utility bar goes. Its two links, Trade enquiries and Help & contact, are
   both in the footer, and the delivery promise it carried is said again by the
   USP strip a few hundred pixels below. It was the only band on the page
   competing with the products for attention while adding nothing a phone
   visitor needed first.

   What is left is arranged rather than merely stacked: the logo centred, the
   three icons centred beneath it, the menu on the right where a thumb is. The
   grid is what makes the logo TRULY centred - it sits in the middle column of
   1fr auto 1fr, so the menu button on the right cannot pull it off centre the
   way a flex row would.
   ========================================================================== */
@media (max-width:768px){
  .util{display:none}

  .hdr .wrap{display:grid;align-items:center;
             grid-template-columns:1fr auto 1fr;
             grid-template-areas:
               ".      logo   menu"
               "icons  icons  icons"
               "search search search";
             row-gap:12px;column-gap:8px}
  .hdr .logo{grid-area:logo;justify-self:center}
  .hdr .mobnav{grid-area:menu;justify-self:end}
  .hdr .hicons{grid-area:icons;justify-content:center;gap:34px}
  .hdr .search{grid-area:search;flex-basis:auto}
}

/* ============================================================================
   PHONE LISTING CONTROLS
   ----------------------------------------------------------------------------
   Filter and sort were three full-width controls stacked down the page - a
   "Sort by" label, a select, a Go button - and then the Filter trigger beneath
   them, so a shopper scrolled past four rows of chrome to reach the first toy.

   One row instead: Filter on the left, sort on the right, equal halves. The
   heading and count keep the row above to themselves.

   Go is hidden only once the enhancement script has said so by setting
   data-autosubmit on the form. Hiding it in CSS and relying on a script that
   might not run would leave a select with no way to apply it.
   ========================================================================== */
@media (max-width:768px){
  .plphead{display:grid;grid-template-columns:1fr 1fr;
           grid-template-areas:"title title" "filter sort";
           gap:12px 10px;align-items:center}
  .plphead > div:first-child{grid-area:title}
  .plphead .fbar{grid-area:filter;padding:0}
  .plphead .fbar button{width:100%}
  .plphead .sortbox{grid-area:sort;width:auto;gap:0}
  /* The label is the select's accessible name and stays in the accessibility
     tree; on a 375px row its four words are what pushed the control to a line
     of its own. */
  .plphead .sortbox label{position:absolute;width:1px;height:1px;padding:0;
                          margin:-1px;overflow:hidden;clip-path:inset(50%);
                          white-space:nowrap;border:0}
  .plphead .sortbox form{display:flex;gap:8px;width:100%}
  .plphead .sortbox select{width:100%;min-height:var(--tap-min)}
  .plphead .sortbox form[data-autosubmit] .btn{display:none}
}

/* ============================================================================
   THE FILTER SHEET'S GEOMETRY
   ----------------------------------------------------------------------------
   Last word on width and height, and it has to be last. The sheet rule higher
   up sets width:100%, but a `.filters{width:232px}` in a max-width:1024px band
   AFTER it also matches on a phone and won on source order - so the sheet
   opened at 232px with the listing showing beside it, which read as a panel
   that had failed to open rather than a sheet.

   Height: 68svh rather than 84vh. Two changes. Shorter, because a sheet that
   nearly fills the screen hides the thing it is filtering and gives no sense
   of what is behind it. And svh rather than vh, for the same reason the footer
   uses it - vh counts the mobile browser chrome that hides on scroll, so a
   sheet sized in vh is taller than the space actually available and its last
   control sits under the toolbar.

   The confirm bar is fixed below the sheet, so the sheet's own bottom padding
   only has to clear it.
   ========================================================================== */
@media (max-width:768px){
  .filters{width:auto;left:0;right:0;max-height:68svh;
           padding-bottom:84px}
  /* A sheet is modal in intent, so the listing behind it stops taking the
     eye. No JavaScript: the sibling combinator already used to hide the
     confirm bar drives this too. */
  .filters[data-open] ~ .sheetbar{box-shadow:0 -1px 0 var(--line)}
}

/* =============================================================================
   CATEGORY EDITORIAL - the lead line and the below-grid guide
   -----------------------------------------------------------------------------
   The 12 category pages that exist earn 73 clicks between them, while 527
   non-brand commercial queries sit at positions 8 to 30 on 119,777
   impressions. A listing that renders a heading straight into a grid offers a
   search engine nothing to rank but product titles.

   Two placements, and the split is the whole point. The LEAD is one line
   above the grid, so it has to be short enough that it never becomes the
   reason the first row of products is below the fold. The GUIDE is the long
   copy and it sits after the products, where a shopper who wanted the range
   has already got it and a shopper who wanted advice has scrolled to find it.

   Line length is capped in ch, the unit the rest of the file already uses for
   running text (.binfo h3 at 52ch, the character blurb at 60ch). A category
   guide left at the full 1200px page width would be the widest running text
   on the site by a factor of two.
   ========================================================================== */
.plplead{max-width:68ch;color:var(--ink-2);font-size:var(--fs-md);
         line-height:var(--lh-body);margin:-4px 0 18px}

.plpguide{max-width:68ch;margin:36px 0 8px;padding-top:26px;
          border-top:1px solid var(--line)}
.plpguide h2{font-size:var(--fs-2xl);letter-spacing:-.4px;margin-bottom:10px}
.plpguide p{color:var(--ink-2);font-size:var(--fs-sm);
            line-height:var(--lh-body);margin-top:12px}

@media (max-width:768px){
  /* The lead earns its place on a phone least of all - it is the one screen
     where a line above the grid is a row of products pushed off it - so it
     goes down to the caption size the rest of the phone chrome uses. The
     guide is unchanged: it is already past the products. */
  .plplead{font-size:var(--fs-sm);margin-bottom:14px}
  .plpguide{margin-top:28px}
}

/* =============================================================================
   TAP TARGETS ON INLINE LINKS - WCAG 2.5.8
   -----------------------------------------------------------------------------
   Measured on a 375px phone against the rendered pages, not against the source.
   Almost everything already passes, and mostly because the target is not the
   element it looks like: a filter checkbox is 18x18 but its label is 351x32 and
   the label is what takes the tap; a product card's title is 163x39 and the art
   above it carries a 323x200 stretched link to the same product.

   Two did not pass, and both are inline links with no larger control going
   anywhere near the same place:

     .crumb a      34x16   breadcrumb, on every listing and product page
     .brandline a  46x15   the brand line on a card and on the product page

   Vertical padding on an INLINE element grows its hit area without touching the
   line box, so both reach 24px and nothing on the page moves. Measured before
   and after: identical layout, targets 16 -> 26 and 15 -> 25.

   padding-top and padding-bottom rather than the shorthand, on purpose. A
   padding shorthand on a companion class is what wiped the page gutters twice
   in this file, and these two selectors both sit on elements that carry
   horizontal padding from elsewhere.

   A card's title is the third case and it is the arguable one: at 266x19 it is
   under the threshold, but the picture above it carries a 323x200 stretched
   link to the same product, which is exactly the "Equivalent" exception the
   success criterion allows. It is padded anyway. A target that passes only by
   citing an exception is a conversation every future audit has to have again,
   and the padding costs nothing here because the link is inline.
   ========================================================================== */
.crumb a{padding-top:5px;padding-bottom:5px}
.brandline a{padding-top:5px;padding-bottom:5px}
.card h3 a{padding-top:3px;padding-bottom:3px}

/* The section headers' "See all ride-ons" links, 20px on the homepage.
   Unlike the three above, these are display:block, so the padding genuinely
   does grow the box: .sech goes 86px to 92px and the homepage gains 24px
   overall. That is the honest cost and it is worth paying at this size.
   Absorbing it with a negative margin would have kept the number identical
   and left a hit area quietly overlapping its neighbours, which is a
   cleverness the next person to read this would have to unpick. */
.sech a{padding-top:3px;padding-bottom:3px}

/* =============================================================================
   FOOTER COMPANY IDENTIFICATION
   -----------------------------------------------------------------------------
   Companies Act 2006 s.82 and the Ecommerce Regulations 2002 reg. 6 require the
   registered name, number, place of registration and registered office to be
   "easily, directly and permanently accessible". The footer of every page is
   what that means, and the site carried none of it.

   It sits INSIDE the about column, under the sentence that already says who
   this shop is - same subject, same column, same measure. It was briefly a
   full-width band below the link columns, and that was wrong twice over: it
   gave a legal footnote more of the page than the navigation above it, and it
   aligned to the viewport edge rather than to the column, so it read as
   something bolted on afterwards rather than part of the footer.

   Smaller and dimmer than the copy above it, because that is the honest
   hierarchy - it has to be present and permanently available, not prominent.
   --pale on --tt-navy at 9.56:1 is the pair the footer already uses and one
   contrast-check.js measures, so this inherits a checked ratio rather than
   introducing an unchecked one.
   ========================================================================== */
.ftrlegal{margin-top:12px;font-size:var(--fs-2xs);line-height:var(--lh-relaxed);
          opacity:.82}
.ftrlegal + .ftrlegal{margin-top:8px}

/* =============================================================================
   PRODUCT FAMILIES - the picker, and the card that stands for a family
   -----------------------------------------------------------------------------
   REQ-014. A family groups the same toy in several sizes, colours or piece
   counts WITHOUT converting anything to a variant: every child keeps its own
   URL, its own title and its own indexed position, and 5,611 measured search
   clicks ride on those URLs.

   That decides the markup, and therefore this CSS. The picker is a list of
   LINKS, not a select or a set of radio buttons, because each one navigates to
   a whole product rather than swapping a variant in place. So it is styled as
   what it is: a row of targets, 44px, with the current one marked and not a
   link at all.

   An out-of-stock sibling is dimmed rather than removed. Knowing the 16 inch
   exists and is out today is worth more to a shopper than a picker that
   silently offers two sizes and lets them assume there is no third.
   ========================================================================== */
.fampick{margin:18px 0 4px}
.fampick h2{font-size:var(--fs-xs);letter-spacing:.4px;text-transform:uppercase;
            color:var(--faint-ink);margin-bottom:9px}
.fampick ul{list-style:none;display:flex;flex-wrap:wrap;gap:8px}
.fampick a,.fampick .on{display:flex;align-items:center;justify-content:center;
        min-width:var(--tap-min);min-height:var(--tap-min);padding:0 14px;
        border-radius:var(--r-pill);font-size:var(--fs-sm);font-weight:700;
        border:2px solid var(--line-control);color:var(--tt-navy);background:#fff;
        transition-property:scale,border-color;transition-duration:var(--t-press);
        transition-timing-function:var(--ease-out)}
.fampick a:hover{border-color:var(--tt-navy)}
.fampick a:active{scale:var(--press-scale)}
.fampick .on{background:var(--tt-navy);border-color:var(--tt-navy);color:#fff}
/* Unavailable: dimmed AND dashed, not struck through. A line through a
   number reads as a crossed-out PRICE, which is what it means everywhere
   else on a shop - and these are ages and wheel sizes. Sean read a struck
   "2-3" as out of stock, which was right about the intent and wrong about
   the product, because the picker was marking in-stock siblings.

   Dashed rather than dimmed alone: colour on its own must not be the only
   carrier of meaning (WCAG 1.4.1), and the border is the non-colour signal.
   The screen-reader text says it in words either way.

   Still a link. The page it goes to is where a shopper finds out when it is
   back, and soon where they can ask to be told. */
.fampick a.oos{color:var(--faint-ink);border-style:dashed;
               border-color:var(--line-control)}

/* The listing card standing for a whole family says how many it stands for,
   or the row looks as though products went missing. */
.fambadge{font-size:var(--fs-2xs);font-weight:700;letter-spacing:.3px;
          color:var(--faint-ink);margin-bottom:6px}

/* PRE-LOVED, on a listing card and on a basket row. The badge on the art is
   the glance; this is the sentence, and it is deliberately NOT the muted grey
   the family badge above uses. A card carries an Add to basket button, so
   this line is part of what somebody buys from rather than a footnote to it:
   --ink-2 at 10.3:1, the same weight the bundle explanation carries for the
   same reason. */
.preline{font-size:var(--fs-xs);font-weight:600;color:var(--ink-2);
         line-height:var(--lh-snug);margin-bottom:10px}

/* =============================================================================
   BACK IN STOCK - the form that replaces a dead end
   -----------------------------------------------------------------------------
   388 product pages said "Back in stock soon" and offered nothing to do about
   it. 50 of those products have sold before, for £27,858 between them.

   Styled as a quiet block rather than a promotion: a shopper who wanted this
   toy has already had the bad news, and the form is help rather than an offer.
   It sits where the buy row would be, so the thing you do on this page is
   always in the same place.
   ========================================================================== */
.notifybox{margin:16px 0 4px;padding:16px;background:var(--surface);
           border-radius:var(--r-card)}
.notifybox label{display:block;font-size:var(--fs-sm);font-weight:700;
                 color:var(--tt-navy);margin-bottom:8px}
.notifyrow{display:flex;gap:8px;flex-wrap:wrap}
.notifyrow input{flex:1;min-width:180px;min-height:var(--tap-min);
                 border:2px solid var(--line-control);border-radius:var(--r-pill);
                 padding:0 16px;font-size:var(--fs-sm);background:#fff}
.notifyrow .btn{flex:0 0 auto}
.notifybox small{display:block;margin-top:9px;font-size:var(--fs-2xs);
                 color:var(--faint-ink);line-height:var(--lh-body)}
.notifydone{font-size:var(--fs-sm);font-weight:700;color:var(--tt-green);
            line-height:var(--lh-body)}
.notifydone.warn{color:var(--tt-navy)}

@media (max-width:400px){
  /* The button drops under the field rather than squeezing it to nothing. */
  .notifyrow .btn{width:100%}
}

/* =============================================================================
   COOKIE CONSENT
   -----------------------------------------------------------------------------
   Pinned to the bottom, full width, above everything. NOT a modal: it does not
   trap focus and it does not dim the page, because nothing non-essential has
   loaded yet - there is nothing to protect a shopper from while they decide,
   and a consent wall is its own dark pattern.

   THE TWO BUTTONS ARE THE SAME SIZE AND THE SAME WEIGHT. The ICO is explicit
   that refusing must be as easy as accepting, and the usual way sites fail
   that is a bright Accept beside a grey "Manage preferences" leading to a
   second screen. .btn and .btn.ghost differ in fill, not in prominence: same
   height, same padding, same type. Reject comes FIRST in source order, so a
   keyboard reaches it first.

   Navy on white rather than the orange fill: this is not an offer.
   ========================================================================== */
/* STICKY RATHER THAN FIXED, so the banner reserves the space it occupies.
   Fixed took the bar out of flow, and with nothing holding a place for it at
   the foot of the document it covered the last rows of the footer at maximum
   scroll - ten links at 375px, seven at 1280px, Refund & returns policy among
   them both times, on the one visit where the banner is on screen. Sticky
   pins it to the viewport bottom for as long as there is document below it,
   exactly as fixed did, and then lets it come to rest in its own space under
   the footer where it covers nothing. It is the last flow child of <body>
   (base.html), which is what makes bottom:0 behave that way. */
.consent{position:sticky;left:0;right:0;bottom:0;z-index:90;background:#fff;
         border-top:2px solid var(--tt-navy);box-shadow:0 -6px 24px rgba(19,34,87,.12)}
/* A SHOPPER WHO CANNOT DISMISS A CONSENT BANNER CANNOT USE THE SHOP, and
   inside a native shell there is no address bar and no reload button to
   escape with. This bar is fixed to the bottom of the viewport, which on a
   modern handset is behind the home indicator, so the Accept and Reject
   buttons are the exact controls the inset protects. Left and right too: on a
   landscape iPhone the notch eats one side. */
.consentin{max-width:1200px;margin:0 auto;
           padding:18px calc(32px + env(safe-area-inset-right,0px))
                   calc(18px + env(safe-area-inset-bottom,0px))
                   calc(32px + env(safe-area-inset-left,0px));
           display:flex;align-items:center;gap:28px;flex-wrap:wrap}
.consent h2{font-size:var(--fs-md);color:var(--tt-navy);margin-bottom:5px}
.consent p{font-size:var(--fs-xs);color:var(--ink-2);line-height:var(--lh-body);
           max-width:68ch}
.consentmore{margin-top:6px}
.consentmore a{color:var(--tt-navy);text-decoration:underline;
               padding-top:4px;padding-bottom:4px}
.consentacts{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
.consentacts .btn{white-space:nowrap}

/* IDENTICAL, and this rule is the reason. The first version used .btn for
   accept and .btn.ghost for reject, so the orange fill made accepting the
   obvious one - the exact nudge the ICO's equal-prominence rule exists to
   stop. Both are now outlined navy on white: legible, tappable, and neither
   easier to reach for than the other. */
.consentbtn{background:#fff;color:var(--tt-navy);
            border:2px solid var(--tt-navy);font-weight:800}
.consentbtn:hover{background:var(--surface)}

@media (max-width:768px){
  .consentin{padding:16px 18px;gap:14px}
  /* Full width and equal, so neither button is the easy one to hit. */
  .consentacts{width:100%;margin-left:0}
  .consentacts form{flex:1}
  .consentacts .btn{width:100%}
}

/* The footer link that makes the choice changeable, which the regulations
   require and which most banners quietly omit. A button because it posts. */
.consentreset{background:none;border:0;padding:4px 0;color:var(--pale);
              font-size:var(--fs-2xs);text-decoration:underline;cursor:pointer}
.consentreset:hover{color:#fff}

/* ============================= BLOG ==================================
   The blog index cards, the related rail, the article page, and the
   fragment lane's (.qorx-content) older furniture.

   THE ARTICLE PAGE WAS REBUILT ON 2 SEP 2026 against the client's review
   of the first template, which said: larger type and headings; everything
   left, not centred; CTAs not stacked in the centre; a hover on a CTA that
   "sharpens" the text when the pointer leaves; a yellow left-aligned lede
   in the hero; too much blank white space; 4x4 image grids that strand a
   fifth image; black bullets; FAQs too central; and "do not base the design
   off the older WordPress pages". Every rule below answers one of those,
   and the measurements that sized the answers are in content.py beside
   the helpers that make the decisions (which paragraph is the lede, which
   images form a gallery, which headings are questions).

   SECOND REVIEW, 2 SEP 2026, on the deployed template: "These are better
   but that block of text here is a bit chunky, unsure if I like the sliding
   products as well." Two changes answer it: the masthead lede is a
   sentence or two rather than the paragraph (see .arthero-lede), and runs
   of three or more pictures are a wrapping grid rather than a scroll-snap
   strip (see .artgallery).

   THREE THINGS THE OLD BLOCK GOT WRONG, fixed in passing:
     --card and --wash were used eight times and defined nowhere - not in
       tokens.css, not here, not in base.html - so they resolved to initial
       and the hero's "white card", the split plates and the card art
       backgrounds were all transparent. #fff and --surface are what they
       always meant.
     .artband a.artband-btn had background:transparent at rest and
       transitioned translate over --t-base. That is the "sharpening" the
       client saw: see the note on .artcta.
     .article-body centred its column with margin:auto and its captions
       with text-align:center. Nothing in this block uses margin:auto.

   DARK MODE, because the client asked: the site has no dark theme
   (tokens.css keeps the prefers-color-scheme block commented out, and
   :root{color-scheme:light} plus the meta tag stop the browser darkening
   controls behind the contrast gate). This block does not add one - a
   blog-only dark block would be a second, un-gated colour system. It is
   written dark-READY instead: every colour is a token except the #fff on
   the image plates and the chip fill, and the plates are white as a
   product decision (a packshot is photographed on white and stays on white
   in any theme), so a future dark block redefines --surface, --ink,
   --tt-navy and --line and leaves .artframe alone. */

/* ---- index cards, shared with the related rail --------------------------- */
.blogcards{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;
           list-style:none;margin:0;padding:0}
.blogcard{background:#fff;border:1px solid var(--line);
          border-radius:14px;overflow:hidden;display:flex;
          flex-direction:column}
.blogcard-art{aspect-ratio:16/9;background:var(--surface);overflow:hidden}
.blogcard-art img{width:100%;height:100%;object-fit:cover;display:block}
.blogcard-body{padding:16px 18px 18px;display:flex;
               flex-direction:column;gap:6px}
/* --fs-lg and --fs-md rather than md and sm: the article's h2 moved to
   --fs-4xl and its body to --fs-lg, and the index and the article should
   sit on the same rungs, one step apart, rather than the index reading as
   the small print of the page it links to. */
.blogcard-body h2,.blogcard-body h3{font-size:var(--fs-lg);letter-spacing:-.3px;
                  line-height:var(--lh-tight)}
.blogcard-body h2 a,.blogcard-body h3 a{color:var(--ink);text-decoration:none}
.blogcard-body h2 a:hover,.blogcard-body h3 a:hover{color:var(--tt-orange-ink);
                          text-decoration:underline}
.blogcard-date{font-size:var(--fs-xs);color:var(--faint-ink)}
.blogcard-excerpt{font-size:var(--fs-md);line-height:var(--lh-relaxed);
                  color:var(--ink-2)}

/* ---- the fragment lane: .qorx-content ------------------------------------ */
/* Builder-authored posts serve as fragments and never pass through
   blog_post.html; _normalise_fragment_images in content.py restructures
   their markup into the grid furniture below. These rules are the older
   design, kept as they were so a fragment renders as it did. FOLLOW-UP:
   once the JSON template above is approved, bring this lane onto the same
   .artframe / .artgallery recipes - the two lanes were meant to be
   indistinguishable, and for the moment they are not. */
.article{max-width:none}
.qorx-content h2{font-size:var(--fs-xl);letter-spacing:-.3px;
    border-bottom:3px solid var(--tt-orange);width:fit-content;
    padding-bottom:6px;
    line-height:var(--lh-tight);margin:28px auto 10px;color:var(--ink)}
.qorx-content h3{max-width:680px;font-size:var(--fs-md);
    letter-spacing:-.2px;margin:22px auto 8px;color:var(--ink)}
.qorx-content p{font-size:var(--fs-md);line-height:var(--lh-relaxed);
    color:var(--ink-2);margin:0 auto 15px;max-width:680px}
.qorx-content ul{margin:0 auto 16px;padding-left:22px;max-width:680px}
.qorx-content li{font-size:var(--fs-md);line-height:var(--lh-relaxed);
    color:var(--ink-2);padding:2px 0}
.artgrid{display:grid;grid-template-columns:repeat(2,1fr);
  gap:16px;margin:22px 0}
.artgrid figure{margin:0}
.artgrid.cols3{grid-template-columns:repeat(3,1fr)}
.artgrid figure{background:#fff;border:1px solid var(--line);
  border-radius:12px;padding:10px;box-shadow:var(--shadow-card)}
/* Scoped one level up ON PURPOSE: .artgrid img ties the shared
   .qorx-content img{height:auto} at (0,1,1) and LOSES on order - these
   caps silently never applied once. (0,2,1) settles it. */
.qorx-content .artgrid img{
  margin:0;width:100%;height:280px;object-fit:contain;background:#fff}
.qorx-content .artgrid.cols3 img{height:230px}
.qorx-content figure{margin:22px auto;max-width:640px}
.qorx-content figure img{margin:0}
.qorx-content figcaption{font-size:var(--fs-xs);color:var(--faint-ink);
  text-align:center;padding-top:8px;max-width:60ch;margin:0 auto}
.qorx-content > img{max-width:640px;margin-left:auto;margin-right:auto}
.qorx-content img{max-width:100%;height:auto;border-radius:12px;
  display:block;margin:20px 0}
.qorx-content a{color:var(--tt-orange-ink)}
.qorx-content svg{max-width:100%}
@media (max-width:600px){
  .artgrid,.artgrid.cols3{grid-template-columns:repeat(2,1fr)}
  .qorx-content .artgrid img,.qorx-content .artgrid.cols3 img{height:150px}
}

/* ---- the article masthead ------------------------------------------------ */
/* The homepage hero's language at article scale: same gradient, same
   eyebrow, display type in white, meta in --pale, tag chips in the
   eyebrow's yellow. It MUST stay navy: the lede is --tt-yellow, which is
   9.83:1 on --tt-navy and 6.47:1 on --tt-navy-3 (the gradient's end, under
   the lede's right edge) and 1.6:1 on white. Yellow rather than
   --tt-orange because the client said yellow, because --tt-yellow is
   already the text-on-navy accent the eyebrow and the chips use (a fourth
   warm colour in one masthead is one too many), and because it measures
   better than orange's 8.10 / 5.33 on the same two surfaces.

   The bottom padding is 96px because the feature image below sits 64px up
   into it (.artfeat) - that overlap is what removes the white band the old
   layout left between the header and the copy. One text column at 76ch,
   left-aligned; the h1 takes --fs-7xl, the rung tokens.css reserved for
   exactly this: a display size between the PDP price and the homepage
   hero. */
.arthero{background:linear-gradient(105deg,var(--tt-navy) 0%,
  var(--tt-navy-2) 62%,var(--tt-navy-3) 100%);color:#fff}
.arthero .wrap{padding-top:44px;padding-bottom:96px}
.arthero-txt{max-width:76ch}
.arthero h1{font-size:var(--fs-7xl);line-height:var(--lh-flat);
  letter-spacing:-.8px;margin:2px 0 14px;text-wrap:balance}
.arthero-meta{color:var(--pale);font-size:var(--fs-sm);
  margin-bottom:14px}
.arthero-tags{display:flex;gap:8px;flex-wrap:wrap;list-style:none;
  margin:0;padding:0}
/* The one hard-coded tint in the block: yellow-derived, on navy, and
   already what the eyebrow chips were. */
.arthero-tags a{display:inline-flex;align-items:center;
  min-height:var(--tap-min);background:rgba(255,201,74,.17);
  color:var(--tt-yellow);font-size:var(--fs-xs);font-weight:800;
  letter-spacing:.6px;padding:4px 13px;border-radius:20px;
  text-decoration:none}
.arthero-tags a:hover{background:rgba(255,201,74,.28)}
/* A STANDFIRST, NOT A PARAGRAPH. The first draft set the post's whole
   opening paragraph here - 268 to 623 chars, seven lines at 62ch - at
   --fs-2xl and 600 weight, and the client's second review called it "a bit
   chunky". content.py now hands over the first sentence or two (56-245
   chars, median 135, cut only on a sentence boundary), and the type comes
   down to match: --fs-xl at 400 weight. --fs-xl rather than --fs-lg
   because --fs-lg is the body rung, and a standfirst on the body rung
   reads as a body paragraph that wandered into the hero; 20px is one step
   above the body, three below the h1, and is the size the <=1099px band
   already used, so the two widths become one rule. At 62ch a median lede
   is two to three lines rather than seven. The weight is 400 because the
   yellow carries the emphasis on navy on its own (9.83:1; see the ledger
   rows "Blog lede on navy masthead" / "on masthead gradient end") and the
   bold was half of the heaviness. Still yellow: the client asked for
   yellow, and the complaint was bulk, not colour. */
.arthero-lede{font-size:var(--fs-xl);line-height:var(--lh-relaxed);
  font-weight:400;color:var(--tt-yellow);max-width:62ch;margin:18px 0 0}
/* The navy focus ring vanishes on navy; the same swap the hero, the
   footer and the lightbox make. */
.arthero :focus-visible{outline:var(--focus-ring-light)}

/* ---- the article grid ---------------------------------------------------- */
/* One left-aligned reading column with a sticky aside on wide screens.
   "Shift everything left" and "too much blank white space" pull against
   each other: a left column alone in a 1216px wrap leaves ~560px empty on
   the right. So at >=1100px the page is a 860px main column and a 300px
   aside that follows the reader with the section list and one CTA. Prose
   runs at 68ch (~640px at 18px in the system stack) inside the main
   column; media runs the full 860px, so the right of the column is filled
   every few paragraphs by a picture. Both share one left edge. */
.artpost{display:block}
.article-main{min-width:0}
.article-main > p,.article-main h2,.article-main h3,.artlist,.artctas,
.arttoc,.article-main .faqs{max-width:68ch}

/* ---- type ---------------------------------------------------------------- */
/* Every rung moved up one or two steps from the first template (body 16,
   h2 20, h3 16, h1 37): body is --fs-lg in --ink rather than --ink-2,
   because larger AND darker is what reads as "bigger"; the h2 at 29/23 is
   the pair the homepage .sech h2 uses, so article sections and shop
   sections agree. The rhythm is four values the sheet already uses:
   8 / 16 / 28 / 44. */
.article-main p{font-size:var(--fs-lg);line-height:var(--lh-relaxed);
  color:var(--ink);margin:0 0 20px}
.article-main h2{font-size:var(--fs-4xl);letter-spacing:-.6px;
  line-height:var(--lh-tight);color:var(--tt-navy);
  border-bottom:3px solid var(--tt-orange);width:fit-content;
  padding-bottom:6px;margin:44px 0 12px}
.article-main h3{font-size:var(--fs-xl);letter-spacing:-.3px;
  line-height:var(--lh-tight);color:var(--tt-navy);margin:28px 0 8px}

/* An expired-offer line or a sale-end date, rendered where it sits in the
   copy but as a warning plate: eight posts open with one, and the first
   template made it the lead. --warn-ink on --peach-2 is 5.49:1. */
.article-main .artnotice{background:var(--peach-2);color:var(--warn-ink);
  font-size:var(--fs-sm);line-height:var(--lh-body);
  border-left:4px solid var(--tt-orange);border-radius:0 10px 10px 0;
  padding:12px 16px;margin:0 0 20px}

/* ---- lists --------------------------------------------------------------- */
/* An amber disc inside a navy ring, drawn rather than a ::marker. Amber
   alone is 1.86:1 on white and a plain ::marker{color:--tt-orange} would
   vanish; the navy boundary is 15.06:1 and is the same keyline as the
   image frames, so bullets and frames read as one family. The disc's own
   contrast is not in the ledger on purpose: its boundary is the ring.
   "Label: value" items (199 of 277) get the label in navy strong, marked
   up by content.py - the text itself is not changed. */
.artlist{list-style:none;padding-left:0;margin:0 0 24px}
.artlist li{position:relative;padding-left:28px;margin:0 0 10px;
  font-size:var(--fs-lg);line-height:var(--lh-relaxed);color:var(--ink)}
.artlist li::before{content:"";position:absolute;left:2px;
  top:calc(.8em - 6px);width:12px;height:12px;border-radius:50%;
  background:var(--tt-orange);border:2px solid var(--tt-navy)}
.artlist strong{color:var(--tt-navy);font-weight:700}

/* ---- images: the frame --------------------------------------------------- */
/* Used on every picture on the page, feature included. The client's "blue
   frame", and two reasons beyond the ask: 301 of the 436 pictures are
   square packshots on white, and a navy keyline is what stops a white
   packshot dissolving into the white page; and for the 293 product-linked
   pictures the frame is the hover affordance - the border goes amber-ink.
   No scale, no transform on hover, for the reason on .artcta below.

   THE PLATE IS WHITE, AND STAYS WHITE IN ANY THEME: a packshot is
   photographed on white, and a dark block that tinted this plate would
   put a white-cornered photograph on a grey card. Leave the #fff. */
.artframe{background:#fff;border:2px solid var(--tt-navy);
  border-radius:12px;padding:8px;margin:0;
  transition-property:border-color;transition-duration:var(--t-fast);
  transition-timing-function:var(--ease-out)}
.artframe.artlinked:hover,.artframe.artlinked:focus-within{
  border-color:var(--tt-orange-ink)}
.artlink{display:block;border-radius:6px}
/* Concentric with the frame rather than the 4px the global ring draws. */
.artframe a:focus-visible{border-radius:12px}
.article-main img{max-width:100%;height:auto;display:block}
.artframe img{width:100%;object-fit:contain;display:block;border-radius:6px}
/* Only for product-linked pictures, and the text is the product name cut
   from the alt by content.py - never a bare alt, because 110 of the alts
   are the section heading and a caption from them repeats the heading
   under the picture. Left, like everything else here. */
.artframe figcaption{font-size:var(--fs-sm);color:var(--tt-orange-ink);
  font-weight:700;text-align:left;padding:8px 2px 0;
  line-height:var(--lh-snug)}

/* ---- images: the three run shapes ---------------------------------------- */
/* Runs of consecutive images across the 30 posts: 1x82, 2x37, 3x19,
   4x29, 5x14, 6x5, 7x1. One is a full-column landscape figure - contain in
   a 16/10 frame is what makes a square packshot "more landscape" without
   cropping the product, and the 107 genuinely landscape files fill it.
   Two are a pair of 4/3 frames, which never strand anything. Three or
   more are a wrapping grid of framed cards (.artgallery below).

   The aspect ratio is set on the img, scoped one level up (0,2,1) so it
   outranks .article-main img{height:auto} at (0,1,1) - the same cascade
   loss that once silently dropped every grid height cap. With width:100%
   and height:auto the ratio sets the box; object-fit:contain letterboxes
   the file inside it and max-height clamps the single figure so a wide
   column does not make a 525px-tall beat. */
.artfeat{margin:-64px 0 36px;position:relative}
.article-main .artfeat img{aspect-ratio:16/9}
.artfig{margin:24px 0 32px}
.article-main .artfig img{aspect-ratio:16/10;max-height:480px}
.artpair{display:grid;grid-template-columns:1fr 1fr;gap:14px;
  margin:24px 0 32px}
.article-main .artpair img{aspect-ratio:4/3}
/* THE GALLERY. The first rebuild made a run of three or more a horizontal
   scroll-snap strip, to avoid the stray last cell the client saw under the
   old centred 2x2; the second review said "unsure if I like the sliding
   products", so it is a grid again, and this time the last row is simply
   LEFT-ALIGNED. No centring, no spanning, no stranded-cell tricks: a
   short last row in a left-aligned editorial column is a ragged right
   edge, the same thing every paragraph in the column has, and the
   orphan the client objected to was a centred cell under a centred pair.
   The cells are 1fr with minmax(0,...) so a wide packshot cannot push the
   track past the column. Three across at >=1100px (the main column is
   ~700-860px there, 205-260px pictures), two across in between (a full
   wrap of up to ~1150px, so 500px pictures), and one across at <=600px:
   the pair collapses there too, and two-up at a 390px viewport would be
   136px pictures - under the floor the shop grid itself sets when it goes
   single-column at 400px. The one-across frame takes the pair's 16/10, for
   the same reason the pair does. Column math, not a browser measurement:
   the frames here are the same .artframe the pair uses at these widths. */
.artgallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
  gap:14px;list-style:none;margin:24px 0 32px;padding:0}
.artgallery li{margin:0;padding:0;min-width:0}
.article-main .artgallery img{aspect-ratio:4/3}

/* ---- CTAs ---------------------------------------------------------------- */
/* Never a centred band, never a stack. One CTA is a left-aligned pill on
   the .btn recipe: amber fill, navy label at 8.10:1, the site's own
   button. Two or more are chips on the .linkchip recipe, because four
   colour variants are four choices and chips are what choices look like.

   THE "SHARPENING" THE CLIENT SAW, so it does not creep back. The old
   band button was background:transparent at rest and transitioned
   translate over --t-base. A transform transition promotes the link to
   its own compositor layer; because that layer was not opaque, Chromium
   and Edge on Windows dropped subpixel (LCD) antialiasing and rasterised
   the label greyscale for the duration of the transition. When the
   pointer left and the transition ended, the layer was torn down and the
   text re-rasterised with subpixel AA - which reads as the text snapping
   "sharper". Two rules stop it: the fill is OPAQUE at rest, and
   transition-property names only background-color, border-color, color
   and scale (the press, per the sheet's contract). No translate, no
   transform, no filter, no text-shadow, no will-change on any element in
   this block that carries text - and none on the image frames either,
   which is why .artlink:hover img{scale:1.03} is gone and the frame's
   border colour is the hover. The site's .btn:hover{translate:0 -1px}
   uses the same mechanism on an opaque amber fill, which is why nobody
   sees it there; .artcta is written out rather than reusing .btn so it
   cannot inherit that lift. Anyone adding a hover lift here should know
   what it costs. A test pins it: no rule on .artcta, .artctas, .artframe
   or .artlink may name translate, transform or filter. */
.artcta{display:inline-flex;align-items:center;justify-content:center;
  gap:9px;background:var(--tt-orange);color:var(--tt-navy);
  font-weight:800;font-size:var(--fs-md);padding:14px 26px;border:0;
  border-radius:var(--r-pill);min-height:var(--tap-min);max-width:100%;
  margin:8px 0 32px;text-decoration:none;text-align:left;
  transition-property:background-color,border-color,color,scale;
  transition-duration:var(--t-fast),var(--t-fast),var(--t-fast),var(--t-press);
  transition-timing-function:var(--ease-out)}
.artcta:hover{background:var(--tt-orange-hover)}
.artcta:active{scale:var(--press-scale)}
.artcta:focus-visible{border-radius:var(--r-pill)}
.artcta svg{flex:none}
.artctas{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 32px}
.artctas .linkchip{text-decoration:none;text-align:left}

/* ---- "In this post" and the aside ---------------------------------------- */
/* Below 1100px the section list is a closed disclosure above the body, on
   the .readmore summary chip recipe; at 1100px and up it lives in the
   sticky aside and the disclosure is hidden. The two are never both
   visible. Links are stacked, so the box is 32px and a transparent ::after
   carries each to the 44px target the way the breadcrumb's links do -
   and the row pitch is exactly 44px (32 + 12) so two bands meet without
   overlapping, which is the condition the BUTTON SYSTEM sets for using
   the band on a stacked list at all. */
.arttoc{margin:0 0 28px}
.arttoc summary{display:inline-flex;align-items:center;background:#fff;
  border:1.5px solid var(--line-control);border-radius:var(--r-chip);
  padding:9px 16px;font-size:var(--fs-sm);font-weight:700;
  color:var(--tt-orange-ink);min-height:var(--tap-min);cursor:pointer;
  list-style:none;
  transition-property:background-color,border-color,color,scale;
  transition-duration:var(--t-fast),var(--t-fast),var(--t-fast),var(--t-press);
  transition-timing-function:var(--ease-out)}
.arttoc summary::-webkit-details-marker{display:none}
.arttoc summary:hover{background:var(--peach);border-color:var(--tt-orange-ink)}
.arttoc summary:active{scale:var(--press-scale)}
.arttoc summary:focus-visible{border-radius:var(--r-chip)}
.arttoc ol{list-style:decimal;margin:14px 0 0;padding:0 0 0 24px}
.arttoc li{margin:0 0 12px}
.arttoc a,.artaside nav a{position:relative;display:flex;align-items:center;
  min-height:32px;color:var(--tt-navy);font-size:var(--fs-sm);
  font-weight:600;line-height:var(--lh-snug);text-decoration:none}
.arttoc a:hover,.artaside nav a:hover{text-decoration:underline}
.arttoc a::after,.artaside nav a::after{content:"";position:absolute;
  top:min(0px,calc((100% - var(--tap-min)) / 2));
  right:min(0px,calc((100% - var(--tap-min)) / 2));
  bottom:min(0px,calc((100% - var(--tap-min)) / 2));
  left:min(0px,calc((100% - var(--tap-min)) / 2))}

/* The aside: --surface plate, sticky at the basket summary's own 94px
   (78px header + 16px) so the two agree. The eyebrow is the .pbrands h2
   recipe - --fs-xs uppercase in --faint-ink, 5.68:1 on --surface. The
   shop card carries ONE CTA, chosen in content.py: the first that opens a
   range rather than a single product, so the card that follows the reader
   opens the post's subject and not one colour of one scooter. */
.artaside{display:none;position:sticky;top:94px;background:var(--surface);
  border-radius:var(--r-card);padding:20px 22px}
.artaside-h{font-size:var(--fs-xs);letter-spacing:1.1px;
  text-transform:uppercase;color:var(--faint-ink);font-weight:800;
  line-height:var(--lh-snug);margin:0 0 12px}
.artaside ol{list-style:none;margin:0;padding:0}
.artaside li{margin:0 0 12px}
.artaside li:last-child{margin-bottom:0}
.artshop{margin-top:22px;padding-top:20px;border-top:1px solid var(--line)}
.artaside .artcta{width:100%;margin:0}

/* ---- FAQ ----------------------------------------------------------------- */
/* The product page's own .faq markup and rules, sized up for the article
   column and scoped so the sizes above do not reach into it. Left-aligned
   by construction; the accordion is native <details>. */
.article-main .faqs{margin:44px 0 0}
.article-main .faqs h2{margin-top:0;margin-bottom:8px}
.article-main .faq summary{font-size:var(--fs-lg);color:var(--tt-navy);
  font-weight:600;line-height:var(--lh-snug);padding-right:36px}
.article-main .faq p{font-size:var(--fs-md);color:var(--ink-2);
  line-height:var(--lh-relaxed);margin:0;padding:0 36px 16px 0}

/* ---- breakpoints --------------------------------------------------------- */
@media (min-width:1100px){
  .artpost{display:grid;grid-template-columns:minmax(0,1fr) 300px;
    column-gap:56px;align-items:start}
  .artaside{display:block}
  .arttoc{display:none}
  .artgallery{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:1024px){
  .blogcards{grid-template-columns:repeat(2,1fr)}
}
@media (max-width:768px){
  .blogcards{grid-template-columns:1fr;gap:14px}
  .arthero .wrap{padding-top:28px;padding-bottom:84px}
  .arthero h1{font-size:var(--fs-5xl)}
  .article-main h2{font-size:var(--fs-2xl);margin-top:36px}
  .article-main h3{font-size:var(--fs-lg)}
}
@media (max-width:600px){
  .article-main .artfeat img{aspect-ratio:4/3}
  .article-main .artfig img{aspect-ratio:4/3}
  .artpair{grid-template-columns:1fr}
  .article-main .artpair img{aspect-ratio:16/10}
  .artgallery{grid-template-columns:1fr}
  .article-main .artgallery img{aspect-ratio:16/10}
}
@media (max-width:400px){
  .arthero h1{font-size:var(--fs-4xl)}
}

/* =============================================================================
   BUNDLES - what is in the box
   -----------------------------------------------------------------------------
   REQ-028. 42 products are bundles and every one of them came over from
   WooCommerce with an empty description and an empty short_description, so on
   a bundle page this list is not a supporting detail: it is the only thing
   that says what is being bought. 13 of them are in pool-toys, which the
   storefront navigates today.

   A ROW PER COMPONENT, WITH ITS PHOTOGRAPH, because that is what the old site
   showed and what a bundle is. A packshot of a box tells a shopper nothing
   about whether the armbands are in it.

   Contained rather than cropped, on white: these are cut-out packshots at
   mixed aspect ratios, and cover would slice the top off a pool float.

   An unavailable component is dimmed AND dashed, never dimmed alone - colour
   must not be the only carrier of meaning (WCAG 1.4.1) - and the row says it
   in words underneath as well. Same treatment as the family picker's
   out-of-stock sibling, for the same reason and so the two read as one idea.
   ========================================================================== */
.bundlebox h2{font-size:var(--fs-lg);letter-spacing:-.3px;color:var(--tt-navy);
              margin-bottom:4px}
.bundlelist{list-style:none}
.bundlepart{display:grid;grid-template-columns:72px 1fr;gap:13px;
            align-items:center;padding:12px 0;border-top:1px solid var(--line)}
.bundleart{width:72px;height:72px;object-fit:contain;background:#fff;
           border:1px solid var(--line);border-radius:12px;display:block}
.bundlemeta h3{font-size:var(--fs-sm);line-height:var(--lh-snug);
               color:var(--tt-navy)}
.bundlemeta h3 a{color:inherit;text-decoration:underline}
.bundleqty{font-size:var(--fs-2xs);color:var(--faint-ink);margin-top:3px}
/* The amber of .stock.low, because it is the same fact about the same box. */
.bundleoos{font-size:var(--fs-2xs);font-weight:700;color:var(--tt-amber);
           margin-top:3px}
.bundlepart.out .bundleart{opacity:.55;border-style:dashed;
                           border-color:var(--line-control)}
.bundlepart.out .bundlemeta h3{color:var(--faint-ink)}
/* The sentence that stops "Back in stock soon" reading as a mistake on a box
   whose own SKU is sitting on the shelf. */
.bundlewhy{font-size:var(--fs-xs);color:var(--ink-2);
           line-height:var(--lh-body)}

@media (max-width:400px){
  .bundlepart{grid-template-columns:56px 1fr;gap:11px}
  .bundleart{width:56px;height:56px}
}


/* =============================================================================
   RELATED RAIL - the row of neighbours under the product
   -----------------------------------------------------------------------------
   THE PRODUCT PAGE ALREADY CARRIES ONE HORIZONTAL STRIP. The family picker
   (.fampick) sits inside the info column as a row of pill links, and it means
   "other sizes of this exact product". This row means "other products", and
   the two must not read as one confused strip.

   Three things separate them and none of them is colour. The picker is inside
   the column and this is full width; the picker is pills and this is cards;
   and the rule below is the page saying the product has ended. .sec already
   carries the vertical space, so the border is the only new thing here.

   The heading is the rule in words - "More in Arts & Crafts", "More from
   Bruder" - so it needs no strapline underneath it. The mock's cross-sell
   has one ("chosen by us, not an algorithm") because that row IS curated;
   this one is computed, and a strapline is where a claim like that would
   creep in.
   ========================================================================== */
.relrail{border-top:1px solid var(--line)}

/* =============================================================================
   TAP TARGETS AT 44px - the hit area, not the box
   -----------------------------------------------------------------------------
   The section further up took the small inline links to the 24px of WCAG
   2.5.8. This one takes them to 44px, which is the number a design critique
   measured against at 375px and the size a thumb actually covers.

   THE HIT AREA IS NOT THE VISIBLE BOX. A transparent ::after belongs to the
   link for hit testing, so it grows what a tap lands on while the text, the
   line box and everything around it stay where they are. That is the whole
   reason to do it this way here: this shop is dense on purpose, and 44px of
   VISIBLE chip everywhere would cost more page than it buys thumb.

   The band is written as insets rather than a width and a height so one
   declaration serves every link it is applied to. min(0px, ...) is what makes
   that safe: the inset is negative on a link under 44px and grows it, and 0
   on a link already over, where the element's own box is the target and a
   centred 44px band would have made it smaller.

   WHERE THIS IS NOT APPLIED, and why:

     .brandline a on a card   boxed in. 9px under a 323x200 picture link and
                              5px over the title link, and 25px of type
                              between two targets cannot become 44px without
                              taking the difference out of one of them or
                              adding about 18px to every card in the shop.
                              It keeps the 24px of 2.5.8 and gets the clear
                              space instead. The same link on a product page
                              has an h1 under it rather than a target, so
                              there it does get the band.
     a link inside a sentence "Full terms" in the promotion paragraph is
                              running text, and 2.5.5 exempts a target whose
                              size is set by the line it sits in. Padding
                              prose to 44px moves the sentence, not the odds
                              of hitting the link.
     a filter checkbox        18x18, inside a label that is 351x44 and takes
                              the tap already.
   ========================================================================== */
.crumb a,.sech a,.card h3 a,.pinfo .brandline a,.stock.check a,.consentmore a,
.bempty a,.contlink a,.blogcard-body h2 a,.blogcard-body h3 a,
.drawlink{position:relative}

.crumb a::after,.sech a::after,.stock.check a::after,.consentmore a::after,
.bempty a::after,.contlink a::after,
.blogcard-body h2 a::after,.blogcard-body h3 a::after{
  content:"";position:absolute;
  top:min(0px,calc((100% - var(--tap-min)) / 2));
  right:min(0px,calc((100% - var(--tap-min)) / 2));
  bottom:min(0px,calc((100% - var(--tap-min)) / 2));
  left:min(0px,calc((100% - var(--tap-min)) / 2))}

/* Downward from their own top rather than centred on it, because both of
   these have another target close above and clear room below: a card title
   has the brand link a few px over it and the price under it, and the brand
   line on a product page has the gallery 17px over it and the h1 under it.
   Neither of the things underneath is a target, so the room is free. */
.card h3 a::after,.pinfo .brandline a::after{content:"";position:absolute;
                  top:0;left:0;right:0;
                  bottom:min(0px,calc(100% - var(--tap-min)))}

/* The brand disclosure in the filter rail is a real control that was 351x23.
   Padding rather than a band: it is a block in the flow, so the rail below it
   moves down by 21px and nothing is overlaid. Vertical sides only, because
   the shorthand would reset the horizontal padding the rail sets. */
.morefacets summary{padding-top:11px;padding-bottom:10px}

/* The promotion's standalone link, which sits in a flex row beside a .btn
   that is already taller than this. Stating the minimum costs the row
   nothing and stops the link being 21px of a 44px line. */
.drawlink{display:inline-flex;align-items:center;min-height:var(--tap-min);
          color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-sm)}
.drawlink:hover{text-decoration:underline}

/* ---- clear space between adjacent targets ---------------------------------
   Two small links a few pixels apart mis-tap even when both are 44px, because
   the bands meet in the middle. Three places on the phone had that:

     breadcrumb      "Home / Blog" left 16px between the two link boxes and
                     the bands want 14.5px of it. The COLUMN gap carries the
                     difference; the row gap is untouched, so a breadcrumb
                     that wraps wraps exactly as it did.
     product card    the brand link's box and the title link's box already
                     overlapped by 1px, before either had a band. 6px more
                     under the brand line separates them, and 6px is what it
                     costs the card.
     cookie banner   the policy link sat 11px above Accept and Reject, which
                     is the pair of controls a mis-tap there matters most on.
                     The ROW gap carries it to 19px.
   -------------------------------------------------------------------------- */
.crumb ol{gap:6px 16px}

@media (max-width:768px){
  .card .brandline{margin-bottom:12px}
  .consentin{gap:22px 14px}
}

/* =============================================================================
   SUPPLIER BRAND MARKS - the /brands index grid, and the homepage strip
   -----------------------------------------------------------------------------
   Tickets #11331 and #11335. Twenty-five of the forty-six brands in the
   catalogue have a supplier logo and twenty-one do not (storefront/
   brandlogos.py says why that is the catalogue rather than an unfinished
   job), so the index below has to be right in BOTH states at once. Three
   rules do that work, and they are the whole design:

     1. THE TILE SIZE IS THE GRID'S, never the contents'. grid-auto-rows is
        not used because the rows must grow with a long brand name, but every
        tile in a row is the height of its row and carries the same min-height,
        so a run of name-only tiles does not step down out of the line.
     2. THE NAME IS IN EVERY TILE and the mark is the extra. A tile with no
        mark is a wordmark set in type; a tile with one is that same name with
        the supplier's own mark above it.
     3. THE STACK IS CENTRED rather than pinned to a mark row at the top. A
        fixed row would leave twenty-one tiles with an empty box over the name,
        which is exactly the "broken image" look this has to avoid.

   THE TILE IS WHITE IN EVERY STATE, hover included, which is why the hover is
   carried by the border and a shadow rather than by --peach the way .linkchip
   and .catchip carry theirs. Sixteen of the twenty-five stored marks have a
   white background baked into the source file and were deliberately not keyed
   out - several use white as an internal colour, so keying would damage the
   mark - and on a tinted tile those sixteen would show as a white rectangle.

   THE IMAGE IS NEVER GIVEN A SHAPE. max-width and max-height bound the box;
   width:auto and height:auto keep the file's own ratio inside it, and the
   <img> carries its stored pixel dimensions so the space reserved before it
   loads is the shape it actually is. These are other companies' trade marks:
   not stretched, not cropped, not recoloured. That is a rights rule that
   happens to also be the right design.

   Tokens only, and the motion is declared with named properties at the
   documented durations like every other control in this sheet.
   ============================================================================= */

.brandgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(178px,1fr));
           gap:14px;list-style:none;padding-bottom:46px}
.brandgrid > li{min-width:0}
.brandtile{display:flex;flex-direction:column;align-items:center;justify-content:center;
           gap:12px;height:100%;min-height:134px;padding:18px 14px;text-align:center;
           background:#fff;border:1.5px solid var(--line-control);
           border-radius:var(--r-card);
           transition-property:border-color,box-shadow,scale;
           transition-duration:var(--t-fast),var(--t-fast),var(--t-press);
           transition-timing-function:var(--ease-out)}
.brandtile:hover{border-color:var(--tt-orange-ink);box-shadow:var(--shadow-card)}
.brandtile:active{scale:var(--press-scale)}
.brandtile:focus-visible{outline:var(--focus-ring);outline-offset:3px}

/* A fixed band so the marks share a baseline down the grid, and flex:none so a
   short logo does not let the band collapse under a long brand name. */
.brandtile-mark{display:flex;align-items:center;justify-content:center;
                width:100%;height:56px;flex:none}
.brandtile-mark img{max-width:100%;max-height:100%;width:auto;height:auto;display:block}

/* Navy rather than .linkchip's --tt-orange-ink: the tile is the control and
   its border is the affordance, so the name is reading matter here the way
   .catchip .l is. 15.58:1 on white. */
.brandtile-name{font-size:var(--fs-sm);font-weight:700;color:var(--tt-navy);
                line-height:var(--lh-snug)}
.brandtile .cnt{color:var(--faint-ink);font-weight:600}

@media (max-width:768px){
  .brandgrid{grid-template-columns:repeat(auto-fill,minmax(146px,1fr));gap:10px;
             padding-bottom:38px}
  .brandtile{min-height:116px;padding:14px 10px;gap:10px}
  .brandtile-mark{height:46px}
}

/* ---- the homepage strip ----------------------------------------------------
   The same marks, one row, under the category tiles. It replaces six text
   chips, so it is built to the weight those chips had rather than to the
   weight of the index above: a fixed-width plate per mark keeps the row even
   whatever shape the logos are, and the plate is --tap-min tall because it is
   a link on a phone before it is a picture. The row wraps rather than
   scrolling sideways - a wrapping row cannot hide a brand behind a swipe, and
   this page never moves horizontally (see SCROLL-CONTAINED ROWS above). */
.brandstrip{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;
            list-style:none;flex:1 1 auto;max-width:100%;
            overscroll-behavior-x:contain}
/* min-height is 54px rather than --tap-min so the row stays level once a
   square mark is allowed its taller cap below: .brandstrip centres its items
   rather than stretching them, so a tile that grew to fit its own mark would
   sit proud of the rest. 54 = the 40px square cap plus this padding. */
.brandmark{display:flex;align-items:center;justify-content:center;
           width:112px;min-height:54px;padding:7px 10px;
           background:#fff;border:1.5px solid var(--line-control);
           border-radius:var(--r-chip);
           transition-property:border-color,scale;
           transition-duration:var(--t-fast),var(--t-press);
           transition-timing-function:var(--ease-out)}
.brandmark:hover{border-color:var(--tt-orange-ink)}
.brandmark:active{scale:var(--press-scale)}
.brandmark:focus-visible{outline:var(--focus-ring);outline-offset:2px}
.brandmark img{max-width:100%;max-height:28px;width:auto;height:auto;display:block}
/* A SQUARE DEVICE NEEDS THE HEIGHT A WORDMARK DOES NOT. 28px is set by what a
   wide mark needs: Geomag at 6.5:1 is 92px wide and 14px tall in this tile and
   would look absurd taller. A 1:1 mark under the same cap is 28px in a 92px
   tile - Multiprint and SES Creative both read as a missing icon rather than a
   small one. 40px still leaves them inside the tile and the row level. */
.brandmark--square img{max-height:40px}

/* Sits at the end of the same wrapping row as the eyebrow and the marks, and
   states its own minimum for the same reason .drawlink does: it is a 21px line
   of text in a row of 44px targets otherwise. */
.brandstrip-all{display:inline-flex;align-items:center;min-height:var(--tap-min);
                color:var(--tt-orange-ink);font-weight:700;font-size:var(--fs-sm)}
.brandstrip-all:hover{text-decoration:underline}

@media (max-width:768px){
  .brandmark{width:98px}
  .brandmark img{max-height:24px}
}
