:root {
  /* one shared measure, so every text block shares a right edge */
  --measure: 34rem;

  /* Page metrics, in one place because they depend on each other. The
     distance from the window to the text is spent twice over: --gutter
     outside the pane, then the pane's own padding inside it. Narrow, the
     panes sit within the gutter and the two shares are split; from 64rem
     up the panes bleed back out over the gutter (see the frosted panes)
     and the padding costs the text nothing. */
  --gutter: clamp(0.85rem, 2.6vw, 1.35rem);
  --pane-pad-x: clamp(1rem, 3vw, 1.5rem);
  --pane-pad-y: clamp(1.4rem, 4vw, 2rem);
  --pane-pad-y-hero: clamp(1.6rem, 4.5vw, 2.2rem);
  --pane-brd: 1px;
  --pane-bleed: 0px;

  /* the one gap between the switch and the first pane, at every width */
  --switch-gap: 0.9rem;

  /* the page is dark at every system setting, so there is one palette */
  color-scheme: dark;

  --bg: #08080a;
  --fg: #f1efec;
  --muted: #9a97a6;
  --rule: rgba(255, 255, 255, 0.14);
  --panel: rgba(255, 255, 255, 0.045);
  --panel-hi: rgba(255, 255, 255, 0.09);
  --panel-brd: rgba(255, 255, 255, 0.10);
  --panel-brd-hi: rgba(139, 124, 255, 0.42);
  --accent: #8b7cff;
  /* The wash used to be the only thing happening behind the text, so it
     carried the page. The panes carry it now, and at full strength the same
     colour reads as glare through the glass -- so it comes down about a
     quarter, far enough to sit behind them and no further. */
  --glow-1: rgba(124, 108, 255, 0.115);
  --glow-1-mid: rgba(124, 108, 255, 0.038);
  --glow-2: rgba(124, 108, 255, 0.078);
  --glow-2-mid: rgba(124, 108, 255, 0.025);
  --shadow: rgba(0, 0, 0, 0.55);
}

* { box-sizing: border-box; }

/* Glows and giant type bleed past the sides on purpose, and must not make
   the page scroll sideways. `hidden` on body alone does not hold on iPad
   Safari: it passes to the viewport, and Safari widens the layout to take
   the overflow in anyway. Setting html's own overflow keeps body's on body,
   so the bleed is clipped at the body's edges and never reaches the
   viewport. `clip`, not `hidden`, so neither box becomes a scroller. */
html { overflow-x: clip; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: ui-sans-serif, -apple-system, "SF Pro Text", "Segoe UI", Helvetica, Arial, sans-serif;
  font-size: 17px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: clip;
}

/* The floor the wash and the dither stand on.

   The dither at the foot of this stack is grey noise in `overlay`, which
   comes to nothing over black -- but only over black it can see. Safari
   paints the rubber band it opens when a scroll runs off the end of the page,
   and paints it in the page's own colour, but that paint is not in the group
   the blend reads: over the band the noise had no backdrop to darken against
   and landed as flat grey, so every bounce at the top or the foot of the page
   flashed a grey bar across it.

   So the black is laid a second time, as a fixed layer of the page's own
   beneath both. Fixed, it covers the band exactly as they do -- they are why
   the band is a problem at all -- and the noise has something to blend into
   wherever it paints. */
html::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background: var(--bg);
}

/* ambient colour wash, slowly drifting behind everything */
body::before {
  content: "";
  position: fixed;
  inset: -12vmax;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(34vmax 34vmax at 30% 29%, var(--glow-1) 0%, var(--glow-1-mid) 30%, transparent 56%),
    radial-gradient(30vmax 30vmax at 74% 39%, var(--glow-2) 0%, var(--glow-2-mid) 30%, transparent 56%),
    radial-gradient(28vmax 28vmax at 54% 75%, var(--glow-1) 0%, var(--glow-1-mid) 30%, transparent 58%);
  animation: drift 19s ease-in-out infinite alternate;
  will-change: transform, opacity;
}

/* second wash on a period that does not divide into the first, so the two
   never line up and the tint keeps shifting instead of visibly looping */
body::after {
  content: "";
  position: fixed;
  inset: -12vmax;
  z-index: -1;
  pointer-events: none;
  opacity: 0.6;
  background:
    radial-gradient(32vmax 32vmax at 66% 64%, var(--glow-1) 0%, var(--glow-1-mid) 30%, transparent 57%),
    radial-gradient(26vmax 26vmax at 26% 57%, var(--glow-2) 0%, var(--glow-2-mid) 30%, transparent 58%);
  animation: drift-alt 26s ease-in-out infinite alternate;
  will-change: transform, opacity;
}

/* only transform and opacity, so this stays on the compositor */
@keyframes drift {
  from { transform: translate3d(-6vmax, 4vmax, 0); opacity: 0.58; }
  to   { transform: translate3d(9vmax, -7vmax, 0); opacity: 1; }
}
@keyframes drift-alt {
  from { transform: translate3d(7vmax, -5vmax, 0); opacity: 0.4; }
  to   { transform: translate3d(-8vmax, 6vmax, 0); opacity: 0.9; }
}

/* Dither. 8-bit colour cannot represent these very low-contrast gradients
   smoothly, so they band into visible rings. A faint noise layer over the
   wash breaks the steps up. Sits above both wash layers (::after on the root
   element paints after body's pseudo-elements) but below all content, so
   text stays crisp. */
html::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0.14;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27160%27%20height%3D%27160%27%3E%3Cfilter%20id%3D%27n%27%20x%3D%270%27%20y%3D%270%27%20width%3D%27100%25%27%20height%3D%27100%25%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%270.75%27%20numOctaves%3D%273%27%20stitchTiles%3D%27stitch%27%2F%3E%3CfeColorMatrix%20type%3D%27saturate%27%20values%3D%270%27%2F%3E%3C%2Ffilter%3E%3Crect%20width%3D%27160%27%20height%3D%27160%27%20filter%3D%27url%28%23n%29%27%2F%3E%3C%2Fsvg%3E");
  background-size: 160px 160px;
}

.wrap {
  position: relative;
  max-width: 60rem;
  margin: 0 auto;
  /* Top padding is the room above the language switch, which stands in the
     flow ahead of the hero; the switch's own margin sets the distance from
     there to the first pane. */
  padding: clamp(1.35rem, 4vw, 3rem) var(--gutter) 4rem;
}

a { color: inherit; }

/* ---------- shared editorial grid ---------- */

.hero, section, .contact {
  display: grid;
  gap: clamp(1.5rem, 5vw, 3.5rem);
  align-items: start;
}
/* Single column: the intro's own children join the hero grid so the portrait
   can sit between the location line and the lede. Rows are spaced by those
   elements' margins, so the grid gap is dropped here. */
.hero { row-gap: 0; }
.hero > .intro { display: contents; }
.intro > h1 { order: 1; }
.intro > .role { order: 2; }
.intro > .location { order: 3; }
.hero > .portrait { order: 4; margin-top: 1.75rem; }
/* Stacked, the lede sits under the portrait, so it wants the same gap the
   portrait takes above itself -- a hair more, since the frame's faded bottom
   edge blurs where the photo ends. */
.intro > .lede { order: 5; margin-top: 2rem; }
@media (min-width: 46rem) {
  .hero, section, .contact {
    grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
    column-gap: 3.5rem;
  }
  .hero > .intro { display: block; grid-column: 2; }
  .hero > .portrait { order: 0; margin-top: 0; }
  .intro > .lede { margin-top: 1.75rem; }
}

/* On a phone the hero is the whole first screen, and where it ends decides
   whether the page looks finished. Ending level with the bottom edge is the
   worst case: nothing of the next pane breaks the edge, so there is no sign
   the page continues -- and it is not a reveal that is missing, there is
   genuinely nothing there to reveal.

   Finnish lands exactly there. Its lede wraps a line further than the English
   one, which makes the hero 26px taller, which is the whole difference between
   the next pane clearing the fold by 38px and missing it by 4. Measured on an
   iPhone at 714px: hero to 678, next pane at 718.

   So the hero gives back about fifty pixels of its own spacing on phones,
   which puts the next pane back over the edge in both languages. This buys a
   margin, not a guarantee: a short enough window will still be filled by the
   hero alone, and nothing in CSS can promise otherwise while the height is
   the content's to decide. */
@media (max-width: 46rem) {
  :root { --pane-pad-y-hero: clamp(1.1rem, 4vw, 1.6rem); }
  .portrait { max-width: 14rem; }
  .hero > .portrait { margin-top: 1.25rem; }
  .intro > .lede { margin-top: 1.5rem; }
}

/* ---------- hero ---------- */

.portrait {
  margin: 0;
  width: 100%;
  max-width: 15rem;
  aspect-ratio: 480 / 565;
  border-radius: 14px;
  overflow: hidden;
  border: 1px solid var(--panel-brd);
  background: var(--panel);
  /* Two casts, because one cannot do both jobs. The tight one sits under the
     frame and says the photograph rests on the pane rather than being printed
     into it; the wide one is the lift. Both run darker than --shadow, which
     was mixed for the panes: those sit on the page, and this sits on a pane
     that is barely lighter than the page, so a shadow blended for the one is
     invisible on the other. The old cast pulled itself back under the frame
     with a -24px spread and could not be seen at all. */
  box-shadow:
    0 4px 9px -2px rgba(0, 0, 0, 0.85),
    0 34px 64px -22px rgba(0, 0, 0, 0.95);
}
.portrait img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 22%;
  border-radius: 13px;
}
.portrait[hidden] { display: none; }

h1 {
  margin: 0;
  font-size: clamp(2.1rem, 6.4vw, 3.1rem);
  line-height: 1.06;
  letter-spacing: -0.03em;
  font-weight: 650;
}
.role {
  margin: 0.8rem 0 0;
  font-size: clamp(1.05rem, 2.6vw, 1.2rem);
  color: var(--muted);
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.location {
  margin: 0.35rem 0 0;
  font-size: 0.98rem;
  color: var(--muted);
}

.lede {
  margin: 1.75rem 0 0;
  font-size: clamp(1.1rem, 2.8vw, 1.3rem);
  line-height: 1.5;
  letter-spacing: -0.015em;
  max-width: var(--measure);
  color: var(--muted);
}
/* The page's brightest text, set against paragraphs that run muted: the
   phrase lifts by contrast rather than by colour, the same way the client
   names do in the work section. */
.hl {
  color: var(--fg);
  font-weight: 600;
}
/* Only the lede's short spans ("25 vuotta") stay unbroken; prose
   highlights are whole clauses and must wrap like the text around them. */
.lede .hl { white-space: nowrap; }

/* ---------- sections ---------- */

section { margin-top: clamp(3.25rem, 8vw, 5rem); }

h2 {
  margin: 0;
  /* Big enough to read as a heading rather than a caption. Tracking eases
     off as the lettering grows -- uppercase needs less of it at this size. */
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--muted);
}
@media (min-width: 46rem) {
  /* drop the label's first line onto the same optical line as the prose
     beside it, which sets on a taller line box. Most of that drop is now
     spent inside the label's own line box, which is the tile's height so
     that their tops can meet (see the section ornaments); what is left here
     is the remainder. */
  h2 { padding-top: 0.125rem; }
}

.prose { max-width: var(--measure); }
.prose p { margin: 0 0 1.1rem; color: var(--muted); }
.prose p:last-child { margin-bottom: 0; }

/* ---------- competence tags ---------- */

.tags {
  margin: 0;
  padding: 0;
  list-style: none;
  max-width: var(--measure);
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}
.tags li {
  padding: 0.4rem 0.85rem;
  border: 1px solid var(--panel-brd);
  border-radius: 999px;
  background: var(--panel);
  font-size: 0.9rem;
  line-height: 1.35;
}

/* ---------- app icons ---------- */

/* A caricature of a home-screen icon: the superellipse corner Apple rounds
   to, a tint falling light-to-dark down the tile, the hairline the platform
   draws around it to keep pale icons off a pale wall, and a white glyph. */
.app-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 2.4rem;
  height: 2.4rem;
  border-radius: 22.4%;
  background: var(--icon-fill);
  /* named once because the lit layer below has to wear the same edge */
  --icon-edge:
    inset 0 1px 0 rgba(255, 255, 255, 0.3),
    inset 0 0 0 1px rgba(0, 0, 0, 0.22);
  box-shadow: var(--icon-edge), 0 6px 14px -9px #000;
  position: relative;
  isolation: isolate;
  color: #fff;
  /* the switch's spring, so the two moving parts on the page settle alike */
  transition: transform 0.3s cubic-bezier(0.3, 1.2, 0.4, 1), box-shadow 0.3s ease;
}
.app-icon svg {
  display: block;
  width: 58%;
  height: 58%;
}
/* Each still wears its own app's hue, but drained to about a fifth of its
   strength: enough that mail and phone are recognisably a blue and a green
   and not the same grey tile twice, not so much that three painted squares
   become the loudest thing on a page whose other tiles are all glass.
   Mail's runs top to bottom rather than across the diagonal, the way
   Apple's does.

   Mail and LinkedIn are both blue, and side by side in a list this short
   the two ran together. With the colour this far down it is lightness
   rather than hue that has to keep them apart, so mail takes the pale end
   of the range and LinkedIn the dark. */
.icon-mail  { --icon-fill: linear-gradient(180deg, #869fb1, #5f7b95 45%, #536581); }
.icon-phone { --icon-fill: linear-gradient(165deg, #7da887, #446751); }
.icon-in    { --icon-fill: linear-gradient(165deg, #475d6e, #354552); }

/* Drained is what the page wears at rest; under a pointer the tile comes
   back up to the colour its app actually has -- the same three fills, at
   full strength, so hovering a link is the app answering rather than a
   grey square growing. */
.icon-mail  { --icon-lit: linear-gradient(180deg, #63bdff, #1e90ff 45%, #0b6ff0); }
.icon-phone { --icon-lit: linear-gradient(165deg, #6ee08d, #22a94e); }
.icon-in    { --icon-lit: linear-gradient(165deg, #1a76bd, #0a599f); }

/* One gradient cannot be transitioned into another, so the lit fill is a
   layer of its own lying over the drained one and fading up, rather than a
   background that swaps on the frame the pointer arrives. It carries the
   tile's edge with it so the hairline and the lit top edge are not buried
   underneath -- both layers draw the same edge, so the fade shows nothing
   of the change. Negative z-index inside the tile's own stacking context
   puts it over the tile and under the glyph. */
.contact-links .app-icon::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: var(--icon-lit);
  box-shadow: var(--icon-edge);
  opacity: 0;
  transition: opacity 0.3s ease;
}

/* The envelope is white paper and its folds are the tile showing through,
   so the seams take the tile's own blue rather than a flat grey -- muted
   at rest, and up with the tile when it lights. */
.icon-mail .seam { stroke: #5e7992; transition: stroke 0.3s ease; }
.icon-mail svg { width: 62%; height: 62%; }

/* Under a pointer the icon swells in place, which is what iPadOS does to a
   home-screen icon the cursor has taken hold of -- not a lift, which is a
   web hover convention rather than a platform one. The shadow grows with it
   so the tile reads as nearer rather than merely bigger.

   Touch has no hover to leave, and an icon left swollen after a tap reads as
   stuck, so the whole treatment is held back for pointers that can actually
   leave again. */
@media (any-hover: hover) {
  .contact-links a:hover { color: var(--fg); }
  .contact-links a:hover .app-icon {
    transform: scale(1.11);
    box-shadow: var(--icon-edge), 0 10px 18px -9px #000;
  }
  .contact-links a:hover .app-icon::before { opacity: 1; }
  .contact-links a:hover .icon-mail .seam { stroke: #1b8bfb; }
}

/* ---------- section ornaments ---------- */

/* Each section's label gets a tile in front of it: one glyph for what the
   section is about, in the same caricature the contact links wear but in
   glass rather than paint, so it leads the heading without outshouting it.

   It is ornament and nothing else -- no link, no label, no text, and
   aria-hidden, so a reader who is listening to the page hears none of it.
   It belongs to the heading rather than to the label column, so it comes
   along at every width: stacked, the heading still stands over its content
   and the tile still stands in front of the heading. */
/* A one-line label wants the tile centred on it; a label that wraps wants
   its first line's caps level with the tile's top edge, not the tile hung
   in the gutter between two lines. Those are the same rule, written once,
   if you say the tile is pinned to the top of the pair and the label rides
   at the centre of whatever height the pair has: one line and the tile is
   the taller of the two, so the label centres against it; two lines and the
   label is the taller, so centring does nothing and the two tops meet. No
   line counting, and nothing to switch between. */
h2 {
  display: flex;
  align-items: flex-start;
  gap: 1.4rem;
  /* the label's lines when it wraps -- the tile's own height, so the pair
     keeps a square rhythm rather than the page's reading leading */
  line-height: 2rem;
}
h2 .label {
  align-self: center;
  /* For any of that to land where the eye expects, the label's box has to
     be the lettering rather than the line box around it: trim the leading
     off the top and bottom, so the box runs cap height to baseline. Where
     the trim is not understood the box stays the full line box, which is
     the tile's height -- so a one-line label still centres and a wrapped
     one still starts at the tile's top, just half a line lower. */
  text-box: trim-both cap alphabetic;
  /* and when it does wrap, split it evenly rather than leaving one word
     stranded on the second line */
  text-wrap: balance;
}

/* Smaller than the contact icons, which sit beside reading-sized text; this
   one sits beside a small uppercase label and has to stay the lesser of the
   two things on the line. */
h2 .app-icon {
  width: 2rem;
  height: 2rem;
  /* The glyph rides as the upper of two background layers over a fill that
     is the panes' own glass, a shade brighter -- no tint at all, the same
     grey for all of them. So the ornament is one empty span, and what tells
     one panel from the next is its drawing rather than its colour.

     Being glass rather than paint, the tile takes the panes' edge too -- a
     lit top and a white hairline -- rather than the hard dark ring a solid
     icon needs to hold its shape. The cast comes down with it. */
  background:
    var(--glyph) center / var(--glyph-size, 56%) no-repeat,
    linear-gradient(165deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.04));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    inset 0 0 0 1px rgba(255, 255, 255, 0.09),
    0 6px 16px -12px #000;
  /* and the whole tile, glyph included, sits a little under full strength,
     so it reads as something the pane is showing through rather than as an
     object lying on top of it */
  opacity: 0.82;
}

/* The drawings, on the same 24-unit grid the contact icons use so the two
   sets read as one family. Colour is what separates them: the contact icons
   wear their apps' own, because they are links and are meant to be the only
   lit things on the page, while the section tiles are grey glass and carry
   nothing but the glyph. */

/* About: the person the page is about -- head and shoulders, the way a
   contact card draws someone who has not supplied a photograph. */
.g-person {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='8.2' r='3.6'/><path d='M5 20.6c0-3.7 3.1-6 7-6s7 2.3 7 6'/></svg>");
}

/* Focus: what the work is written in. */
.g-code {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='m8.6 8.4-4.2 3.6 4.2 3.6m6.8-7.2 4.2 3.6-4.2 3.6M13.4 6.2l-2.8 11.6'/></svg>");
}

/* Competence: the work checked and passed. */
.g-shield {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='M12 2.8 4.6 6.1v5.8c0 4.2 3 7.5 7.4 9.3 4.4-1.8 7.4-5.1 7.4-9.3V6.1Z'/><path d='m8.7 12 2.4 2.4 4.3-4.6'/></svg>");
}

/* Work: a folder of shipped apps. */
.g-folder {
  /* A folder is not an app: iOS gives it the wall behind it, frosted, and
     four blank tiles inside. The wall is what every tile here is made of
     now, so all this one needs is the tiles -- drawn a little larger, since
     they have to read as four things rather than one glyph. */
  --glyph-size: 70%;
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'><rect x='2.6' y='2.6' width='8.4' height='8.4' rx='2.5' opacity='.8'/><rect x='13' y='2.6' width='8.4' height='8.4' rx='2.5' opacity='.55'/><rect x='2.6' y='13' width='8.4' height='8.4' rx='2.5' opacity='.66'/><rect x='13' y='13' width='8.4' height='8.4' rx='2.5' opacity='.45'/></svg>");
}

/* Personal projects: the idea that became an app of its own. */
.g-bulb {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3.2a5.6 5.6 0 0 0-3.4 10.1c.7.5 1.1 1.3 1.1 2.1v.4h4.6v-.4c0-.8.4-1.6 1.1-2.1A5.6 5.6 0 0 0 12 3.2Z'/><path d='M9.9 18.6h4.2M10.7 21h2.6'/></svg>");
}

/* New projects: the app that is not written yet. */
.g-plus {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round'><path d='M12 5.4v13.2M5.4 12h13.2'/></svg>");
}

/* ---------- frosted panes ---------- */

/* Every band of the page -- the hero and each section -- sits on its own
   pane of frosted glass: the drifting wash behind it is blurred rather than
   hidden, so the panes read as lifted off the page and the colour keeps
   moving underneath.

   The panes carry the same rules at every width; only the numbers behind
   them change, and they all change together, so the columns inside one pane
   always line up with the columns inside the next.

   Narrow, a pane fills the page's content box exactly and the gutter is what
   holds it clear of the window. From 64rem up .wrap has stopped growing at
   60rem, so every further pixel of window becomes margin outside the page:
   there the gutter widens and the panes bleed back out across it by their
   own padding, which buys the text its full measure back and lets the panes
   stand well clear of the glass. The hairline sits inside the padding box,
   so the bleed has to clear that too or the columns land a pixel right of
   where they belong. */
.hero, section {
  margin-left: calc(-1 * var(--pane-bleed));
  margin-right: calc(-1 * var(--pane-bleed));
  padding: var(--pane-pad-y) var(--pane-pad-x);
  border: var(--pane-brd) solid var(--panel-brd);
  border-radius: clamp(14px, 2.4vw, 22px);
  background: rgba(255, 255, 255, 0.055);
  box-shadow: 0 30px 70px -50px var(--shadow);
}
/* the hero is the page's face, so it gets the deeper pane */
.hero { --pane-pad-y: var(--pane-pad-y-hero); }

/* Each pane already holds its content well clear of its own edges, so the
   long gap that separated bare sections would leave the stack adrift. What
   is left is the gap between panes, not between blocks of text. */
section { margin-top: clamp(1.5rem, 4vw, 2rem); }

/* With a real backdrop blur the panes can be far fainter and still read. */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .hero, section {
    background: var(--panel);
    -webkit-backdrop-filter: blur(26px) saturate(130%);
            backdrop-filter: blur(26px) saturate(130%);
  }
}

/* the wide numbers: a roomier pane, bleeding out over a roomier gutter */
@media (min-width: 64rem) {
  :root {
    --gutter: 2.5rem;
    --pane-pad-x: 2rem;
    --pane-pad-y: 2.3rem;
    --pane-pad-y-hero: 2.6rem;
    --pane-bleed: calc(var(--pane-pad-x) + var(--pane-brd));
  }
}

/* ---------- entrance motion (only when JS is running) ---------- */

html.js-anim .reveal {
  opacity: 0;
  transform: translateY(16px);
}
html.js-anim .reveal.in {
  opacity: 1;
  transform: none;
  transition:
    opacity 0.95s cubic-bezier(0.2, 0.7, 0.2, 1) var(--d, 0s),
    transform 0.95s cubic-bezier(0.2, 0.7, 0.2, 1) var(--d, 0s);
}

/* In the single-column layout the intro is display:contents, so the portrait
   can sit between the location line and the lede -- see the hero grid above.
   That leaves it with no box of its own, and a fade on a box that does not
   exist does nothing, so its children carry the reveal instead. From 46rem up
   the intro is a block again and fades as one piece, and they hand it back.
   Both inherit --d from the intro, so either way the whole block moves
   together. */
html.js-anim .intro.reveal > * {
  opacity: 0;
  transform: translateY(16px);
}
html.js-anim .intro.reveal.in > * {
  opacity: 1;
  transform: none;
  transition:
    opacity 0.95s cubic-bezier(0.2, 0.7, 0.2, 1) var(--d, 0s),
    transform 0.95s cubic-bezier(0.2, 0.7, 0.2, 1) var(--d, 0s);
}
@media (min-width: 46rem) {
  html.js-anim .intro.reveal > *,
  html.js-anim .intro.reveal.in > * {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* The swell is a transform and so is the reveal that brings the pane in; an
   animation beats a transition outright, so left running it would pin the
   pane at its landed position and the fade would never rise. Hold the swell
   and its hairline until the pane has arrived -- the 1.2s delay above then
   clears the 0.95s fade before the first breath starts. */
html.js-anim .cta.reveal:not(.in),
html.js-anim .cta.reveal:not(.in)::after { animation: none; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
  }
  html.js-anim .reveal,
  html.js-anim .intro.reveal > * { opacity: 1 !important; transform: none !important; }
}

/* ---------- the app page ---------- */

/* One product page, /apps/roomtospare/, built out of the same parts as the
   front page: the same panes, the same two-column grid, the same reveal. What
   it changes is the colour, and it changes it in one place -- the wash, the
   accent and the lit hairline are all custom properties, so redeclaring them
   on the body repaints the whole page in the app's own teal and amber without
   a single rule below having to know about it.

   The two front pages load this same stylesheet, so nothing below may reach
   them: the palette is redeclared on this page's body alone, and every other
   rule is written against a class only this page carries. */
.app-page {
  --accent: #2fd0c8;
  --panel-brd-hi: rgba(47, 208, 200, 0.42);
  /* Teal in front and amber behind, the two colours the app's own icon is
     made of -- the same strengths the violet ran at, since the panes have to
     stay readable over whichever one drifts under them. */
  --glow-1: rgba(47, 208, 200, 0.105);
  --glow-1-mid: rgba(47, 208, 200, 0.034);
  --glow-2: rgba(245, 176, 106, 0.085);
  --glow-2-mid: rgba(245, 176, 106, 0.028);
}

/* ---------- app hero ---------- */

/* The front page's hero is a portrait beside an introduction, and its grid is
   built around exactly that. This one is a product's title page: icon, name,
   promise, button, picture, each the full width of the pane and in that order.
   So it opts out of the grid rather than bending it -- and takes a stacking
   context of its own, because the bloom behind the picture is a negative-z
   layer that must not escape the pane on a browser without backdrop-filter to
   pin it. */
.app-hero {
  display: block;
  isolation: isolate;
  /* And it crops what that layer does. The bloom is laid out wider than the
     picture on purpose; narrow, where the picture is very nearly the pane's
     own width, the spread runs off the right of the window and turns the
     whole page into something you can drag sideways. So the pane cuts it at
     its own edge -- which is where the light should stop anyway. `clip`
     rather than `hidden`: this is a crop, not a scroller, and it keeps the
     pane's corner radius. The one thing on this page meant to move sideways
     is the row of answers, and that scrolls inside its own box. */
  overflow: clip;
}
/* Given the room, the picture stands beside the words rather than under them:
   the name, the promise and the way in down the left, and the thing the app
   draws down the right. 52rem is where the pane is wide enough that a phone
   can take its column without the lede wrapping to four words a line.
   Narrower, the two stack and the phone sits under the pills, centred. */
@media (min-width: 52rem) {
  .app-hero {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 16rem);
    column-gap: clamp(1.8rem, 4vw, 3rem);
    /* the shared grid starts every section's columns at the top; here the two
       have to be the same height before either can spread inside it */
    align-items: stretch;
  }
  /* The phone is much the taller of the two. Centred against it, the words
     sat as one dense block in the middle with dead air above and below --
     the column read as crowded precisely because it was using so little of
     its own height. So they spread instead: the name starts level with the
     top of the phone, the pills finish level with its foot, and what was air
     at the ends becomes the room between them. */
  .app-intro {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
  }
  /* and the other way round, at widths where the words run taller than the
     picture, the phone stays in the middle of the row rather than at its top */
  .hero-shot {
    display: grid;
    align-content: center;
  }
}

.app-head {
  display: flex;
  align-items: center;
  gap: clamp(0.9rem, 3vw, 1.3rem);
}
/* The icon as the platform draws it: the superellipse corner, the hairline
   that keeps a dark icon off a dark wall, and a cast underneath. */
.app-mark {
  flex: none;
  width: clamp(3.6rem, 13vw, 5rem);
  height: auto;
  border-radius: 22.4%;
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.10),
    0 10px 22px -12px #000;
}

/* The name is lifted off the pane rather than lying on it. drop-shadow, not
   text-shadow: the glyphs are a window on to a gradient, and a text-shadow is
   painted behind them -- through transparent letters it would show as a
   silhouette filling the name in. A filter shadows what was actually painted,
   which is the letters themselves, and works the same for the flat fallback
   below.

   The pane is nearly black already, so a shadow on it is bought with distance
   rather than with opacity: past a point there is no darker to go, and only
   moving the cast clear of the glyph makes it something the eye can see. So
   the offsets carry it, and the colour is black outright. Three casts: one
   hard against the letters for the edge, one just below to seat them, and a
   deep soft one for the room underneath. */
.app-page h1 {
  filter:
    drop-shadow(0 1px 2px #000)
    drop-shadow(0 5px 6px rgba(0, 0, 0, 0.9))
    drop-shadow(0 16px 26px rgba(0, 0, 0, 0.85));
}

/* The name takes the app's two colours across it, left to right, so the page
   says whose it is before a word of it is read. Guarded, because a browser
   without background-clip would paint the heading in nothing at all. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .app-page h1 {
    background: linear-gradient(96deg, var(--fg) 10%, #86e7e0 54%, #f7bd7e 98%);
    -webkit-background-clip: text;
            background-clip: text;
    color: transparent;
    -webkit-text-fill-color: transparent;
    /* the gradient stops at the glyphs, and a descender clipped by the box is
       a name with its tail cut off */
    padding-bottom: 0.06em;
  }
}
/* The hero's opening runs to more than one paragraph, and the two have to
   stay one block: the column spreads its children apart to fill the height,
   and a lede split across two of them would open a gap in the middle of a
   thought. So it is one element holding two paragraphs, not two ledes. */
.app-page .lede p { margin: 0 0 0.9rem; }
.app-page .lede p:last-child { margin-bottom: 0; }

/* The promise stands under the name and is lifted with it, a shade less far
   off the pane so the two stay in their order. Opaque text, so this one can
   be a plain text-shadow. */
.app-page .role {
  margin-top: 0.5rem;
  color: var(--fg);
  opacity: 0.86;
  text-shadow:
    0 1px 2px #000,
    0 4px 5px rgba(0, 0, 0, 0.85),
    0 10px 18px rgba(0, 0, 0, 0.7);
}

/* ---------- the App Store button ---------- */

.store-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* Stacked, the badge is the one thing on the screen being asked for, so it
     sits on the middle of the column with the note under it -- set side by
     side it is the pair that centres, and the badge itself lands off centre.
     Given its own column beside the picture it goes back to ranging left with
     the prose, note alongside, which is where it reads best. */
  flex-direction: column;
  justify-content: center;
  text-align: center;
  /* The note is a caveat rather than a second label, so it needs enough air
     beside the badge to read as standing apart from it -- and Apple ask for
     clear space round the badge in any case. */
  gap: 0.8rem 2rem;
  margin: 1.9rem 0 0;
}

.store-note {
  font-size: 0.92rem;
  color: var(--muted);
}
/* The App Store badge is Apple's own artwork and is served as they publish
   it: not redrawn, not recoloured, not retyped, and not the site's own pill
   wearing their words. Size is the one thing they let you change, and only in
   proportion -- so the link is a box the right shape and the SVG fills it.
   Their clear-space rule (a tenth of the badge's height, all round) is met by
   the row's own gap several times over.
   That leaves nothing to do on hover but size, which is why this grows a
   little rather than lighting up: brightening it would be altering it, and
   scaling it whole is the one change they allow. The row's gap keeps the
   clear space well past its own rule even at the larger size. */
.app-store {
  display: inline-block;
  line-height: 0;
  border-radius: 9px;
  transition: transform 0.22s ease, box-shadow 0.22s ease;
}
.app-store img {
  display: block;
  width: 156px;
  height: auto;
}
@media (any-hover: hover) {
  .app-store:hover {
    transform: scale(1.06);
    box-shadow: 0 14px 30px -14px var(--shadow);
  }
}
/* pressing it settles it back to its own size */
.app-store:active { transform: scale(1); }

/* ---------- the badge before the app is on sale ---------- */

/* There is no store page to send anyone to yet, so the badge is not a link
   and does not pretend to be one: no lift under a pointer, no pointing hand,
   and nothing for the keyboard to land on. It says so when you go for it.

   Apple's artwork is untouched. At rest it is exactly the file they publish,
   at full strength; what dims it and what stands over it are the page's own,
   laid on top for as long as the pointer is there, and both come away with
   this whole block when the real link goes in. */
.app-store.is-soon {
  position: relative;
  cursor: default;
}

/* The label. A pill floated in the middle of the badge was tried first and
   left the wordmark showing round every side of it -- two pieces of type at
   two sizes fighting over one small box. This covers the badge instead: a
   plate the exact size and shape of it, so what the pointer turns up is one
   object with one thing written on it, the badge dim behind the glass saying
   which store is meant.

   It is aria-hidden, and the span around the pair carries the one label, so a
   reader hears "Coming soon on the App Store" once rather than twice over --
   and hears it whether or not there is a pointer to hover with. */
.soon-flag {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  /* the badge's own corner, so the plate sits inside its edge rather than
     squaring it off */
  border-radius: 9px;
  background: rgba(8, 8, 10, 0.66);
  -webkit-backdrop-filter: blur(4px) saturate(120%);
          backdrop-filter: blur(4px) saturate(120%);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
  /* its own leading, since .app-store sets line-height to nothing so the
     badge leaves no gap under itself */
  line-height: 1;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--fg);
}

/* Where there is a pointer, the plate is what the pointer finds: the badge
   stands unmarked until someone goes for it, and the answer arrives on the
   way to the tap rather than sitting on the page as a caption.

   Where there is none it stays put, because a phone has no hover to turn it
   up with -- and a badge that can be tapped, does nothing, and never says why
   is the one thing worse than a badge wearing its own label. */
@media (any-hover: hover) {
  .soon-flag {
    opacity: 0;
    transition: opacity 0.22s ease;
  }
  .app-store.is-soon img { transition: opacity 0.22s ease; }
  .app-store.is-soon:hover .soon-flag { opacity: 1; }
  .app-store.is-soon:hover img { opacity: 0.3; }
  /* and none of the lift the real badge has: there is nothing to press */
  .app-store.is-soon:hover {
    transform: none;
    box-shadow: none;
  }
}

/* The page's last word is the badge and nothing else, so this one is not a
   pane: a sheet of glass with one small thing in the middle of it is a frame
   round nothing. It keeps every measurement the panes have -- the same
   padding, the same bleed, and a border that is there but colourless -- so
   dropping the glass moves the badge by not one pixel. It steps out of the
   two-column grid as well, having no label to stand in the gutter. */
.get {
  display: block;
  text-align: center;
  --pane-pad-y: clamp(2rem, 5.5vw, 2.9rem);
  background: none;
  border-color: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
}

/* The line under the badge: quiet, small and centred with it, and set well
   enough clear of the badge that it reads as the page signing off rather
   than as a caption to the button. The name is the only lit thing in it,
   since the name is the only part worth following. */
.colophon {
  margin-top: clamp(1.5rem, 4vw, 2.2rem);
  text-align: center;
}
.colophon p {
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.6;
  color: var(--muted);
}
/* No rule under it: at this size an underline is a smudge, and the line has
   only one lit word in it, so what marks the name as a link is that it is
   brighter than the sentence around it -- and that it answers in the app's
   own teal under a pointer. Held back where there is no pointer to leave,
   the same as every other hover on the page. */
.colophon a {
  color: var(--fg);
  text-decoration: none;
  transition: color 0.22s ease;
}
@media (any-hover: hover) {
  .colophon a:hover { color: var(--accent); }
}

/* Stacked, the pills sit under a centred badge and centre with it. Given a
   column of their own they range left again, with the prose above them. */
.hero-tags {
  margin-top: 1.3rem;
  justify-content: center;
}
@media (min-width: 52rem) {
  .hero-tags { justify-content: flex-start; }
  .store-row {
    flex-direction: row;
    justify-content: flex-start;
    text-align: left;
  }
}

/* ---------- the hero picture ---------- */

.hero-shot {
  position: relative;
  margin: clamp(1.8rem, 5vw, 2.6rem) 0 0;
  text-align: center;
}
@media (min-width: 52rem) {
  /* in its own column it needs no room made above it */
  .hero-shot { margin: 0; }
}
/* A bloom under the picture in the app's own two colours, wide and faint
   enough that it reads as light coming off the render rather than as a shape
   behind it. Negative z-index inside the hero's stacking context puts it over
   the pane's glass and under the picture.

   The softness is in the gradients themselves; there is deliberately no
   `filter: blur()` over the top of them. That is not a saving, it is the
   whole bug this element used to have: a background is painted to the border
   box in every engine, so a wash still carrying colour where it meets that
   box is cut off square there, and a blur can only hide the cut if the engine
   lets the filter spread past the box. Blink does. WebKit clips the filter to
   the same box, so it hid nothing -- the teal ran a few per cent past the
   left edge, and in Safari the soft light was a hard-edged green panel down
   the side of the phone.

   So the rule is: both washes must fall to nothing before they reach any edge
   of this box, and the box is drawn wide and low enough that they do. Check
   that before touching a radius, a centre, or an inset -- a gradient's colour
   is gone at its `transparent` stop, not at 100%, so the reach is the stop
   times the radius. Nothing here may arrive at an edge still tinted. */
.hero-shot::before {
  content: "";
  position: absolute;
  inset: 0 -56% -18% -50%;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(54% 64% at 39% 47%, rgba(47, 208, 200, 0.26), transparent 70%),
    radial-gradient(50% 60% at 63% 55%, rgba(245, 176, 106, 0.21), transparent 72%);
}
.hero-shot .device { max-width: 16rem; margin: 0 auto; }

/* ---------- the hero picture's arrival ---------- */

/* The pane fades up as one piece; the phone inside it does something of its
   own. It comes in the way the app looks at a room -- turned in space, seen
   from slightly above -- and settles square, while the light behind it swells
   and one band of teal runs down the glass. That band is the app's own scan,
   played once, and it lands about where the film inside the frame starts
   rolling, so the picture appears to wake rather than to fade in.

   Everything here hangs off `html.js-anim`, so a reader who has asked for
   less motion never has the phone hidden in the first place, and off the
   figure's own `.in`, so it plays when the phone is reached rather than when
   the page is opened. */
html.js-anim .hero-shot { perspective: 1100px; }
/* and it hands back the plain reveal it would otherwise get: the figure holds
   nothing but the phone and the light behind it, both of which have an
   entrance of their own below, and two fades over one picture read as a
   stutter */
html.js-anim .hero-shot.reveal,
html.js-anim .hero-shot.reveal.in {
  opacity: 1;
  transform: none;
  transition: none;
}

html.js-anim .hero-shot .device {
  opacity: 0;
  /* turned about a point low in the frame, so the phone swings up onto its
     own foot rather than pivoting around its middle */
  transform-origin: 50% 78%;
  transform: rotateY(-17deg) rotateX(7deg) translateY(26px) scale(0.94);
}
html.js-anim .hero-shot.in .device {
  /* eased in as well as out: the turn wants to be watched, and a curve that
     spends four fifths of itself in the first tenth is over before it reads */
  animation: shot-land 1.45s cubic-bezier(0.42, 0.06, 0.18, 1) 0.15s both;
}
@keyframes shot-land {
  from {
    opacity: 0;
    transform: rotateY(-17deg) rotateX(7deg) translateY(26px) scale(0.94);
  }
  55% { opacity: 1; }
  to { opacity: 1; transform: none; }
}

/* the bloom behind it comes up with it, a touch later and a touch slower, so
   the light reads as thrown by the screen rather than switched on beside it */
html.js-anim .hero-shot::before {
  opacity: 0;
  transform: scale(0.88);
}
html.js-anim .hero-shot.in::before {
  animation: shot-bloom 1.9s cubic-bezier(0.2, 0.7, 0.2, 1) 0.3s both;
}
@keyframes shot-bloom {
  from { opacity: 0; transform: scale(0.88); }
  to { opacity: 1; transform: none; }
}

/* The sweep. Inset by the bezel and given the screen's own radius, so it is
   light on the glass and nothing else; `screen` keeps it additive over a
   bright screenshot instead of greying it. It travels by moving a gradient
   three times the height of the screen, which needs no second element to clip
   it -- the background is already cut to this box and its corners. */
html.js-anim .hero-shot .device::after {
  content: "";
  position: absolute;
  inset: 0.4rem;
  border-radius: 1.25rem;
  pointer-events: none;
  opacity: 0;
  mix-blend-mode: screen;
  background-image: linear-gradient(to bottom,
    transparent 0 43%,
    rgba(47, 208, 200, 0.14) 46%,
    rgba(47, 208, 200, 0.40) 49%,
    rgba(230, 255, 253, 0.78) 50%,
    rgba(47, 208, 200, 0.40) 51%,
    rgba(47, 208, 200, 0.14) 54%,
    transparent 57% 100%);
  background-repeat: no-repeat;
  background-size: 100% 300%;
  background-position: 50% 100%;
}
html.js-anim .hero-shot.in .device::after {
  animation: shot-scan 1.5s cubic-bezier(0.35, 0, 0.2, 1) 0.5s both;
}
@keyframes shot-scan {
  from { opacity: 0; background-position: 50% 100%; }
  12% { opacity: 1; }
  72% { opacity: 1; }
  to { opacity: 0; background-position: 50% 0%; }
}

/* ---------- and on a narrow screen, by the scroll rather than the clock ---------- */

/* Stacked under the words the phone is the better part of a window tall, and
   it comes over the fold a sliver at a time. Played on the clock the way it
   is above, the whole entrance -- turn, light, sweep -- is spent in the two
   seconds after the first few pixels show, which is most of a screen of
   scrolling before the frame is anywhere near looked at. The reader arrives
   at a phone that has already landed.

   Holding the start until a line further up the window is the other half of
   the same fault, and worse: it parks a hand's width of empty pane on the
   glass with nothing in it, and there it stays for as long as the reader
   stops there. That is the trap the reveal script's own note describes.

   So on a narrow screen the entrance is not played, it is scrubbed. The three
   animations are the same three off the same keyframes, but their progress is
   the figure's own progress up the window, so there is no moment at which the
   phone is on screen and not yet arriving: how far in it is IS how far up it
   is. Scroll back down and it turns back out again.

   `entry` runs from the figure's top edge touching the bottom of the window
   to its bottom edge reaching the same line, so the three ranges below
   stagger the entrance the way the delays stagger it above. All three read
   the figure's box and not their own -- the bloom is a pseudo-element wider
   than the pane and the sweep one inset in the glass, and neither should keep
   its own reckoning.

   Safari learned this in 26; an older phone falls through to the clock above,
   which is what it has today. */
@media (max-width: 46rem) {
  @supports (animation-timeline: view()) {
    html.js-anim .hero-shot { view-timeline-name: --shot; }

    html.js-anim .hero-shot.in .device,
    html.js-anim .hero-shot.in::before,
    html.js-anim .hero-shot.in .device::after {
      animation-timeline: --shot;
      animation-duration: auto;
      animation-delay: 0s;
      /* the finger does the easing; a curve on top of it reads as lag */
      animation-timing-function: linear;
    }

    html.js-anim .hero-shot.in .device { animation-range: entry 0% entry 70%; }
    html.js-anim .hero-shot.in::before { animation-range: entry 5% entry 85%; }
    html.js-anim .hero-shot.in .device::after { animation-range: entry 30% entry 92%; }
  }
}

/* Motion off, the phone is simply there: the block at the top of the
   stylesheet stops every animation, which would otherwise leave the frame
   parked at its hidden first frame. */
@media (prefers-reduced-motion: reduce) {
  html.js-anim .hero-shot,
  html.js-anim .hero-shot .device {
    opacity: 1 !important;
    transform: none !important;
  }
  html.js-anim .hero-shot::before { opacity: 1 !important; transform: none !important; }
  html.js-anim .hero-shot .device::after { display: none; }
}
.app-page figcaption {
  margin-top: 0.85rem;
  margin-inline: auto;
  max-width: var(--measure);
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--muted);
}

/* ---------- sections that want the whole pane ---------- */

/* A gallery of phones and a picture of an iPad are wider than the column the
   prose sits in. Rather than let those sections break the page's grid, the
   grid keeps its columns and their children span both: the label lands on the
   same left edge every other label does, and what follows starts there and
   runs to the pane's far side. */
.wide > * { grid-column: 1 / -1; }
.wide {
  row-gap: 1.3rem;
  /* the label sits directly over its content here rather than beside it, so
     the column gap's worth of air above would read as a gap between panes */
  align-content: start;
}

/* ---------- device frames ---------- */

/* A screenshot with nothing round it is a picture of a website. The frame is
   the least that makes it a picture of a phone: one bezel's worth of padding
   in the panes' own glass, the screen's corner radius inside it, and a cast
   underneath so it stands off the pane. */
.device {
  display: block;
  position: relative;
  padding: 0.4rem;
  border-radius: 1.6rem;
  background: linear-gradient(160deg,
    rgba(255, 255, 255, 0.19), rgba(255, 255, 255, 0.05) 45%, rgba(255, 255, 255, 0.13));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.22),
    0 24px 44px -30px #000,
    0 6px 14px -10px #000;
}
.device img,
.device video {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 1.25rem;
  /* the screenshots are dark, so the frame holds the same near-black while
     the JPEG is still on its way and nothing flashes pale under it */
  background: #12141a;
}
/* ---------- the policy page ---------- */

/* /apps/roomtospare/privacy/ is the app page with the product taken out of it:
   the same palette on the body, the same panes, the same title block, and
   four short sections in place of the pictures. Everything below is written
   against a class only that page carries.

   Its hero has no picture, so it stays one column at every width. Two classes
   rather than one, because the app hero becomes a grid inside a media query
   and this has to outrank it wherever it is written. */
.app-page .doc-hero { display: block; }

/* The mark is the way back to the app page, and this is the only link in the
   title block, so it lifts under a pointer the way the App Store badge does.
   It wears the icon's own rounding, so the focus ring follows the tile
   instead of boxing it in a rectangle. */
.app-mark-link {
  display: inline-block;
  line-height: 0;
  border-radius: 22.4%;
  transition: transform 0.22s ease;
}
@media (any-hover: hover) {
  .app-mark-link:hover { transform: translateY(-2px); }
}

/* The lede here is the whole policy in one sentence -- the four sections only
   say why it is true -- so it is set in the page's bright text rather than the
   muted grey a lede usually takes. */
.doc-hero .lede p { color: var(--fg); }

/* The colophon runs to two lines on this page: who wrote the app, then the
   way back to it. .colophon p is margin-free so the pair would otherwise set
   solid. */
.colophon p + p { margin-top: 0.5rem; }

/* ---------- the policy page's glyphs ---------- */

/* Same 24-unit grid, same stroke weight as the other two sets. */

/* Camera: what it looks through. The lens is drawn large and empty, which is
   the point of the section -- the camera is a rangefinder here, not a
   recorder. */
.g-camera {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><rect x='2.9' y='7.2' width='18.2' height='12.6' rx='2.6'/><path d='M8.7 7.2 10.1 4.6h3.8l1.4 2.6'/><circle cx='12' cy='13.5' r='3.5'/></svg>");
}

/* Your scans: kept, and kept shut. A padlock rather than a cloud -- the scans
   live on the device first, and the section's last line is about who cannot
   reach them. */
.g-lock {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><rect x='4.4' y='10.4' width='15.2' height='10.6' rx='2.6'/><path d='M7.9 10.4V7.3a4.1 4.1 0 0 1 8.2 0v3.1'/></svg>");
}

/* Nothing else: the sign that means none of it. A stroke across a ring is the
   one drawing that says no at 32px without a word in it. */
.g-none {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round'><circle cx='12' cy='12' r='8.6'/><path d='M5.9 5.9 18.1 18.1'/></svg>");
}

/* Deleting: the bin, and it is the whole of it that goes. */
.g-trash {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='M4.4 6.9h15.2M9.6 6.9V4.8h4.8v2.1'/><path d='M6.5 6.9 7.4 19.5h9.2l.9-12.6'/><path d='M10.3 10.4v5.6M13.7 10.4v5.6'/></svg>");
}

/* ---------- the controls tour ---------- */

/* The app's first-launch tour: one live demo over one step at a time, the
   page dots and a Next under them, at the prose's measure. All of that is
   tour.js's doing and hangs off the .is-live it adds -- without it this is the
   steps as a plain list, which is the whole of the content. */
.tour { max-width: var(--measure); }

/* The demo's own frame, as the app draws it: black, rounded, a faint white
   hairline. The hairline is laid over the canvas rather than round it, since
   a border would sit outside the picture and an inset shadow under it. */
.tour-screen {
  display: none;
  position: relative;
  margin-bottom: 1.3rem;
  aspect-ratio: 16 / 10;
  border-radius: clamp(12px, 2vw, 18px);
  overflow: hidden;
  background: #000;
  box-shadow: 0 24px 44px -30px #000, 0 6px 14px -10px #000;
}
.tour-screen::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: inherit;
  pointer-events: none;
}
.tour-screen canvas {
  display: block;
  width: 100%;
  height: 100%;
  /* a sideways swipe turns the page; an upward one still scrolls it */
  touch-action: pan-y;
}
.tour.is-live .tour-screen { display: block; }
.tour-screen.is-turning canvas { animation: tour-turn 0.45s ease both; }
@keyframes tour-turn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.tour-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 1.1rem;
}
.tour-step h3 {
  margin: 0 0 0.3rem;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--fg);
}
.tour-step p { margin: 0; color: var(--muted); }

/* Live, every step shares one cell, so the block is as tall as the longest
   caption and turning a page never moves what is under it. The outgoing words
   go quickly and the incoming ones follow, so the two never sit on top of
   each other legibly. Both drift a little the way the page is turning: in
   from the right and out to the left going forward, the other way going back
   (.is-back, set by tour.js). The hidden ones wait on the incoming side and
   jump there at once, since nobody can see them go. */
.tour.is-live .tour-steps { gap: 0; --turn: 0.9rem; }
.tour.is-live .tour-steps.is-back { --turn: -0.9rem; }
.tour.is-live .tour-step {
  grid-area: 1 / 1;
  visibility: hidden;
  opacity: 0;
  transform: translateX(var(--turn));
  transition: opacity 0.15s ease, visibility 0s linear 0.15s, transform 0s;
}
.tour.is-live .tour-step.is-leaving {
  transform: translateX(calc(-1 * var(--turn)));
  transition: opacity 0.15s ease, visibility 0s linear 0.15s, transform 0.2s ease-in;
}
.tour.is-live .tour-step.is-current {
  visibility: visible;
  opacity: 1;
  transform: none;
  transition: opacity 0.3s ease 0.12s, transform 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) 0.12s;
}

.tour-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 1.1rem;
}
/* the first dot's pill lines up with the caption's left edge, not its hit area */
.tour-dots { display: flex; margin-left: -4px; }
.tour-dot {
  appearance: none;
  margin: 0;
  padding: 10px 4px;
  border: 0;
  background: none;
  cursor: pointer;
}
.tour-dot::before {
  content: "";
  display: block;
  width: 8px;
  height: 8px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.3);
  transition: width 0.25s ease, background-color 0.25s ease;
}
.tour-dot[aria-current="step"]::before {
  width: 20px;
  background: var(--fg);
}
.tour-next {
  appearance: none;
  margin: 0;
  padding: 0.45rem 1.25rem;
  border: 1px solid var(--panel-brd-hi);
  border-radius: 999px;
  background: rgba(138, 168, 255, 0.14);
  color: var(--fg);
  font: inherit;
  font-size: 0.94rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.22s ease;
}
@media (any-hover: hover) {
  .tour-dot:hover::before { background: rgba(255, 255, 255, 0.6); }
  .tour-dot[aria-current="step"]:hover::before { background: var(--fg); }
  .tour-next:hover { background: rgba(138, 168, 255, 0.26); }
}

/* The trademark line at the foot of the app page: a footnote, and set as one --
   smaller and dimmer than the sign-off above it, and given room so it does
   not read as a third link. It keeps the colophon's centring and its
   measure comes from the wrap. */
.colophon .legal {
  margin-top: 1.1rem;
  max-width: 40rem;
  margin-inline: auto;
  font-size: 0.78rem;
  line-height: 1.55;
  opacity: 0.72;
  text-wrap: pretty;
}

/* ---------- 360 Image ---------- */

/* /apps/360image/ and its policy are the same pages as the other apps', built
   from the same parts, with the colour changed and one thing added: this page
   is mostly its App Store pictures. The icon is a camera in a wireframe globe
   on orange running into pink, so orange takes the accent and the two washes
   are the icon's two ends. Written against .pano, and after .app-page in the
   file as well as above it in specificity, since it overrides that block's
   teal. */
.app-page.pano {
  --accent: #f7a062;
  --panel-brd-hi: rgba(247, 160, 98, 0.42);
  --glow-1: rgba(254, 176, 84, 0.10);
  --glow-1-mid: rgba(254, 176, 84, 0.032);
  --glow-2: rgba(208, 68, 134, 0.09);
  --glow-2-mid: rgba(208, 68, 134, 0.03);
}

/* The name runs the icon from corner to corner: orange, coral, pink. Same
   restated clip as the other app pages -- `background` is a shorthand that
   resets background-clip. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .app-page.pano h1 {
    background: linear-gradient(96deg, #ffc47a 4%, #f39561 50%, #e0569a 96%);
    -webkit-background-clip: text;
            background-clip: text;
  }
}

/* The bloom under the picture in the icon's orange and pink. Colours only;
   see the note on .hero-shot::before before touching its geometry. */
.app-page.pano .hero-shot::before {
  background:
    radial-gradient(54% 64% at 39% 47%, rgba(254, 176, 84, 0.22), transparent 70%),
    radial-gradient(50% 60% at 63% 55%, rgba(208, 68, 134, 0.22), transparent 72%);
}

/* ---------- the App Store pictures ---------- */

/* The pictures are the App Store's own: each already draws its device and
   its headline on a background of its own, so a bezel round one would be a
   phone framed inside a picture of a phone. .poster keeps .device's box and
   cast -- so the hero's entrance, which hangs off .device, plays unchanged --
   and drops the glass, leaving a rounded card with a hairline edge. */
.device.poster {
  padding: 0;
  background: none;
  border-radius: 1.4rem;
}
.device.poster img {
  border-radius: 1.4rem;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10);
}
/* ---------- the first picture's arrival: a shot being taken ---------- */

/* The other app pages bring their phone in turned in space with a band of
   light down it. This one arrives the way the app takes a picture: the
   capture reticle's ring fills yellow while it is held, then the picture
   opens out of the ring with the app's white flash. One sequence, three
   layers: the ring is the card's ::before (unclipped), the picture and the
   flash (::after) open together on one clip-path circle.

   The card's cast moves onto the picture, so nothing hangs in the air
   before there is a picture to throw it; the last circle is drawn wide
   enough to take the cast in. Every step is a percentage of one timeline,
   so the same keyframes play on the clock here and on the scroll below. */
@property --hold {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
html.js-anim .pano .hero-shot .device,
html.js-anim .pano .hero-shot.in .device {
  opacity: 1;
  transform: none;
  animation: none;
  box-shadow: none;
}
html.js-anim .pano .hero-shot .device img {
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.10),
    0 24px 44px -30px #000,
    0 6px 14px -10px #000;
  clip-path: circle(0 at 50% 50%);
}
/* the ring: white, and yellow as far as it has been held */
html.js-anim .pano .hero-shot .device::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 5.2rem;
  height: 5.2rem;
  margin: -2.6rem 0 0 -2.6rem;
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  background: conic-gradient(#ffd60a calc(var(--hold) * 1turn), rgba(255, 255, 255, 0.8) 0);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
          mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
}
/* the flash, over the picture and opening with it */
html.js-anim .pano .hero-shot .device.poster::after {
  inset: 0;
  border-radius: 1.4rem;
  mix-blend-mode: normal;
  background: #fff;
  opacity: 0;
  clip-path: circle(0 at 50% 50%);
}

html.js-anim .pano .hero-shot.in .device::before {
  animation: pano-ring 1.9s linear 0.2s both;
}
html.js-anim .pano .hero-shot.in .device img {
  animation: pano-open 1.9s linear 0.2s both;
}
html.js-anim .pano .hero-shot.in .device.poster::after {
  animation: pano-open 1.9s linear 0.2s both, pano-flash 1.9s linear 0.2s both;
}
/* comes up, fills over a hold the length of the app's (0.8s), and is gone
   into the picture as it opens */
@keyframes pano-ring {
  0%   { opacity: 0; --hold: 0; transform: scale(0.85); }
  12%  { opacity: 1; --hold: 0; transform: none; animation-timing-function: ease-in-out; }
  56%  { opacity: 1; --hold: 1; transform: none; animation-timing-function: ease-in; }
  70%  { opacity: 0; --hold: 1; transform: scale(1.5); }
  100% { opacity: 0; --hold: 1; transform: scale(1.5); }
}
/* nothing until the shot, then the ring's own size, then all of it */
@keyframes pano-open {
  0%, 57% { clip-path: circle(0 at 50% 50%); }
  58% { clip-path: circle(2.6rem at 50% 50%); animation-timing-function: cubic-bezier(0.3, 0.6, 0.2, 1); }
  100% { clip-path: circle(120% at 50% 50%); }
}
@keyframes pano-flash {
  0%, 57% { opacity: 0; }
  58% { opacity: 0.85; animation-timing-function: ease-out; }
  85%, 100% { opacity: 0; }
}

/* Narrow, not played at all: the picture, and the light behind it, are
   simply there, the same as with motion off. */
@media (max-width: 46rem) {
  html.js-anim .pano .hero-shot::before,
  html.js-anim .pano .hero-shot.in::before {
    opacity: 1;
    transform: none;
    animation: none;
  }
  html.js-anim .pano .hero-shot .device img,
  html.js-anim .pano .hero-shot.in .device img {
    clip-path: none;
    animation: none;
  }
  html.js-anim .pano .hero-shot .device::before,
  html.js-anim .pano .hero-shot .device.poster::after { display: none; }
}

/* Motion off while the page is open: the picture is simply there. */
@media (prefers-reduced-motion: reduce) {
  html.js-anim .pano .hero-shot .device img { clip-path: none !important; }
  html.js-anim .pano .hero-shot .device::before,
  html.js-anim .pano .hero-shot .device.poster::after { display: none; }
}

/* ---------- the tour, as the page's centrepiece ---------- */

/* SpherePlayer's tour sits in a section beside a label, at the prose's
   measure. Here it is the page: no label, the whole pane, and given the room,
   the picture beside the words the way the app lays its tour out on a phone
   held sideways -- picture first, words and dots to its right. */
.tour-pane { display: block; }
.pano .tour { max-width: none; }
@media (min-width: 46rem) {
  .pano .tour.is-live {
    display: grid;
    grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr);
    grid-template-rows: 1fr auto;
    column-gap: clamp(1.5rem, 3.5vw, 2.5rem);
    align-items: center;
  }
  .pano .tour.is-live .tour-screen { grid-row: 1 / 3; margin-bottom: 0; }
  .pano .tour.is-live .tour-steps { align-self: end; }
  .pano .tour.is-live .tour-nav { align-self: start; }
}
/* which half of the tour a step belongs to, above its title, in the app's
   accent -- as the app sets it */
.tour-step .tour-section {
  margin-bottom: 0.35rem;
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
}
/* Once the picture is live it carries these words in its own corner (tour.js
   reads them from here), so the line steps out of sight -- but not out of
   reach: the canvas is aria-hidden, so a screen reader still needs it. */
.tour.is-live .tour-step .tour-section {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.pano .tour-step h3 { font-size: 1.25rem; }
/* Next in this page's orange rather than SpherePlayer's blue */
.pano .tour-next { background: rgba(247, 160, 98, 0.14); }
@media (any-hover: hover) {
  .pano .tour-next:hover { background: rgba(247, 160, 98, 0.26); }
}

/* ---------- one row of pictures ---------- */

/* A phone and an iPad at one height, nothing cropped: every card's share of
   the row is its own width-to-height ratio (--aspect, set on the card), so
   the row divides its width in exactly the proportions that make the heights
   equal. Narrow, the same row is a strip you swipe, at a fixed height. */
.shots { display: block; }
.shot-row {
  list-style: none;
  margin: 0;
  /* air for the hover zoom and the cast; a scroller clips both ways */
  padding: 0.6rem 0 1.2rem;
  display: flex;
  gap: clamp(0.8rem, 2vw, 1.2rem);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
}
.shot-row > li {
  flex: none;
  height: 24rem;
  aspect-ratio: var(--aspect);
  scroll-snap-align: start;
}
.shot-row .device,
.shot-row img { height: 100%; }
.shot-row img { width: 100%; object-fit: cover; }
@media (min-width: 46rem) {
  .shot-row { overflow: visible; }
  .shot-row > li {
    flex: var(--aspect) 1 0;
    height: auto;
  }
  .shot-row .device,
  .shot-row img { height: auto; }
}
.shots-note {
  margin: 0.4rem auto 0;
  max-width: var(--measure);
  text-align: center;
  font-size: 0.92rem;
  color: var(--muted);
  text-wrap: pretty;
}

/* The picture under the pointer comes nearer, the same as the answers do on
   Room to Spare's page, minus their coloured light: here the pictures carry
   colour enough of their own. */
@media (any-hover: hover) {
  .shot-row .device {
    transition: transform 0.4s cubic-bezier(0.2, 0.7, 0.2, 1), box-shadow 0.4s ease;
  }
  .shot-row li { position: relative; }
  .shot-row li:hover { z-index: 1; }
  .shot-row li:hover .device {
    transform: scale(1.05);
    box-shadow:
      0 34px 54px -30px #000,
      0 8px 18px -10px #000;
  }
}

/* ---------- this app's own glyphs ---------- */

/* Same 24-unit grid and stroke weight as every other set on the site. */

/* Location, on the policy page: the map pin, and nothing on the map. */
.g-pin {
  --glyph: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='M12 21.2s-6.6-6-6.6-11.2a6.6 6.6 0 0 1 13.2 0c0 5.2-6.6 11.2-6.6 11.2Z'/><circle cx='12' cy='10' r='2.4'/></svg>");
}

