/* OnlySmile Finland
   Ported from _reference/prototype.html, then reworked.

   Two presentations from one markup:
     - default        plain scrolling document. What you get with no script,
                      and what prefers-reduced-motion users get.
     - html.js + motion allowed
                      the pinned stage: one screen, scenes stacked on top of
                      each other, scroll length supplied by the body.

   The narrow layout is not the wide one squeezed. It has its own product
   sizes, its own padding and its own type sizes.

   The engine drives transform on .shot, transform and opacity on
   .scene-copy and .contact, and transform on .scene-title .w; nothing
   here animates on its own. */

/* ---------------------------------------------------------------- tokens */

:root {
  --ink: #241C22;
  --ink-soft: rgba(36, 28, 34, .70);
  --rule: rgba(36, 28, 34, .13);

  /* Not interchangeable — see the Accessibility section of CLAUDE.md. */
  --brand: #E23C87;        /* wordmark; exempt from contrast */
  --accent-text: #C81E6B;  /* highlighted heading word on a light scene */
  --cta: #C81E6B;          /* buy buttons; measured 5.45:1 with white text */

  --hero-top: #F7F4F5;   --hero-bot: #EEE8EB;
  --lamp-top: #2A1A2E;   --lamp-bot: #3D2440;   /* the one dark scene */
  --gel-top: #F6F0F3;    --gel-bot: #EDE3E8;
  /* Warm, because the photographs are: the powder itself is a peach
     rgb(233,206,203) and the hands holding it rgb(192,147,138), both
     measured off the cutouts. The lilac this replaces belonged to the video
     still that stood in for them. */
  --powder-top: #F0DFD6;  --powder-bot: #FAEFE8;

  /* Three objects stand in the opening composition and each has its own
     width. The device used to be the one that could not move — its source
     was 292px, so 146 was 1:1 and everything else was built around that.
     rev5 lifted it to 795px, and the number that changed most is not the
     device's own size but its share of the group.

     Real proportions: the lamp is about 60mm across and the package about
     180mm, so a third. It was drawn at 61% because it could not afford to
     be smaller and stay legible. Now it can: in the group it is a third of
     the package, and on its own scene it is 260px — which makes the first
     pan a real push-in of 3.3x rather than the 1.14x it used to be. That
     growth is the camera approaching, and the engine already says so:
     size is distance.

     --device-width is the largest use, its own scene; the group takes a
     share of it.

     Each of these comes in a pair: what the composition asks for, and what
     the object is actually drawn at. In the plain document they are the same
     number. Pinned, an object also has to fit the height its scene gives it
     — see "objects have to fit their row" below the tokens — and everything
     downstream, the pool and the contact shadow included, is sized from the
     second of the pair. */
  --package-want: 300px;   --package-width: var(--package-want);
  --device-want: 260px;    --device-width: var(--device-want);

  /* The tube in the wordmark's slot. Its length is set against the eyebrow
     of the scene it stands over — 239px, which is the Finnish one and the
     longest of the three — so it reads as a product lying in the corner
     rather than as an icon. The cutout is 92% ink across, so 260 of canvas
     gives 239 of tube.

     Fixed rather than a share of the viewport: the slot wants the same
     object at every width, and the largest use of this element is not the
     slot at all but its own scene, which is what the cap has to cover.

     The cutout is mostly transparent: 1392x380 of ink inside 1516x989. The
     stations hang it by the ink, not by the canvas — anchoring the canvas
     put the tube 28% of the canvas height lower than asked, which walked it
     into the heading. */
  --tube-want: 440px;      --tube-width: var(--tube-want);

  /* The pink off the side of the carton and the stripe on the tube. The
     pool under the tube is light the product throws, not a shadow, so it
     is this and not the aura's plum — which on a pale ground reads as
     grey dirt.

     Measured against the white of the tube over the pale ground: .34 gives
     1.65:1 and the tube still merges, .58 gives 2.30:1 — which is also more
     than the plum it replaces managed at .34 (2.08:1), so this is a real
     strengthening and not only a change of colour. Past .70 the edges start
     reading as a stain rather than as light. */
  --pool-rose: 229, 56, 141;
  --pool-rose-density: .58;

  /* The light the device itself makes. Not chosen: device-on minus
     device-off is exactly what the diodes add, and its mean is
     rgb(184, 221, 233) — the same family as the glow on the dark scene.

     It cannot be used at that lightness. A pool on a PALE ground works by
     darkening, and pale cyan does not darken: over --hero-top it takes the
     white device from 1.09:1 to 1.31:1 and the object still merges. This is
     that hue at the lightness that does the job — the same colour of light,
     enough of it. .50 here lands within a hundredth of what the rose does at
     .58 (2.37 against 2.40), so the two are the same strength and differ
     only in colour, which is what makes them worth comparing. */
  --pool-blue: 12, 105, 151;
  --pool-blue-density: .50;

  /* The aura's darkening. A different pool with a different job:
     seen while the ground is changing, mostly against the dark side
     of the horizon, where plum belongs. Do not point one of these
     tokens at the other. */
  --aura-plum: 92, 24, 58;
  /* What the tube in the slot uses, and what ?pool= moves. The device's own
     density travels with its colour, below. */
  --pool-density: var(--pool-rose-density);

  --carton-want: 150px;    --carton-width: var(--carton-want);
  /* Its shadow answers to the ink, not to the canvas: 1.55 times this is
     the width drawn, and the upright tube is about 0.10 of the canvas. */
  --tube-contact: calc(var(--tube-width) * .155);

  --pad: clamp(20px, 4.4vw, 76px);
  /* The extra top padding the pinned scenes carry, over and above --pad.
     Named because the height left for a scene's rows is measured from it. */
  --head-room: 44px;
  --gap: clamp(14px, 3.6vw, 60px);
  /* The cap and the width are two things. Every scene but the last puts the
     copy in a column of its own and the cap is the width; the last one puts
     the copy between two objects, and what it needs there is the number, not
     the column — hence a name for it. The narrow layout redefines the width
     to 100% and must not touch the cap: a percentage inside the arithmetic
     below has no containing block to resolve against. */
  --copy-cap: 30rem;
  --copy-width: var(--copy-cap);

  /* ------------------------------------------------ the last scene's flanks

     Both objects on the fourth scene are cut by their own canvas edge — the
     brush's sleeve leaves its photograph on the left, the hands' wrists leave
     theirs on the right — so neither stands in a column: each is placed
     against the frame it runs off, and its size is not a preference but what
     is left over.

     Between them they have the whole frame, plus what hangs past each edge,
     less whatever stands in the middle. Half of that each, so the two arrive
     at the same size and the air between them and the words is the same on
     both sides at every width. Wide: the words are the thing in the middle.
     Narrow: nothing is, and they take the frame between them.

     --overhang is how far past the edge each cut edge stands, and it is the
     0.12 both stations carry, in the unit the composition is written in. It
     is a token because the arithmetic here has to agree with the stations; a
     literal would drift the day one of them moved.

     Capped at 410px. That is 91% of the hands' master (869px of drawing) and
     76% of the brush's (1073px), so neither is upscaled on a 2x screen. Above
     about 1300px the cap binds and the air in the middle grows instead. */
  --overhang: calc(.12 * var(--package-width));
  --flank: min(410px, calc((100vw - var(--copy-cap)) / 2
                           - var(--gap) + var(--overhang)));
  --brush-want: var(--flank);   --brush-width: var(--brush-want);

  /* The jar with the watermelon, on the other flank. Its size is first a
     statement about the brush, not about the screen: the same jar is
     photographed in both cutouts — 400px of a 1089 canvas there, 660 of
     1262 here — so canvas parity of the jars is 0.702, and the want is
     that times 1.15: a touch closer to the camera, because two identical
     jars side by side read as copies and the one that is the subject is
     the one that comes forward.

     First, not only. Between 821 and about 1100px the half left of the
     centred copy is too small for jar parity plus the designed air:
     measured at 821x800, parity put the jar's ink 10px INTO the copy box.
     So the want passes through the same gate every object on this page
     passes — what the composition asks against what the layout has — and
     the jar gives, the way everything here gives to its row. The room is
     the flank to the left boundary (the copy on the wide layout, the brush
     on the narrow one, where the block below overrides it), less the
     designed gap; .969 is the drawing's share of the canvas and .14 of a
     package width is the station's inset from the frame edge, which the
     engine holds in js/scene.js — keep the two in step.

     Both numbers were re-measured when the portrait master landed
     (20.08). The jar fills 0.825 of the new canvas against 0.523 of the
     wide one it replaces — the crop is tighter, not the jar bigger — so
     jar parity is 0.445 of the brush rather than 0.702, and the same
     deliberate 1.15 step toward the camera lands the want at 0.512. */
  --melon-room: calc((100vw - var(--copy-cap)) / 2
                     - var(--gap) - .14 * var(--package-width));
  --melon-want: min(calc(var(--brush-width) * .512),
                    calc(var(--melon-room) / .969));
  --melon-width: var(--melon-want);
}

/* ------------------------------------------- objects have to fit their row

   The pinned presentation puts every scene on one screen and gives its media
   a share of that screen's height. A cutout taller than the share it is given
   does not shrink to fit: `max-height` with `object-fit: contain` keeps the
   BOX and letterboxes the drawing inside it — the img carries width/height
   attributes, so its width is not auto and the height clamp has nothing to
   scale. Everything then sized from the token belongs to an object that is
   not on screen. Measured at 320x568: the gel carton's box was 117px against
   81px of drawing and its contact shadow was 44% too wide; the device, the
   tube and the poster were out by 42%, 6% and 23%.

   So the height a row gives is turned into a width the object may be, and
   each token is the smaller of the two. The pair of numbers in each calc is
   the asset's own pixel size — the same pair the markup carries as width and
   height, which build.py checks against the manifest. Here they are not
   checked: too generous a ratio is caught, because max-height binds again
   and the drawing shrinks inside its box; too mean a ratio is not, and only
   makes the object smaller than it could be.

   `max-height` stays where it is, in the rules below. It is the statement of
   the limit; this is the statement of what fits inside it, and the two are
   arrived at differently on purpose: tools/geometry.py measures the drawing
   against its box and fails if they ever stop agreeing.

   Only .js. The plain document has no rows and clamps nothing.

   One thing to know before adding another of these: a custom property is not
   a computed value. getComputedStyle hands it back as written, so anything
   in JS that used to parseFloat a token gets the string "min(300px, ...)"
   and quietly falls back to a default. The engine measures elements instead
   — see measure() — and it should stay that way. */
:root.js {
  /* The legal links row in the bottom chrome, as vertical room. The wide
     layout only needs it on short windows (the relief block below); the
     narrow layout, where the rows are shares of the screen, subtracts it
     from --stage-h and reserves it in the scene's bottom padding — its own
     deeper value lives in the narrow block. Measured, not modelled: 26px
     left the powder copy 7px INTO the links at 320x568. */
  --foot-room: 28px;
  --media-h: 76vh;                 /* what a cutout may take, wide layout */
  --lamp-h: var(--media-h);        /* the lamp scene asks for its own share */

  --package-width: min(var(--package-want), calc(var(--media-h) * 633 / 636));
  --device-width:  min(var(--device-want),  calc(var(--lamp-h)  * 398 / 454));
  --carton-width:  min(var(--carton-want),  calc(var(--media-h) * 463 / 1190));
  --tube-width:    min(var(--tube-want),    calc(var(--media-h) * 1516 / 989));
  --brush-width:   min(var(--brush-want),   calc(var(--media-h) * 544 / 534));
  --melon-width:   min(var(--melon-want),   calc(var(--media-h) * 1151 / 1367));
}

@media (max-width: 820px) {
  :root {
    --pad: 24px;
    /* Smaller objects in a bigger field of air. */
    --package-want: 168px;
    --device-want: 195px;
    --carton-want: 117px;
    --tube-want: 340px;
    /* Nothing stands between the two of them here — the copy is in the row
       underneath — so the brush has half the frame, and the jar's room runs
       from the brush's ink edge to its own inset from the frame. */
    --flank: calc(50vw - var(--gap) + var(--overhang));
    --melon-room: calc(100vw - var(--brush-width) * .9853 + var(--overhang)
                       - var(--gap) - .14 * var(--package-width));
    --copy-width: 100%;
  }
  /* Here the height a cutout may take is not a share of the viewport but the
     grid row it is standing in, and the rows below are written from these:
     the fractions live here and nowhere else. --stage-h is what a scene has
     after its padding, which is --pad twice plus the head room. */
  :root.js {
    /* Deeper than the wide value: the narrow rows are shares of
       --stage-h, so the reserve shrinks the media proportionally and only
       part of it reaches the copy's clearance — 26px measured out as the
       powder copy 7px INTO the links at 320x568; 44 holds it clear.
       tools/geometry.py asks this at every rest stop; take the reserve
       away and it says so. */
    --foot-room: 44px;
    --stage-h: calc(100vh - 2 * var(--pad) - var(--head-room) - var(--foot-room));
    --media-h: calc(.44 * var(--stage-h));
    --lamp-h: calc(.38 * var(--stage-h));
  }
}

/* ------------------------------------------------------------------ base */

* { box-sizing: border-box; margin: 0; padding: 0 }

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

body {
  background: var(--hero-top);
  color: var(--ink);
  font-family: "Helvetica Neue", Inter, -apple-system, "Segoe UI", Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img, canvas { display: block; max-width: 100%; height: auto }
ul { list-style: none }

/* The browser's own [hidden] rule loses to any author rule that sets
   display — not on specificity but on origin, which the cascade settles
   first and which always puts author styles above the user agent. Restating
   it here is not enough either: a later author rule of equal weight, such
   as `.cta-bar { display: inline-block }` in the pinned blocks below, wins
   again on order alone. (The example used to name `.results-toggle`, which
   left with the ribbon's pause control on 21.08 — an example that names
   nothing is a reader sent to grep for a class that is not there.)

   The !important is deliberate and is not a workaround for a specificity
   mistake somewhere else — do not remove it. `hidden` states that the
   element is not on the page. That is not a layout opinion to be weighed
   against other layout opinions, so nothing in this file gets a vote.
   Both a hidden canvas and a hidden readout have already shipped visible
   because this line was missing. */
[hidden] { display: none !important }

.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 20;
  padding: 10px 18px; background: var(--ink); color: #fff;
  font-size: 13px; text-decoration: none; border-radius: 0 0 4px 0;
}
.skip-link:focus { left: 0 }

/* The order page brought the site its first <button> and first visible
   <input>; they join the one focus rule rather than growing their own. */
a:focus-visible, .skip-link:focus-visible,
button:focus-visible, input:focus-visible {
  outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 2px;
}
main:focus { outline: none }

/* ------------------------------------------- how this site answers a touch

   ONE grammar for every control (22.08, client). A page where each button
   answers differently is a page the reader has to learn twice, so there
   are two families here and one sentence each:

     words     the ink goes to full strength, and the control rises 1px
     pictures  the picture dims a little, and it rises the same 1px

   FOCUS IS ANSWERED EXACTLY AS HOVER IS, never more faintly. The ring is
   added to that answer rather than standing in for it: a reader arriving
   by keyboard is told what a reader arriving by mouse is told.

   Hover lives behind (hover: hover) and (pointer: fine) throughout. On a
   touchscreen :hover fires on the tap and stays fired until something
   else is tapped — the sticky hover every phone carousel has — and there
   the answer that matters is the focus one, which a tap leaves behind.

   prefers-reduced-motion drops the movement and keeps the colour: the
   answer is the point and the travel is the decoration.

   TWO EXCEPTIONS, both measured rather than editorial.

   The scene props (.prop-link .shot) answer with their focus ring alone:
   the engine writes their transform every frame, and a filter on a
   continuously scaled element costs 36% of frames past 20ms unthrottled
   and 61% at 6x (tools/blurcost.py). Their ink is a photograph, not a
   colour this stylesheet owns.

   And opacity is not in the shared transition. The engine writes it on
   the buy bar frame by frame as the bar rises, and a CSS transition on a
   property the scroll engine owns is the one thing the engine forbids
   outright. The rail dot, which nothing writes, adds it back below. */
:root {
  --tap-ms: 140ms;
  --tap-ease: cubic-bezier(.33, 0, .67, 1);
  --tap-lift: -1px;    /* how far a control rises under a pointer */
  --tap-dim: .92;      /* how far a picture darkens under one */
}
:is(.skip-link, .buy, .cta-bar, .contacts-mail-link, .lamp-more-link,
    .order-zone-choice, .qty-step, .contacts-legal a,
    .contacts-facts dd a, .order-consent a, .thanks-home a,
    .product-nav a, .scene-nav a, .doc-foot a, .doc-legal a),
.product-nav-thumb {
  transition: color var(--tap-ms) var(--tap-ease),
              background-color var(--tap-ms) var(--tap-ease),
              border-color var(--tap-ms) var(--tap-ease),
              box-shadow var(--tap-ms) var(--tap-ease),
              filter var(--tap-ms) var(--tap-ease),
              transform var(--tap-ms) var(--tap-ease);
}
/* THE CHROME ANSWERS IN COLOUR INSTANTLY, and only its movement eases.
   The wordmark, the language switcher and the rail dots are painted from
   --chrome-ink, which the engine rewrites every frame of every pan, and
   a 140ms transition on a colour the scroll engine owns makes the ink
   lag the ground it is standing on. Measured, and this is not a taste:
   at the halfway point of the second pan the lagging ink and the arrived
   ground were the SAME colour — "lang, current 1.00 against 4.5", "rail
   dot 1.00 against 3.0". The engine's own rule, which this broke and
   which put it back: no CSS transition on a property the scroll drives. */
:is(.lang-open, .lang-list a, .rail-dot, a.mark, .hint a) {
  transition: transform var(--tap-ms) var(--tap-ease);
}
.rail-dot { transition-property: transform, opacity }

/* The movement, and the one place the two families agree exactly. */
@media (hover: hover) and (pointer: fine) {
  :is(.skip-link, .buy, .cta-bar, .contacts-mail-link, .lamp-more-link,
      .order-zone-choice, .qty-step, .lang-open, .lang-list a,
      .contacts-legal a, .contacts-facts dd a, .order-consent a,
      .thanks-home a, .product-nav a, .rail-dot, a.mark, .hint a,
    .scene-nav a, .doc-foot a, .doc-legal a):hover {
    transform: translateY(var(--tap-lift));
  }
  /* The colour, family by family. Filled controls brighten, because
     their ink is already the strongest thing on the page; everything
     drawn in the soft ink goes to the full one, which is the same
     statement in the other direction. */
  :is(.buy, .cta-bar):hover { filter: brightness(1.08) }
  :is(.contacts-legal a, .contacts-facts dd a, .order-consent a,
      .lang-list a, .product-nav a, .thanks-home a, a.mark,
    .doc-foot a, .doc-legal a):hover {
    color: var(--ink);
  }
  /* Its own line: the hint's ink is --chrome-ink, which the engine
     rewrites every frame, so it answers with the chrome's colour and
     not with the page's. */
  .hint a:hover { color: var(--chrome-ink, var(--ink)) }
  .scene-nav a:hover { color: var(--ink) }
  .contacts-mail-link:hover { box-shadow: inset 0 0 0 1px var(--ink) }
  .lang-open:hover { border-bottom-color: var(--chrome-ink, var(--ink)) }
  :is(.order-zone-choice, .qty-step):hover { border-color: var(--ink) }
  .order-zone-choice:has(:checked):hover { filter: brightness(1.35) }
  .rail-dot:hover { opacity: 1 }
  /* Pictures: the dim. The thumbnail in the product strip is the one
     picture on this site that is part of a word control, so it dims
     while its own link rises. */
  .product-nav a:hover .product-nav-thumb { filter: brightness(var(--tap-dim)) }
}
/* The same answer for the keyboard, written apart from the hover block
   because that block is behind a pointer query a keyboard never matches. */
:is(.skip-link, .buy, .cta-bar, .contacts-mail-link, .lamp-more-link,
    .order-zone-choice, .qty-step, .lang-open, .lang-list a,
    .contacts-legal a, .contacts-facts dd a, .order-consent a,
    .thanks-home a, .product-nav a, .rail-dot, a.mark, .hint a,
    .scene-nav a, .doc-foot a, .doc-legal a):focus-visible {
  transform: translateY(var(--tap-lift));
}
:is(.buy, .cta-bar):focus-visible { filter: brightness(1.08) }
:is(.contacts-legal a, .contacts-facts dd a, .order-consent a,
    .lang-list a, .product-nav a, .thanks-home a, a.mark,
    .doc-foot a, .doc-legal a):focus-visible {
  color: var(--ink);
}
.hint a:focus-visible { color: var(--chrome-ink, var(--ink)) }
.scene-nav a:focus-visible { color: var(--ink) }
.contacts-mail-link:focus-visible { box-shadow: inset 0 0 0 1px var(--ink) }
:is(.order-zone-choice, .qty-step):focus-visible { border-color: var(--ink) }
.order-zone-choice:has(:focus-visible) { transform: translateY(var(--tap-lift)) }
.rail-dot:focus-visible { opacity: 1 }
.product-nav a:focus-visible .product-nav-thumb { filter: brightness(var(--tap-dim)) }

/* Movement off, colour kept. Written as a zero lift rather than as
   `transform: none`, so a control whose transform means something else
   keeps it. */
@media (prefers-reduced-motion: reduce) {
  :root { --tap-lift: 0px }
  :is(.skip-link, .buy, .cta-bar, .contacts-mail-link, .lamp-more-link,
      .order-zone-choice, .qty-step, .lang-open, .lang-list a,
      .contacts-legal a, .contacts-facts dd a, .order-consent a,
      .thanks-home a, .product-nav a, .rail-dot, a.mark, .hint a,
    .scene-nav a, .doc-foot a, .doc-legal a),
  .product-nav-thumb { transition: none }
}

/* -------------------------------------------------- between the pages

   Inside the front page everything flows; a click to a product page used
   to be the one hard cut on the site. @view-transition hands that join to
   the browser: no JavaScript, no build step, no library — the two
   documents each opt in and the browser cross-fades between them.

   Progressive enhancement in the strict sense. A browser without support
   (Firefox at the time of writing) ignores the at-rule and navigates the
   way it always did, which is not a breakage. There is deliberately NO
   JavaScript fallback: a scripted fade is a timer racing a page unload,
   and this project has spent itself removing exactly that class of
   fragility.

   Things are named because they are THE SAME THING on both pages rather
   than because a morph would look nice. Two are chrome: the wordmark in
   the top-left corner and the language switcher in the top-right, both
   of which keep their corner across every navigation on the site, so a
   browser that carries them is telling the truth.

   The third case is the product photographs, and it passes the same
   test — not by luck but because the pairing was built to. On every
   product page the CURRENT product's name belongs to its MAIN
   photograph, and every OTHER product's name to its thumbnail up in the
   strip (build.py, render_product_nav). Leaving the gel page for the
   powder page, the powder thumbnail here meets the powder main
   photograph there, and the gel main photograph meets the gel thumbnail:
   both halves true, and true again on the way back, because the rule is
   the same rule on both documents. Neither has to know which way the
   reader is going — which is the whole constraint, since CSS cannot ask.

   What is still deliberately NOT named is a prop on the front page: the
   lamp morphing into a photograph is only true on the way there, and the
   same rule fires coming back, where the front page lands on the hero
   and the object is not in frame. A transition may not claim more than
   the navigation model can keep — see CLAUDE.md. The product pair is not
   an exception to that rule; it is what satisfying it looks like.

   One name per page, never two. A duplicate does not degrade in current
   Chromium — it silently abandons the WHOLE transition, so the failure
   looks like "the fade stopped working" and points nowhere near itself.
   tools/product.py asks for the exact set and refuses repeats.

   The duration is the page's own: 320ms, near the light toggle's 350 and
   far under the shortest pan, on a curve that approximates the smoothstep
   everything else here moves on.

   And the two halves are NOT symmetrical, for a reason that is already
   law inside the front page: objects may overlap, text may not — the
   scene copy arrives late into its window and leaves early out of it
   (MOTION.copyIn / copyOut) precisely so two headings are never in one
   column at once. A plain crossfade breaks that rule at the page level:
   measured on the lamp-to-kit navigation, the middle frame carried both
   headings at once. So the leaving page goes first and the arriving one
   follows, the same skew, one join later. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto }

  ::view-transition-group(*) {
    animation-duration: 320ms;
    animation-timing-function: cubic-bezier(.33, 0, .67, 1);
  }
  /* The named pairs are one object moving, so both halves keep the whole
     window: there is no second text to collide with. */
  ::view-transition-old(*), ::view-transition-new(*) {
    animation-duration: 320ms;
    animation-timing-function: cubic-bezier(.33, 0, .67, 1);
  }
  /* The page itself: the leaver goes a little quicker than the arriver
     comes, so the window where both are legible is short. Measured, not
     guessed: a full skew (out by 170ms, in from 90ms) opened a gap where
     neither page was there and the join read as a pale flicker; equal
     durations put both headings on screen at half strength for 150ms,
     which is the one thing the copy windows inside the page exist to
     prevent. Sharing the start and shortening the leaver keeps the sum
     of the two near one throughout. */
  ::view-transition-old(root) { animation-duration: 220ms }
  /* And the product photographs leave on the leaving page's clock, for
     the navigations where they have no partner at all — see the note by
     the .vt--* names below. A timing rule, so it lives with the timing. */
  ::view-transition-old(vt-kit),
  ::view-transition-old(vt-gel),
  ::view-transition-old(vt-powder) { animation-duration: 220ms }

  /* The FLIGHT is longer than the page (20.08, client: "you cannot see
     it happen"). The two are different events and it was a mistake to
     give them one number: the cross-fade is a page being replaced and
     wants to be over, the flight is one object moving across the screen
     and is the only thing on the join a reader is meant to follow. A
     thumbnail 30px wide becoming a photograph most of a column wide
     covered that distance in 320ms — 4 or 5 frames of travel at 60Hz,
     which is not a movement, it is a jump with motion blur.

     620ms is the page's own arithmetic rather than a taste: the shortest
     pan on the front page is 900ms across a whole stratum, and this
     crosses well under half a screen. Under 500 it still reads as a
     snap; over 800 the arrived page is sitting there waiting for its own
     photograph. Both ends were measured as filmstrips before this was
     written down. */
  ::view-transition-group(vt-kit),
  ::view-transition-group(vt-gel),
  ::view-transition-group(vt-powder) { animation-duration: 620ms }
  /* The two halves still cross-fade on the page's clock, NOT on the
     flight's, and the reason is the same asymmetry the root records: the
     leaver leaves once. Left at 620 the arriving half would still be at
     a third of its opacity when the leaving half had gone, so the object
     the reader is following would dim to nothing in mid-air and come
     back. The old half keeps the leaving page's 220 and the new one
     answers it at 300, so the sum stays near one and what travels the
     remaining 300ms is a solid picture. */
  ::view-transition-new(vt-kit),
  ::view-transition-new(vt-gel),
  ::view-transition-new(vt-powder) { animation-duration: 300ms }
}

/* The wordmark and the language links keep their corner across every
   navigation on the site, so they are carried rather than cross-faded.
   Outside the reduced-motion query on purpose: a name with no opt-in
   costs nothing, and keeping the two statements apart means the names
   cannot drift from the elements they belong to. */
.mark { view-transition-name: wordmark }
.lang { view-transition-name: langswitch }

/* The product pair. Which element on a page wears which of these is
   build.py's decision, per page; here they are only three distinct
   names. Outside the reduced-motion query for the same reason as the two
   above: a name with no opt-in costs nothing. */
.vt--kit    { view-transition-name: vt-kit }
.vt--gel    { view-transition-name: vt-gel }
.vt--powder { view-transition-name: vt-powder }
/* The two ends of this pair are different shapes — a 30x36 crop and a
   photograph four fifths as tall as it is wide — and the group animates
   the rect between them, so each half has to fill that rect and crop
   rather than sit in it at its own proportions.

   BOTH declarations are needed and `object-fit` alone does nothing: the
   UA stylesheet gives these pseudos `inline-size: 100%; block-size:
   auto`, so the box already follows the snapshot's own ratio and there
   is nothing for object-fit to resize. Measured — a frame frozen at the
   same currentTime was byte-identical with the declaration and without
   it, while the height changed it (196.6px against 226.4px at the same
   width). Written without the height it looked exactly like a fix.

   The leaving half runs at the LEAVING PAGE'S duration, not the pair's,
   and that is about the navigations where there is no pair at all.
   These three names live only on product pages, so a click from one to
   the order page leaves three exit-only fades with no partner; at 320ms
   they were still painting the old product's photographs over the
   arrived page 100ms after the old page itself had gone. Matching root's
   220ms is the same statement the asymmetry above makes: the page that
   is leaving leaves once, and nothing that belonged to it outlives it. */
::view-transition-old(vt-kit), ::view-transition-new(vt-kit),
::view-transition-old(vt-gel), ::view-transition-new(vt-gel),
::view-transition-old(vt-powder), ::view-transition-new(vt-powder) {
  height: 100%;
  object-fit: cover;
}

/* ----------------------------------------------------------------- chrome

   The wordmark, the language links, the rail and the hint sit above every
   scene, including the dark one, so their colour cannot come from a scene.
   The engine interpolates --chrome-* alongside the backdrop; the fallbacks
   are what the static document uses. */

.site-head {
  position: relative; z-index: 6;
  display: flex; align-items: center; justify-content: space-between;
  padding: var(--pad) var(--pad) 0;
}

/* The wordmark, drawn rather than typeset since 20.08: the client's own
   file, one colour. While something is occupying its slot this is 0, and
   it comes back by exactly as much as that thing leaves — and on the
   front page a second term takes it away on the gel scene as well
   (see --mark-alpha in js/scene.js).

   The BOX keeps the size the typeset mark had, and that is load-bearing
   rather than tidy: the slot's occupant hangs from this element's top
   edge, `.site-head` centres its children, and a shorter box would have
   dropped the whole slot by about 3px — into clearances measured in
   ones. min-height is the old line box (1.33em of the same font-size),
   the image is centred in it, and the drawing lands at .9em tall, which
   is 59px wide against the 57px the letters used to take.

   One colour, and it may be: the mark is only ever shown over a pale
   ground now — the front page hides it on all three scenes that could
   be dark, and every standalone page stands on a pale one. A drawn mark
   could not follow --chrome-ink across the plum the way the letters
   did. The wordmark is exempt from contrast either way. */
/* Bigger on the front page too, since 22.08 (client: "for the first
   screen this is small, and the mark is the only thing that names the
   brand now that the carton is in the slot"). 51px of drawing at 892 was
   the old clamp's floor.

   The note below used to say the front page could not have this because
   the slot's occupant hangs off the box. It hangs off the box's TOP edge,
   which is set by the head's padding and does not move when the font
   grows — the box grows DOWNWARD. What growing it does cost is the head
   band's own height, and that is what the copy has to clear, so it was
   measured rather than argued: at 1280x800 the band goes 80px to 89 and
   the closing scene, the tightest of the five up there, keeps 11px of the
   20 it had. Same clamp as the standalone pages now: one number for the
   whole site. */
.mark {
  font-size: clamp(15px, 1.7vw, 20px);
  display: flex; align-items: center; min-height: 1.33em;
  opacity: var(--mark-alpha, 1);
}
.mark img { display: block; height: .9em; width: auto }
/* Bigger everywhere the slot does not exist (21.08, client). The front
   page's box is load-bearing — the slot's occupant hangs from its top
   edge and the clearances around it are measured in single pixels — but
   no standalone page has a slot, so nothing here is holding anything up.
   Only the font-size moves: the image is .9em of it and the box 1.33em,
   so the whole thing grows in proportion and the corner keeps its
   arithmetic. */
.doc .mark { font-size: clamp(15px, 1.7vw, 20px) }

/* Folded away (21.08, client): three codes in a row became one, and the
   other two arrive under it when asked for.

   <details> and not a scripted menu. The browser's own disclosure comes
   with the pointer, Enter, Space, a place in the tab order and an
   announced expanded state — five things a scripted one would have to be
   given back by hand, and the fifth is the one that always goes missing.
   It also works in a page with no script, which is the rule here.

   The open panel takes the GROUND's own colour rather than a colour of
   its own: on the front page it floats over four scenes, one of them
   plum, and a white card there would be a foreign object. --chrome-panel
   is interpolated by the engine beside --chrome-ink; the fallback is
   what every standalone page uses. */
.lang { position: relative }
.lang-open {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 5px 9px 4px; cursor: pointer;
  font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--chrome-ink, var(--ink));
  border-bottom: 1px solid var(--chrome-ink, var(--ink));
}
/* The disclosure triangle goes: the flag and the code are the affordance,
   and the marker sits in the middle of them. Both spellings — Safari
   still wants the pseudo-element. */
.lang-open { list-style: none }
.lang-open::-webkit-details-marker { display: none }
.lang-open::marker { content: "" }
.lang-flag {
  display: block; flex: none; width: 18px; height: 12px;
  border-radius: 1.5px;
}
.lang-list {
  position: absolute; right: 0; top: calc(100% + 7px); z-index: 2;
  display: flex; flex-direction: column; align-items: stretch;
  padding: 5px; border-radius: 10px;
  background: var(--chrome-panel, var(--hero-top));
  box-shadow: 0 2px 10px rgba(36, 28, 34, .14),
              inset 0 0 0 1px var(--chrome-rule, var(--rule));
}
.lang a {
  display: flex; align-items: center; gap: 7px;
  padding: 5px 9px 4px; text-decoration: none; white-space: nowrap;
  font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--chrome-soft, var(--ink-soft));
  border-radius: 6px;
}
/* Marked by weight and ink, not by a filled pill. A pill has to invert
   between the pale scenes and the dark one, and on the way across it and
   its own label pass through the same lightness — the label disappears into
   its own background. One colour cannot do that to itself. */
.lang a[aria-current="page"] {
  color: var(--chrome-ink, var(--ink));
  font-weight: 600;
}

.site-foot {
  position: relative; z-index: 6; padding: 0 var(--pad) var(--pad);
  display: flex; justify-content: space-between; align-items: baseline;
}
/* Three links now, not one instruction (22.08). The weight is the whole
   design: 10px capitals in the soft chrome ink, against a magenta pill in
   the copy. They are navigation and the pill is the offer, and a reader
   should be able to tell which is which without reading either. */
.hint {
  display: flex; flex-wrap: wrap; gap: 4px 18px;
  font-size: 10px; line-height: 1.1; letter-spacing: .24em;
  text-transform: uppercase;
  color: var(--chrome-soft, var(--ink-soft));
}
.hint a { color: inherit; text-decoration: none }
/* The chrome's row shares the foot band with the buy bar, and on the
   narrowest cell the two longest strings in the site meet there: Swedish
   "BESTÄLL BLEKPULVRET" on the bar and "KONTAKTUPPGIFTER" beside it, 11px
   more than the 272px the band has. The tracking gives it back — 16
   letters at .12em less is 19px — and it is the only thing here that can:
   the word is a word, the bar's label names the product it sells, and
   stacking them would spend vertical room this cell does not have. */
@media (max-width: 820px) {
  .hint { letter-spacing: .1em }
}
/* The first screen's own row, in the copy column under the pill (22.08,
   client). Same 10px capitals in the soft ink as the chrome's, so the
   hierarchy in that column reads at a glance: a filled magenta pill, then
   two quiet lines of navigation. It is the copy's ink here rather than the
   chrome's — it stands on the scene's ground, not over it. */
.scene-nav {
  display: flex; flex-wrap: wrap; gap: 4px 18px;
  margin-top: 14px;
  font-size: 10px; line-height: 1.1; letter-spacing: .24em;
  text-transform: uppercase; color: var(--ink-soft);
}
.scene-nav a { color: inherit; text-decoration: none }
/* No .foot-nav rules here any more (removed 23.08). The row of legal links
   left the chrome for the contacts scene on 19.08 and nothing has carried
   that class since -- checked against every generated page, both templates
   and build.py. The rules outlived it by four days, and their comment was
   worse than the rules: it described a fading hint and legal pages one tap
   away, in the present tense, three days after both stopped being true. */

/* The buy bar: the buy row's stand-in for layouts whose copy cannot carry
   one — every narrow cell, and wide cells under 700px of height. The looks
   live here once; the pinned blocks that use it only switch it on and place
   it in the corner the scroll hint used to have.

   It is on from the first frame, and the engine drives both the opacity and
   the interactivity. It used to rise over the first pan instead, on the
   hint's own fade window — which is why the landing state had no buy
   control at all on these layouts for six days. That was never a decision
   about the first screen: the bar took the hint's SPOT and the hint's
   WINDOW, the hint was lit at the landing, so the bar was dark there by
   arithmetic. Both halves went (19.08, 22.08) and the darkness outlived
   them.

   The one exception is measured rather than written: where the first
   screen's copy is standing in this corner — 320x568 and 320x640 — the bar
   waits out the first pan as before. See landingBuy in js/scene.js. */
.cta-bar {
  display: none; background: var(--cta); color: #fff;
  padding: 6px 14px; border-radius: 999px; text-decoration: none;
  font-weight: 600; font-size: 11px; letter-spacing: .08em;
  text-transform: uppercase; white-space: nowrap;
  opacity: 0; pointer-events: none;
}
/* Same boundary arithmetic as the buy row on the lamp scene: magenta on
   plum is 2.88:1, so while the resting scene is the dark one the engine
   sets this class and the boundary is a ring instead. */
.cta-bar.on-dark { box-shadow: 0 0 0 1px rgba(247, 240, 246, .85) }

/* Visible only in the pinned presentation: in the plain document the
   browser's own scrollbar is the position indicator. */
.rail { display: none }

.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* ---------------------------------------------------------- stage layers */

.stage { display: none }
.backdrop {
  position: absolute; inset: 0;
  background: linear-gradient(178deg, var(--hero-top) 0%, var(--hero-bot) 100%);
}
.backdrop-next {
  position: absolute; inset: 0;
  transform: translateY(100%); opacity: 0;
}
.bloom {
  position: absolute; left: 72%; top: 50%;
  width: min(70vw, 780px); aspect-ratio: 1;
  transform: translate(-50%, -50%); opacity: 0;
  background: radial-gradient(circle,
              rgba(111, 232, 245, .62) 0%, rgba(111, 232, 245, .2) 36%, transparent 68%);
  filter: blur(30px);
}

/* Carried light. Two layers, one clipped to the ground being left and one
   to the ground arriving, so during a pan the top half of the pool is pink
   and the bottom half is blue with the join exactly on the horizon. Soft by
   gradient, never by filter: a filter on something that moves every frame
   is a fresh raster every frame. */
.aura { display: none }
.aura > i {
  position: absolute; left: 0; top: 0; display: block;
  width: var(--aura-size, 360px); height: var(--aura-size, 360px);
  margin: calc(var(--aura-size, 360px) / -2) 0 0 calc(var(--aura-size, 360px) / -2);
}
/* Two darkening pools, two jobs, two colours — and they are named apart on
   purpose. They were the same literal written in two places, which is worse
   than a shared token: it looks deliberate and drifts silently.

   --plum is the aura's, seen while the ground is changing and mostly read
   against the dark side of the horizon. --rose is the light a product casts
   on the pale first screen, and it is the pink off the packaging itself.
   Kept as components so density is one number rather than four. */
.aura--dark { --aura-peak: rgba(var(--aura-plum), .60) }
.aura--glow { --aura-peak: rgba(126, 228, 247, .52) }

.aura--dark > i {
  background: radial-gradient(circle,
    rgba(var(--aura-plum), .60) 0%, rgba(var(--aura-plum), .40) 26%,
    rgba(var(--aura-plum), .16) 50%, rgba(var(--aura-plum), .04) 68%, transparent 80%);
}
.aura--glow > i {
  background: radial-gradient(circle,
    rgba(126, 228, 247, .52) 0%, rgba(126, 228, 247, .28) 28%,
    rgba(126, 228, 247, .09) 54%, transparent 76%);
}

/* ------------------------------------------------- scenes, document flow */

.scene { position: relative; z-index: 1; padding: clamp(40px, 7vw, 90px) var(--pad) }

/* Styled by class, never by id: an id selector outweighs any class, and
   rules hung on #hero would quietly beat the pinned layout's overrides. The
   ids stay for anchors and aria-labelledby, which is what they are for. */
:where(.scene--hero)  { background: linear-gradient(178deg, var(--hero-top),  var(--hero-bot)) }
:where(.scene--gel)   { background: linear-gradient(178deg, var(--gel-top),   var(--gel-bot)) }
:where(.scene--powder) { background: linear-gradient(178deg, var(--powder-top), var(--powder-bot)) }
/* The closing scene ends on the ground the page opened with: the loop is
   the point, so these are the hero's stops, not a fourth pale pair. */
:where(.scene--contacts) { background: linear-gradient(178deg, var(--hero-top), var(--hero-bot)) }

/* The dark scene. A lit lamp on a pale ground has nothing to light; on this
   one the blue glow reads as actual light and the white body separates.
   Every ink token is redefined here and inherits down the subtree. */
:where(.scene--lamp) {
  background: linear-gradient(178deg, var(--lamp-top), var(--lamp-bot));
}
.scene--lamp {
  --ink: #F7F0F6;
  --ink-soft: rgba(247, 240, 246, .78);
  --rule: rgba(247, 240, 246, .22);
  --accent-text: var(--brand);   /* brand magenta clears its threshold on plum */
  /* Leader lines and markers say which words belong to which point, so they
     are informational graphics under 1.4.11 and answer to 3:1 — not the
     decorative --rule, which is exempt and far too faint for the job. */
  --callout-rule: rgba(247, 240, 246, .62);
  --rule-strong: rgba(247, 240, 246, .62);
}

.scene-media {
  display: flex; align-items: center; justify-content: center;
  margin-bottom: clamp(24px, 4vw, 48px);
}
.scene-copy { max-width: var(--copy-width); margin: 0 auto }

/* Sources are web previews, not masters. Each object is capped so it stays
   sharp on a 2x screen; the device is small because its source is 292px. */
.shot { max-width: min(100%, var(--shot-cap, 358px)); object-fit: contain }
/* Each object is laid out at its LARGEST station and scaled down from
   there by the engine, so nothing is ever stretched past the resolution of
   its source. The device is largest in its own scene, the gel in its own,
   the package only ever appears on the first screen.

   There is no --shot-h any more: the contact shadow has to drop by half the
   object's height AFTER the scale, and a CSS calc would use the height
   before it, leaving a receded object standing above its own shadow. The
   engine measures the height and does the arithmetic. */
.scene--hero   { --shot-cap: var(--package-width) }
.scene--lamp   { --shot-cap: var(--device-width) }
/* The lit frame lies exactly on the unlit one and is faded in on top. */
.shot--lit { position: absolute; left: 0; top: 0 }
/* Two objects in one scene, so the cap is per object rather than per
   scene. The tube's is the laid canvas; what is seen is 92% of it wide and
   25% of it tall. */
.scene--lamp .pool[data-prop="device"] {
  --shot-cap: var(--device-width); --pool-w: 1.25; --pool-h: 1.40;
  /* The device's own light, at the lightness that darkens — the choice
     between this and the packaging's rose was settled by measurement (the
     two are equal in strength at these densities, .50 against .58) and the
     switch that compared them is gone with the rest of the page variants.
     Colour and density travel together: they answer one question. */
  --pool-ink: var(--pool-blue);
  --pool-density: var(--pool-blue-density);
}
.scene--gel .shot[data-prop="tube"],
.scene--gel .contact[data-prop="tube"],
.scene--gel .pool[data-prop="tube"] {
  --shot-cap: var(--tube-width); --contact-cap: var(--tube-contact);
}
/* The tube is the one shot that is not held to its media box. Every other
   object is placed inside a column and must not spill out of it; this one
   is placed by the engine against a slot in the chrome, and the box it
   happens to live in is not where it is drawn. Left with the usual
   min(100%, cap) it shrank with the column instead of with its token, and
   came out 48px short of its mark on a 320px screen. */
.js .scene--gel .shot[data-prop="tube"] { max-width: var(--shot-cap) }
.scene--gel .shot[data-prop="carton"],
.scene--gel .contact[data-prop="carton"] { --shot-cap: var(--carton-width) }
/* This one is drawn standing — 139 of ink across against 375 up — so a pool
   that hugs it is the taller of the two. The engine reads these two numbers
   as "how much larger than the drawing, along each of its own sides" and
   carries them through whatever turn the station gives it; lying in the
   wordmark's slot the same pair comes out as 201x80, which is what it was
   when the pool was a flat band with no opinion about the object. */
.scene--gel .pool[data-prop="carton"] {
  --shot-cap: var(--carton-width); --pool-w: 1.14; --pool-h: 2.87;
}
/* The brush is a hand holding the jar in mid-air: no shadow, no pool —
   light on the floor under it would put it on the floor. The watermelon jar
   STANDS on that floor, so it casts the contact shadow that stops it
   floating. No pool, though its lid merges with the warm ground: a pool was
   tried and the audit failed it — it shares the frame's right corner with
   the rail, and the outlined dot over the darkened ground measured 2.89:1
   against 3:1. The reasoning lives with the prop in js/scene.js. */
.scene--powder .shot[data-prop="brush"] { --shot-cap: var(--brush-width) }
.scene--powder .shot[data-prop="melon"],
.scene--powder .contact[data-prop="melon"] { --shot-cap: var(--melon-width) }

/* Every cutout may shrink to the row it stands in.

   Three of the sizes above are written for the pinned stage and are wider
   than the plain document's row on purpose: the last scene's two objects get
   half the frame each PLUS what hangs past its edge, and the tube gets its
   token rather than its column because the engine places it against the
   chrome. In the plain document there is no frame to hang off and no chrome
   to place against — the cutouts simply stand side by side in one flex row —
   and both pairs overflowed it: the powder scene by 34px on a 390px phone,
   the gel scene by the same 34px. The browser clipped both.

   It takes saying, because the automatic minimum size of a replaced flex
   item is its own drawing: without this they refuse to shrink at all.

   Not scoped to :not(.js): the pinned stage takes every .shot out of the
   flow entirely — absolutely positioned, sized by its cap — so there is no
   flex line for a minimum to bind in, and tools/geometry.py measures that
   nothing moved. */
.shot { min-width: 0 }

/* Decorative, engine-driven, hidden from assistive technology in the markup.
   The contact shadow is what stops three cutouts floating over the ground.
   It is not a plate: it fades to nothing well before its own edge. */
.contact, .pool { display: none }

/* Every cutout is a link (19.08). display: contents: the anchor adds no
   box, the imgs stay the flex items / absolutely-positioned elements they
   were, and the engine keeps writing transforms on them. The one thing a
   boxless element cannot do is paint its own focus ring, so the ring is
   painted by the drawing it wraps — the lit frame is welded to the base
   one, so one ring per object. */
.prop-link { display: contents }
.prop-link:focus-visible .shot:not(.shot--lit) {
  outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 2px;
}

/* The game's second act exists only where the game does: the pinned
   presentation. The plain document and reduced motion have no light to
   put out, so there is nothing for this to say there. */
.lamp-more { display: none }

/* -------------------------------------------------------- plain text pages

   The legal pages. Ordinary scrolling documents on the same tokens: no
   engine, no js flag, nothing pinned. The seller's details and every other
   piece of client data live in <dd> slots — see template-legal.html for why
   that is a rule and tools/deploycheck.py for who enforces it. */

body.doc { background: var(--hero-top) }
.doc .site-head a.mark { text-decoration: none }
.doc-main {
  max-width: 42rem; margin: 0 auto;
  padding: clamp(24px, 5vw, 56px) var(--pad) 40px;
}
.doc-main h1, .order-main h1 {
  font-weight: 200; font-size: clamp(1.7rem, 3.4vw, 2.4rem);
  letter-spacing: -.02em; margin-bottom: clamp(20px, 3vw, 32px);
}
/* The body-text rules are selector LISTS on purpose: the product pages'
   long-form body (.product-body) reads exactly like the legal pages, and a
   list keeps the values written once. The product main is NOT .doc-main —
   its opening screen runs on the front page's unscoped scene classes
   (.eyebrow, .scene-title, .lede, .contents), and inside .doc-main the
   (0,1,1) element rules here would out-specify every one of them. That
   near-miss is why .product-main is its own hook; see CLAUDE.md. */
.doc-main h2, .product-body h2 {
  font-size: clamp(1rem, 1.5vw, 1.15rem); font-weight: 600;
  margin: 30px 0 8px;
}
.doc-main p, .product-body p {
  font-size: .95rem; line-height: 1.65; color: var(--ink-soft);
  margin: 0 0 10px;
}
.doc-main dl, .product-body dl { margin: 8px 0 14px }
.doc-main dt, .product-body dt {
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft); margin-top: 8px;
}
.doc-main dd, .product-body dd {
  font-size: .95rem; line-height: 1.5; color: var(--ink);
  min-height: 1em;
}

/* The product pages' facts are SECTION headings, not labels in a
   sentence, and they were dressed as the latter: 10px caps in soft ink,
   which the eye walked straight past. Here they take the body heading's
   size and weight, full ink, and a rule with air above — the same
   furniture .contents already uses to say "a new block starts".
   Scoped to .product-body on purpose: the legal pages' <dt> really IS a
   label beside its value ("Toimituskulut: 5 €") and keeps the small
   caps. The shared rule above still carries everything both agree on. */
.product-body dt {
  font-size: clamp(1rem, 1.5vw, 1.15rem); font-weight: 600;
  letter-spacing: normal; text-transform: none; color: var(--ink);
  border-top: 1px solid var(--rule);
  margin-top: 30px; padding-top: 14px;
}
.product-body dd + dt { margin-top: 30px }
.doc-foot {
  max-width: 42rem; margin: 0 auto;
  display: flex; justify-content: space-between;
  padding: 0 var(--pad) 48px;
}
.doc-foot a {
  font-size: 12px; color: var(--ink-soft); text-decoration: none;
  border-bottom: 1px solid var(--rule);
}
.doc-legal { display: flex; gap: 18px }

/* ------------------------------------------------------------- product page

   The opening screen is a scene at rest: the product's ground, the scene
   typography (all unscoped classes, reused as-is), the contents strip and
   a priced buy pill — then a long-form body in the legal pages' column.

   The ground is the scene the page is bought from. Contrast was measured
   against the WORST stop of each pair (the powder's is its TOP stop —
   that gradient runs dark to light): --ink 12.8:1+, --ink-soft 5.4:1+,
   the pill 4.2:1+ against every pale ground, --accent-text 4.21:1 on the
   powder — clear of 3:1 for the large H1 it appears in, and NOT enough
   for body text, so the accent word stays in headings. The dark lamp
   ground is deliberately not among these: it would need the whole
   .scene--lamp token flip plus a ring on .buy, and the kit page takes the
   hero's pale ground instead (see PRODUCTS in build.py). */

body.doc.ground--hero   { background: linear-gradient(178deg, var(--hero-top),   var(--hero-bot)) }
body.doc.ground--gel    { background: linear-gradient(178deg, var(--gel-top),    var(--gel-bot)) }
body.doc.ground--powder { background: linear-gradient(178deg, var(--powder-top), var(--powder-bot)) }

/* The top padding is small because the product strip is the page's top
   structure now: the air that used to open the document is the strip's
   own row. The pill must still clear the fold on every cell — measured
   the day the strip landed: 1366x650 went 48px under and 390x844 up to
   39px, and every pixel of the fix comes out of spacing rather than out
   of words. */
.product-main { padding: clamp(14px, 1.8vw, 22px) var(--pad) 40px }

/* Copy beside the shelf above 820px, copy first in one column below — the
   same split and the same 821px seam the scenes use. Text-first is
   measured, not preferred: it keeps the buy pill above the fold on every
   supported viewport, photo-first pushes it under on 320x568. */
.product-hero {
  max-width: 74rem;
  margin: 0 auto clamp(28px, 5vw, 56px);
  display: grid; gap: var(--gap); align-items: center;
}
.product-intro { max-width: var(--copy-width) }
@media (min-width: 821px) {
  .product-hero { grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr) }
  /* The same cap the pinned stage took on 22.08, and for the same reason:
     the product headings grew from four words to eight or nine, and at 1366
     a 4.2rem heading ran to four lines of 72px. Cap down, slope untouched,
     so nothing under ~1046px moves. Here it is not about a frame — a
     product page scrolls — it is about the buy pill, which this page's
     whole order exists to keep above the fold. */
  .product-intro .scene-title { font-size: clamp(1.9rem, 5.2vw, 3.4rem) }
}
/* Short-wide relief, on the page's one height threshold (700px, the same
   seam as the slot's). Centred against the taller shelf column, the copy
   slides down and the pill leaves the viewport — measured by
   tools/product.py at 1366x650: the pill sat 633..680 in a 650px window.
   Top-aligned, the kit's whole stack ends ~77px clear of that fold. */
@media (min-width: 821px) and (max-height: 700px) {
  .product-hero { align-items: start }
  .product-intro .eyebrow { margin-bottom: 10px }
  .product-intro .scene-title { margin-bottom: 10px }
  /* And the strip's own air since 22.08. The kit's contents list is a
     LOOKUP into the hero scene's, so when the client's package took it
     from five items to six the extra row landed here too, and 1366x650
     put the pill at 620..655 in a 650px window — five pixels under the
     fold, fi and sv. The strip pays for its own row: 32+15 of spacing
     becomes 16+11, which is the same relief the narrow block above
     already takes and twenty pixels against a five-pixel debt. */
  .product-intro .contents { margin-top: 16px; padding-top: 11px }
  /* And one more step of type on the shortest wide windows, where the
     22.08 headings put the pill 77px under the fold at 1366x650 with the
     cap above already applied. */
  .product-intro .scene-title { font-size: clamp(1.9rem, 4.2vw, 2.9rem) }
}

/* ------------------------------------------ the copy arrives (20.08)

   The heading rises out of its masks, a line at a time, exactly the way
   a scene's does when the camera settles on it — and this is the answer
   to "the words should change too, in the same grammar".

   The thing worth writing down is that it is ORDINARY CSS on ordinary
   elements, and that this works ACROSS a cross-document view transition
   without the heading being named. Naming it would do the opposite of
   what is wanted: a name hands the element to the browser as a pair of
   snapshots and the browser morphs one flat picture into another, which
   is a dissolve. The heading stays unnamed, so it is part of the root
   capture — and the NEW half of a view transition is a LIVE rendering,
   not a frozen bitmap, so an animation running in the arriving document
   plays inside it. Verified frame by frame on a cross-document
   navigation before this was relied on: at 90ms the words were still
   below their masks, at 270ms the first line was half out and the
   second had not started.

   Which line each word wrapped onto is the one thing CSS cannot ask, so
   js/gallery.js writes --line per mask (see the block at the top of that
   file). With no script every mask reads 0 and the heading rises as one
   piece — the entrance without the stagger.

   140% of travel and a smoothstep-shaped curve are the engine's own
   numbers (MOTION.wordTravel, and the cubic-bezier this file already
   uses for the page join). The step between lines is FIXED here rather
   than normalised to the line count the way the engine normalises it:
   the engine has a deadline to hit — the pan ends when it ends — and an
   arriving page has none.

   The photographs are not in this block. The first one is the page's
   LCP candidate and loads eagerly; an entrance that starts it at zero
   opacity would be an entrance paid for out of the largest paint. */
@media (prefers-reduced-motion: no-preference) {
  .product-intro > * {
    animation: copy-in 420ms cubic-bezier(.33, 0, .67, 1) 60ms both;
  }
  .product-intro .scene-title .w > span {
    animation: line-in 360ms cubic-bezier(.33, 0, .67, 1) both;
    animation-delay: calc(60ms + var(--line, 0) * 110ms);
  }
  @keyframes copy-in {
    from { opacity: 0; transform: translateY(9px) }
    to   { opacity: 1; transform: none }
  }
  @keyframes line-in {
    from { transform: translateY(140%) }
    to   { transform: translateY(0) }
  }
}

/* Narrow and TALL: the shelf rises into the copy, under the heading —
   "what it is, here it is, why, what it costs". Order, not DOM: the
   markup keeps the reading order every other cell uses, the intro
   dissolves into the hero grid (display: contents) and order lifts the
   shelf two slots up. The one cost, stated: on these cells the shelf
   sits visually above the pill but after it in the tab order — the two
   are the row's only interactive elements, and a photo strip after the
   copy is still a meaning-preserving read order.

   Gated at 781px of height because the numbers are hard, not close:
   with the shelf in the copy the pill's bottom lands at 748/766 on
   390x844 (78-96px clear) but 711/727 on 360x640 and 666/701 on
   320x568 — under those folds by 71..133px, and no height cap saves
   them: the copy stack alone runs ~540px of a 568px window. 780 is the
   page's existing narrow height step (the lamp's first relief), not a
   new threshold. Below it the shelf stays where the markup puts it,
   after the pill, and the landing keeps its buy. */
/* Narrow, any height: the intro's own rhythm tightens to pay for the
   strip above it. Air only — the same trade the lamp scene's height
   steps make. */
@media (max-width: 820px) {
  .product-intro .contents { margin-top: 10px; padding-top: 8px }
  .price-line { margin-top: 6px }
  .product-intro .eyebrow { margin-bottom: 8px }
  .product-intro .scene-title { margin-bottom: 8px }
  /* And the type, since 22.08: the same trade as the wide block, one step
     smaller because a phone has less to give. Measured at 320x568 and
     390x844, where the longer headings put the pill 26 and 82px under the
     fold. Still 27.2px at the floor, so a heading stays large text and
     keeps its 3:1. */
  .product-intro .scene-title { font-size: clamp(1.6rem, 7.4vw, 2.2rem) }
}

@media (max-width: 820px) and (min-height: 781px) {
  .product-intro { display: contents }
  /* The hero's own gap would now open between every row; the copy's
     rhythm is carried by the elements' own margins, so it goes. */
  .product-hero { gap: 0 }
  .product-intro > .eyebrow      { order: 1 }
  .product-intro > .scene-title  { order: 2 }
  .product-hero  > .gallery      { order: 3; margin: 2px 0 8px }
  /* And it takes less of the row than a shelf normally does. Lifted
     above the copy, a portrait photograph at the usual 86% is 420px of
     an 844px phone, and everything under it — lede, contents, price,
     stock, pill — has to fit in what is left. It did not: measured
     across the grid, the worst cell (Finnish powder, 390x844) had FIVE
     pixels of room under the pill before the strip ever grew a
     thumbnail, which is not a margin, it is a coincidence. 76% gives it
     32, and the card that peeks past the edge peeks further, which is
     the affordance working harder rather than a loss. */
  /* 76% became 70% on 22.08. The copy above it grew by a heading line and
     a longer lede, and the shelf is the only thing in this column whose
     size is a choice rather than a word. Measured again on the same worst
     cell: the pill lands clear on 390x844 in all three languages. */
  .product-hero > .gallery figure { flex-basis: 67% }
  .product-intro > .lede         { order: 4 }
  .product-intro > .contents     { order: 5 }
  /* Every child needs a number here, and this is why: `display: contents`
     dissolves .product-intro into the hero grid, and a child with no
     `order` takes 0 — which sorts BEFORE order 1, not after the list.
     The price line landed with the introductory offer and was left out,
     so on every tall narrow cell it stood ABOVE the product's own name,
     as the first thing on the page. Caught by a screenshot, not by a
     number; tools/product.py now asks the intro's order outright. */
  .product-intro > .price-line   { order: 6 }
  /* The same trap, sprung a second time. The stock line landed after
     the note above was written and was left out of this list, so it
     took 0 and sorted above the product's own name — the reader met
     "in stock, 2-4 days" before being told what was in stock. It
     shipped that way, and a screenshot found it, not a number: every
     question here was green because every element was present,
     readable and above the fold. tools/product.py now refuses any
     child of the intro that this list does not place. */
  .product-intro > .stock-line   { order: 7 }
  .product-intro > .product-cta  { order: 8 }
  .product-intro > .vat-note     { order: 9 }
  /* And the payment row's number, written BEFORE the row exists — which
     is the only interesting thing about this line. Twice now a child
     landed in this block without a number, took 0, and stood above the
     product's own name; both times it shipped and a screenshot found
     it. The row is coming (PAYMENT_MARKS in build.py), its place in the
     reading is known, and the number costs nothing while the row is
     empty. The third time this trap springs will not be this element. */
  .product-intro > .pay-row      { order: 10 }
}

/* The introductory offer: the selling price beside the struck former one.
   Display only — the machine `was` in build.py's PRODUCTS reaches this
   line and nothing else. The <s> opens with a visually hidden word
   ("aiemmin") so the strike survives a screen reader. */
.price-line { margin: 12px 0 0; font-size: 1.05rem }

/* Availability. The dot is decoration — the word beside it says the same
   thing, the way the numbered list carries the callouts' numbering — but
   it is #166534 rather than a lighter green so it clears 3:1 against the
   worst stop of every product ground anyway (5.51:1 on the powder's top
   stop, which is the tightest). --ink-soft on the words: 5.4:1+, and
   this is small text. */
.stock-line {
  margin: 6px 0 0; font-size: .8rem; color: var(--ink-soft);
  display: flex; align-items: center; gap: 8px;
}
.stock-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: #166534; flex: none;
}
.price-now { color: var(--ink); font-weight: 600 }
.price-was { color: var(--ink-soft); margin-left: 10px; font-size: .9rem }

/* The long-form body: the legal pages' measure and, via the selector lists
   above, the legal pages' type. */
.product-body { max-width: 42rem; margin: 0 auto }

/* The pill's own gap, product pages only — and .product-hero is in the
   selector because .scene-cta sits LATER in this file at equal weight,
   the same trap .order-form .order-cta-row records. */
.product-hero .product-cta { margin-top: 14px }

/* The VAT note under the pill. Mandatory, but crowded inside the label —
   the price rides in the pill like the scene rows, the tax wording sits
   here. --ink-soft holds 5.4:1 against the darkest stop of every product
   ground, so the small size is lawful (4.5:1). */
.vat-note { font-size: .75rem; color: var(--ink-soft); margin-top: 8px }

/* ------------------------------------------------------------- the shelf

   A horizontal scroller with snap; every photograph is a rounded card
   (two different baked backgrounds — lilac and light grey — read as the
   content of a photograph once framed). One photograph still gets the
   shelf: js/gallery.js answers the pull with elastic resistance either
   way, which is what makes the element read as alive.

   The card basis is fixed (86%), not auto-fit: the next card peeking past
   the edge is the scroll affordance, and the lone powder card must not
   stretch past what its 2x source resolves. The scrollbar is hidden only
   under .shelf--live, the class the script sets when it is in charge —
   with no JS the thin scrollbar is the only affordance a mouse has.

   The mechanics below are written once, on `data-shelf`, because there
   are two shelves now: these photographs and the product strip above
   them. The looks stay per element; what is shared is the scroller, and
   the alternative was a second copy of the elastic curve, which is how
   two implementations drift. */

[data-shelf] {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}
.gallery:focus-visible {
  outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 2px;
}
.shelf--live { scrollbar-width: none }
.shelf--live::-webkit-scrollbar { display: none }
/* The elastic offset lives on the track as a transform — never on the
   scroller, which owns scrollLeft, the snap geometry and (on iOS) the
   native rubber-band. Transform-only, like everything animated here. */
[data-shelf-track] { display: flex }
.gallery-track { gap: 14px }
.gallery figure {
  margin: 0; border-radius: 14px; overflow: hidden;
  flex: 0 0 86%;
  scroll-snap-align: start;
}
.gallery img { display: block; width: 100%; height: auto }
/* While a mouse drag is scrubbing scrollLeft directly, mandatory snap
   would quantise every write; the script lifts it for the drag's duration
   and settles onto a snap point itself on release. */
.shelf--drag { scroll-snap-type: none }
.shelf--drag, .shelf--drag figure, .shelf--drag a { user-select: none }
/* When nothing overflows there is no native gesture response on any
   platform, so the script owns the horizontal pull — pan-y leaves the
   vertical page scroll native, pinch-zoom keeps the photographs zoomable
   (pan-y alone would revoke pinch, a WCAG problem on a gallery). */
.shelf--static { touch-action: pan-y pinch-zoom }

/* ------------------------------------------------ the results ribbon

   Before/after pairs under the shelf: one photograph per pair, before
   above and after below, no words baked in. It is a shelf underneath —
   drag, elastic edge, keyboard — and a marquee on top of that, which is
   why it declares no snap: snapping is for a pager, and this runs.

   Cards are sized by HEIGHT with auto width. The five pairs are not all
   the same proportion (one is 774 wide against 870), and a fixed width
   would make a ragged bottom edge out of that; a fixed height makes it
   a ribbon of pictures the way a contact sheet is.

   The disclaimer is part of this block, not decoration under it: a
   before/after claim without "individual results vary" and without the
   source of the photographs is the claim standing alone, which is what
   655/2013 is about. tools/product.py fails the page if it goes. */
/* --eye is the window at rest and --eye-peak the size a photograph has
   to reach before it can be read. A LENGTH and not a multiplier: the
   multiplier it replaces made a phone's reading size two thirds of a
   laptop's, which is backwards — the small screen is the one that needs
   the picture bigger. 700px of height is the site's existing short-window
   seam; no new one is opened here.

   NOTHING IN THIS BLOCK IS SCALED (22.08, client). The magnified pair is
   given room BY THE ROW, so the painted box is the layout box, and "the
   magnified card covers its neighbour and eats its clicks" is not a state
   this code can enter — flex hit-testing does the proving, not arithmetic
   of ours. That is why the old comments here saying "TRANSFORM and not
   width/height" are gone rather than edited: they described the mechanism
   that caused the complaint. The front page's rule is untouched; this
   ribbon is the exception and the reason is in js/results.js — the row is
   anchored so a reflow never moves the card under the reader's hand. */
.results {
  /* A step smaller again (24.08, client). The reading size stays: --eye
     is the price of the row at rest, --eye-peak is what a photograph
     needs to be read, and only the first is the client's "smaller". */
  --eye: clamp(14px, 1.6vw, 19px);
  --eye-peak: 88px;
  --gap: 12px;
  --halo: 12px;              /* the peak shadow reaches furthest: 4 down, 8 blur */
  /* Everything in a card that is not the two windows, summed: 9 padding
     + 11 word + 7 + 5 between the windows + 7 + 11 word + 10 padding. The
     words are 10px at line-height 1.1, so every term is a number rather
     than a font metric. Checked against a real --k: 1 card, not trusted. */
  --card-chrome: 60px;
  --card-h: calc(var(--eye-peak) * 2 + var(--card-chrome));
  max-width: 74rem; margin: 0 auto clamp(28px, 5vw, 56px);
}
@media (max-height: 700px) { .results { --eye-peak: 64px } }

/* --k is the one number this ribbon runs on: 0 is a pair at rest, 1 a
   pair at reading size. REGISTERED so that it interpolates — with no
   script the two rules further down are the whole magnifier, and this is
   their ease. */
@property --k { syntax: "<number>"; inherits: true; initial-value: 0 }

/* The fold (22.08, client): the ribbon is shut until its own heading is
   pressed. A native <details>, so the whole control — pointer, Enter,
   Space, the tab stop, the focus ring and the announced
   expanded/collapsed state — is the browser's and works with no script.

   The default triangle goes and ours arrives; ::-webkit-details-marker is
   still needed for older WebKit, where `display: block` on the summary is
   not enough on its own. */
.results-fold > summary { list-style: none; cursor: pointer }
.results-fold > summary::-webkit-details-marker { display: none }
.results-head {
  display: flex; align-items: baseline; gap: 16px;
  padding: 4px 0;
}
.results-h {
  font-size: clamp(1rem, 1.5vw, 1.15rem); font-weight: 600;
  color: var(--ink); margin: 0;
}
/* The state, in a word and in a chevron. One word ships visible and the
   other waits; which one is a matter of [open] and nothing else. */
.results-state {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft);
}
.results-word--open { display: none }
.results-fold[open] .results-word--shut { display: none }
.results-fold[open] .results-word--open { display: inline }
.results-chevron {
  width: 7px; height: 7px; flex: none;
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
  transition: transform var(--tap-ms) var(--tap-ease);
}
.results-fold[open] .results-chevron {
  transform: translateY(1px) rotate(-135deg);
}
/* The reveal. ::details-content is the browser's own wrapper around
   everything after the summary, and animating it is the only way to move
   a <details> open without taking the element apart and rebuilding it out
   of a button and a div — which would cost every free behaviour listed
   above. `interpolate-size` is what makes auto an interpolable keyword;
   where it is not supported the fold still opens, just at once. That is
   the same bargain this site already took with @view-transition: a plain
   result in a browser that cannot do the fancy one is not a fault. */
:root { interpolate-size: allow-keywords }
.results-fold::details-content {
  block-size: 0;
  overflow: clip;
  transition: block-size 320ms var(--tap-ease),
              content-visibility 320ms allow-discrete;
}
.results-fold[open]::details-content { block-size: auto }
@media (prefers-reduced-motion: reduce) {
  .results-fold::details-content { transition: none }
  .results-chevron { transition: none }
}
/* The band is the ROW's own height now rather than the scroller's
   padding: the growth happens inside it instead of pushing the page
   about. The strip keeps only the halo, so a shadow and a focus ring are
   not clipped by the overflow. */
.results-strip {
  scroll-snap-type: none;
  padding: var(--halo); margin-inline: calc(var(--halo) * -1);
  contain: layout;
}
/* At the RIGHT edge while the five fit (24.08, client — the heading and
   the disclaimer hold the left edge, and a small row floating in the
   middle of a wide column belonged to neither), and scrolled when they
   do not: `safe` turns end-alignment into start-alignment on an
   overflowing row, so the narrow layout keeps every card reachable and
   nothing here has two arrangements. */
.results-track {
  list-style: none; margin: 0; padding: 0; gap: var(--gap);
  align-items: center; min-height: var(--card-h);
  justify-content: safe flex-end;
}
/* min-content asks the browser what the words need instead of asking
   this file, so a longer Finnish label gets a wider card and nothing
   here has to know which language it is in. */
.results-card { flex: none; display: flex; min-width: min-content }

/* With no script this is the whole magnifier, and it is the SAME
   mechanism: --k goes to 1, the card's auto width becomes its max-content
   width, the row gives it the room and the neighbours move. Gated on
   :not(.results--dock) so that measure() in js/results.js cannot be
   answered by a hover instead of by the stylesheet. */
@media (hover: hover) and (pointer: fine) {
  .results:not(.results--dock) .results-card:hover { --k: 1 }
}
.results:not(.results--dock) .results-card:has(.results-pair:focus-visible) { --k: 1 }
.results-card { transition: --k 180ms var(--tap-ease) }
@media (prefers-reduced-motion: reduce) { .results-card { transition: none } }

/* The CARD is the control, so what magnifies is the pair and not one
   half of it: before and after come up together, at one size, with their
   two words. Magnifying one window at a time asked the reader to compare
   a big picture with a small one, which is the only comparison this
   ribbon exists to prevent. */
.results-pair {
  width: 100%;
  display: flex; flex-direction: column; align-items: center;
  border: 0;
  background: #fff; border-radius: 16px;
  padding: 9px 10px 10px;
  cursor: zoom-in;
  /* The one thing that answers a pointer, and it answers a keyboard with
     the same number, because both arrive as --k. Still no dimming: a
     photograph a reader has just asked to see better may not get darker. */
  box-shadow: 0 calc(1px + 3px * var(--k, 0)) calc(2px + 6px * var(--k, 0))
              rgba(36, 28, 34, calc(.07 + .09 * var(--k, 0)));
}
/* 2px, drawn: the ring that had to be divided by the magnification went
   with the magnification. */
.results-pair:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px }

/* The round windows (20.08, client). The pair is ONE file and is still
   never cut: the same <img> is placed twice and each copy is shown
   through a circle over one half of it. A cut at 50% takes a corner off
   the photograph on pairs 1 and 4, where the two tilted frames meet
   across the middle with no paper between them; a circle discards the
   corners by construction, and it also drops the frame numbers and the
   FILM NEGATIVE down the margins.

   One rule for all five, measured on the 2x files: the largest circle
   holding nothing but photograph sits at (50%, 26.90%) and (50%, 73.05%)
   of the file, and its diameter is 0.396 of the file's height on the
   tightest of them (pair-1) and 0.424 on the loosest. 0.39 is used for
   all five, and everything below follows from that one number:

     the picture is drawn 1 / 0.39 = 256.410% of the circle's own height
     the upper window offsets it by  0.5 - 0.2690 / 0.39 = -18.974%
     the lower window offsets it by  0.5 - 0.7305 / 0.39 = -137.308%

   Percentages of the circle box on both axes, so the whole thing scales
   with the window and nothing here is a pixel. */
.results-eye {
  display: block; position: relative;
  width: calc(var(--eye) + (var(--eye-peak) - var(--eye)) * var(--k, 0));
  height: calc(var(--eye) + (var(--eye-peak) - var(--eye)) * var(--k, 0));
  border-radius: 50%; overflow: hidden;
  background: var(--rule);
}
.results-eye + .results-eye { margin-top: 5px }
.results-eye img {
  position: absolute; left: 50%;
  width: auto; max-width: none; height: 256.410%;
  transform: translateX(-50%);
}
.results-eye--before img { top: -18.974% }
.results-eye--after  img { top: -137.308% }

/* The words do not change size, and no longer have to be argued out of
   it: nothing is scaled, so the counter-scale, the two transform-origins
   and the transition that reduced motion forgot to cancel are DELETED
   rather than replaced. */
.results-when {
  display: block; margin: 0; font-size: 10px; line-height: 1.1;
  letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft); white-space: nowrap;
}
.results-eye + .results-when { margin-top: 7px }
.results-when:first-child { margin-bottom: 7px }
.results-note {
  font-size: .75rem; color: var(--ink-soft);
  margin: clamp(8px, 1.2vw, 12px) 0 0;
}

/* ---- the script is in charge (js/results.js) ------------------------

   --w0, --w1 and --rail are MEASURED off these elements rather than read
   from here: --eye is inside a clamp() and a custom property does not
   compute, so getComputedStyle hands back the string.

   THE RAIL NEVER CHANGES WIDTH. That is what buys the right to run
   inside somebody else's scroller: scrollWidth never moves, so
   canScroll(), shelf--static, the wheel's edge release and the elastic
   ceiling in js/gallery.js all keep measuring the shelf they measured a
   frame ago — markStatic() is only re-read on resize. --lead is where
   the row starts inside the rail, and moving it is how the row stays
   anchored under the reader's hand while the widths change. */
.results--dock .results-track {
  width: var(--rail); justify-content: flex-start;
  padding-left: var(--lead, 0px);
  /* auto left, zero right: the rail hugs the right edge the resting row
     is asked to hug (24.08). The row's own place inside the rail is
     --lead, and its rest value leaves only a sliver of the reserve on
     the right — see home in js/results.js. */
  margin-inline: auto 0;
}
.results--dock .results-card {
  width: calc(var(--w0) + (var(--w1) - var(--w0)) * var(--k, 0));
  min-width: 0;
  /* No transition on anything the frame loop writes. A 180ms ease under
     a per-frame writer is the trap this project has paid for once. */
  transition: none;
}
/* The two reads measure() takes, and the only time the explicit width is
   lifted while the dock class stays on. */
.results--measure .results-card { width: auto; min-width: min-content }

/* ------------------------------------------------------- product strip

   Every product, above every product page, the current one marked. Built
   from the PRODUCTS map in build.py, so the fourth and fifth products are
   a config entry and nothing else.

   ONE element for both layouts. Wide: three (soon five) short names do
   not overflow and it reads as a plain row. Narrow: they do, and it is
   the shelf — native snap, drag, elastic edge, and the next name cut by
   the frame doing the work a row of dots would otherwise do. No dots,
   and that is the reason: the strip already says where you are (the
   marked name) and that there is more (the cut one), and a second
   alphabet for the same two facts is the kind of addition this page
   removes.

   Marked by weight and a rule rather than a filled pill — the same
   decision and the same reason as the language links: a pill has to
   invert between grounds, and on the way its label passes through its
   own background. */
.product-nav {
  max-width: 74rem; margin: 0 auto clamp(10px, 1.4vw, 16px);
  border-bottom: 1px solid var(--rule);
}
.product-nav-track { list-style: none; margin: 0; padding: 0 }
.product-nav li { flex: none; scroll-snap-align: start }
.product-nav a {
  display: flex; align-items: center; gap: 9px;
  padding: 5px 22px 7px 0; text-decoration: none;
  font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--ink-soft); white-space: nowrap;
  border-bottom: 2px solid transparent; margin-bottom: -1px;
}
/* The thumbnail is what flies. Cropped square-ish and small: the strip is
   a row of names with a picture each, not a row of pictures. */
.product-nav-thumb {
  flex: none; width: 30px; height: 36px;
  object-fit: cover; border-radius: 6px; display: block;
}
/* No a[aria-current="page"] rule here any more (removed 23.08). The strip
   stopped listing the current product on 22.08, so nothing inside it can
   carry the attribute. The comment that kept it said it was "kept for the
   language switcher" -- which this selector cannot reach, being scoped to
   .product-nav, while the switcher is .lang. A rule kept for a reason its
   own selector forbids, and a comment that made it read as a decision. */
/* The foot copy of the strip. Same mechanics, same look, one difference:
   it opens with a rule instead of closing with one, because it is the
   bottom of the reading rather than the top of it. */
.product-nav--foot {
  border-bottom: 0; border-top: 1px solid var(--rule);
  margin: clamp(28px, 5vw, 56px) auto 0;
}
.product-nav--foot a { padding-top: 9px; padding-bottom: 3px }

/* Wide and short: here the scarce axis IS the strip's own, so the strip
   pays for its picture. 1366x650 is the tightest cell on the site for
   the buy pill and it kept 26px of room before the thumbnails landed;
   at full size they took 22 of those. Slimmed, the picture costs this
   cell 8px and leaves 18 — a price paid where the room is, rather than
   a photograph hidden on the one layout that can least afford to lose
   an affordance.

   HERE and not in the wide-and-short block up in the product-page
   section, and the difference is not tidiness: that block stands
   EARLIER in this file, and .product-nav-thumb above weighs the same,
   so the media query lost on order alone and the strip stayed 50px
   tall. Written there it looked completely correct. The same trap
   .product-hero .product-cta and .order-form .order-cta-row record. */
@media (min-width: 821px) and (max-height: 700px) {
  .product-nav-thumb { width: 24px; height: 28px }
  .product-nav a { padding: 2px 22px 4px 0 }
}

/* Narrow: the strip must hold EVERY product with no scroll, including
   the fourth when it arrives (24.08, client). The names went short for
   it (products.*.short) and the chrome pays the rest: the same 24x28
   thumbnail the wide-short cells already take, and the trailing air cut
   to 14px. Measured on 320 (272px of column): three five-letter names —
   the strip the fourth product will make — need ~250px dressed this
   way, and 290 dressed the old way. Same trap as the block above: this
   stands AFTER the base rules it overrides, and the existing 820 seam —
   no new threshold. */
@media (max-width: 820px) {
  .product-nav a { padding-right: 14px }
  .product-nav a .product-nav-thumb { width: 24px; height: 28px }
}

/* The buy pill. --cta is its own token, not --brand and not --accent-text:
   the wordmark's magenta holds only 4.02:1 against white, and the token
   rule on this page is that a colour serves one purpose. This one was
   picked by measurement — 5.45:1 with white text, clear of the 4.5:1 the
   type size asks for. The label carries the price, like the scene rows. */
.buy {
  display: inline-block; background: var(--cta); color: #fff;
  padding: 12px 28px; border-radius: 999px; text-decoration: none;
  font-weight: 600; font-size: .95rem; letter-spacing: .02em;
}

/* ------------------------------------------------------------- order page

   The purchase form (template-order.html): one centred column, the seed
   product's row baked at quantity 1, the add-ons below at 0, a submit
   pill that reuses .buy. The page runs on the same tokens as the product
   pages — its ground is the seed's scene ground, so --ink / --ink-soft
   hold the same measured contrast they hold there. Input borders take
   --ink-soft, not --rule: an input's boundary identifies a control
   (WCAG 1.4.11 wants 3:1), and --ink-soft holds 5.4:1+ on every pale
   ground where --rule is decoration-grade.

   Compact on purpose: the submit button must clear the fold before any
   scrolling on every grid cell, 320x568 included — tools/order.py asks. */

.order-main {
  max-width: 34rem; margin: 0 auto;
  padding: clamp(24px, 5vw, 56px) var(--pad) 40px;
}
.order-rows { list-style: none; margin: 0; padding: 0 }
.order-row {
  display: flex; align-items: center; gap: 14px;
  padding: 8px 0; border-bottom: 1px solid var(--rule);
}
.order-thumb { width: 46px; height: auto; border-radius: 8px; flex: none }
.order-what {
  display: flex; flex-direction: column; gap: 2px;
  min-width: 0; margin-right: auto;
}
.order-name { font-size: .95rem; color: var(--ink) }
.order-price { font-size: .8rem; color: var(--ink-soft) }
.order-qty { display: flex; align-items: center; gap: 10px }
/* The steppers js/order.js injects, and the label between them. They are
   not in the markup because without a script they would be two controls
   that do nothing — the number input's own spinner is the no-JS path and
   always was. 32px clears the 24px minimum target size AA asks for; the
   boundary takes --ink-soft, not --rule, for the same reason the input's
   does: a control's edge is informational graphics at 3:1, and --rule is
   decoration-grade. */
.order-qty-group { display: flex; align-items: center; gap: 8px; flex: none }
.qty-step {
  flex: none; width: 32px; height: 32px; border-radius: 999px;
  border: 1px solid var(--ink-soft); background: transparent;
  color: var(--ink); font: inherit; font-size: 15px; line-height: 1;
  cursor: pointer; padding: 0;
}
.qty-step:disabled { opacity: .4; cursor: default }
.order-qty-label {
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft);
}
.order-qty input {
  width: 3.4em; padding: 6px 4px; text-align: center;
  font: inherit; color: var(--ink);
  background: transparent; border: 1px solid var(--ink-soft);
  border-radius: 8px;
}
.order-addons-h {
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  font-weight: 600; color: var(--ink-soft);
  margin: 16px 0 0;
}
/* The delivery zone: a native radio pair. The country choice Stripe's
   hosted page cannot make is made here — see template-order.html. */
.order-zone { border: 0; padding: 0; margin: 16px 0 0 }
.order-zone-h {
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  font-weight: 600; color: var(--ink-soft);
  padding: 0; margin-bottom: 4px;
}
/* Two tiles rather than two bare radios. The radio is DRAWN AWAY and
   not replaced (21.08, client): it keeps its box, its focus, its
   arrow-key roving, its name and its required-ness, and the tile carries
   the state instead of the little circle. That is a different thing from
   a custom control, which would have to reinvent all five.

   Two consequences are load-bearing. The tile takes the focus ring,
   through :focus-within, because the thing that used to show focus is
   now invisible — a control whose focus cannot be seen is a control a
   keyboard cannot use. And where :has() is missing the tiles stop
   marking the choice, so the radio has to stay reachable rather than
   removed: `clip` hides it from the eye and from nothing else.

   ONE COLUMN, at every width (20.08, client). They used to sit side by
   side, and on the narrow layout that was a measurement rather than a
   preference: "Övriga EU-länder" does not fit beside "Finland" on a
   320px screen, and the second row it wrapped onto put the submit under
   the fold. The row is back, deliberately, and it is paid for in
   spacing — see the narrow block below and .order-row's padding. A
   stacked pair is what a choice between two countries looks like; two
   half-width tiles read as a segmented control, which is a different
   promise.

   The look is the product strip's, which is where the reader has just
   come from: small caps, wide tracking, a soft ink at rest. What is NOT
   borrowed is the strip's way of marking the current item — the strip
   marks by weight and a rule, because a pill would have to invert
   between the front page's grounds and its label would pass through its
   own background on the way. This page has one ground and nothing
   crosses it, so the chosen tile can do the plainer thing and fill.

   The fill is --ink and NOT --cta, and that is the one decision here
   worth the paragraph. --cta was tried first and looked right on its
   own: the tile in the page's own accent, matching the submit. Seen
   together it was the defect — the submit sits directly under these
   tiles, so the page offered two magenta fills in a column, and a
   colour that means "this is the action" cannot also mean "this is the
   country you picked". One call to action, one magenta. Ink is the
   page's neutral emphasis and competes with nothing.

   Both marks are kept, not one: the fill is colour, and the edge is
   what survives a reader who cannot use it. tools/order.py asks for
   both, and asks the chosen tile's words against ITS OWN fill rather
   than against the ground, which is the only surface they touch. */
.order-zone-choices { display: grid; gap: 6px; justify-items: start }
.order-zone-choice {
  display: inline-flex; align-items: center;
  margin: 0; padding: 8px 18px 9px;
  border: 1px solid var(--rule); border-radius: 999px;
  font-size: 11px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft);
  cursor: pointer;
}
.order-zone-choice:has(:checked) {
  background: var(--ink); border-color: var(--ink);
  color: #fff; font-weight: 600;
}
/* Out of sight, in the accessibility tree, and still the thing the
   keyboard drives. NOT display:none and NOT visibility:hidden — both
   take the control out of the tab order and out of the group. */
.order-zone input {
  position: absolute; width: 1px; height: 1px;
  margin: 0; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
/* The ring the invisible radio can no longer draw. :focus-within rather
   than :focus, because what is focused is inside the tile. */
.order-zone-choice:has(:focus-visible) {
  outline: 2px solid var(--ink); outline-offset: 2px;
}

/* Two classes, not one: the row also carries .scene-cta, whose own
   margin-top rule sits LATER in this file at equal specificity and wins —
   a single-class declaration here is dead on arrival (found by the
   falsify case attacking it and catching nothing). */
.order-form .order-cta-row { margin-top: 18px }
/* .buy is written for <a>; the submit is the site's first <button>. */
.order-submit { border: 0; cursor: pointer; font-family: inherit }
.order-submit:disabled { opacity: .45; cursor: default; filter: none }
.order-note { font-size: .75rem; color: var(--ink-soft); margin-top: 4px }

/* Narrow: the row is thumbnail, name, price and a stepper group that may
   not shrink, and on a 320px screen the written word "Määrä" is what
   does not fit — measured, it landed on top of the product's own name.
   It goes, and nothing goes with it: the input carries its own accessible
   name (aria-label, the product included) and each stepper carries the
   verb plus the product, so the row reads the same aloud at every width. */
@media (max-width: 820px) {
  .order-row { gap: 10px }
  .order-qty-label { display: none }
}

/* The tight cells, and the two of them are ONE rule with two entrances.
   Stacking the delivery tiles cost this page a row, and the row has to
   be bought back wherever the fold is close: on every narrow cell, and
   on the wide cells under 700px of height — the site's one height
   threshold, shared with the slot's relief and the product page's.
   Measured before and after by tools/order.py, which fails the run when
   the submit is not whole inside the window before any scrolling: at
   320x568 and at 1366x650, the two cells that decide.

   A media LIST rather than two blocks. The trims are the same trims and
   the reason is the same reason, and the way two copies of a relief
   drift apart is by being written twice. Air, not words. */
@media (max-width: 820px), (min-width: 821px) and (max-height: 700px) {
  .order-main { padding-top: clamp(14px, 2vw, 24px) }
  .order-row { padding: 5px 0 }
  .order-thumb { width: 40px }
  .order-addons-h { margin-top: 12px }
  .order-zone { margin-top: 12px }
  /* The consent sentence is three lines in English at 320px and two in
     Finnish, so this cell is decided by the longest translation — it
     went 1px under the fold, measured, and the pixels come out of the
     air around the sentence rather than out of the sentence. */
  .order-consent { margin-top: 10px }
  .order-form .order-cta-row { margin-top: 12px }
  /* 22.08: the product names in the rows got longer — "OnlySmile
     pafyllningsgel", "Teeth whitening powder" — and at 320px each of the
     three rows wraps to two lines where it used to take one. Swedish went
     7px under the fold and English one. The rows pay in their own leading
     and padding; the names are not shortened, because a cart row that
     will not say which product it is is worse than a tight one. */
  .order-row { padding: 4px 0 }
  .order-name { font-size: .88rem; line-height: 1.35 }
}
/* Consent, above the submit. A native checkbox with `required`: without
   a script the browser refuses the submit and points here, and with one
   nothing changes. The label wraps the control, so the whole sentence is
   the hit area — a 13px box is not a target — and the ring goes on the
   drawn box rather than on the sentence, which would outline three
   lines of text. */
.order-consent { margin: 14px 0 0 }
.order-consent-label {
  display: flex; align-items: flex-start; gap: 10px;
  font-size: .8rem; line-height: 1.5; color: var(--ink-soft);
  cursor: pointer;
}
.order-consent input {
  flex: none; width: 18px; height: 18px; margin: 1px 0 0;
  accent-color: var(--cta);
}
.order-consent a { color: var(--ink); text-decoration: underline }
/* The payment marks (22.08). Nothing draws here until the official
   files land — see PAYMENT_MARKS in build.py — but the surface they land
   ON is a decision, not a default, and it is made here.

   EVERY MARK GETS A WHITE PLATE, including the ones that would read
   against the ground on their own. Two reasons, and neither is taste.
   The brand books ask for clear space around the mark and specify it as
   a fraction of the mark's own height, which is what --pay-clear
   carries; and the grounds these rows stand on are pale gradients, so a
   mark drawn in a light blue or a white-heavy lockup would sit on a
   surface it cannot be told apart from. A plate answers both at once:
   the clear space becomes the padding, and the mark is measured against
   white on every page instead of against four different grounds.

   Not a link and not a control: it is a statement about what this shop
   takes. The row is a list because it is one, and the <ul> carries the
   name so a reader hears "payment methods" before hearing three brands.

   A mark is drawn at 20px of cap height and keeps its own ratio — the
   width comes off each file's viewBox at build time. Squaring them all
   up would be redrawing two trademarks to fit a box. */
.pay-row {
  --pay-h: 14px;
  list-style: none; display: flex; flex-wrap: wrap;
  gap: 6px; margin: 12px 0 0; padding: 0;
}
.pay-mark {
  display: flex; align-items: center;
  background: #fff; border-radius: 6px;
  /* The clear space is a fraction of the mark's own height, so it has to
     be that height and not a number typed twice. */
  padding: calc(var(--pay-clear, .2) * var(--pay-h));
  box-shadow: 0 0 0 1px rgba(36, 28, 34, .08);
}
.pay-mark img { display: block; height: var(--pay-h); width: auto }

.thanks-home { margin-top: 24px }
.thanks-home a { color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--rule) }

/* ------------------------------------------------------------ typography */

.eyebrow {
  font-size: clamp(10px, .7vw, 11px); letter-spacing: .26em; text-transform: uppercase;
  color: var(--ink-soft); margin-bottom: clamp(16px, 2.2vw, 28px);
}

.scene-title {
  font-weight: 200; font-size: clamp(2rem, 5.2vw, 4.2rem); line-height: 1.02;
  letter-spacing: -.035em; margin-bottom: clamp(14px, 1.8vw, 24px);
  color: var(--ink);
}
.scene-title em { font-style: normal; font-weight: 300; color: var(--accent-text) }

/* One mask per word, so the number of lines is never assumed. The padding
   keeps descenders and umlauts out of the clip: overflow cuts at the
   padding edge, and the negative margin cancels it in layout. Measured
   headroom at these values: +25.5 / +11.5 per 100px of type. */
.scene-title .w {
  display: inline-block; overflow: hidden; vertical-align: top;
  padding-top: .18em;    margin-top: -.18em;
  padding-bottom: .26em; margin-bottom: -.26em;
}
.scene-title .w > span { display: inline-block; overflow-wrap: break-word }

.lede {
  font-size: clamp(.84rem, 1.02vw, 1rem); line-height: 1.62;
  color: var(--ink-soft); max-width: 29rem;
}

.contents {
  margin-top: clamp(20px, 2.4vw, 32px); padding-top: 15px;
  border-top: 1px solid var(--rule);
  display: flex; flex-wrap: wrap; gap: 7px 24px;
  font-size: clamp(10px, .68vw, 11px); letter-spacing: .16em;
  text-transform: uppercase; color: var(--ink-soft);
}
.contents b { color: var(--ink); font-weight: 600 }

/* The buy row: the copy block's closing line, a pill on --cta with white
   text (5.45:1). Present on every scene in the plain document; the pinned
   narrow and short layouts hide it and hand the job to .cta-bar, because
   the copy there has no vertical room for another line — measured, not
   assumed: 320x568 rests the powder copy 5px over the links row.

   In the pinned presentation scenes are stacked and carry pointer-events:
   none; which scene's row may take a click is the ENGINE's to grant, one
   resting scene at a time — see markBuy() in js/scene.js and the rule in
   the pinned block below. */
/* The price on a scene. Smaller than the product page's line — a scene's
   copy is denser and the price is one fact among several rather than the
   page's own headline — and it closes the block just above the buy row,
   which is where the reader's eye is already going. The colours are the
   scene's own tokens, so the dark scene flips them with everything else
   and the struck price stays legible on plum. */
.scene-price { margin-top: clamp(12px, 1.6vw, 18px); font-size: .95rem }
.scene-cta { margin-top: clamp(12px, 1.8vw, 20px) }
.scene-cta a {
  display: inline-block; background: var(--cta); color: #fff;
  padding: 9px 20px; border-radius: 999px; text-decoration: none;
  font-weight: 600; font-size: 13px; letter-spacing: .04em;
  white-space: nowrap;
}
/* Over the plum ground the pill's own magenta is 2.88:1 — under the 3:1 a
   component boundary asks for — so the one dark scene adds a ring of its
   own ink. Both presentations: the section is dark in the plain document
   too. */
.scene--lamp .scene-cta a { box-shadow: 0 0 0 1px rgba(247, 240, 246, .85) }

/* ------------------------------------------------------- contacts scene

   The seller block as scene content. The values are the same seller.*
   the legal pages carry; the type restates the legal facts list rather
   than borrowing .doc-main's element rules, which must not reach into a
   scene (see the cascade section). The mailto and the legal links are
   the first in-scene links outside a buy row: in the pinned presentation
   they answer only at this scene's rest, gated by the same engine state
   as the buy rows (markBuy in js/scene.js).

   Since 20.08 this section is a FOOT rather than a fifth scene, and the
   type says so first: the heading comes down from the page's 4.2rem cap
   to 2.2rem. The floor of the clamp is 1.5rem and that floor is not a
   taste — 1.5rem is 24px, the size at which WCAG still calls type large,
   and tools/contrast.py asks every scene heading for 3:1 on that basis.
   Below 24px this heading would owe 4.5:1 and --accent-text does not
   hold it on the pale grounds. */

.scene--contacts .scene-title { font-size: clamp(1.5rem, 2.6vw, 2.2rem) }
.scene--contacts .lede { max-width: 24rem }
/* Meistä: the half of the foot that used to be empty (21.08). The
   paragraph opens with the wordmark's own word, and .mark-word is the
   box the travelling drawing lands on — kept in the flow at the size of
   the type around it, so the sentence measures the same whether the
   drawing arrives or not. */
.contacts-about { margin-top: 0 }
/* The foot is what everything else in this scene stands above, so the
   air belongs to it and not to the block before it — that block is the
   first thing in the section on every layout since 21.08 evening. */
.contacts-foot { margin-top: clamp(20px, 2.4vw, 32px) }
.contacts-about-h {
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft); margin: 0 0 8px;
}
.contacts-about-text {
  margin: 0; font-size: .95rem; line-height: 1.55; color: var(--ink);
  max-width: 34rem;
}
.mark-word { font-weight: 600; color: var(--ink); white-space: nowrap }
/* The drawing has somewhere to arrive and something to arrive as: the
   mark's own transform is written by the engine, and it needs an origin
   at its top-left corner or the growth would push it out of the corner
   it is leaving. */
.js .mark { transform-origin: 0 50%; will-change: transform }

/* The stars. One path, drawn twice: once in the rule's grey for the
   whole row and once in the brand's ink clipped to the score, so 4.8 of
   5 is 96% of the row rather than four stars and an opinion. */
.rating { display: flex; align-items: center; gap: 10px; margin: 14px 0 0 }
.rating-stars { display: block; line-height: 0 }
.rating-stars svg { display: block; height: 16px; width: auto }
.rating-empty { fill: var(--rule) }
.rating-full { fill: var(--brand) }
.rating-said { font-size: .9rem; color: var(--ink) }
.rating-said b { font-weight: 600 }
/* Whose rating this is, and the ONLY caption under it: a small-caps
   line saying "OnlySmile ratings worldwide" stood here as well until
   21.08 evening, and every word of it was already inside this sentence
   — which also says who counted them. A duplicate is not a second
   safeguard, it is 29px of the shortest phone spent twice. */
.rating-note {
  margin: 6px 0 0; font-size: .75rem; line-height: 1.45;
  color: var(--ink-soft); max-width: 30rem;
}
.contacts-details { margin-top: clamp(20px, 2.4vw, 32px) }
.contacts-facts dt {
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink-soft); margin-top: 10px;
}
.contacts-facts dt:first-child { margin-top: 0 }
.contacts-facts dd { font-size: .95rem; line-height: 1.5; color: var(--ink) }
.contacts-facts a {
  color: var(--ink); text-decoration: none;
  border-bottom: 1px solid var(--rule);
}
/* The one gesture in the block, under the address it acts on. An outline
   pill rather than a filled one: --cta is the buy colour on this site and
   nothing here is a purchase. It is a link and not a button because it
   navigates — mailto: is a destination — and it takes the same ring every
   other control takes. */
.contacts-mail { display: block; margin-top: 9px }
.contacts-mail-link {
  display: inline-block; padding: 6px 15px 7px; border-radius: 999px;
  text-decoration: none; border: 0;
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink-soft);
}
/* The company name, last and quietest. It is a fact the page owes and
   not the way anyone reaches us, so it sits under the business id in the
   same soft ink the labels use, at reading size. */
.contacts-seller {
  margin: 12px 0 0; font-size: .8rem; line-height: 1.4;
  color: var(--ink-soft);
}
/* The words that used to be the chrome's foot-nav, restated as scene
   copy: same small caps, same soft ink — measured 5.4:1+ against the
   pale grounds, the only grounds this scene has. */
.contacts-legal { display: flex; gap: 22px; margin-top: clamp(16px, 2vw, 26px) }
.contacts-legal a {
  padding: 5px 0 4px; text-decoration: none;
  font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--ink-soft);
}

/* ------------------------------------------------------------- callouts

   Annotated points on the device. In the plain document they are simply a
   numbered list under the scene's copy — which is also what the narrow
   pinned layout uses, because four labels around a 111px object on a 342px
   screen is not a layout. The wide pinned layout lifts them out to flank
   the device and draws a line from each to the point it names.

   The numeral is what ties a marker on the device to its words, so it is
   drawn the same both places: same circle, same size, same ink. It is a
   counter rather than markup because the list is <ol> and already carries
   the enumeration semantically; this is the visible half of that. */

.callouts { counter-reset: callout; margin-top: clamp(20px, 2.4vw, 32px) }
.callout {
  counter-increment: callout;
  display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 2px 11px;
  margin-bottom: 13px;
}
.callout::before {
  content: counter(callout);
  grid-row: 1 / span 2; align-self: start;
  width: 22px; height: 22px; border-radius: 50%;
  border: 1px solid var(--rule-strong, var(--ink-soft));
  display: grid; place-items: center;
  font-size: 10px; color: var(--ink); font-variant-numeric: tabular-nums;
}
.callout-title {
  font-size: clamp(10px, .72vw, 11px); letter-spacing: .14em;
  text-transform: uppercase; font-weight: 600; color: var(--ink);
  line-height: 1.5;
}
.callout-text {
  font-size: clamp(11.5px, .82vw, 12.5px); line-height: 1.5;
  color: var(--ink-soft);
}

/* Drawn decoration, engine-driven, hidden from assistive technology in the
   markup. Never present without the words they point at. */
.callout-lines, .callout-marks { display: none }

/* ================================================= pinned stage, any width */

@media (prefers-reduced-motion: no-preference) {

  /* The pinned presentation no longer scrolls. Progress is a tween between
     section anchors, not a scroll position, so a scrollbar here would be a
     second, contradictory source of truth. The plain document below still
     scrolls normally. */
  html.js, .js body { height: 100%; overflow: hidden; overscroll-behavior: none }

  /* The first screen's links, in the OTHER corner of the foot band from
     the buy bar — one placement for every cell of the grid, and no height
     threshold at all. The threshold this replaces (22.08, shipped for
     hours) hid the whole row below 700px of height on the assumption that
     it would collide with the bar. It does not, and the assumption was
     never measured: the bar is anchored left and 128..135px wide, and the
     band is 272px of room at 320 and 814 at 892 — 141 and 683 free beside
     it. The client found it at 892x663, which is a laptop window with a
     task bar, an address bar and a bookmarks row, not a corner case. */
  .js .hint {
    position: absolute; right: var(--pad); bottom: calc(var(--pad) - 8px);
    justify-content: flex-end; text-align: right;
  }

  .js .stage { display: block; position: fixed; inset: 0; overflow: hidden;
               z-index: 0; pointer-events: none }
  .js .aura { display: block; position: absolute; inset: 0; opacity: 0 }

  /* The chrome dims to nothing while the ground swaps between a pale scene
     and the dark one, and comes back in the other colour. Neither a pale nor
     a dark ink clears 4.5:1 against a half-mixed ground — there is no colour
     that does — so the answer is not to show any. */
  .js .site-head, .js .site-foot, .js .rail { opacity: var(--chrome-alpha, 1) }

  .js .site-head { position: fixed; top: 0; left: 0; right: 0 }
  /* right: 0 matters now. Fixed with only a left edge, the bar shrinks to
     its content, and the legal links that are supposed to sit in the right
     corner queue up next to the hint on the left. Invisible for as long as
     the hint was the only tenant. */
  .js .site-foot { position: fixed; left: 0; right: 0; bottom: 0 }

  /* Four sections, four dots, the current one filled. Real links, so the
     keyboard reaches them and the engine turns the jump into a transition. */
  .js .rail {
    display: flex; flex-direction: column; gap: 14px;
    position: fixed; z-index: 6; right: calc(var(--pad) * .6); top: 50%;
    transform: translateY(-50%); pointer-events: auto;
  }
  /* The unvisited dots are an outline, the current one is filled. Their
     opacity is permanent rather than a fade, so it counts towards contrast:
     an outline at .5 measured 3.01 against the palest scene, which clears
     1.4.11 by a hundredth and is no margin at all for something that tells
     the reader where they are. .58 measures 3.74. Fill against outline is
     still what separates the current dot from the rest. */
  .js .rail-dot {
    display: block; width: 7px; height: 7px; border-radius: 50%;
    background: transparent;
    box-shadow: inset 0 0 0 1px var(--chrome-ink, var(--ink));
    opacity: .58;
  }
  .js .rail-dot[aria-current] {
    background: var(--chrome-ink, var(--ink));
    opacity: 1;
  }
  .js .rail-dot:focus-visible { outline-offset: 5px }

  .js main { position: fixed; inset: 0; overflow: hidden; z-index: 1 }

  /* All four sit on top of one another. They stay in the accessibility tree
     at opacity 0, so the whole page is still readable aloud. Without
     pointer-events the topmost one answers every hit test whatever its
     opacity; nothing inside a scene is interactive. */
  .js .scene {
    position: absolute; inset: 0; background: none; opacity: 0;
    display: grid; gap: var(--gap); padding: var(--pad);
    pointer-events: none;
  }
  .js .scene--hero { opacity: 1 }   /* static state; the engine takes over */

  /* Nothing inside a scene answers the pointer until the engine grants it.
     The sections are stacked and the layer order is composition depth, not
     visibility, so an ungated link in the top scene would swallow clicks
     aimed at the visible one under it. markBuy() grants the resting
     scene's links — the buy row, and on the contacts scene the seller's
     and the legal links — and revokes the rest; this is the same statement
     for the window before the engine boots. Inheritance from .scene
     already implies it; it is restated where someone will look for it. */
  .js .scene a { pointer-events: none }

  .js .scene-media { position: relative; margin: 0 }
  .js .scene-copy { margin: 0 }

  /* Pinned to the centre of the media box explicitly. Left to the flex
     container's static position each object would start from a different
     origin, and browsers disagree about it for absolutely positioned flex
     children. The engine's translate(-50%,-50%) recentres, and every
     station it places a prop at is an offset from this point. */
  .js .shot, .js .contact {
    position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%);
  }
  /* Both frames of the device get the same transform from the engine, so
     they stay welded; only the top one's opacity moves. */
  .js .shot--lit { opacity: 0 }
  /* No filter on anything the engine scales. Measured: a filter on a
     continuously scaled element forces a fresh raster every frame and costs
     36% of frames past 20ms unthrottled, 61% at 6x. Depth comes from the
     contact layer below, which is a plain gradient and composites free. */
  .js .shot { max-height: var(--media-h) }

  /* Lying on the floor under the object: dropped by half the object's own
     height, wider than it is, and flattened. A shadow that keeps the
     object's proportions reads as a stain behind it.

     The palette comes from the root, not from the section. Three objects
     stand on the first screen and they share one floor, so what colours
     their shadows is the ground under all of them — which the engine
     crosses with the ground itself. A per-section palette would put the
     dark scene's near-black shadow under a device standing on pale grey. */
  .js .contact {
    display: block; pointer-events: none;
    width: calc(var(--contact-cap, var(--shot-cap)) * 1.55);
    height: calc(var(--contact-cap, var(--shot-cap)) * .20);
    background: radial-gradient(ellipse at center,
      var(--contact-core, rgba(60, 40, 58, .30)) 0%,
      var(--contact-mid, rgba(60, 40, 58, .13)) 36%,
      transparent 76%);
  }

  /* The two decorative layers cover the whole stage and are positioned in
     its coordinates, so the engine can put a line end anywhere without
     caring which grid cell it lands in. */
  .js .callout-lines, .js .callout-marks {
    display: block; position: absolute; inset: 0;
    pointer-events: none; overflow: visible;
  }
  .js .callout-line {
    stroke: var(--callout-rule); stroke-width: 1; fill: none;
  }
  .js .callout-dot { fill: var(--callout-rule) }
  .js .callout-mark {
    position: absolute; left: 0; top: 0;
    width: 22px; height: 22px; border-radius: 50%;
    display: grid; place-items: center; opacity: 0;
    border: 1px solid var(--callout-rule);
    background: var(--lamp-top);
    font-size: 10px; color: var(--ink); font-variant-numeric: tabular-nums;
  }

  /* Carried light for a prop rather than for a pan: the tube is white and
     the first screen is pale, so it needs the same darkening the device
     gets crossing that ground. Declared with --aura-peak so the contrast
     tool reads its strongest point from here instead of carrying its own
     copy of the number. Rides the prop's own transform, so it scales and
     travels with it and needs nothing per frame but an opacity. */
  .js .pool {
    display: block; position: absolute; left: 50%; top: 50%;
    /* The shape follows the object it sits under: a pool tuned for a tube
       lying down is a flat band, and under a standing device that reads as
       a smear rather than as light around it. */
    width: calc(var(--shot-cap) * var(--pool-w, 1.06));
    height: calc(var(--shot-cap) * var(--pool-h, .42));
    opacity: 0; pointer-events: none;
    /* The colour is a property of the object standing in the pool, not of
       the pool layer, so it arrives as a variable with the packaging pink as
       the default. Whoever needs another one says so per prop. */
    --pool-ink: var(--pool-rose);
    --aura-peak: rgba(var(--pool-ink), var(--pool-density));
    background: radial-gradient(ellipse at center,
      rgba(var(--pool-ink), var(--pool-density)) 0%,
      rgba(var(--pool-ink), calc(var(--pool-density) * .64)) 30%,
      rgba(var(--pool-ink), calc(var(--pool-density) * .21)) 58%,
      transparent 78%);
  }

  /* The light toggle's companion. Positioned by the engine beside the
     device in stage coordinates (like the callout marks), faded by the
     complement of the light: it exists exactly as much as the light is
     out. The wrap takes the place and the opacity; the link inside takes
     the pointer and the tab stop, gated by the game's own state on top
     of the resting-scene gate. Ink and ring are the lamp scene's own
     tokens — pale on plum, the measured pair. */
  .js .lamp-more {
    display: block; position: absolute; left: 0; top: 0;
    margin: 0; opacity: 0; pointer-events: none;
    will-change: transform, opacity;
  }
  .js .lamp-more-link {
    display: inline-block; padding: 7px 16px; border-radius: 999px;
    text-decoration: none; white-space: nowrap;
    font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
    color: var(--ink);
    background: var(--lamp-top);
    box-shadow: 0 0 0 1px var(--rule-strong, var(--ink-soft));
  }

  /* Promoted because the engine writes to these every frame. Props no
     longer fade — objects that travel have nothing to fade between — so
     transform is the only thing promised here. */
  .js .shot, .js .contact, .js .pool { will-change: transform }
  .js .callout { will-change: transform, opacity }
  .js .scene-copy { will-change: transform, opacity }
  .js .scene-title .w > span { will-change: transform }
}

/* ==================================================== pinned stage, wide */

@media (prefers-reduced-motion: no-preference) and (min-width: 821px) {
  .js .scene {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
  }

  /* The copy gained a line when the price arrived on the scenes (20.08),
     and a centred block that grows grows in BOTH directions: measured,
     1280x600 put the copy 21px into the foot band and 1280x710 raised it
     until the carton in the slot lay 14px into the hero eyebrow — the
     defect --slot-room exists for, at a height the reserve does not
     cover. The line is paid for out of the copy's own air rather than by
     moving a seam or hiding the price: the eyebrow and the heading give
     back about what the price takes. */
  .js .scene .eyebrow { margin-bottom: 14px }
  .js .scene .scene-title { margin-bottom: 14px }
  /* And the price's own gap. 1280x710 is the cell with the least room
     above 700 — the slot reserve does not reach it — and it was the last
     two pixels of the carton over the hero eyebrow. */
  .js .scene-price { margin-top: 10px }
}

/* THE HEADING CAP ON THE PINNED WIDE STAGE (22.08). 4.2rem was calibrated
   when a scene heading was three or four words; the client's package writes
   seven to nine, and at 1280 the hero's ran to four lines of 68px. That is
   not a short-window problem — it broke the reference cell: at 1280x800 the
   carton in the slot lay 8px into the hero's eyebrow, measured.

   The CAP comes down and the slope does not, which is what makes this cost
   nothing where nothing is wrong: 5.2vw is 53.2px at 1024 and already under
   3.4rem, so every cell up to ~1046px stays pixel-identical and 1280 comes
   down from 66.6px to 54.4.

   The pinned stage only. The plain document and reduced motion keep 4.2rem
   — a scrolling page has no frame to run out of — and so do the product
   pages, which is why HEADING_MAX_FONT_REM in build.py still mirrors 4.2:
   the word-width budget's worst case is the presentation with the biggest
   type, and that is still the plain one.

   Not the closing scene; the reason is spelled out at the floor block. */
@media (prefers-reduced-motion: no-preference) and (min-width: 821px) {
  .js .scene:not(.scene--contacts) .scene-title {
    font-size: clamp(1.9rem, 5.2vw, 3.4rem);
  }
}

/* ------------------------------------------------- wide and short, measured

   The slot hangs from the top chrome at a fixed depth; the copy centres in
   the viewport; nothing tied them. On a short wide window the centred copy
   rises into the slot band and the carton lies ON the eyebrow — reported
   from a real laptop, reproduced at 1280x580, and linear in height:
   +87px of air at 1280x800, +12 at x650, -13 at x600, -63 at x500, with
   the zero crossing near x575. Every cell of the old check grid was a tall
   window, which is why no measurement ever said so.

   The narrow layout has reserved head-room from the start; this is the
   same relief for the wide one, keyed to height like the lamp scene's
   height steps, so the reference cells above 700px stay pixel-identical.

   --slot-room is derived, not tuned: the slot's occupant hangs from the
   mark's top, which sits 1px under the head's padding, and reaches down
   by up to slotMax x slotAspect = 239 x 0.273 = 65px of drawing — the
   carton's own drawing runs 68 — plus 15px of air. The head's padding
   cancels on both sides of the inequality, so one number holds at every
   wide width: content-top = pad + 84 clears slot-bottom = pad + 69 by 15
   at 821 and at 1920 alike. */
@media (prefers-reduced-motion: no-preference)
   and (min-width: 821px) and (max-height: 700px) {
  .js .scene {
    padding-top: calc(var(--pad) + var(--slot-room, 84px));
    /* And the same courtesy at the bottom: the centred copy must clear the
       legal links row. Measured at 1280x600 without this: the Finnish copy
       sat 8px into the links. */
    padding-bottom: calc(var(--pad) + var(--foot-room));
  }

  /* Short cells have no room for a buy row either — 1280x600 rests the
     hero copy 6px over the links band before a row is even added — so the
     same trade the narrow layout makes: the row goes, the bar carries the
     buy, swapping into the hint's spot as the hint fades. */
  .js .scene-cta { display: none }

  /* The five-item contents strip wraps to two lines on the wide cells and
     the hero copy grew ~22px with it; on 1280x600 that walked the copy's
     box 5px into the hint's band at the landing — measured for the first
     time when the band question learned to see the hint (the links row
     left the chrome for the contacts scene). The strip's own air gives:
     spacing, not words — the same trade the narrow height steps make. */
  .js .contents { margin-top: 14px; padding-top: 10px }
  .js .cta-bar {
    display: inline-block; position: absolute;
    left: var(--pad); bottom: calc(var(--pad) - 8px);
  }
  /* And the type, since 22.08. The 4.2rem cap was chosen when a heading was
     three or four words; the client's package writes seven to nine, and at
     1280 the hero's ran to FOUR lines of 68px — 100px past the hint's band
     on a 600px window, and 63 past it at 1366x650. Measured, all three
     languages, before and after:

         1280x600  hero  +100 -> -19      1366x650  hero  +63 -> -25
         1024x550  hero   +19 -> -46      1280x710  hero   +9 -> clear

     This is the same trade the floor cells below already make, one step
     milder, and it is the only one available: air cannot pay 100px, and
     the words are the client's. At 1280 the cap comes to 49.9px and the
     hero's heading folds to three lines instead of four.

     Not the closing scene, and for the reason spelled out at the floor
     block: its heading is already smaller than this, and a rule that
     SHRINKS headings must not grow one. */
  .js .scene:not(.scene--contacts) .scene-title {
    font-size: clamp(1.9rem, 3.9vw, 3.4rem);
  }
}

/* The copy's rhythm on the short wide cells (22.08). The first screen took
   a navigation row into its copy column, and a centred block that grows
   grows in BOTH directions: measured, the carton in the wordmark's slot
   came 10px down onto the hero's eyebrow at 1280x710, which is the cell
   with the least room above the 700 reserve. Air, not words, and only
   where it is short: 780 is the lamp scene's existing first height step,
   and 1280x800 and 1440x900 keep the rhythm they had. */
@media (prefers-reduced-motion: no-preference)
   and (min-width: 821px) and (max-height: 780px) {
  .js .scene .eyebrow { margin-bottom: 9px }
  .js .scene .scene-title { margin-bottom: 9px }
  .js .scene-price { margin-top: 5px }
  .js .scene-nav { margin-top: 6px }
}

/* The floor cells. Below ~540px the right column — the gel copy, the lamp's
   flank labels — still reached the links' corner (25px into it at 1280x500,
   measured with the horizontal check on). One more slice of bottom padding
   lifts the composition clear; the hero's left column may overflow the box
   at this height as it always has — it shares no horizontal span with the
   links, so nothing readable is under anything. */
@media (prefers-reduced-motion: no-preference)
   and (min-width: 821px) and (max-height: 540px) {
  .js .scene { padding-bottom: calc(var(--pad) + var(--foot-room) + 26px) }
  /* Padding stops helping here: the box shrinks as fast as the overflow
     grows, and the gel's right-column copy stayed 25px into the links'
     corner. The floor pays in type size instead — the same trade the lamp
     scene's height steps make in margins.

     Not the closing scene. Its heading is already the foot's own size
     (2.2rem down to 1.5), which is smaller than this relief, and a rule
     meant to SHRINK a heading must not grow one. The exception is
     written here, at the rule it excepts, rather than as a second copy
     of the foot's size somewhere later in the file. */
  .js .scene:not(.scene--contacts) .scene-title {
    font-size: clamp(1.7rem, 2.9vw, 2.4rem);
  }
  /* The closing scene's own floor relief is NOT here, and that is the
     point: written in this block it lost to the wide contacts rules
     further down the file, same selector, same weight, later line. It
     lives next to what it overrides — search for "the floor cells, for
     the closing scene only". */
  /* And the rest of the copy, since 22.08. On a 500px window every line of
     the 22.08 texts is a line the frame does not have: the hero read 62px
     into the hint's band with the type relief above already applied, so
     the heading alone cannot pay. Everything here is air or size and
     nothing is hidden — the same trade this file makes at every other
     height step. The lede's floor is a size and not a rule: it is soft ink
     on a pale ground at 4.5:1, which does not change with the type size,
     unlike a heading, which is measured at 3:1 only while it is large. */
  .js .scene .lede { font-size: 12.5px; line-height: 1.5 }
  .js .contents { margin-top: 8px; padding-top: 6px }
  .js .scene .eyebrow { margin-bottom: 8px }
  .js .scene .scene-title { margin-bottom: 8px }
  .js .scene-price { margin-top: 6px }
}

@media (prefers-reduced-motion: no-preference) and (min-width: 821px) {
  .js .scene-media { grid-column: 2; grid-row: 1; height: 100% }
  .js .scene-copy  { grid-column: 1; grid-row: 1; width: min(100%, var(--copy-width)) }

  /* Right, right, left, and then neither. The device cannot move: it is
     carried down out of the opening group, and a camera that carries
     something does not also swap the side of the frame it is on. So the break
     in the rhythm falls on the gel — which is where the camera changes hands
     anyway, and the object crossing the frame is that handover being
     visible. */
  .js .scene--gel .scene-media { grid-column: 1 }
  .js .scene--gel .scene-copy  { grid-column: 2; justify-self: end }

  /* The last scene has no column for its objects. Both of them are cut by
     their own canvas edge and both have to run off the frame, so they take
     one side each and the words go between them — which is not a flourish
     to end on but the only place left. Anywhere else on this layout the copy
     column reaches to one page padding of an edge, and a cut edge landing on
     it is exactly the straight-line-with-ground-behind-it the frame anchor
     exists to prevent.

     One column, both children in it: the media is the whole frame, because
     that is what the two objects are placed against, and the copy floats in
     the middle of it. */
  .js .scene--powder { grid-template-columns: minmax(0, 1fr) }
  .js .scene--powder .scene-media { grid-column: 1; grid-row: 1 }
  .js .scene--powder .scene-copy {
    grid-column: 1; grid-row: 1; justify-self: center; align-self: center;
  }

  /* The lamp scene has to hold four labels either side of the device, so it
     borrows width from the copy column — which loses nothing, being capped
     at --copy-width and nowhere near its half of the screen. Measured: 639
     against 560, which is what turns two 160px labels plus the device from
     26px short into 79px of air on each side. */
  .js .scene--lamp { grid-template-columns: minmax(0, .86fr) minmax(0, 1.14fr) }

  /* The contacts scene has no media column: one grid column, the copy
     spans it, and the copy itself splits in two — the intro on the left,
     the seller block on the right. The intro holds the copy cap every
     other scene's column holds. CHOSEN BY EYE against an even 50/50
     (client, 19.08) — the even split read as a finale to some eyes and
     as a glitch to others, and the snapshots of both are in tools/shots.

     A FOOT, not a fifth scene (20.08, client). Three changes, and all
     three are the same statement: the block is capped at 46rem instead
     of running the width of the frame, it sits at the BOTTOM of the
     stratum instead of in the middle of it, and it opens with a hairline
     rule. The heading comes down with it — a foot does not carry 4.2rem
     of type — and stops at 1.5rem, which is 24px: the size at which
     large-text contrast still applies, and tools/contrast.py asks this
     heading for 3:1 like every other scene heading.

     The scene keeps its stratum, its ground, its dot and its pan. The
     axis is derived from the NUMBER of sections (deriveWindows in
     js/scene.js divides the axis by the count), so nothing about a
     shorter block reaches it — the section is exactly as long along the
     page as it was. */
  .js .scene--contacts {
    grid-template-columns: minmax(0, 1fr);
    /* minmax(0, 1fr) and not 1fr: an auto row grows to its content and
       carries the copy straight through the padding, which is how the
       foot arrived 27px inside the buy bar at 1280x600. Bounded, the
       row is the room, and the copy's own second row is what gives. */
    grid-template-rows: minmax(0, 1fr);
    /* The one scene on this layout with words at the TOP of the frame,
       so the one that has to reserve for the head — the wide layout
       never needed it before, because every other scene's copy is
       centred. Head-room and NOT the slot reserve the short cells give
       every scene: the slot is empty here (the wordmark is the slot's
       tenant on this scene, and it leaves for the paragraph), so 84px
       of clearance for an object that is not there is 40px this scene
       has nowhere to spare. */
    padding-top: calc(var(--pad) + var(--head-room));
  }
  /* Two bands, not one block (21.08 evening, client). The empty half of
     this scene was the TOP one, and the answer to an empty top is not to
     spread the foot across it: Meistä takes the room it was asking for,
     and the contact facts stay a foot — capped, bottom-aligned, opening
     with a hairline, exactly as they were before Meistä arrived.

     The copy now spans the stratum instead of sitting at the end of it,
     and the two rows do the placing: min-content for the block at the
     top and 1fr of air under it, which pushes the foot to the floor with
     no absolute positioning and no fixed heights. */
  .js .scene--contacts .scene-copy {
    grid-column: 1; align-self: stretch;
    width: 100%; max-width: none;
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: min-content minmax(0, 1fr);
    /* Its own row gap, not --gap: --gap is the air between a scene's
       media and its words, and at 1280 it is 77px. Two bands and one
       gap that size do not fit a 600px-tall window — measured, 90px
       over — and the air between two paragraphs was never that number
       anyway. */
    row-gap: clamp(16px, 2.4vw, 40px);
  }
  /* The rating stands BESIDE the paragraph here rather than under it.
     Stacked, the band is 162px tall and the foot 227, and the short
     desktop cells (1280x600 is the tightest) have 376px between the
     chromes: it did not fit by 90px. Side by side the band is 101 and
     the same cell has 20px to spare. The heading spans both, because it
     names both. */
  .js .scene--contacts .contacts-about {
    margin-top: 0;
    display: grid;
    grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr);
    column-gap: var(--gap); align-items: start;
  }
  .js .scene--contacts .contacts-about-h { grid-column: 1 / -1 }
  /* The hairline lives on the foot rather than on the copy, because it
     is the foot it draws the top of. On the copy it would now be at the
     top of the stratum, ruling off the whole scene from the page. */
  .js .scene--contacts .contacts-foot {
    align-self: end; max-width: 46rem;
    display: grid;
    grid-template-columns: minmax(0, var(--copy-cap)) minmax(0, 1fr);
    gap: var(--gap); align-items: end;
    padding-top: clamp(16px, 2vw, 26px);
    border-top: 1px solid var(--rule);
  }
  .js .scene--contacts .contacts-intro { max-width: var(--copy-cap) }
  .js .scene--contacts .contacts-details { margin-top: 0 }

  /* The bottom band stopped being empty on 22.08: the scroll hint became
     three navigation links and no longer fades out over the first pan, so
     what used to be a strip of air after the first gesture is now chrome
     for the life of the page. This is the only scene pinned to the BOTTOM
     of its stratum, so it is the only one that has to reserve it —
     measured, 14px into the row at 1280x800 without this line. Above 700
     only: below it the bar owns that corner and the links are not drawn,
     and the <=700 block already reserves the band for every scene. */
  .js .scene--contacts { padding-bottom: calc(var(--pad) + var(--foot-room)) }

  /* Lifted out of the grid entirely and placed by the engine in stage
     coordinates, beside the point each one names. The line does the work
     the numeral does on the narrow layout, so the numeral goes away. */
  .js .scene--lamp .callouts {
    position: absolute; inset: 0; margin: 0; pointer-events: none;
  }
  .js .scene--lamp .callout {
    position: absolute; left: 0; top: 0; width: 160px;
    display: block; margin: 0; opacity: 0; text-align: right;
  }
  .js .scene--lamp .callout--right { text-align: left }
  .js .scene--lamp .callout::before { display: none }
  .js .scene--lamp .callout-title { display: block; margin-bottom: 3px }
  .js .scene--lamp .callout-text { display: block }

  /* No numbered markers here: the line already says which label belongs to
     which point, and a numeral on top of it would be the same fact twice. */
  .js .callout-marks { display: none }

  /* The stack-trap default for in-scene links lives in the any-width
     pinned block: .js .scene a { pointer-events: none }. It moved there
     when the contacts scene brought the first links outside a buy row —
     the trap was never a wide-layout fact. */
}

/* The floor cells, for the closing scene only, and HERE rather than up in
   the ≤540 block with the rest of the floor relief. Written there it lost:
   the wide contacts rules above set the same properties on the same
   selector at the same weight and come LATER in the file, so the floor's
   override was decided by document order and never applied. Measured
   before and after — 34px of overlap either way — which is the whole
   reason a per-layout override lives next to what it overrides.

   What it buys: at 1280x500 the foot's hairline was drawn through the last
   line of the Meista paragraph. Both blocks sit inside .scene-copy, so
   every band question in geometry.py stayed green while the defect was
   live; the question that sees it now is "the closing scene's blocks keep
   off each other". The air above the foot pays — 30.7 + 25.6 at this
   width, against the 34 that were missing. */
@media (prefers-reduced-motion: no-preference)
   and (min-width: 821px) and (max-height: 540px) {
  .js .scene--contacts .contacts-foot { margin-top: 10px; padding-top: 10px }
  /* The air above the foot got 16 of the 34 back; the rest comes out of the
     foot's own rhythm, which is the same trade the ≤680 narrow step already
     makes on exactly these elements. The heading goes to 25.6px and stops
     there: 24px is the floor under which a heading is no longer large text
     and its 3:1 becomes 4.5:1, which --accent-text does not hold on the
     pale grounds. Nothing is hidden — the whole seller block, both links
     and both buttons are on a 500px screen. */
  .js .scene--contacts .scene-title {
    font-size: clamp(1.5rem, 2vw, 1.7rem); margin-bottom: 4px;
  }
  .js .scene--contacts .lede { line-height: 1.35 }
  .js .scene--contacts .contacts-facts dt { margin-top: 5px }
  .js .scene--contacts .contacts-mail { margin-top: 4px }
  .js .scene--contacts .contacts-seller { margin-top: 4px }
  .js .scene--contacts .contacts-legal { margin-top: 8px }
}

/* ------------------------------------------------------- narrow, shared */

@media (max-width: 820px) {
  .scene-title { font-size: clamp(1.9rem, 8.6vw, 2.6rem) }
  .lede { font-size: 15px; line-height: 1.6 }
  /* The row used to be hidden here outright, back when it was a one-word
     scroll instruction a phone does not need. It carries navigation now,
     and a phone needs that more than a desktop does — it has no rail. One
     link, measured to fit beside the bar. */
}

/* ================================================== pinned stage, narrow */

@media (prefers-reduced-motion: no-preference) and (max-width: 820px) {
  .js .rail { display: none }

  /* Media above, copy below, and more room given to the object than the
     wide layout gives it: on a phone the picture is the argument. */
  /* The copy is shorter than the half it was given, so the page used to sit
     against the top with an empty third under it. The rows now take what
     they need and the whole thing is centred: the air is shared between top
     and bottom instead of all being at the bottom. */
  .js .scene {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: var(--media-h) min-content;
    align-content: center;
    padding: calc(var(--pad) + var(--head-room)) var(--pad)
             calc(var(--pad) + var(--foot-room));
    gap: 0;
  }
  .js .scene-media { grid-column: 1; grid-row: 1; height: 100% }
  .js .scene-copy  { grid-column: 1; grid-row: 2; width: 100%; align-self: start }

  /* No room for a buy row under the copy — 320x568 rests the powder copy
     5px over the links band with one — so the bar in the bottom chrome
     carries the buy instead, in the corner the scroll hint used to have.

     The parenthetical that stood here — "the hint is not shown on narrow at
     all" — was true when it was written and stopped being true on 22.08,
     when the chrome row came back to every cell. It stands in the OPPOSITE
     corner (.js .hint is right, this is left), which is what keeps the two
     off each other; geometry.py asks it by name. */
  .js .scene-cta { display: none }
  .js .cta-bar {
    display: inline-block; position: absolute;
    left: var(--pad); bottom: calc(var(--pad) - 8px);
  }

  .js .shot { max-height: 100% }

  /* The price line's fare on the narrow layout, and the same trade the
     wide one makes: measured at 320x568, the added line pushed the whole
     centred grid up until the package lay 7px into the language links
     and the powder copy 7px into the foot band. Air, not words. */
  .js .scene .eyebrow { margin-bottom: 10px }
  .js .scene .scene-title { margin-bottom: 10px }
  .js .scene-price { margin-top: 8px }

  /* No media row on the contacts scene: the copy takes the one row, and
     since 20.08 that row sits at the BOTTOM of the stratum rather than
     in the middle of it — the same statement the wide layout makes, and
     the reason there is no --media-h here to centre against. The details
     stack under the intro, and the hairline above the block is what
     makes it read as a foot at this width too. */
  .js .scene--contacts {
    grid-template-rows: min-content;
    align-content: end;
  }
  .js .scene--contacts .scene-copy { grid-row: 1; align-self: end }
  /* One column, and the same order the document has: who we are, then
     how to reach us. The hairline is on the foot, so Meistä stands above
     the rule and the contact facts below it — the block that is a foot
     is the block the rule opens. */
  .js .scene--contacts .contacts-about { margin-top: 0 }
  .js .scene--contacts .contacts-foot {
    margin-top: clamp(16px, 3vw, 24px);
    padding-top: clamp(14px, 3vw, 20px);
    border-top: 1px solid var(--rule);
  }

  /* The glow belongs behind the object. On the wide layout the object is in
     the right-hand column; here it is in the top row, and a bloom left at
     the wide position would sit behind the paragraph instead. */
  .js .bloom { left: 50%; top: 30%; width: min(96vw, 460px) }

  /* Three rows instead of two: the device with its numbered markers, the
     copy, and the list the numbers refer to. The device gives up a slice of
     its half because a marker only needs to clear the object, while four
     descriptions need real column.

     The per-scene contents strip goes: the full package is listed on the
     first screen, and four callouts say more about this object than
     "1x LED-valo" does. It is the only thing here that could be cut without
     losing a fact. */
  .js .scene--lamp {
    grid-template-rows: var(--lamp-h) min-content min-content;
  }
  .js .scene--lamp .callouts {
    grid-column: 1; grid-row: 3; align-self: start;
    margin-top: clamp(12px, 3vw, 20px);
  }
  .js .scene--lamp .contents { display: none }
  .js .callout-mark { display: grid }
}

/* --------------------------------------------- narrow and short, measured

   Four facts with descriptions, a heading and a lede do not fit under a
   device on a short phone, and the arithmetic is not close. Measured, worst
   case per viewport, with the layout above:

       390x844   fits, 94px spare        (Finnish, the tightest language)
       360x740   fits, 14px spare
       320x640   overflows by 64px
       320x568   overflows by 109px

   Finnish is the worst case, not Swedish: its lede runs to three lines and
   its callout text wraps further. So the relief comes in two steps, and
   both are keyed to height, because height is what is actually short.

   Both steps give back what costs nothing. The device needs its own size
   plus room for its markers and no more, so the media row stops being a
   share of the screen and becomes what it needs; the spacing between items
   tightens with it.

   Nothing is hidden. An earlier version dropped the lede of this scene
   below 680px and it fitted, but hiding is the third-best answer: the lede
   was long because it described the LEDs and the tray, which is what the
   callouts now do. It was shortened to one line instead, and one line is
   exactly the difference between fitting and not on the shortest screen.
   If it ever grows back past a single line at 320px, this is where it will
   show up. */

@media (prefers-reduced-motion: no-preference)
   and (max-width: 820px) and (max-height: 780px) {
  /* The device's own size follows from this and needs no line of its own:
     --device-width is capped by --lamp-h up in the tokens. */
  :root.js { --lamp-h: calc(.32 * var(--stage-h)) }
  .js .scene--lamp .callout { margin-bottom: 9px }

  /* The copy's rhythm, moved UP to this step from the 680 one on 23.08.
     It was written for the 22.08 texts on the shortest phones; it turned
     out to be owed one step higher as well, and the reason is the buy bar
     learning to carry the buy from the landing state. The bar and the
     first screen's copy share the bottom-left corner, and at 740px of
     height the copy was 5 to 20px into it — Finnish worst, as usual.
     Measured before and after, air between the copy and the bar's box:

         390x740  fi -17 -> +14   sv/en  -5 -> +26
         360x740  fi -20 -> +11   sv/en -13 -> +18

     No new height step: 780 is the lamp scene's own first one, and these
     values are the ones the 680 step already carried. The 680 step keeps
     the lede's size, which is the only thing here that is type rather
     than air and the only thing these cells did not need.

     .scene-nav joins them, at the 6px the WIDE 780 step has used since
     22.08 — the one line of the narrow copy no narrow block had ever
     touched, still asking for the 14px of a layout with room. */
  .js .contents { margin-top: 4px; padding-top: 4px; gap: 4px 20px }
  .js .scene .eyebrow { margin-bottom: 4px }
  .js .scene .scene-title { margin-bottom: 3px }
  .js .scene-price { margin-top: 2px }
  .js .scene-nav { margin-top: 6px }
}

@media (prefers-reduced-motion: no-preference)
   and (max-width: 820px) and (max-height: 680px) {
  /* No --tube-width override here any more: the tube in the slot is sized
     from the band it has to fit, measured, so the short screen answers
     itself. */
  /* The media row now holds the device and the reach of its markers and
     nothing else: on the shortest screen this has to work on, 27% comes to
     128px of row, which is 112px of device plus the 61px its markers reach
     on either side. Four callouts have the rest, and that order was settled
     long ago — first it does not get in the way, then everything else.

     Against the UNRESERVED stage, deliberately: that is the base the 27%
     was derived on, and the row it sizes is the very thing the push-in
     floor protects, while the foot reserve protects the words band under
     the COPY — two guards, two bases. When the legal-links reserve landed
     it silently changed this row's base too, took the device from 112px
     to 102 and the push-in to 1.38x against the 1.4 floor, and nothing
     said so until the group was remeasured (BACKLOG 13a). Giving the row
     its own base back is the cause and the fix agreeing. */
  :root.js { --lamp-h: calc(.27 * (var(--stage-h) + var(--foot-room))) }

  /* The 22.08 package on the shortest phones. Every selling scene's copy
     grew — the lamp's lede went from one line to four, the powder's from
     four to six, and the kit's list from five items to six — and all three
     came down into the foot band: lamp +36, gel +26, powder +34 at
     320x568, measured. This step already existed and already had the right
     answer written at it: air and type size, never words. Nothing is
     hidden and nothing is shortened; what pays is the spacing around the
     words and one step off the lede's own size.

     The heading is not in the list, here as at the closing scene: at this
     width it is already 1.9rem = 30.4px, and the next step down would take
     scene titles under the 24px floor where large-text contrast stops
     applying and 4.5:1 starts. --accent-text does not hold 4.5 on the pale
     grounds, so shrinking the heading would cost a colour, not a margin. */
  .js .scene .lede { font-size: 13px; line-height: 1.4 }
  /* The rest of that package — the strip's rhythm and the margins around
     the words — moved UP to the 780 step on 23.08, where the 740-tall
     cells need it too; see the note there. What is left here is the one
     line of it that is type and not air.

     The lesson those lines were written for stays, because it is not about
     any of them: the hero's copy — the tallest of the five, with six
     contents rows — turned out to have been 11px inside the foot band all
     along, and NOTHING ASKED, because nothing occupied that band at the
     landing state. A dark control is not an empty corner; it is a corner
     whose occupant is invisible, and a check that reads what is drawn
     cannot tell those apart. On 23.08 the bar was lit at the landing on
     every cell that has room for it, and the two that have not — 320x568
     and 320x640 — are recorded in BACKLOG as a decision. */

  /* The lamp's own two margin rules are GONE rather than kept: they said 8
     and 10 against a base of 10, which was relief when this scene was the
     only tight one at this height, and would now be the lamp asking for
     MORE room than its neighbours out of equal-weight selectors that
     happen to come later. One scene wound back another scene's relief is
     the cascade doing something nobody wrote down. */
  .js .scene--lamp .callouts { margin-top: 2px }
  /* The callouts' own rhythm, one step tighter since 22.08: the lamp's
     lede went from one line to four and its USB text gained one, and after
     everything above had been spent the scene still read 11px into the
     foot band at 320x568. Four rows of leading and four gaps is where the
     last of it comes from; the words, the numbers and the circles are all
     still there. */
  .js .scene--lamp .callout { margin-bottom: 2px }
  .js .scene--lamp .callout-title { line-height: 1.3; margin-bottom: 1px }
  .js .scene--lamp .callout-text { line-height: 1.35 }

  /* And the closing scene, which since 21.08 evening carries two bands
     instead of one and is the tallest block of words on the site. The
     same trade every height step here makes: air and type size, never
     words. Nothing is hidden — the whole seller block, the whole
     attribution and the whole paragraph are on the screen at 320x568,
     which is the floor cell this is measured against.

     The heading is not in the list. It is already at the foot's own
     1.5rem, which is the 24px floor under which large-text contrast
     stops applying, and a relief that SHRINKS type must not push a
     heading under the rule it is measured by. */
  .js .scene--contacts .contacts-about-h { margin-bottom: 4px }
  .js .scene--contacts .contacts-about-text { font-size: .8rem; line-height: 1.4 }
  .js .scene--contacts .rating { margin-top: 6px }
  .js .scene--contacts .rating-note {
    margin-top: 5px; font-size: .68rem; line-height: 1.35;
  }
  .js .scene--contacts .contacts-facts dd { font-size: .9rem }
  .js .scene--contacts .contacts-foot { margin-top: 6px; padding-top: 8px }
  .js .scene--contacts .scene-title { margin-bottom: 4px }
  .js .scene--contacts .lede { line-height: 1.35 }
  .js .scene--contacts .contacts-details { margin-top: 10px }
  .js .scene--contacts .contacts-facts dt { margin-top: 5px }
  .js .scene--contacts .contacts-mail { margin-top: 4px }
  .js .scene--contacts .contacts-seller { margin-top: 4px }
  .js .scene--contacts .contacts-legal { margin-top: 7px }
}
