/* Speedix — main stylesheet (BEM)

   Graphite Precision, in its Day ambience: the design system the app runs
   on, applied to the site so the two are one product. Every value below is
   read from speedix-app (lib/core/theme/, lib/shared/theme/) and from the
   speedix-graphite-design skill; nothing is chosen here. When a token moves
   in the app, it moves here in the same breath.

   The rules that hold on every page:

   - No colour at a call site. A rule takes a token from :root, never a hex.
   - One accent, for what is active or selected: the current page in the
     nav, the current language, a link. Never decoration, never a title.
   - One material. Matte for the structure (the header, the alternate
     bands, the step discs): the surface under a 1px outline hairline, no
     shadow. The app's satin is for what is pressed, and the site has no
     button: its one action is the store's badge, and every other way out is
     a link. Lists stay plain lists: whitespace separates them, never a card.
   - No shadow on a block: it is set apart from the page by one tone step and
     one hairline. A capture is the exception, as on the store slides: a
     hairline and the slides' drop shadow. Two lights and no more: the scene's
     bloom behind a capture (the accent at 16 %, 22 % across the first screen)
     and the soft glow the type pictograms keep from the app.
   - Geist reads everything. Text goes through a named role (a section
     title, a row title, supporting text, an eyebrow). Oswald counts in the
     app, where a figure is a measurement — a speed, a limit, a distance. The
     site displays none: its two figures, the step numbers and the 404, are an
     ordinal and a code, both read rather than counted, so they stay in the
     reading face and the family is not loaded at all.
   - Sentence case everywhere; capitals only on the label roles (eyebrow,
     language switch), which carry their own tracking.
   - Every distance comes off the spacing scale, every corner off the radii.

   Breakpoints — two, and only these two. Media queries cannot read custom
   properties, so the scale lives here and every query must quote one of them:

     768px   tablet   two columns appear; the header becomes sticky
     1024px  desktop  enough width to cap the copy at the reading measure

   A third value is a bug, not a refinement. */

:root {
  color-scheme: dark;

  /* ---- palette: GraphitePalette.day ---- */
  --page: #2b3033;             /* the page, the field's well, the scene's ground */
  --surface: #353d41;          /* every block laid on the page */
  --ink: #f3f6f7;              /* what the page is about */
  --ink-secondary: #bac6cc;    /* what is read alongside it */
  --ink-tertiary: #7e888d;     /* muted at 55 %: only for what cannot be used */
  --accent: #a6dbea;           /* the one accent */
  --on-accent: #16323a;        /* ink on the accent */
  --outline: #505d64;          /* every hairline */
  --warning: #e1955a;          /* a warning in progress, never decoration */
  --danger: #c3302d;
  --grain: url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20width=%27180%27%20height=%27180%27%3E%3Cfilter%20id=%27g%27%3E%3CfeTurbulence%20type=%27fractalNoise%27%20baseFrequency=%270.85%27%20numOctaves=%273%27%20stitchTiles=%27stitch%27/%3E%3CfeColorMatrix%20type=%27saturate%27%20values=%270%27/%3E%3C/filter%3E%3Crect%20width=%27180%27%20height=%27180%27%20filter=%27url%28%23g%29%27%20opacity=%270.035%27/%3E%3C/svg%3E");  /* the slides' noise, ±1 % of luminance: a material the
                          whole page shares, and what keeps a dark gradient
                          from banding on an OLED screen */
  --shade: #1a1d1f;            /* the page mixed 40 % into black: the shade the
                                  Live scene falls into away from its light.
                                  Not on the board — it is the store slides'
                                  own ground (screenshots.py, SHADE). */

  /* ---- derived tones: GraphiteTheme.surfaceTokens, one recipe each ----
     Only the recipes a rule below actually takes. The palette above and the
     scales further down are mirrors of the app and stay whole even where the
     site uses no rung: the set is the thing, and a gap in it would read as a
     value someone forgot. A derived tone is not a mirror, it is a recipe for
     one component, so it has no reason to exist without that component. Five
     sat here for components this site does not have: the separator and the
     pressed state of a card's row (no list here is a card), the accent wash,
     and the chrome's lit edge and sheen, which are the very finish the bar
     was refused on 21/09. The satin and the two pressed shades followed on
     11/10 with the site's buttons: none is left, and the block stays as the
     place the next one goes. */

  /* ---- the Live's grid, as the back office draws it ---- */
  --grid-cell: 44px;
  --grid-line: rgb(80 93 100 / 28%);   /* the outline, at 28 % */

  /* ---- the light: the accent as the scene's bloom, glow(0.16) ---- */
  --light-16: rgba(166, 219, 234, 0.16);
  --light-22: rgb(166 219 234 / 22%);    /* the same, across the first screen */
  --light-out: rgb(166 219 234 / 0%);    /* where either dies away */

  /* ---- the first screen's frame and what lies under a capture: the store
     slides' own values (screenshots.py, ground()) ---- */
  --vignette: rgb(0 0 0 / 22%);
  --vignette-out: rgb(0 0 0 / 0%);
  --capture-shadow: 0 1.125rem 2.25rem rgb(0 0 0 / 55%);

  /* ---- the pictograms' soft glow: LightEffects.softGlow, the ink at 40 %,
     then the surface at 35 % and 15 % ---- */
  --glow-lit: rgba(243, 246, 247, 0.4);
  --glow-mid: rgba(53, 61, 65, 0.35);
  --glow-far: rgba(53, 61, 65, 0.15);

  /* ---- the same light where it reaches the chrome ---- */
  --light-x: 50%;                        /* where the scene's light stands across the
                                            width: on a phone, the captures are centred */
  --edge-lit: rgb(166 219 234 / 85%);    /* the bar's hairline where the light strikes it */
  --light-cast: rgb(166 219 234 / 14%);  /* what it casts up onto the underside of the bar */
  --chrome-edge: rgb(255 255 255 / 8%);  /* the hairline elsewhere: one step off the shade,
                                            not a rule — a rule would read as drawn */

  /* ---- radii: AppGeometry ---- */
  --radius-button: 12px;
  --radius-tile: 8px;

  /* ---- the spacing scale: Spacing, 4-point base ---- */
  --space-xxs: 0.125rem;   /* 2  hairline-scale nudges */
  --space-xs: 0.25rem;     /* 4  an icon and its label, a title and its line */
  --space-s: 0.5rem;       /* 8  siblings in a list */
  --space-m: 1rem;         /* 16 a screen's margin; the air between a row's parts */
  --space-l: 1.5rem;       /* 24 between sections; a button's side padding */
  --space-xl: 2rem;        /* 32 a block set apart from the screen */
  --space-xxl: 3rem;       /* 48 the same, larger */
  --space-xxxl: 4rem;      /* 64 the end of a scroll */

  --header: 4.0625rem; /* 65: the closed bar, hairline included — what the
                          first screen reaches back up over so its composition
                          starts at the window's edge and not under the bar.
                          81 from the tablet, where the bar breathes more. */
  /* ---- a capture's width: one figure, four rules ---- */
  --capture: 20rem;         /* 320: a phone, where a capture stands alone */
  --capture-beside: 17rem;  /* 272: from the tablet, where copy stands next to it */

  /* ---- sizes: AppGeometry ---- */
  --touch-target: 3rem;    /* 48 what a finger can hit; every action button */
  --pictogram: 2.5rem;     /* 40 a type pictogram on a row */
  --pictogram-sm: 1.5rem;  /* 24 a sub-type's pictogram */
  --status-disc: 3rem;     /* 48 a status disc: the step numbers */
  --hairline: 1px;

  /* ---- type: ThemeCore, the named roles ---- */
  --font-text: "Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
  --weight-medium: 500;
  --weight-emphasis: 600;
  --tracking-title: -0.018em;   /* -0.5/28 em, from 28 px up */
  --tracking-caps: 0.05em;      /* 0.6/12 em on the label roles */

  /* ---- motion: AppAnimations, and the three transitions the site uses ----
     The durations and the curve are the app's (`AppAnimations.xs/s/m`,
     `defaultCurve`). What follows are whole transition values held in custom
     properties: a rule takes one of these, never a list of its own. Two rules
     writing their own timing for the same kind of change is how a page ends up
     with two speeds — which is what this stylesheet had. */
  --motion-xs: 100ms;
  --motion-s: 200ms;
  --motion-m: 350ms;
  --curve: ease-in-out;

  /* A colour changes: ink, ground, hairline. */
  --motion-tone:
    color var(--motion-s) var(--curve),
    background-color var(--motion-s) var(--curve),
    border-color var(--motion-s) var(--curve);

  /* Something turns or travels: the burger's bars crossing. */
  --motion-shape:
    transform var(--motion-s) var(--curve),
    top var(--motion-s) var(--curve);

  /* Something unfolds: the menu's panel and what it pushes down. Longer,
     because it moves the page rather than a detail of it. */
  --motion-unfold:
    grid-template-rows var(--motion-m) var(--curve),
    visibility var(--motion-m) var(--curve),
    padding-top var(--motion-m) var(--curve);
}

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

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--page);
  color: var(--ink-secondary);
  font-family: var(--font-text);
  /* Body: 16 / 24. */
  font-size: 1rem;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  /* A column the height of the window, the main taking what is left: on a
     page shorter than the screen — the 404 — the footer stood in the middle
     of it with a band of bare page underneath. */
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

main {
  flex: 1;
}

/* The way past the bar for a keyboard: off the page until it takes the focus,
   then the first thing on it. */
.skip-link {
  position: absolute;
  top: var(--space-s);
  left: var(--space-m);
  z-index: 200;
  padding: var(--space-s) var(--space-m);
  border-radius: var(--radius-tile);
  background: var(--accent);
  color: var(--on-accent);
  transform: translateY(-200%);
}

.skip-link:focus {
  transform: none;
}

h1,
h2,
h3 {
  margin: 0;
  color: var(--ink);
  font-weight: var(--weight-emphasis);
  text-wrap: balance;
}

p {
  margin: 0 0 var(--space-m);
}

img {
  max-width: 100%;
  height: auto;
}

/* A link takes the accent: the one place the accent meets running text. */
a {
  color: var(--accent);
  font-weight: var(--weight-medium);
  text-decoration: none;
  transition: var(--motion-tone);
}

a:hover {
  text-decoration: underline;
}

strong {
  color: var(--ink);
  font-weight: var(--weight-emphasis);
}

@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0ms !important;
  }
}

.container {
  max-width: 80rem;
  margin: 0 auto;
  padding: 0 var(--space-m);
}

@media (min-width: 768px) {
  :root {
    /* 61.8: the wordmark's line (24 × 1.2) between 16 of padding each side,
       and the hairline. */
    --header: 3.8625rem;
    /* The captures' centre: 76 % across the container's content, whatever
       the window — the light is computed, never placed by eye. */
    --light-x: calc(50% + 0.26 * (min(100%, 80rem) - 2 * var(--space-l)));
  }

  .container {
    padding: 0 var(--space-l);
  }
}

/* ---------- type roles ---------- */

/* Page title: 28 / 34, 600, tightened. The one title size of the site,
   shared by section titles, the legal pages and the 404. */
.title {
  font-size: 1.75rem;
  line-height: 1.214;
  letter-spacing: var(--tracking-title);
}

/* The eyebrow: 12 / 16, 500, capitals with their own tracking, in the
   secondary ink. A label, never the accent. */
.eyebrow {
  margin: 0 0 var(--space-s);
  font-size: 0.75rem;
  line-height: 1.333;
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--ink-secondary);
}

/* ---------- header: the chrome ---------- */

/* The bar is posed from the first pixel on every page, in the shade — the
   deep tone the first screen's ground ends on. Two other bars were tried. On
   the page's own tone: a band of that mid grey is an app's chrome, and on a
   website it reads as a panel. With no ground at all at the top of the page,
   taking one the moment anything scrolled under it: a bar that changes its
   nature on the first pixel of scroll is two bars. The shade is darker than
   everything it sits over, so this one is the same object everywhere, and it
   is a colour the site already has.

   Graphite's chrome — a lighter slab, a sheen along its lower edge — exists
   in the app because the console floats above a map that moves under it. A
   page moves under nothing; carried over here, the finish only announced the
   bar as a bar, which is the look of a 2012 site.

   The lower edge is where the site's one light lands. The first screen's
   bloom stands behind the captures; the hairline under the bar is one step
   off the shade everywhere except above that bloom, where it takes the accent
   and dies away on either side, and a little of the light is cast up onto the
   underside of the chrome. Three background layers, bottom-aligned: the lit
   part of the edge, the edge itself, the cast. The edge is painted rather than
   drawn as a border because a border cannot carry a gradient; the border is
   kept, transparent, so the bar keeps its height and the layers have its
   border-box to align to.

   The bar is a rectangle in every state, at every width. Open, it was given
   the driving console's silhouette — its lower edge leaving the bar's height
   on one side, sweeping down and running on under the pages. Turned down on
   21/09: on a page the shape read as an effect laid over the chrome rather
   than as the chrome's own form, and it made the open menu a different object
   from the closed bar. The app's console earns that silhouette by floating
   over a map; a bar that spans the window does not. */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background:
    radial-gradient(22rem 100% at var(--light-x) 50%, var(--edge-lit), transparent)
      bottom / 100% var(--hairline) no-repeat,
    linear-gradient(var(--chrome-edge) 0 0)
      bottom / 100% var(--hairline) no-repeat,
    radial-gradient(26rem 100% at var(--light-x) 100%, var(--light-cast), transparent)
      bottom / 100% 2.5rem no-repeat,
    linear-gradient(var(--shade) 0 0);
  background-origin: border-box;
  border-bottom: var(--hairline) solid transparent;
  /* 8 rather than 16: the lockup grew to 48 and the row is what sets the
     height now, not the padding around it. The bar stays where it was. */
  padding: var(--space-s) 0;
}

/* Pinned at every width. It was static on a phone as long as the links were
   laid out in full: at 190px tall it would have held a third of the screen
   hostage for the whole scroll. Behind the button it is the same 65px row as
   the desktop's, so it costs what it costs there and the way back to the other
   pages is never more than a thumb away.

   `overflow-x: clip` rather than `hidden` on the hero and the sections is what
   keeps this working: `hidden` would make them scroll containers and the
   sticky header would stop sticking.

   Phone layout: the lockup, the language and the button on one line, and the
   pages on a second line that only exists while the button says so. Laid out
   in full they cost 190px of header before a word of the page was read — a
   menu you had to scroll past to reach the thing you came for.

   `display: contents` on the side wrapper lets the switch, the button and the
   nav be placed in this grid independently, without a second element in the
   markup. */
.site-header__inner {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  /* No row gap: it would hold 4px of header open under a shut panel. The air
     between the bar and the first page lives inside the panel instead, so it
     folds away with it. */
  column-gap: var(--space-xs);
  row-gap: 0;
}

.site-header__side {
  display: contents;
}

.lang {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
}

.burger {
  grid-column: 3;
  grid-row: 1;
}

/* The panel unfolds rather than appears: the row it sits in goes from no
   height to its own, which carries the header's height, the silhouette's sweep
   and the page below it in one movement — the console's `lift`, animated the
   way the app animates it. `display` cannot be transitioned; a grid row can.

   `visibility` keeps the links out of the tab order while the panel is shut,
   and it is special-cased in transitions: it turns visible at once on the way
   in and waits for the end on the way out. */
.site-nav {
  grid-column: 1 / -1;
  grid-row: 2;
  display: grid;
  grid-template-rows: 0fr;
  visibility: hidden;
  transition: var(--motion-unfold);
}

/* The legal pages and the 404 have no scene under the bar, so nothing lights
   its edge there: the light is the first screen's, not the bar's own. `:has()`
   reads the page from the main it precedes, so no template has to declare it. */
body:has(main.legal) .site-header,
body:has(main.notfound) .site-header {
  --edge-lit: transparent;
  --light-cast: transparent;
}

.site-header--open .site-nav {
  grid-template-rows: 1fr;
  visibility: visible;
}

/* Open, the bar is the same object, taller. */
.site-header--open {
  /* The air under the last page has to be the air between two pages, or the
     list reads as cut rather than as finished. Between two words there is
     4 of one link's padding, 4 of the next one's and two half-leadings —
     16.8 measured. Under the last word there is one padding, one half-leading,
     this padding and the hairline: 8 lands it on 17.3. Change the links'
     padding and this follows, one step of the scale above it. */
  padding-bottom: var(--space-s);
}

/* The button: a touch target with three bars in it, and no box of its own —
   the header has no other control, and drawing one around this would make it
   the loudest thing in the row. The bars cross while the panel is open, so
   the button says what it will do next. */
.burger {
  --burger-bar: 1.125rem;

  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--touch-target);
  height: var(--touch-target);
  /* The box is 48 wide so a thumb can hit it; the bars inside are 18. Centred,
     the icon stops 15px short of the margin the pages below are ranged on, and
     the button reads as pushed in. Pulled out by exactly that half-difference,
     so what the eye takes for the button's edge is the margin itself. The box
     then runs into the page's own 16 of gutter, never past it. */
  margin-right: calc((var(--burger-bar) - var(--touch-target)) / 2);
  padding: 0;
  border: 0;
  border-radius: var(--radius-tile);
  background: none;
  color: var(--ink-secondary);
  cursor: pointer;
}

.burger:hover,
.burger:focus-visible {
  color: var(--accent);
}

.burger__bars {
  position: relative;
}

.burger__bars,
.burger__bars::before,
.burger__bars::after {
  display: block;
  width: var(--burger-bar);
  height: 2px;
  border-radius: 1px;
  background: currentcolor;
  transition: var(--motion-shape), var(--motion-tone);
}

.burger__bars::before,
.burger__bars::after {
  content: "";
  position: absolute;
  left: 0;
}

.burger__bars::before {
  top: -6px;
}

.burger__bars::after {
  top: 6px;
}

.site-header--open .burger__bars {
  background: transparent;
}

.site-header--open .burger__bars::before {
  top: 0;
  transform: rotate(45deg);
}

.site-header--open .burger__bars::after {
  top: 0;
  transform: rotate(-45deg);
}

/* The word alone. The mark was tried beside it — the app's astre, on nothing,
   after the store tile turned out to be built in the Night ambience and read
   as a square of near-black on a Day page. Even as the mark alone it was one
   object too many over a picture that already has a subject: the header's job
   here is to name the site and get out of the way. The mark keeps the favicon,
   the store and the app, where it stands on its own. */
.brand__link {
  display: flex;
  align-items: center;
  text-decoration: none;
}

.brand__link:hover {
  text-decoration: none;
}

/* The wordmark is not a heading: the h1 belongs to the page's own subject.
   Screen title: 20 / 24, 600. */
.brand__name {
  font-size: 1.5rem;
  line-height: 1.2;
  font-weight: var(--weight-emphasis);
  color: var(--ink);
}

/* The language is one word: the other one, the one a click switches to. Two
   words side by side needed something between them and a mark on the current
   one — a control, in a header that has no other, and made a segmented
   control in satin it drew the eye before the pages did. One word needs
   neither: a label in capitals, tracked, in the secondary ink, told from the
   pages by its size alone. */
.lang {
  display: flex;
  align-items: center;
  font-size: 0.75rem;
  line-height: 1.333;
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

.lang__link {
  display: inline-flex;
  align-items: center;
  min-height: var(--touch-target);
  padding: 0 var(--space-s);
  color: var(--ink-secondary);
}

/* The nav reads like the console's tabs: every link in the secondary ink,
   the current page marked by colour alone, in the accent. No block, no
   underline, no rule under the word. Button label: 15 / 20, 500. */
.site-nav__list {
  display: flex;
  /* Ranged left on a phone, on the page's own margin: the wordmark, the
     pages, the eyebrow and the title all start on the same line, and the
     panel reads as part of the page rather than as a block hung off the
     button. They were ranged right at first, under the thumb that opened
     them — one column of its own against three that share a margin. The row
     at 768 takes its own alignment. */
  flex-direction: column;
  align-items: flex-start;
  margin: 0;
  padding: 0;
  /* Clipped, so the list folds away with its row rather than spilling over the
     bar while the row closes. */
  overflow: hidden;
  list-style: none;
  font-size: 0.9375rem;
  line-height: 1.333;
  font-weight: var(--weight-medium);
}

/* Stacked, each link is a row of its own: the line box between 8 above and 8
   below, so the whole 36px row answers, not just the word. Four links at the
   app's 48 turned the phone's header into a column of gaps — the menu read as
   four separate things rather than one list. 36 keeps the rows flush, with no
   dead band between them, and stays well clear of the 24px WCAG 2.2 asks of a
   target. */
.site-nav__link {
  display: flex;
  align-items: center;
  padding: var(--space-xs) 0;
  color: var(--ink-secondary);
}

.site-nav__link--active {
  color: var(--accent);
}

.site-nav__link:hover,
.site-nav__link:focus-visible,
.lang__link:hover,
.lang__link:focus-visible {
  color: var(--accent);
  text-decoration: none;
}

@media (min-width: 768px) {
  /* One line, but with more air than the phone's: 8 of padding round a 24px
     wordmark makes a 46px bar, which is thin across a screen this wide. 16
     puts it at 62. */
  .site-header {
    padding: var(--space-m) 0;
  }

  /* One row: the wordmark, the pages beside it, and the language alone
     against the right margin. Ranged right, the pages sat over the captures,
     which are the first screen's subject; beside the wordmark they read as
     one line — the site's name and its three pages — and the right of the
     screen is left to the picture. The language is the only thing on that
     side, and a word of twelve pixels is not mistaken for a fourth page.

     Everything on the row is centred on the bar's middle, and the three line
     heights are what make that true: 24 × 1.2 for the wordmark, 15 × 1.333
     for the pages, 12 × 1.333 for the language. In each of them the baseline
     sits below the box's middle by almost exactly half a cap — 8.5 of 8.5,
     5.5 of 5.33, 4.0 of 4.26 — so centring the boxes puts every cap-height
     centre within a third of a pixel of the bar's own. One baseline for all
     three was tried first and is what the row does *not* do: it hangs the
     smaller words off the wordmark's foot, three and four pixels below the
     middle of a bar that has nothing else in it. Type sits on a baseline
     inside a paragraph; a masthead's line is centred in its bar. The phone's
     4 of column gap is taken back too, so the wordmark's margin is the only
     thing between it and the first page. */
  .site-header__inner {
    display: flex;
    align-items: center;
    column-gap: 0;
  }

  /* 64 between the wordmark and the pages, twice the 32 between two pages:
     the step between two groups, not the step within one. At 48 the wordmark
     read as the first word of the nav. */
  .brand {
    margin-right: var(--space-xxxl);
  }

  .site-header__side {
    display: flex;
    flex: 1;
    align-items: center;
    justify-content: space-between;
  }

  /* The pages ahead of the language in the row, whatever the markup says: on
     a phone the language stays in the bar and the pages drop under it.

     And the panel stops being a panel: it is the row itself, unfolded for
     good. Its collapsed row and the `visibility` that keeps the links out of
     the tab order are the phone's business only, so both are undone here
     rather than left to the class the button toggles. */
  .site-nav {
    order: -1;
    grid-template-rows: 1fr;
    visibility: visible;
  }

  /* Nothing between two pages but air: 32, a step above the 24 that keeps a
     section's parts apart. A rule was tried, then a dot: the Live's grid runs
     behind the bar in the rule's own tone, and a mark between two words was
     one more thing to read on a line that has three. */
  .site-nav__list {
    flex-direction: row;
    align-items: center;
    gap: var(--space-xl);
  }

  .burger {
    display: none;
  }

  /* The words keep their place and the row its height: what answers to a
     finger grows around each word, and the negative margin takes the growth
     back out of the layout. 52 tall, and 16 wider than the word. */
  .site-nav__link,
  .lang__link {
    min-height: 0;
    padding: var(--space-m) var(--space-s);
    margin: calc(var(--space-m) * -1) calc(var(--space-s) * -1);
  }

}

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

/* The first screen is composed like a store slide, not like an app screen.

   Graphite is the app's system and the app is looked at through a windscreen:
   no shadow, no halo, one tone step for everything. A page is looked at once,
   from a chair, and that austerity left the site flat — three near-identical
   greys and nothing to arrive on. The slides already solved this, with Jules's
   own values: `speedix-marketing/scripts/screenshots.py`, function `ground()`.
   Five layers, all of them from there, in its order:

     1. the ground deepens towards the bottom, page to SHADE, "so the device
        reads as lit above the ground rather than pasted onto it";
     2. an ambient glow of the accent at 22 %, wide and centred at 42 % of the
        height — the site's own light was 16 % and the width of one capture;
     3. a vignette at 22 %, which is what turns a background into a frame;
     4. grain, which is in the script for a reason that holds here too: "a
        clean gradient over 1920 px bands visibly on an OLED screen";
     5. a real drop shadow under the captures, which the script uses and the
        app forbids.

   4 and 5 are the two that step outside the system, and they are the point:
   they are photographic, not decorative. Nothing here is invented. */
.hero {
  /* The anchor for the two layers below: the grid on ::before, the light and
     the frame on ::after. */
  position: relative;
  padding: calc(var(--header) + var(--space-xxl)) 0 var(--space-xxl);
  background: linear-gradient(to bottom, var(--page), var(--shade));
  /* Up under the bar, then the copy down under it again: the box starts at
     the window's edge, the content starts where it always did. The bar is
     opaque, so what this buys is the grain and the grid running to the top of
     the window behind it rather than starting under it. */
  margin-top: calc(var(--header) * -1);
}

/* The light, the frame and the grain, over the grid and under the content. */
.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    var(--grain),
    radial-gradient(farthest-corner at 50% 50%, var(--vignette-out) 32%, var(--vignette) 100%),
    radial-gradient(90% 140% at 50% 42%, var(--light-22), var(--light-out) 72%);
  background-repeat: repeat, no-repeat, no-repeat;
  background-size: 180px 180px, auto, auto;
}

/* The shadow the slides put under the device — black, offset down, blurred
   wide. It is what separates one capture from the one it covers, what lifts
   them off the ground, and it goes under every capture of the page and not
   only the hero's: one of them lit and the others flat read as two sites. The
   app has none because nothing there is ever laid on anything.

   A box-shadow on the image, not a `filter: drop-shadow()` on its wrapper.
   The two draw the same thing here, because what casts the shadow is an
   opaque rounded rectangle, but the filter promotes the wrapper to a
   composited layer that iOS re-rasterises while the page scrolls. It is the
   only thing on the page that does, so it is the first suspect for the
   horizontal seam reported on an iPhone on 21/09 — a seam that could not be
   reproduced on WebKit in the simulator, where the only steps found around
   the captures were these sections' own borders. Unproven, but it costs
   nothing. */
.phone-row__item .screen,
.feature__media .screen {
  box-shadow: var(--capture-shadow);
}

/* The Live's grid, seen head-on, under the one thing the page is about.

   The site had no texture at all: three greys, a hairline, and captures. The
   back office solved the same emptiness on 2026-09-20 with this exact figure
   (`admin/src/styles/_grid.scss`, `login.scss`) — the same 44px cell, the same
   line, masked so it is strongest where the content stands and gone at the
   edges, and one light behind it. Here the light already exists: the accent
   bloom the captures carry.

   Head-on, not in perspective. The perspective was tried on both surfaces and
   rejected: the app draws that floor 390pt wide under a sky and a scope, and
   what makes it read is the light over it; spread across a desktop it is only
   a large empty floor. Flat, the cells keep the Live's texture without
   pretending to be a ground, and they cover any width by construction. What
   was turned down on the site on 19/09 was the *scene* behind the hero — the
   astre and the floor in perspective, "too much". This is the other object. */
.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background-image:
    repeating-linear-gradient(to right, var(--grid-line) 0 1px, transparent 1px var(--grid-cell)),
    repeating-linear-gradient(to bottom, var(--grid-line) 0 1px, transparent 1px var(--grid-cell));
  mask-image: radial-gradient(70% 70% at 50% 50%, #000 20%, transparent 100%);
  pointer-events: none;
}

.hero > .container {
  position: relative;
  z-index: 1;
}

/* The ambient light behind a capture is a pseudo-element that overflows its
   stage by 30% on each side. Anchored left on a phone, it would reach past
   the viewport and open a horizontal scroll. `clip` trims it without creating
   a scroll container, so the sticky header keeps working — which
   `overflow: hidden` would break. */
.hero,
.feature {
  overflow-x: clip;
}

.hero__layout {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-xxl);
}

.hero__copy {
  flex: 1;
}

/* The headline: the page title, let grow with the viewport on the one page
   that is read at arm's length. Weight and tracking are the title's. */
.hero__title {
  margin-bottom: var(--space-l);
  font-size: clamp(2rem, 4.5vw, 3rem);
  line-height: 1.167;
  letter-spacing: var(--tracking-title);
}

/* The lead sits a step above the body: 18 / 28, in the primary ink. The
   site's one size outside the app's scale, for a sentence read at arm's
   length; the rest of the copy is the app's 16 / 24. */
.hero__lead {
  font-size: 1.125rem;
  line-height: 1.556;
  color: var(--ink);
}

.hero__copy .eyebrow {
  margin-bottom: var(--space-m);
}

/* On the same left margin as the eyebrow, the headline and the paragraph it
   follows. Centred, it was the one element on a phone that answered to no
   margin — and it is the element the whole page is pointing at. */
.hero__cta {
  margin-top: var(--space-xl);
  display: flex;
  justify-content: flex-start;
}

/* The hero always carries a capture: it is the page's whole claim and it
   cannot be made on words alone. One of them, until there is room for three —
   at anything under 1024 the row renders them around 110px each, too small to
   read an interface. On a phone it sits under the copy, at the size every
   other capture of the page takes — 20rem here, 17rem from the tablet, the
   two values .feature__media .screen carries — so that the hero's proof and
   the sections' are one series and not two. */
.hero__gallery {
  flex: 1;
  width: 100%;
  max-width: var(--capture);
}

/* Three captures overlapping, the middle one in front. Set side by side and
   apart they were three separate pictures competing for a half-column — and
   the wider the screen, the smaller each one got: 272px alone at 768, 147 at
   1024, 192 at 1440. Three things at once, shown separately, is the worst of
   both.

   Overlapped they stop dividing the width and start sharing it: each one is
   wider than it could ever be in a row, and the group reads as one object.
   The centre stands in front because it is the alert itself; the two beside
   it are what surrounds it, cropped by the one in front the way a hand of
   cards is. The size difference and the offset are now what gives the depth,
   which is exactly what made them look broken when they were laid flat. */
.phone-row {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Stated, not left to the content: the row's shares are percentages of it,
     and sized on its content it took the width of whatever had loaded. The
     two lazy captures arrived, the row widened, and the first screen grew by
     160px under the reader — a layout shift of 0.11 measured in production. */
  width: 100%;
}

/* One capture below 1024, so it takes the column it is given. */
.phone-row__item {
  flex: 0 1 100%;
}

.phone-row__item + .phone-row__item {
  display: none;
}

/* A capture is a block on the page: its corners cut like the device's, one
   hairline around it, no shadow and no glow. On a dark ground the hairline
   is what draws its edge, as it draws a card's. The captures are the cleaned
   Android set of speedix-marketing (screens/clean/android/<lang>/), all
   1080x2308: the radius is a share of the element, 7.5% of the width against
   3.5% of the height, so the corner stays circular at every rendered size. */
.screen {
  display: block;
  /* Declaring the ratio reserves the box before the lazy-loaded file arrives.
     It is the cleaned captures' own ratio and must follow them if the cut or
     the device changes (CROPS in speedix-marketing's scripts/clean.py). */
  aspect-ratio: 1080 / 2308;
  border: var(--hairline) solid var(--outline);
  border-radius: 7.5% / 3.5%;
}

.stage {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The one light the site is allowed, and the thing that makes the grid under
   it read as a scene rather than as graph paper: the accent, bloomed behind
   the captures. It was a `closest-side` circle, which on a stage far taller
   than it is wide drew a disc the width of one capture and died there. An
   ellipse sized on the stage itself covers the three, and the grid lights up
   where the product stands. */
.stage::before {
  content: "";
  position: absolute;
  inset: -18% -30%;
  background: radial-gradient(
    55% 45% at 50% 45%,
    var(--light-16),
    var(--light-out) 72%
  );
  pointer-events: none;
}

.stage > * {
  position: relative;
}

.screen--sm {
  width: 100%;
}

@media (min-width: 768px) {
  .hero {
    padding: calc(var(--header) + var(--space-xxxl)) 0 var(--space-xxxl);
  }

  /* Side by side as soon as there is a second column to have. The hero is the
     page's whole claim and it stood on words alone: stacked under the copy,
     the capture sat below the fold and the first screen of the site showed no
     product at all. One capture here — the map, where a zone is announced
     ahead — because three at this width render around 150px each, too small
     to read; the other two come back at 1024. */
  .hero__layout {
    flex-direction: row;
  }

  .hero__gallery {
    flex: 0 0 var(--capture-beside);
    width: auto;
  }
}

/* ---------- store badge ---------- */

/* The store badge is Google's own artwork and keeps its look; it stands at
   the height of a touch target. */
.store-badge__img {
  display: block;
  height: var(--touch-target);
  width: auto;
}

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

.feature {
  padding: var(--space-xxl) 0;
}

/* An alternate band is a block on the page: one tone step up, a hairline
   above and below. */
.feature,
.site-footer {
  background-image: var(--grain);
  background-repeat: repeat;
  background-size: 180px 180px;
}

.feature--alt {
  background-color: var(--surface);
  border-top: var(--hairline) solid var(--outline);
  border-bottom: var(--hairline) solid var(--outline);
}

main > .feature--alt:last-child {
  border-bottom: 0;
}

.feature__grid {
  display: grid;
  grid-template-columns: 1fr;
  align-items: center;
  gap: var(--space-xxl);
}

/* Shown at every width. Most visitors arrive on a phone, so hiding the captures
   below 768px meant serving the claims without the evidence to the majority —
   on a product whose proof is what the screen looks like. Centred at every
   width: stacked, the capture takes 80 to 95% of the column, so what is left
   reads as an even margin rather than as a drift. */
.feature__media {
  display: flex;
  justify-content: center;
}

/* Stacked on one column, the heading introduces the capture and never the
   reverse. The two-column order is restored at 768px. */
.feature__copy {
  order: 1;
}

.feature__media {
  order: 2;
}

@media (min-width: 768px) {
  .feature {
    padding: var(--space-xxxl) 0;
  }

  /* Proportional here: a fixed 40rem copy column would leave the capture 24px
     wide at this width. The measure is capped at 1024px instead. */
  .feature__grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
    gap: var(--space-xxxl);
  }

  .feature__grid--media-end {
    grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  }

  .feature__copy,
  .feature__media {
    order: 0;
  }

  .feature__media--end {
    order: 2;
  }

  .feature__copy--start {
    order: 1;
  }
}

@media (min-width: 1024px) {
  /* The copy column is capped at the reading measure so the text reaches the
     edge of its column instead of stopping in the middle of it.

     And the capture's column is the capture, not a share of what is left. At
     `1fr` it ran to around 530px around a 272px image centred in it, so the
     product and the sentence about it stood some 190px apart — the gap was
     the gap plus two half-columns of nothing. Sized on its content and the
     pair centred in the container, the distance between them is the gap and
     the two outer margins are equal. */
  .feature__grid {
    grid-template-columns: var(--capture-beside) minmax(0, 40rem);
    justify-content: center;
  }

  .feature__grid--media-end {
    grid-template-columns: minmax(0, 40rem) var(--capture-beside);
  }

  /* Wide enough for the three: the gallery takes its half of the row back, and
     the captures shares of it rather than pixel widths, so they hold the same
     proportions from here to the widest desktop. */
  .hero__gallery {
    flex: 1;
    max-width: none;
  }

  /* 38 + 44 + 38 = 120 % of the row, less the two 10 % overlaps: exactly the
     row. Shares rather than pixels, so the composition holds its proportions
     from 1024 to the widest desktop. */
  .phone-row__item {
    position: relative;
    z-index: 1;
    flex: 0 0 38%;
  }

  .phone-row__item--main {
    z-index: 2;
    flex: 0 0 44%;
  }

  .phone-row__item:first-child {
    margin-right: -10%;
  }

  .phone-row__item:last-child {
    margin-left: -10%;
  }

  /* What separates one capture from the one it covers: a ring of the page
     itself, outside the hairline that already draws each edge. Not a shadow —
     the page shows through, as if the capture were cut out of it. Two bright
     map screenshots meeting on a 1px line would read as one torn image. */
  .phone-row .screen {
    outline: var(--space-xs) solid var(--page);
  }

  .phone-row__item + .phone-row__item {
    display: block;
  }
}

/* A section without a capture starts where the others' pairs start: the
   width of a capture, the gap and the copy, centred like them. Left on the
   container's margin, its title stood 128px left of every other one and the
   page read on three left edges. */
@media (min-width: 1024px) {
  .feature__plain {
    max-width: calc(var(--capture-beside) + var(--space-xxxl) + 40rem);
    margin-inline: auto;
  }
}

/* Sized against the copy beside it, not against the column it sits in: a
   capture much taller than its paragraph makes the section about the image. */
.feature__media .screen {
  width: 100%;
  max-width: var(--capture);
}

@media (min-width: 768px) {
  .feature__media .screen {
    max-width: var(--capture-beside);
  }
}

.feature__title {
  margin-bottom: var(--space-l);
}

.feature__text {
  margin-bottom: var(--space-l);
}

.feature__text--closing {
  margin: var(--space-xl) 0 0;
}

/* Reading measure. Nothing capped the prose, so the sections without a media
   column ran the full 1240px of the container — around 145 characters a line,
   where the eye starts losing the next line on its way back to the margin. */
.hero__lead,
.hero__copy p,
.feature__text,
.legal__intro {
  max-width: 62ch;
}

.feature__cta {
  margin-top: var(--space-xl);
  display: flex;
  justify-content: flex-start;
}

/* ---------- lists of typed things ----------
   Plain lists, as before: no card, no panel, no rule. Whitespace does the
   separating, and the pictogram anchors the title and the sentence under it.
   The rows read as the app's settings rows would without their card. */

.rows {
  display: flex;
  flex-direction: column;
  gap: var(--space-l);
  margin: 0 0 var(--space-l);
  padding: 0;
  list-style: none;
}

.rows__item {
  display: grid;
  grid-template-columns: var(--pictogram) 1fr;
  column-gap: var(--space-m);
  align-items: start;
}

.rows__head {
  display: contents;
}

/* Row title: 16 / 24, 500, in the primary ink. */
.rows__name {
  font-size: 1rem;
  line-height: 1.5;
  font-weight: var(--weight-medium);
  color: var(--ink);
}

.rows__body {
  grid-column: 2;
}

/* Supporting text: 14 / 20, in the secondary ink, on the reading measure. */
.rows__desc {
  margin: 0;
  max-width: 62ch;
  font-size: 0.875rem;
  line-height: 1.429;
}

.rows__item .icon {
  grid-row: span 2;
  align-self: center;
}

/* A row without a description: the pictogram and the name on one line. */
.rows__item--plain {
  align-items: center;
}

.rows__item--plain .icon {
  grid-row: auto;
}

/* Sub-types, under their family, each with its own pictogram at 24. */
.rows__sub {
  grid-column: 2;
  display: flex;
  flex-direction: column;
  gap: var(--space-s);
  margin: var(--space-s) 0 0;
  padding: 0;
  list-style: none;
}

.rows__sub-item {
  display: flex;
  align-items: center;
  gap: var(--space-s);
  font-size: 0.875rem;
  line-height: 1.429;
}

/* The app's soft glow, laid over a pictogram exactly as ItemIconStackWidget
   stacks it: the icon, then LightEffects.softGlow on top, lit from the top
   left — the ink at 40 %, then the surface at 35 % and 15 %. The one glow
   Graphite keeps. */
.icon {
  position: relative;
  display: inline-block;
  width: var(--pictogram);
  height: var(--pictogram);
  flex-shrink: 0;
  line-height: 0;
}

.icon img {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: var(--radius-tile);
}

.icon::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius-tile);
  background: radial-gradient(ellipse 100% 100% at 25% 20%,
      var(--glow-lit) 0%,
      var(--glow-mid) 50%,
      var(--glow-far) 100%);
  pointer-events: none;
}

.icon--sm {
  width: var(--pictogram-sm);
  height: var(--pictogram-sm);
}

.icon--round img,
.icon--round::after {
  border-radius: 50%;
}

/* ---------- alerts list ---------- */

/* Five titles with a sentence under each; two columns once there is room. */
.alert-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-l) var(--space-xxl);
  margin: 0 0 var(--space-l);
  padding: 0;
  list-style: none;
}

.alert-list__name {
  display: block;
  font-size: 1rem;
  line-height: 1.5;
  font-weight: var(--weight-medium);
  color: var(--ink);
}

.alert-list__desc {
  margin: var(--space-xs) 0 0;
  max-width: 34ch;
  font-size: 0.875rem;
  line-height: 1.429;
}

@media (min-width: 1024px) {
  .alert-list {
    grid-template-columns: 1fr 1fr;
  }
}

/* ---------- questions ---------- */

/* A question in the body size, its answer under it at the reading measure;
   one column, read top to bottom. */
.faq {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-xl);
}

.faq__question {
  font-size: 1rem;
  line-height: 1.5;
  font-weight: var(--weight-medium);
}

.faq__answer {
  margin: var(--space-xs) 0 0;
  max-width: 62ch;
}

/* ---------- steps ---------- */

.steps {
  display: flex;
  flex-direction: column;
  gap: var(--space-l);
  margin: 0 0 var(--space-l);
  padding: 0;
  list-style: none;
}

.steps__item {
  display: grid;
  grid-template-columns: var(--status-disc) 1fr;
  column-gap: var(--space-m);
  align-items: start;
}

.steps__head {
  display: contents;
}

/* The step's number: a status disc — the matte, as a circle of 48, its
   figure in the page's own face at 500.

   The digit is centred by the flex box, which centres the line box, not the
   glyph: where the two disagree the number sits off-centre in the circle.
   Oswald's ascent runs to 1.193em against a 0.289em descent, so its line box
   is lopsided and the digit landed 0.9px low at 20px — visible, on a shape
   whose whole job is to be round. Geist's metrics are near symmetric and the
   glyph falls within 0.1px of the centre. */
.steps__num {
  grid-row: span 2;
  align-self: center;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--status-disc);
  height: var(--status-disc);
  border-radius: 50%;
  background: var(--surface);
  border: var(--hairline) solid var(--outline);
  color: var(--ink);
  font-size: 1.25rem;
  line-height: 1;
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
}

.steps__name {
  font-size: 1rem;
  line-height: 1.5;
  font-weight: var(--weight-medium);
  color: var(--ink);
}

.steps__desc {
  grid-column: 2;
  margin: 0;
  max-width: 62ch;
  font-size: 0.875rem;
  line-height: 1.429;
}

/* ---------- legal pages ---------- */

.legal {
  padding: var(--space-xl) 0 var(--space-xxxl);
}


/* Same reading measure as the rest of the site. These two pages carry the most
   running text, so they suffered the full-width lines the most. Centred, unlike
   the home page: there is no second column here to align the left edge with.
   52ch was tried on 11/10, to bring a line down to 75 letters, and turned down
   the same day: the column read as shrunk in the middle of the window. */
.legal__article {
  max-width: 70ch;
  margin-inline: auto;
}

.legal__title {
  margin-bottom: var(--space-s);
}

.legal__date {
  font-size: 0.875rem;
  line-height: 1.429;
  margin-bottom: var(--space-l);
}

.legal__intro {
  margin-bottom: var(--space-xl);
}

.legal__section {
  margin: var(--space-xl) 0 var(--space-l);
}

/* Screen title: 20 / 24, 600. */
.legal__heading {
  margin-bottom: var(--space-m);
  font-size: 1.25rem;
  line-height: 1.2;
}

.legal__list {
  margin: 0 0 var(--space-m);
  padding-left: var(--space-l);
}

.legal__list li {
  margin-bottom: var(--space-s);
}

.legal__list li::marker {
  color: var(--ink);
}

/* The zone lengths: a plain table, every cell ruled by the hairline, the
   figures in the running text's face. */
.legal__table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: var(--space-m);
}

.legal__table td {
  border: var(--hairline) solid var(--outline);
  padding: var(--space-m);
}

/* ---------- 404 ---------- */

.notfound {
  padding: var(--space-xxxl) 0;
}

/* The status code, at the display's weight, in the page's own face. */
.notfound__figure {
  margin: 0 0 var(--space-s);
  font-size: 3.75rem;
  line-height: 1.042;
  font-weight: 400;
  letter-spacing: -0.0083em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-secondary);
}

.notfound__title {
  margin-bottom: var(--space-m);
}

.notfound__lead {
  /* Between the sentence and the way out: a section's gap. */
  margin-bottom: var(--space-l);
  max-width: 62ch;
}

/* ---------- footer ---------- */

/* The footer takes the page ground under a hairline: the last block of the
   page, not a raised one. */
.site-footer {
  border-top: var(--hairline) solid var(--outline);
  padding: var(--space-xxl) 0;
}

.site-footer__inner {
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-start;
  gap: var(--space-l);
}

.site-footer__name {
  color: var(--ink);
  font-size: 1.25rem;
  line-height: 1.2;
  font-weight: var(--weight-emphasis);
  margin: 0 0 var(--space-xs);
}

.site-footer__copy {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.429;
}

.site-footer__list {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Stacked, each link is a row of its own, as in the phone's menu: 8 above and
   8 below the line, so 36 answer to a thumb and not the 20 of the word. */
.site-footer__link {
  display: inline-block;
  padding: var(--space-s) 0;
  color: var(--ink-secondary);
  font-size: 0.875rem;
  line-height: 1.429;
}

.site-footer__link:hover,
.site-footer__link:focus-visible {
  color: var(--accent);
  text-decoration: none;
}

@media (min-width: 768px) {
  .site-footer__inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
  }

  /* The links sit against the page's right margin, so they are ranged right:
     stretched to the longest of the two, the shorter one hung off a left edge
     of its own and the block read as ragged against the margin it belongs
     to. */
  .site-footer__list {
    align-items: flex-end;
    gap: var(--space-s);
  }

  .site-footer__link {
    padding: 0;
  }
}
