/* _shell/shell.css
   The house style for every trainer page.

   Kelty, 2026-09-19: "I'm in love with the Airbnb app. Can we make all the
   buttons and elements match the vibe. It needs a full refresh."

   So this file is a rewrite, not a tweak. What came before was CycleCEO's
   liquid glass in sage: frosted translucent cards floating over an ambient
   photographic wash, every label uppercase and condensed, every surface lit
   from four edges. Airbnb is the opposite discipline and it is worth naming
   exactly what that means, because "make it like Airbnb" is easy to hear as
   "make it prettier" and it is not:

     1. TYPE CARRIES THE PAGE. One family, sentence case, real weight
        contrast. Airbnb has almost no uppercase tracked micro-labels; this
        app had one on every button, tab, title and field. That single habit
        was most of what made it read as a web dashboard.
     2. SURFACES ARE OPAQUE AND CRISP. White, hairline borders, nothing
        translucent. A blur is atmosphere, and atmosphere competes with the
        words a trainer is standing outside trying to read.
     3. SHADOW MEANS FLOATING, NOT DECORATION. Static cards cast nothing.
        Only a thing genuinely above the page gets a shadow.
     4. THE BRAND COLOUR IS FOR ACTION, NOT FOR CHROME. Airbnb's header is
        white; the pink lives on the button you are supposed to press. So the
        sage band is gone from the top of the app and the sage is now what a
        primary button is made of, which is a promotion, not a demotion.
     5. SQUARE-ER THAN YOU THINK. The radius ladder came DOWN from 16/22/30
        to 12/16/20. Airbnb buttons are crisp rectangles with soft corners,
        not capsules.

   Every token name and class name from the glass system survives on purpose,
   repointed rather than deleted: sixteen pages say `var(--frost)`, `.glass`,
   `var(--edge-hi)` and `var(--cond)` in their own stylesheets, and repointing
   those is what flattens all sixteen at once, the same way redefining --paper
   is what turned them all to glass in the first place. Nothing here changes
   what a page SAYS. */

/* ── The face ───────────────────────────────────────────────────────────────
   Airbnb's whole voice is Cereal: a geometric sans with a tall x-height, wide
   apertures and softened terminals, so it reads friendly at 13px and confident
   at 28px. Cereal is proprietary. Figtree is the closest open equivalent and
   it is the one real download this app takes on: 20KB, latin only, variable
   300-900, self-hosted so there is no third-party connection on a page a
   trainer opens in a field with one bar.

   Variable is what makes this affordable. The whole point of the refresh is
   weight contrast doing the work that uppercase tracking used to do, and that
   needs 400 next to 600 next to 800 in the same sentence. Three static files
   would cost three times this; one variable file costs one.

   font-display:swap, not optional: the system stack below renders instantly
   and Figtree replaces it. A trainer never waits on a font to read a time. */
@font-face{
  font-family:"Figtree";
  font-style:normal;
  font-weight:300 900;
  font-display:swap;
  src:url(/fonts/Figtree-var.woff2) format("woff2");
  /* Latin only. The app is English and this is the difference between 20KB
     and 90KB. */
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,
    U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,
    U+2212,U+2215,U+FEFF,U+FFFD;
}

:root{
  /* ── Colour ─────────────────────────────────────────────────────────────
     Airbnb's neutral rigor, warmed to SK9's temperature. Their greys are
     dead neutral (#222/#717171/#DDDDDD); these are the same VALUES pulled a
     few degrees toward the sage and the cream so the sage button does not
     look like it was pasted onto somebody else's app. */
  /* The logo green, sampled off _shell/logo.png rather than remembered:
     #808868. Every green in the app is now this hue (75) so the app and the
     mark agree. --sage IS the logo colour. */
  --sage:#808868; --sage-deep:#657439; --sage-soft:#f1f5e5;
  /* What a primary button is made of: a light pastel carrying ink at 13:1.
     Kelty asked for lighter pastels, and white-on-a-dark-fill is the opposite
     of light. */
  --sage-fill:#e0e9c6; --sage-fill-press:#d3e0ab;
  /* The accent's own shadow, as an "r,g,b" triple so each shadow picks its own
     alpha: rgba(var(--sage-shadow),.26). A tinted shadow is most of why a
     button looks like an object, and the tint was a hard-coded green-grey
     until 2026-09-20, which meant a trainer who chose pink got a pink button
     sitting in a green pool. Same class of bug as the gradient that stayed
     green when the accent went blue: an accent is only as complete as its
     least tokenised value. The hue at S22 L22, per accent, in shell.js. */
  --sage-shadow:62,68,44;
  /* One step deeper than --sage-deep, for the pressed state of a primary
     button. A button that only scales when pressed feels broken on a slow
     phone where the scale is dropped; a colour change never is. */
  --sage-press:#4f5a2c;
  /* The top of the primary button's gradient, one step LIGHTER than
     --sage-deep. It was a raw hex inside the .pill rule, which meant a trainer
     who picked a blue accent in Settings got a button that was green at the
     top and blue at the bottom: an accent is only as complete as its least
     tokenised value. Measured at 4.71:1 for white, same as every other accent
     in ACCENTS (app/_shell/shell.js). */
  /* Still declared and still overridden per accent: four pages paint with it
     (both courses, the hub, and the certification header). It is no longer the
     top of the primary button's gradient, because the button is a flat pastel
     now, but a token other pages consume does not get to disappear. Re-derived
     on the logo's hue with the rest of the family. */
  --sage-lift:#6b7a3d;
  --cream:#f6f4ee;
  /* Airbnb's #222222, warmed. 15.9:1 on white. */
  --ink:#221f1c;

  /* --paper is OPAQUE again. It was rgba(255,255,255,.74), and that one token
     is what used to turn every card on every page to frosted glass. Making it
     solid white is what flattens all sixteen back, and it retires the trap
     that came with it: there is no longer any difference between --paper and
     --paper-solid, so a sticky header row or a frozen column can no longer be
     accidentally see-through. --paper-solid stays defined because the team
     calendar names it in half a dozen rules and it is now simply a synonym. */
  --paper:#ffffff;
  --paper-solid:#ffffff;

  /* Airbnb's #DDDDDD hairline, warmed. This is the workhorse of the whole
     refresh: what used to be a glass edge plus three shadow layers is now one
     of these. It reads 1.31:1 against white, which is correct for a divider
     and far too faint for the edge of a control. Controls use --edge. */
  --line:#e8e3d9;
  /* Airbnb's #717171 secondary text. 5.4:1 on white, so it is legitimate body
     copy and not just a whisper. */
  --muted:#6e6a60;
  /* The grouped/inset surface. Airbnb's #F7F7F7, warmed. What a card used to
     use a translucent fill for, an inset block now uses this for. */
  --soft:#f2efe8;
  /* The border of every field and every chip, which is the thing that says
     "this is a control" to somebody who cannot read the label. Airbnb's own
     #B0B0B0 is 2.3:1 on white and WCAG 1.4.11 wants 3:1 for the boundary of a
     UI component, so this is Airbnb's grey walked down until it passes,
     keeping the warm hue: 3.06:1. Text borrows --ink or --muted; this token is
     only ever an edge. */
  --edge:#948f84;

  /* Left exactly as measured 2026-09-15 and NOT re-picked for the refresh.
     These two were chosen because #a9663f and #a08325 read 4.10:1 and 3.31:1
     as text and failed the 4.5:1 floor outdoors, which is the only lighting
     that matters here. They pass on both white and cream. A palette refresh
     is not a reason to re-open a contrast decision that was made with a
     photometer. */
  --terra:#96552f; --gold:#7d6718;

  /* ── Type ───────────────────────────────────────────────────────────────
     ONE family. Airbnb does not pair a display face with a body face; it sets
     everything in Cereal and lets weight and size do the separating. So all
     three font tokens resolve to the same stack, and the tokens survive only
     so sixteen pages keep working. */
  --sans:"Figtree",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,
    "Helvetica Neue",Arial,sans-serif;
  /* --cond was "Avenir Next Condensed". Roughly forty rules across the app
     say font-family:var(--cond), almost all of them with uppercase and wide
     tracking, and condensed uppercase is the single most un-Airbnb thing on
     a screen. Repointing the token de-condenses every one of them at once
     without touching sixteen files. Pages that WANT a small caps label still
     get one, now set in a normal-width face, which is what Airbnb does with
     its few uppercase labels too. */
  --cond:var(--sans);
  --display:var(--sans);

  /* The scale. Airbnb's mobile sizes, near enough: 32/26/22/18/16/15/13/12.
     Stated as tokens so a page stops inventing 1.34rem. */
  /* Kelty, 2026-09-20: "decrease the size of a lot of the text, in the
     materials or the certification course it seems like it's just crammed on
     the screen, there's no breathing room."
     The HEADINGS came down a step, not the body. A heading is what makes a
     phone feel crammed, because it is the thing eating the width; body text at
     15px is what a trainer reads at arm's length outdoors and shrinking that
     to buy space would be trading the wrong thing. The breathing room comes
     from --gap and the line height below, which is where "crammed" actually
     lives. */
  --t-hero:1.58rem; --t-h1:1.28rem; --t-h2:1.08rem; --t-h3:.97rem;
  --t-body:.94rem; --t-sm:.83rem; --t-xs:.76rem;

  /* ── Shape ──────────────────────────────────────────────────────────────
     DOWN a step, not up. The glass pass took the ladder to 16/22/30 chasing
     CycleCEO; Airbnb's cards sit at 12, its images at 12 to 16, its buttons
     at 8 to 12. Capsule-shaped primary buttons are a different app's idea.
     --r-pill survives for the one place a capsule is right, which is a filter
     chip, and for the pages that name it. */
  --r-sm:14px; --r:20px; --r-lg:26px; --r-pill:999px;
  /* A button's own radius, because it is the one shape that should NOT follow
     the card ladder if the ladder ever moves again. Kelty, 2026-09-20: the
     buttons and pills and blocks all need to be rounder. 12px was the desktop
     web figure; the iOS app is noticeably softer than the website, and a 52px
     tall button needs more corner than a 44px one to read as the same shape. */
  --r-btn:16px;

  /* ── Depth ──────────────────────────────────────────────────────────────
     The glass system had four three-layer shadows and put one on every
     surface. Airbnb has essentially two shadows and puts them on almost
     nothing: a static card casts nothing at all, and the only things that
     cast are things genuinely floating. The four tokens survive because
     pages name them; they are just much quieter, and --sh-1 and --sh-2 are
     now deliberately almost invisible. */
  /* Kelty, 2026-09-20: "need more shadows and depth, mimic how they have
     their glossy feel." The first pass read the rule "a static card casts
     nothing" off Airbnb's WEBSITE, where it is true. The iOS app is a
     different object: every card, sheet, pill and bar sits on a soft wide
     shadow, and that is most of what people mean by its glossy feel.

     Two layers each, always. A tight contact shadow says the thing is a real
     object, and a wide soft one says it is above the page. One blurred
     shadow on its own is the flat look this is replacing. Tinted with the
     warm ink rather than grey, or the cream ground underneath goes muddy. */
  /* Kelty, 2026-09-20: "I want the boxes to have a little bit more shadow,
     like Airbnb." Raised across the board, and the wide layer raised more than
     the tight one: the contact shadow is what makes an object look real and it
     goes muddy fast, while the wide one is what makes it look LIFTED and can
     take much more before it reads as heavy. */
  --sh-1:0 1px 2px rgba(35,31,25,.06),0 2px 5px -1px rgba(35,31,25,.06);
  --sh-2:0 1px 2px rgba(35,31,25,.06),0 8px 22px -6px rgba(35,31,25,.20);
  --sh-3:0 2px 5px rgba(35,31,25,.08),0 16px 36px -8px rgba(35,31,25,.24);
  --sh-4:0 4px 12px rgba(35,31,25,.10),0 30px 64px -12px rgba(35,31,25,.32);
  /* The gloss. Not the old frosted glass, which was translucency: this is one
     hairline of white along the TOP of an opaque surface, the way light
     catches the edge of something physical. It is the cheapest part of the
     iOS feel and the easiest to leave out. */
  --gloss:inset 0 1px 0 rgba(255,255,255,.9);
  --gloss-dark:inset 0 1px 0 rgba(255,255,255,.18);

  /* ── Air ────────────────────────────────────────────────────────────────
     "Everything's very airy." Stated as tokens so a page stops inventing its
     own 15px. Airbnb's iOS gutters are wide and its cards are loose inside;
     the single most common mistake in copying it is getting the shapes right
     and then packing them too tight. */
  --gutter:20px; --pad-card:22px; --gap:20px;

  /* ── The retired glass tokens ───────────────────────────────────────────
     These are the whole reason this refresh is one file instead of sixteen.
     Pages compose them into box-shadow lists like
       box-shadow:var(--lens),var(--edge-hi),var(--edge-lo),var(--sh-2)
     so they cannot be deleted without breaking those declarations. Each one
     is now a valid shadow that paints nothing, which makes every such list
     collapse to whatever real shadow it also contains. Keep them. */
  --edge-hi:0 0 rgba(0,0,0,0);
  --edge-lo:0 0 rgba(0,0,0,0);
  --edge-side:0 0 rgba(0,0,0,0);
  --edge-hi-dark:0 0 rgba(0,0,0,0);
  --edge-lo-dark:0 0 rgba(0,0,0,0);
  --edge-side-dark:0 0 rgba(0,0,0,0);
  --lens:0 0 rgba(0,0,0,0);
  /* Same trick for the blur recipe: `backdrop-filter:none` is valid CSS, so
     every page that declares backdrop-filter:var(--frost) stops frosting.
     This is also why the @supports-not fallbacks scattered through the app
     are harmless now rather than wrong: they set an opaque background, which
     is what the refresh wants anyway. */
  --frost:none; --frost-heavy:none; --frost-light:none;
  /* Kept so pages naming it still parse. Nothing composes a shadow from it. */
  --shadow:var(--sh-2);

  color-scheme:light;

  /* ── Motion ─────────────────────────────────────────────────────────────
     Airbnb moves less than iOS does and never overshoots: things ease out and
     stop. The spring tokens stay, because the page transitions built on them
     are the part of this app Kelty actually liked and they are not what he
     asked to change, but --spring-bounce no longer bounces past 1 and --press
     is a much smaller move. A 3% squash on a white card with a hairline border
     reads as the screen flexing; on a frosted floating card it read as depth. */
  --spring-snap:cubic-bezier(.2,.8,.2,1);
  --spring-soft:cubic-bezier(.22,1.12,.4,1);
  --spring-bounce:cubic-bezier(.2,.9,.25,1);
  --dur-tap:.16s; --dur-soft:.28s; --dur-page:.42s;
  --press:.985;

  /* The tab bar is DOCKED now, not a floating capsule, so the space it needs
     is its own height and nothing more. Pages read this token instead of
     repeating a number that then goes stale. */
  --tabbar-space:calc(56px + env(safe-area-inset-bottom));
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}

/* The grey flash on tap is switched off above, so something has to replace it
   or a keyboard, a switch, or Voice Control lands on a control with no sign of
   where it is. :focus-visible, not :focus, so a thumb tap does not leave a
   ring behind on a phone. Airbnb rings in its ink colour, not the brand one. */
:where(a,button,input,select,textarea,summary,[tabindex]):focus-visible{
  outline:2px solid var(--ink);outline-offset:2px;border-radius:6px}

/* Kills the 300ms double-tap-to-zoom delay on every control. Pinch zoom still
   works; only the double-tap gesture on controls goes away. */
a,button,input,select,textarea,summary,label,[role=button]{touch-action:manipulation}

:target,h1,h2,h3{scroll-margin-top:84px}

/* Safari inflates text on its own whenever a page's layout looks like a
   desktop one to it. Pinning it to 100% means the type a trainer sees is the
   type declared here, on every screen, in every state. */
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
html,body{overscroll-behavior-y:none}

/* ── The page ───────────────────────────────────────────────────────────────
   White. The ambient field is gone: three radial brand washes and a blurred
   photograph of dappled plaster, all of which existed for exactly one reason,
   which was to give the frosted cards something with structure to bend. With
   nothing translucent left they have no job, and a texture behind opaque
   white cards is a texture you can only see in the gutters.

   This stays on <html> rather than moving to body, because that is where the
   old field lived and half a dozen pages set `body{background:transparent}` to
   let it through. Painting the canvas here means those pages stay correct
   whether or not anyone remembers to unset them. field.webp and hero.webp are
   left on disk: hero.webp is still used inside the Me card, and 5KB of unused
   image is cheaper than a broken reference. */
/* Kelty, 2026-09-20, with screenshots of the Airbnb app: "we need more crisp
   white in this app in general." He is right and the reason is measurable. The
   ground was #f6f4f0, which is a beige: on a phone next to Airbnb's screen it
   reads as an old paper colour rather than as white. Airbnb's own ground is a
   near-neutral off-white with PURE white cards on it, and the tiny gap between
   the two is what makes the cards look clean rather than making the page look
   tinted.
   So this is now a hair off white and almost neutral, keeping just enough
   warmth that the sage does not look pasted on. The cards stay #fff, so the
   card-to-ground step is still there to hang a shadow on; it is just far more
   subtle, and the page reads white. */
html{background:#faf8f4}
body{margin:0;background:transparent;color:var(--ink);
  font-family:var(--sans);
  font-size:var(--t-body);
  /* 1.45 was set for a condensed face. Figtree has a tall x-height and Airbnb
     sets its body generously; 1.55 is what stops a two-line client name and
     address from reading as one block. */
  line-height:1.62;
  -webkit-font-smoothing:antialiased;
  /* Figtree is already optically tight, so the -.011em that SF Pro wanted is
     too much here and starts closing up the apertures that make this face
     readable at 13px. */
  letter-spacing:-.004em;
  padding-bottom:var(--tabbar-space)}

/* ── Header ─────────────────────────────────────────────────────────────────
   The sage band is gone. This is the biggest visible change in the refresh and
   the one that most deserves the explanation: Airbnb's app has no coloured
   chrome at all. Its header is white, its type is near-black, and the brand
   colour appears only on the thing you are meant to press. A coloured band
   across the top is what a web app does to announce itself, and it costs the
   content the whole top of the screen.

   What replaces it is white with a hairline, the logo as the only mark of
   brand, and the page's name set large in ink. The trainer still knows exactly
   which app they are in, because the logo is right there and the type is the
   app's type.

   Still deliberately NOT sticky: two pages park their own control deck at
   top:0 and a sticky header would sit on top of it. */
[data-shell-header]{position:relative;color:var(--ink);
  background:#fff;
  /* A hairline says "the page continues below this line." A soft shadow says
     "the page is UNDER this bar," which is what a native header does and what
     the ground colour was introduced to make possible. No border, or the two
     cues fight and you see the line rather than the depth. */
  border-bottom:0;
  border-radius:0;
  box-shadow:0 1px 2px rgba(35,31,25,.05),0 8px 20px -12px rgba(35,31,25,.28);
  /* The sides carry the safe-area inset too, not just the top. In landscape on
     any phone with a notch the inset moves to the LEFT or RIGHT edge, and a
     header padded only at the top puts the logo underneath the camera housing.
     max() rather than addition: the inset replaces the gutter when it is
     bigger, so a portrait phone still gets exactly the gutter. */
  padding:calc(15px + env(safe-area-inset-top))
          max(var(--gutter),env(safe-area-inset-right))
          15px
          max(var(--gutter),env(safe-area-inset-left));
  /* 9, not 13. There are three controls in this corner now (inbox, refresh,
     profile) and the air between them was costing the page title 16px it
     needs more. Measured at 390px: the title box goes from 132 back to 150,
     which keeps every page name in the app on one line except "Confirm
     sessions" and "Send the consult link". */
  display:flex;align-items:center;gap:9px;
  z-index:10}
.shell-badge{width:38px;height:38px;border-radius:50%;flex:0 0 auto;background:#fff;
  box-shadow:0 0 0 1px rgba(35,31,25,.07),0 2px 6px -2px rgba(35,31,25,.22)}
/* The date line. Airbnb's small secondary type is sentence case at 13px in
   grey, not uppercase at 10px with .14em of tracking. Tracking out a tiny
   label is a way of making it feel important without making it bigger, and
   it is exactly the tic that made this app feel like a dashboard. */
.shell-eyebrow{font-size:var(--t-xs);text-transform:none;letter-spacing:0;
  color:var(--muted);font-weight:500;line-height:1.3}
.shell-title{margin:1px 0 0;font-family:var(--display);font-size:var(--t-h1);
  text-transform:none;letter-spacing:-.02em;font-weight:800;line-height:1.2;
  /* Declared, not inherited. This element is an <h1>, so a page-level `h1`
     rule carrying a colour would otherwise be the only colour declaration
     matching and would repaint the app's own header title. That is exactly
     what happened on Confirm sessions. It matters MORE now, not less: the
     header is white, so a page setting a light h1 colour for the old sage
     band would render invisible rather than merely wrong. */
  color:inherit}
.shell-head-txt{min-width:0;flex:1}
/* The title used to be one line with an ellipsis, which on a 320px screen
   turned "Confirm sessions" into "Confirm s...". A page title is the one piece
   of text on the screen that must never be guessed at, so it is allowed two
   lines and shrinks a step on a narrow phone before it ever clips. Two lines
   makes the header taller on one screen, which is the correct trade against
   cutting the word in half on every narrow one. */
.shell-title{overflow:hidden;white-space:normal;overflow-wrap:break-word;
  display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
/* ── The profile avatar ─────────────────────────────────────────────────────
   A round disc top right of every screen that goes to /me. 44px, because it is
   a real target a thumb reaches for at the top of the phone. Initials when we
   know the name, a person glyph when we do not, never a blank circle: an empty
   disc reads as a failed image.

   It was a glass lens on a dark band. On white it is Airbnb's account button:
   a soft grey disc with a hairline and ink initials. */
/* reload.js appends its own 44px circle to this header at runtime with
   margin-left:auto, and it lands AFTER the avatar in the DOM, which would put
   the refresh control in the corner and the profile beside it. Flex `order`
   fixes it without either file knowing the other's markup. The specificity is
   deliberate: reload.js injects its stylesheet at runtime and so wins any
   equal-specificity contest. */
/* ── The inbox bell ─────────────────────────────────────────────────────────
   Third thing in the corner, so the order comments above matter more than
   they did. Flex `order` is the only thing keeping these three in a sane
   sequence, because reload.js appends its button at RUNTIME and so always
   lands last in the DOM. Left to source order the corner would read
   profile, refresh, inbox, which is none of the three in the place anybody
   reached for it yesterday.

     0  logo, title      (not declared, the default)
     1  inbox
     2  refresh          (reload.js)
     3  profile          (the corner, where it has always been)

   Airbnb's own header puts the notification bell inboard of the account
   button and that is what this is. Quiet: no fill, no border, just the glyph,
   so the only thing in this corner that can catch the eye is the badge. */
[data-shell-header] .shell-inbox{order:1;flex:0 0 auto;
  width:40px;height:40px;border-radius:50%;position:relative;
  display:flex;align-items:center;justify-content:center;text-decoration:none;
  color:var(--ink);background:none;border:0;
  transition:background var(--dur-tap) ease,transform var(--dur-tap) var(--spring-snap)}
[data-shell-header] .shell-inbox svg{width:23px;height:23px;fill:none;stroke:currentColor;
  stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
[data-shell-header] .shell-inbox:active{transform:scale(.94);background:var(--soft)}
/* The disc is 42px, and 38 on a narrow phone so three of them plus the title
   fit. The TARGET is 44 either way, which is the rule: an invisible box is
   centred on the glyph and does the catching. At an 8px gap two neighbouring
   44px targets still leave 2px between them, so this cannot steal a tap from
   the refresh button. */
[data-shell-header] .shell-inbox::before{content:"";position:absolute;
  left:50%;top:50%;width:44px;height:44px;transform:translate(-50%,-50%)}
/* On /inbox/ itself it is a marker, not a link, the same way the avatar is on
   Settings: ink, not a glow. Filled rather than ringed, because ringing it
   would make two rings in one corner. */
[data-shell-header] .shell-inbox.here{background:var(--soft)}
[data-shell-header] .shell-inbox.here svg{stroke-width:2.2}
/* The badge. A NUMBER, not a bare dot: the dot would be signalling with
   colour and position alone, and the button's aria-label carries the same
   count in words. --terra was contrast-measured, so it is not re-picked here.
   The white ring is what stops the badge dissolving into the glyph behind it. */
[data-shell-header] .shell-inbox-n{position:absolute;top:2px;right:0;
  min-width:18px;height:18px;padding:0 5px;border-radius:9px;
  background:var(--terra);color:#fff;
  font-family:var(--display);font-size:.66rem;font-weight:800;line-height:18px;
  letter-spacing:-.01em;text-align:center;
  box-shadow:0 0 0 2px #fff}
[data-shell-header] .sk9-reload-hdr{order:2;margin-left:0}
.shell-me{order:3;margin-left:auto;flex:0 0 auto;width:42px;height:42px;border-radius:50%;
  display:flex;align-items:center;justify-content:center;text-decoration:none;
  font-family:var(--display);font-weight:700;font-size:var(--t-sm);
  letter-spacing:-.01em;text-transform:none;
  color:var(--ink);background:#fff;
  border:1px solid rgba(35,31,25,.1);
  box-shadow:var(--gloss),var(--sh-1);
  transition:background var(--dur-tap) ease,transform var(--dur-tap) var(--spring-snap)}
.shell-me:active{transform:scale(.94);background:#eae7df}
.shell-me.glyph svg{width:21px;height:21px;fill:none;stroke:currentColor;
  stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
/* On the Me page it is a marker, not a link: ringed in ink rather than
   pressable. Airbnb marks the current thing with its ink, never with a glow. */
.shell-me.here{box-shadow:0 0 0 2px var(--ink);border-color:transparent}
/* With a photograph on it the circle is the photograph: no letters, no inset,
   and a warm fill underneath so a transparent PNG does not show the page
   through it. The image is stored as a data URI (see _shared/prefs.js), which
   is why there is no network request behind this and no layout shift when it
   loads. */
.shell-me.photo{background:var(--soft);overflow:hidden}
.shell-me img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block}

/* ── The narrow phone ───────────────────────────────────────────────────────
   THIS BLOCK MUST STAY LAST IN THE HEADER SECTION. A @media block is not
   stronger than an equally specific rule that comes after it, and this one
   was already losing before anything was added to it: `.shell-me{width:38px}`
   sat above `.shell-me{...width:42px}`, so the narrow-phone avatar has been
   42px on every phone since the avatar was written. Measured at a 320px
   viewport: 42, not 38. Moving the block below the declarations it overrides
   is the fix; raising its specificity would only hide the next one.

   The header now carries THREE controls in the corner, inbox, refresh and
   profile, and not two. At 320px the old numbers leave the title 68px:
     40 gutters + 34 logo + 42 inbox + 44 refresh + 42 profile + 4x13 gaps.
   So the gap comes down to 8 and the two circles this file owns come down to
   38, which gives the title 94px back. The refresh button is left at its own
   44: it belongs to reload.js, and a control another file draws is not this
   file's to resize. It still wraps to two lines before it ever clips, which
   is the rule that has not changed: a page title is the one piece of text on
   the screen that must never be guessed at. The 44px TARGET on the inbox
   button is unaffected, because that is a centred pseudo-element and not the
   disc. */
@media (max-width:380px){
  [data-shell-header]{gap:7px}
  .shell-title{font-size:1.24rem}
  .shell-badge{width:34px;height:34px}
  .shell-me{width:38px;height:38px}
  [data-shell-header] .shell-inbox{width:38px;height:38px}
  [data-shell-header] .shell-inbox svg{width:21px;height:21px}
}
/* At 340px and below the logo goes. Measured at 320px: with it, the title box
   is 98px and nearly every page name in the app wraps to two lines; without
   it, 139px, which is where it was before there were three controls in the
   corner. On the narrowest phone anyone still runs, the name of the page beats
   the brand mark: the trainer knows which app they are in, and the type is the
   app's type either way. This is the same breakpoint the tab labels already
   shrink at, so there is one narrow-phone line to reason about and not two. */
@media (max-width:340px){
  .shell-badge{display:none}
}

/* ── Tab bar ────────────────────────────────────────────────────────────────
   Docked, not floating. The floating frosted capsule was the clearest single
   quote from iOS 26, and Airbnb does the opposite and has for years: a solid
   white bar welded to the bottom edge with one hairline above it, five icons,
   a small sentence-case label under each. It gives the content the full width
   of the screen back and it never puts live text under a blur.

   Which means the @supports-not fallback that used to be here is gone too,
   because there is nothing left to fall back from. */
/* Kelty, 2026-09-20: "make sure the bottom bar doesn't have some dumb shit,
   there's unnecessary space, make sure it looks good for every phone."

   It was 67px of content. Add the 34px home indicator inset that every iPhone
   since the X reports and the bar stood 101px tall, against Apple's own 83
   (49pt of content sitting above a 34pt indicator strip). Worse, --tabbar-space
   reserved 72px + inset, five more than the bar actually occupied, so there was
   a band of dead page above it as well. Both numbers are derived now rather
   than guessed, and they are written down here so they stay in step:

     padding-top            5
     link padding top       4
     icon                  22
     gap                    3
     label at .63rem       ~12
     link padding bottom    3
     padding-bottom         4     = 53px of content
     + env(safe-area-inset-bottom), which is the indicator strip and must NOT
       be padded on top of, only passed through.

   --tabbar-space is 56px + the same inset: 53 plus three of clearance. */
/* Kelty, 2026-09-20: "the tab at the very bottom jumps and doesn't stay in one
   place, I need it to stay locked."
   A position:fixed bar is only as still as the layer it is painted on. During
   momentum scrolling iOS repaints a non-composited fixed element one frame
   behind the page, which is seen as the bar shivering against the content.
   Promoting it to its own compositor layer with a 3D transform is the fix, and
   it is why every native-feeling web app does this: once the bar is a layer,
   scrolling the page underneath cannot touch it.
   backface-visibility goes with it, or Safari can flash the layer white on the
   first paint after a view transition. */
[data-shell-nav]{position:fixed;left:0;right:0;z-index:50;
  /* -1px, not 0, and the padding gets that pixel back. Kelty, twice: "there's
     still a grey line under the bottom tab on some screens." Promoting this
     bar to its own compositor layer, which is what stops it shivering during
     momentum scroll, is also what exposes a hairline seam at its bottom edge:
     the layer's height is rounded to whole DEVICE pixels, and on a phone at
     2.75x or 3x that rounding can leave a sliver of the page showing under it.
     Hanging the bar one CSS pixel below the viewport means there is nothing
     left to show through, whichever way the rounding goes. */
  bottom:-1px;
  transform:translateZ(0);will-change:transform;backface-visibility:hidden;
  display:flex;padding:5px 2px calc(5px + env(safe-area-inset-bottom));
  border-radius:0;
  background:#fff;
  /* Same trade as the header: the shadow replaces the rule, so the content
     scrolls UNDER the bar instead of stopping at a line drawn across it. */
  border-top:0;
  -webkit-backdrop-filter:none;backdrop-filter:none;
  /* The last shadow layer is the belt to the ::after's braces: a SOLID white
     spread 48px straight down. Kelty has reported a grey band under the tabs
     three times, and the two earlier fixes both depended on
     env(safe-area-inset-bottom) reporting a real number. This one does not
     depend on anything: whatever iOS decides the inset is, and wherever it
     decides the web view ends, there is white under the bar. A shadow costs
     no layout, so it cannot change the bar's height or move the labels. */
  box-shadow:0 -1px 2px rgba(35,31,25,.04),0 -10px 26px -14px rgba(35,31,25,.3),
    0 48px 0 0 #fff}
/* On a tablet, in landscape, or on a foldable opened out, five tabs stretched
   across the whole width leave each one marooned in its own empty third. Cap
   the row at the same 640px the content uses and centre it, so the bar stays a
   bar instead of becoming a spread. */
@media (min-width:680px){
  [data-shell-nav]{padding-left:calc(50% - 320px);padding-right:calc(50% - 320px)}
}
/* Same landscape-notch reasoning as the header. The bar is full width, so
   without this the first and last tab sit under the camera housing. */
@media (orientation:landscape){
  [data-shell-nav]{padding-left:max(2px,env(safe-area-inset-left));
    padding-right:max(2px,env(safe-area-inset-right))}
}
/* Kelty, 2026-09-20: "theres still a grey bar at the bottom of the screen
   under the 5 tabs." Measured in a browser at a zero inset there is no gap at
   all: the bar sits flush on the bottom edge and everything below it is white.
   So the strip he can see is the one OUTSIDE the web view, which iOS fills
   from the page's own background, and the ground is a warm off-white while the
   bar is pure white. Against each other those read as a grey band under the
   tabs.
   This paints that strip the same white as the bar. It sits behind the bar
   rather than inside it, so it cannot change the bar's height or push the
   labels, and it is inert to touch. */
[data-shell-nav]::after{content:"";position:fixed;left:0;right:0;bottom:-1px;
  height:calc(env(safe-area-inset-bottom) + 4px);
  background:#fff;z-index:-1;pointer-events:none}
[data-shell-nav] a{flex:1 1 0;min-width:0;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
  padding:4px 1px 3px;text-decoration:none;color:var(--muted);
  font-size:.63rem;font-weight:600;text-transform:none;letter-spacing:-.005em;
  line-height:1.15;text-align:center;
  /* The longest label is "Materials". At 320px, the narrowest screen anyone
     still runs, each tab gets 63px and the word needs 47, so it fits without
     wrapping. Pinned anyway: a wrapped label would push the bar taller on one
     phone and not on another. */
  white-space:nowrap;overflow:hidden;
  border-radius:10px;background:none;
  transition:color var(--dur-tap) ease,transform var(--dur-tap) var(--spring-snap)}
/* Below 340px (an iPhone SE 1st gen, a folded Fold) the labels start to touch.
   Shrink rather than wrap or truncate, because the word is the only thing
   telling somebody what the icon means. */
@media (max-width:340px){
  [data-shell-nav] a{font-size:.58rem;gap:2px}
  [data-shell-nav] .g{width:20px;height:20px}
}
/* A phone on its side has very little height to give. The bar gets slimmer
   rather than eating a third of the screen. */
@media (orientation:landscape) and (max-height:460px){
  [data-shell-nav]{padding-top:3px;padding-bottom:calc(2px + env(safe-area-inset-bottom))}
  [data-shell-nav] a{gap:1px;padding:2px 1px}
  [data-shell-nav] .g{width:19px;height:19px}
}
/* The lit tab. The capsule behind it is gone, which leaves colour doing the
   work, and colour alone is the one cue that fails for somebody who cannot
   tell sage from grey. Airbnb's answer is weight, and it is the right one: the
   active icon is drawn heavier AND filled faintly, and the label steps up to
   700. Both survive greyscale, neither needs a chip. */
[data-shell-nav] a[aria-current=true]{color:var(--sage-deep);font-weight:700;
  background:none;box-shadow:none}
/* The lit tab's icon fills with a wash of its OWN colour. It was a hard-coded
   sage rgba, so a trainer on pink got a pink label over a green-filled icon.
   currentColor here IS --sage-deep, set on the line above. */
[data-shell-nav] a[aria-current=true] svg{stroke-width:2.3;
  fill:currentColor;fill-opacity:.14}
[data-shell-nav] .g{width:22px;height:22px;flex:0 0 auto}
[data-shell-nav] svg{width:100%;height:100%;fill:none;stroke:currentColor;
  stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}

/* Airbnb's mobile gutter is 24px; 15px was set when every card was a floating
   glass tile and the page needed to show through at the edges. With opaque
   cards the gutter IS the whitespace, so it opens up. */
.shell-wrap{
  padding:var(--gutter)
          max(var(--gutter),env(safe-area-inset-right))
          26px
          max(var(--gutter),env(safe-area-inset-left));
  max-width:640px;margin:0 auto}
/* These pages declare apple-mobile-web-app-status-bar-style, so once a trainer
   adds the app to their home screen the page draws UNDERNEATH the status bar
   and the notch. [data-shell-header] already pads for it. A page that draws its
   own top element instead has to say so with this class, or its first line of
   text sits under the clock. */
.shell-top{padding-top:calc(var(--gutter) + env(safe-area-inset-top))}

/* ── The card ───────────────────────────────────────────────────────────────
   The surface every page is made of, so it carries most of the refresh. It was
   frosted white over an ambient field with a bright top edge, a warm bottom
   one, faint side ones and three shadow layers. It is now white with a 1px
   hairline and no shadow at all, which is what a bordered block is in Airbnb:
   a payment method, a price breakdown, a form group.

   A card casting nothing is the point, not an omission. Once every card on a
   stack of five casts a shadow, none of them reads as raised, and the page
   reads as quilted. Save the shadow for the things that float, which in this
   app is a sheet and a sticky action bar. */
.shell-card{position:relative;
  background:#fff;
  -webkit-backdrop-filter:none;backdrop-filter:none;
  /* No border. The shadow and the warm ground do the separating now, and a
     hairline on top of a shadow is belt and braces that reads as a web card.
     Airbnb's iOS cards have one or the other, never both. */
  border:0;border-radius:var(--r);
  padding:var(--pad-card);margin-bottom:var(--gap);
  box-shadow:var(--gloss),var(--sh-2)}
.shell-lab{font-size:var(--t-xs);text-transform:none;letter-spacing:-.005em;
  color:var(--muted);font-weight:600;margin-bottom:10px}
/* A card's own heading, for the pages that want a real title rather than the
   quiet label above. Airbnb's section heads are large, tight and ink. */
.shell-h{font-family:var(--display);font-size:var(--t-h2);font-weight:800;
  letter-spacing:-.02em;line-height:1.3;margin:0 0 6px;text-transform:none;
  color:var(--ink)}
.shell-sub{font-size:var(--t-sm);color:var(--muted);margin:0 0 16px;line-height:1.55}
.shell-nothing{text-align:center;color:var(--muted);font-size:var(--t-sm);padding:26px 10px}
.shell-failed{text-align:center;color:var(--terra);font-size:var(--t-sm);padding:26px 10px;
  font-weight:600}

/* ── The former glass surfaces ─────────────────────────────────────────────
   Three names the pages reach for. They keep their names and their ROLES, so
   a page that asked for "the dark one for a hero" still gets a dark hero; it
   is simply flat now. This is the other half of why sixteen pages re-skin from
   one file. */
.glass{background:#fff;-webkit-backdrop-filter:none;backdrop-filter:none;
  border:0;border-radius:var(--r);box-shadow:var(--gloss),var(--sh-2)}
/* The dark surface, for a hero or a stat block. One flat sage, not a
   three-stop gradient with two radial washes over it. Airbnb's dark blocks are
   flat colour and they hold photographs well precisely because they are flat:
   the Me card lays hero.webp over this and a gradient underneath a photograph
   is two pictures fighting. */
.glass-sage{color:#fff;background:var(--sage-deep);
  -webkit-backdrop-filter:none;backdrop-filter:none;
  border:0;border-radius:var(--r);
  /* Tinted with the surface's own colour rather than grey, so a dark block
     glows rather than only casting. */
  box-shadow:var(--gloss-dark),
    0 2px 6px rgba(var(--sage-shadow),.34),0 16px 34px -12px rgba(var(--sage-shadow),.45)}
.glass-warm{background:var(--soft);-webkit-backdrop-filter:none;backdrop-filter:none;
  border:0;border-radius:var(--r);box-shadow:var(--gloss),var(--sh-1)}

/* The wet-glass streak and the specular highlight. Both are retired, and both
   keep their class names AND their `>*{z-index:1}` line, because that line is
   what stopped the light layer from eating the content and a page that still
   carries the class must not suddenly have its own children re-stacked.
   Paint nothing; break nothing. */
.glass-lit{position:relative;overflow:hidden}
.glass-lit>*{position:relative;z-index:1}
.glass-lit::before{content:none}
.glass-spec{position:relative;overflow:hidden}
.glass-spec>*{position:relative;z-index:1}
.glass-spec::after{content:none}

/* A sticky deck of a page's own. Used by the team calendar and by Session help,
   both of which park controls at top:0. Opaque white with a hairline under it,
   which is what a stuck bar is in Airbnb. Opaque is not a style choice here:
   this element OVERLAPS scrolling content, and that was the first documented
   trap of the glass system. Now it cannot be got wrong. */
.shell-sticky{-webkit-backdrop-filter:none;backdrop-filter:none;
  background:#fff;border-bottom:0;
  box-shadow:0 1px 2px rgba(35,31,25,.05),0 8px 20px -12px rgba(35,31,25,.28)}

/* ── Fields ────────────────────────────────────────────────────────────────
   :where(), never a bare :not() chain. :not() carries the specificity of its
   argument, so the old chain scored 0,3,1 and BEAT a page's own
   `.search-wrap input` rule: the Materials search box lost its 40px left
   padding and the magnifier landed on the placeholder. :where() scores zero,
   which is what "a floor, not a takeover" actually requires.

   16px is not a taste call. It is the size below which iOS zooms the page in
   on focus and never zooms back out. */
input:where(:not([type=checkbox]):not([type=radio]):not([type=range])),select,textarea{
  font:inherit;font-size:16px;color:var(--ink);
  background:#fff;
  border:1px solid var(--edge);border-radius:var(--r-sm);
  padding:15px 16px;
  box-shadow:var(--sh-1);
  transition:border-color var(--dur-tap) ease,box-shadow var(--dur-tap) ease}
/* Airbnb's focus state is a second pixel of ink on the border and nothing
   else: no glow, no brand-coloured halo. The inset shadow is how you get a
   2px border without the box reflowing 1px, which is what changing
   border-width would do. */
:where(input,select,textarea):focus{background:#fff;
  border-color:var(--ink);
  box-shadow:inset 0 0 0 1px var(--ink),var(--sh-2)}
::placeholder{color:var(--muted);opacity:1}

/* ── Date and time fields on iOS ───────────────────────────────────────────
   Kelty airdropped a photo of Add a session on 2026-09-21: the Date and Time
   boxes ran straight off the right edge of the phone with no right border and
   no rounded corner, while every other field on the same form fitted.

   The cause is not this app's CSS. `width:100%`, `max-width:100%`,
   `min-width:0` and `box-sizing:border-box` were ALL already set on those two
   inputs and none of them won. A native `input[type=date]` on iOS sizes to its
   own shadow content, and that intrinsic minimum overrides the width you
   asked for. The tell in the photo is the value sitting CENTRED in the box:
   that is `::-webkit-date-and-time-value`'s own default, so the control was
   definitely rendering natively.

   Clearing `appearance` is what lets the declared width actually apply. The
   pseudo-element needs its own line because it keeps centring otherwise, and
   it carries a min-width of its own.

   Global rather than on the one form, because these two input types are used
   on several screens and every one of them had the same latent bug. :where()
   so a page can still override without a specificity fight. */
:where(input[type=date],input[type=time],input[type=datetime-local],input[type=month]){
  -webkit-appearance:none;appearance:none;
  width:100%;min-width:0;max-width:100%}
/* The value inside the native control. Left, not centred, and allowed to be
   narrower than its content wants. */
:where(input[type=date],input[type=time],input[type=datetime-local],input[type=month])::-webkit-date-and-time-value{
  text-align:left;min-width:0;margin:0}
/* Clearing appearance also strips the little calendar glyph on desktop
   Safari and Chrome. Put it back, pushed to the right edge where it belongs,
   so the control still LOOKS like something you tap. */
:where(input[type=date],input[type=time],input[type=datetime-local],input[type=month])::-webkit-calendar-picker-indicator{
  margin-left:auto;opacity:.55;cursor:pointer}
/* A field's label. Uppercase tracked micro-labels were the app's default and
   are Airbnb's exception; this is the rule. */
.shell-label{display:block;font-size:var(--t-sm);font-weight:600;color:var(--ink);
  text-transform:none;letter-spacing:-.005em;margin:0 0 6px}
:where(button,[role=button],select){min-height:44px}

/* ── Buttons ───────────────────────────────────────────────────────────────
   The thing Kelty actually asked about. Airbnb has exactly three, and the
   discipline is that there is never a fourth:

     PRIMARY    one per screen, solid brand fill, does the thing.
     SECONDARY  white with a full-strength ink border. Everything else.
     TERTIARY   underlined text, for the way out.

   All three are sentence case at 16px and weight 600, on a 12px radius. The
   old pair were uppercase condensed capsules with a gradient fill and a
   coloured glow under them, and the class names stay `.pill` and `.pill-ghost`
   because forty rules across sixteen pages name them, even though neither is
   pill-shaped any more. Renaming them would be a sixteen-file change to say
   the same thing. */
.pill,.pill-ghost{display:inline-flex;align-items:center;justify-content:center;
  gap:8px;min-height:52px;padding:15px 24px;border-radius:var(--r-btn);
  font-family:var(--sans);font-size:1rem;font-weight:600;
  /* Named explicitly rather than left off. A page that sets uppercase on its
     own buttons still wins, but nothing INHERITS uppercase from the old
     system any more. */
  text-transform:none;letter-spacing:-.01em;
  cursor:pointer;text-decoration:none;line-height:1.2;
  transition:background var(--dur-tap) ease,border-color var(--dur-tap) ease,
    transform var(--dur-tap) var(--spring-snap)}
/* The glossy one. Three things together make a button look like an object
   rather than a coloured rectangle, and it is always all three: a very slight
   lift from top to bottom in the fill, one white hairline along the top edge,
   and a shadow tinted with the button's OWN colour so it sits in a pool of
   itself. A grey shadow under a green button is the giveaway. */
/* A light pastel carrying INK, not white on a deep fill. Kelty asked for
   lighter pastel colours, and white text forces a dark fill, which is the
   opposite of light. Ink on --sage-fill measures 13:1 against the 4.98:1 the
   white-on-deep version managed, so this is lighter AND more legible rather
   than a trade against legibility.
   Flat, no gradient: at this lightness a gradient reads as a smudge. The
   shadow is still tinted with the green family so the button glows rather
   than just casting. */
.pill{border:0;color:var(--ink);
  background:var(--sage-fill);
  box-shadow:var(--gloss),
    0 1px 2px rgba(var(--sage-shadow),.16),0 8px 20px -6px rgba(var(--sage-shadow),.26)}
.pill:active{background:var(--sage-fill-press);
  box-shadow:var(--gloss),0 1px 2px rgba(var(--sage-shadow),.2)}
.pill:disabled{background:var(--soft);border:1px solid var(--line);color:var(--muted);
  box-shadow:none;cursor:not-allowed}
/* Airbnb's secondary really is a white button with a near-black border, and it
   is worth not flinching at: the old --edge border was 2.4:1 and read as a
   disabled control sitting next to a saturated primary. A full ink border is
   what makes "No show" look as pressable as "Happened". */
/* The secondary lifts too, on a neutral shadow, and its border came down from
   full-strength ink. Next to a lifted primary a hard black outline read as the
   louder of the two, which is backwards. */
.pill-ghost{border:1px solid rgba(35,31,25,.18);color:var(--ink);background:#fff;
  box-shadow:var(--gloss),var(--sh-1)}
.pill-ghost:active{background:var(--soft);box-shadow:var(--gloss)}
.pill-ghost:disabled{border-color:var(--line);color:var(--muted);cursor:not-allowed}
/* A toggled outline button fills with ink, not with sage. Sage is reserved for
   the one thing on the screen that commits. */
.pill-ghost[aria-pressed=true]{background:var(--ink);border-color:var(--ink);color:#fff}

/* The third button, which this app never had and kept faking with a small
   ghost. Airbnb uses it for every non-committal exit: Cancel, Skip, Clear. */
.btn-text{display:inline-flex;align-items:center;gap:6px;min-height:44px;
  padding:0 2px;border:0;background:none;cursor:pointer;
  font-family:var(--sans);font-size:var(--t-body);font-weight:600;color:var(--ink);
  text-decoration:underline;text-underline-offset:3px;text-decoration-thickness:1px;
  text-transform:none;letter-spacing:-.005em}
.btn-text:active{color:var(--muted)}

/* A filter chip: the one control that stays a capsule, because on Airbnb it is
   one. White with a hairline, ink fill when chosen. */
/* Airbnb's iOS filter pills float: white, round all the way, sitting on a
   shadow rather than boxed in by a border. The hairline stays, at low
   contrast, because a shadow alone disappears against a white card. */
.chip{display:inline-flex;align-items:center;gap:6px;min-height:42px;
  padding:10px 18px;border-radius:var(--r-pill);
  border:1px solid rgba(35,31,25,.1);background:#fff;color:var(--ink);
  box-shadow:var(--gloss),var(--sh-1);
  font-family:var(--sans);font-size:var(--t-sm);font-weight:600;
  text-transform:none;letter-spacing:-.005em;cursor:pointer;text-decoration:none;
  transition:background var(--dur-tap) ease,border-color var(--dur-tap) ease,
    color var(--dur-tap) ease,transform var(--dur-tap) var(--spring-snap)}
.chip[aria-pressed=true],.chip.on{background:var(--ink);border-color:var(--ink);color:#fff;
  box-shadow:var(--gloss-dark),0 1px 2px rgba(35,31,25,.24),
    0 8px 18px -6px rgba(35,31,25,.34)}

/* A full-width button, which on a phone is what a primary usually is. */
.pill.wide,.pill-ghost.wide,.btn-wide{width:100%}

/* ── The list row ──────────────────────────────────────────────────────────
   Airbnb's cleanest pattern and the one this app has the most use for: a stack
   of rows divided by hairlines, the whole row tappable, a chevron on the right
   saying so. Offered for pages to adopt rather than retrofitted onto them,
   because a row is markup and the rest of this file is only paint. */
.a-list{border-top:1px solid var(--line)}
.a-row{display:flex;align-items:center;gap:13px;width:100%;
  padding:18px 2px;border:0;border-bottom:1px solid var(--line);
  background:none;text-align:left;text-decoration:none;color:var(--ink);
  font:inherit;cursor:pointer;
  transition:background var(--dur-tap) ease}
.a-row:active{background:var(--soft)}
.a-row-txt{flex:1;min-width:0}
.a-row-t{display:block;font-weight:600;font-size:var(--t-body);letter-spacing:-.01em}
.a-row-s{display:block;font-size:var(--t-sm);color:var(--muted);margin-top:1px}
.a-row-chev{flex:0 0 auto;width:16px;height:16px;color:var(--muted);
  fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}

/* ── How the app MOVES ────────────────────────────────────────────────────
   Kept, on purpose, and NOT part of the refresh. Every tab in this app is a
   real link to a real document, so tapping one used to throw the page away,
   flash white, and rebuild the header and the tab bar from scratch. A native
   app never blanks. Cross-document view transitions fix exactly that, in the
   browser rather than in a framework, and they are the reason the app stopped
   feeling like a website. Airbnb slides between screens too. */
@view-transition { navigation: auto; }

/* The header and the tab bar are the SAME objects on both pages, so they are
   named and therefore do not fade at all: they sit still while the content
   under them changes. That stillness is most of the effect. Without the names
   the whole screen cross-fades, tab bar included, which reads as a web page
   turning over rather than an app moving. */
[data-shell-header]{view-transition-name:shell-header}
[data-shell-nav]{view-transition-name:shell-nav}

/* NO MOVEMENT. Kelty 2026-09-21: "make sure no other screens do that sliding
   bullshit", and separately, "load speed is slow".

   Those were the same complaint. Every tab tap ran a horizontal push: the
   incoming screen came in from translateX(34%) over 280ms while the outgoing
   one left at -11%. It was modelled on a native tab bar, and on a fast
   connection it looked like one. But it is 280ms of enforced waiting on every
   single navigation whether the page is ready or not, and on a screen you are
   trying to read it is the thing moving under your thumb.

   What is KEPT is the cross-document transition itself, because that is what
   stops the white flash between documents and it costs nothing. The header
   and the tab bar are still named, so they sit perfectly still. The content
   underneath now just appears.

   Deliberately no translateY lift either. A 6px rise is still movement, and
   "the screen is not locked in place" is exactly what a 6px rise looks like
   to somebody who did not ask for it. */
::view-transition-old(root){animation:sk9-out .08s linear both}
::view-transition-new(root){animation:sk9-in .10s linear both}
@keyframes sk9-out{to{opacity:0}}
@keyframes sk9-in{from{opacity:0}to{opacity:1}}

/* Cards land in sequence rather than all at once, which is what makes a list
   read as arriving instead of appearing. Five is the cutoff: past that the last
   card is waiting on an animation nobody is watching. The scale is gone here
   for the same reason it left the page transition. */
html:not(.sk9-reveal-on) .shell-wrap>.shell-card{
  animation:sk9-rise var(--dur-soft) var(--spring-soft) both}
html:not(.sk9-reveal-on) .shell-wrap>.shell-card:nth-child(2){animation-delay:.04s}
html:not(.sk9-reveal-on) .shell-wrap>.shell-card:nth-child(3){animation-delay:.08s}
html:not(.sk9-reveal-on) .shell-wrap>.shell-card:nth-child(4){animation-delay:.12s}
html:not(.sk9-reveal-on) .shell-wrap>.shell-card:nth-child(n+5){animation-delay:.15s}
@keyframes sk9-rise{from{opacity:0;transform:translateY(12px) scale(.985)}
  to{opacity:1;transform:none}}

/* A control that does not answer the finger feels dead, and this app has
   -webkit-tap-highlight-color off everywhere. Scale, not colour, so it works
   on every button whatever it is made of, and transform only, because it runs
   on the compositor and cannot cause a reflow. Much smaller than it was:
   --press went from .97 to .985. */
button,[role=button],.pill,.pill-ghost,.chip,.btn-text,[data-shell-nav] a{
  transition:transform var(--dur-tap) var(--spring-snap)}
button:active,[role=button]:active,[data-shell-nav] a:active,
.pill:active,.pill-ghost:active,.chip:active,.ios-press:active{
  transform:scale(var(--press))}
.ios-press{transition:transform var(--dur-tap) var(--spring-snap);cursor:pointer}
/* A whole card that is itself tappable does not scale at all now. It tints,
   the way an Airbnb row does. A big white rectangle with a 1px border that
   shrinks 1% reads as the border going blurry, because it does. */
/* A tappable card presses by SETTLING: the lift comes out of it and it tints.
   That reads as the card going down onto the page, which is what a finger on
   a real object does, and it is more convincing than a scale. */
.ios-card{transition:background var(--dur-tap) ease,box-shadow var(--dur-tap) ease;
  cursor:pointer}
.ios-card:active{transform:none;background:#fbfaf7;
  box-shadow:var(--gloss),var(--sh-1)}

/* Skeletons. "Loading..." is a sentence asking to be read; a skeleton is the
   shape of the answer, so the eye has somewhere to rest and the page does not
   appear to jump when the real rows land. */
.shell-skel{border-radius:8px;background:
  linear-gradient(90deg,var(--soft) 25%,#faf9f5 37%,var(--soft) 63%);
  background-size:400% 100%;animation:sk9-shimmer 1.4s ease-in-out infinite}
@keyframes sk9-shimmer{from{background-position:100% 0}to{background-position:0 0}}
.shell-skel-row{height:14px;margin:0 0 10px}
.shell-skel-row.w60{width:60%}
.shell-skel-row.w35{width:35%}
.shell-skel-row.tall{height:38px;margin-bottom:14px}

/* Motion is decoration here, never information: every state this app animates
   is also stated in words. Somebody who asked their phone for less movement
   gets the end state immediately. */
@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{
    animation-duration:.01ms !important;animation-iteration-count:1 !important;
    transition-duration:.01ms !important;scroll-behavior:auto !important}
  /* The shimmer is the one animation that would still run: it is on a
     pseudo-free element with its own keyframes and it loops forever. */
  .shell-skel{animation:none;background:var(--soft)}
  /* A view transition is a page-level animation and the rule above does not
     reach it, so it is switched off by name. */
  ::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){
    animation:none !important}
  /* Staggered cards must not be left at opacity 0 by a cancelled animation. */
  .shell-wrap>.shell-card{animation:none;opacity:1;transform:none}
}

/* ── The interactions Airbnb actually has ──────────────────────────────────
   Kelty, 2026-09-20: "they have more animations on the Airbnb app."

   The useful ones are all about TOUCH, not decoration. An app feels alive
   because every control answers the finger within a frame, not because things
   drift about on their own. Four additions, each tied to a real gesture, and
   every one of them is switched off by the reduced-motion block above. */

/* A pressed button settles into the page: the lift comes out from under it at
   the same moment it scales. Scale alone reads as the button shrinking; scale
   plus a collapsing shadow reads as it being pushed DOWN, which is what a
   finger does to a real object. */
.pill,.pill-ghost,.chip{
  transition:transform var(--dur-tap) var(--spring-snap),
    box-shadow var(--dur-tap) ease,
    background var(--dur-tap) ease,
    border-color var(--dur-tap) ease}

/* The lit tab pops once when it becomes current. Airbnb does this and it is
   the single cheapest moment of delight in a tab bar: the icon acknowledges
   the tap rather than simply being a different colour on the next screen.
   On the icon only, never the label, or the row reflows. */
[data-shell-nav] a[aria-current=true] svg{animation:sk9-tab-pop .34s var(--spring-bounce) both}
@keyframes sk9-tab-pop{
  0%{transform:scale(.86)}
  60%{transform:scale(1.08)}
  100%{transform:scale(1)}}

/* A row answers the finger down the whole row, not just under it. */
.a-row{transition:background var(--dur-tap) ease,padding-left var(--dur-tap) var(--spring-snap)}
.a-row:active{padding-left:6px}

/* A sheet arriving from the bottom edge, offered so pages stop hand-rolling
   their own and drifting apart. The scrim fades on its own curve: a scrim that
   springs looks like the page itself is moving. */
.sheet-scrim{animation:sk9-scrim .22s ease both}
.sheet-box{animation:sk9-sheet .36s var(--spring-snap) both}
@keyframes sk9-scrim{from{opacity:0}to{opacity:1}}
@keyframes sk9-sheet{from{transform:translateY(26px);opacity:.6}
  to{transform:none;opacity:1}}
@media (prefers-reduced-motion: reduce){
  [data-shell-nav] a[aria-current=true] svg,
  .sheet-scrim,.sheet-box{animation:none}
  .a-row:active{padding-left:2px}
}

/* ── The scroll reveal ─────────────────────────────────────────────────────
   Kelty, 2026-09-20: "so smooth when you scroll and everything appears."
   shell.js tags the root with .sk9-reveal-on ONLY once its observer is
   actually running, and only ever ADDS .sk9-in to an element. That ordering is
   the whole safety story: if the script never runs, if IntersectionObserver is
   missing, if the page throws before it gets here, none of these rules apply
   and every card is simply visible. Content stranded at opacity 0 is a bug,
   never a flourish, so the hidden state is scoped behind the class that proves
   something is there to un-hide it.

   Transform and opacity only. Both run on the compositor, so a list of fifty
   rows revealing as you scroll cannot cause a single reflow. */
html.sk9-reveal-on [data-sk9-reveal]{
  opacity:0;transform:translateY(14px);
  will-change:opacity,transform}
html.sk9-reveal-on [data-sk9-reveal].sk9-in{
  opacity:1;transform:none;
  transition:opacity .42s var(--spring-snap) var(--sk9-delay,0ms),
    transform .52s var(--spring-soft) var(--sk9-delay,0ms);
  will-change:auto}
/* Two surfaces must never start hidden, whatever else is going on: a sticky
   deck would pop in over content that is already there, and the shared chrome
   fading reads as the app still loading.

   The tab bar is exempted from the transform reset ON PURPOSE. It carries
   translateZ(0) to keep itself on its own compositor layer, which is what
   stops it shivering against the page during momentum scroll, and
   `transform:none` here scored higher and quietly took that away again.
   Opacity is all it ever needed from this rule. */
html.sk9-reveal-on [data-shell-header],
html.sk9-reveal-on .shell-sticky{opacity:1;transform:none}
html.sk9-reveal-on [data-shell-nav]{opacity:1}

/* An anchor jump inside a long manual should glide, not teleport. */
@media (prefers-reduced-motion: no-preference){
  html{scroll-behavior:smooth}
}

@media (prefers-reduced-motion: reduce){
  html.sk9-reveal-on [data-sk9-reveal]{opacity:1;transform:none;transition:none}
}

/* ── The launch screen ──────────────────────────────────────────────────────
   Kelty, 2026-09-20: "can you add the SK9 logo when you first open the app and
   a loading line like Cycle CEO?"

   Built by shell.js rather than sitting in every page's HTML, which is the
   whole safety story: if the script never runs, this never appears, so a
   broken deploy cannot leave a trainer looking at a logo forever. shell.js
   also removes it on a timeout as a second guard.

   It shows on a COLD open only. A tab-to-tab move inside the app is a
   cross-document view transition that already looks continuous, and throwing
   a splash over it would make the app feel slower than it is. */
.sk9-boot{position:fixed;inset:0;z-index:9000;display:flex;
  flex-direction:column;align-items:center;justify-content:center;gap:22px;
  background:var(--ground,#faf8f4);
  animation:sk9-boot-in .18s ease both}
.sk9-boot.out{animation:sk9-boot-out .32s ease both;pointer-events:none}
.sk9-boot img{width:84px;height:84px;border-radius:50%;
  box-shadow:0 0 0 1px rgba(35,31,28,.06),0 10px 28px -10px rgba(35,31,25,.35);
  animation:sk9-boot-mark .5s var(--spring-soft) both}
/* The line. Indeterminate on purpose: the app cannot know how far through it
   is, and a bar that pretends to is a lie that always finishes at the wrong
   moment. This one travels, which reads as working rather than as progress.
   The tennis ball lives on the pull to refresh, not here: Kelty, 2026-09-22,
   "I want it for the pull-down, not for opening the app." */
.sk9-boot i{display:block;width:132px;height:4px;border-radius:99px;
  background:rgba(35,31,28,.10);overflow:hidden;position:relative}
.sk9-boot i::after{content:"";position:absolute;top:0;bottom:0;width:45%;
  border-radius:99px;
  background:linear-gradient(90deg,var(--sage-deep,#657439),var(--sage,#808868));
  box-shadow:0 0 10px rgba(var(--sage-shadow,62,68,44),.5);
  animation:sk9-boot-run 1.15s cubic-bezier(.65,.05,.36,1) infinite}
@keyframes sk9-boot-in{from{opacity:0}to{opacity:1}}
@keyframes sk9-boot-out{to{opacity:0;visibility:hidden}}
@keyframes sk9-boot-mark{from{opacity:0;transform:scale(.88)}to{opacity:1;transform:none}}
@keyframes sk9-boot-run{
  0%{left:-45%}
  100%{left:100%}}

@media (prefers-reduced-motion:reduce){
  .sk9-boot img{animation:none}
  .sk9-boot i::after{animation:none;left:0;width:100%;opacity:.5}
}

/* The sheen sweep. It was written twice inside app/me, and the accent picker
   wanted the same move, so the keyframes live here now and the pages that
   already declare an identical copy keep working unchanged. Long and mostly
   idle on purpose: a sheen that crosses every second is a loading spinner,
   and this one is meant to read as a surface catching the light. */
@keyframes sk9-sheen{
  0%{background-position:200% 0}
  40%{background-position:-100% 0}
  100%{background-position:-100% 0}}

/* ---- The mini player that follows you ----
   Kelty, 2026-09-27: "I need it to be around the whole entire app." The logic,
   and why it comes back paused, is in _shell/miniplayer.js. Same box as the one
   Session Help floats in page, so crossing a tab does not change what it is. */
.sk9mini{position:fixed;right:10px;
  bottom:calc(var(--tabbar-space,calc(56px + env(safe-area-inset-bottom))) + 10px);
  z-index:80;width:min(206px,56vw);border-radius:12px;overflow:hidden;
  background:#15140f;box-shadow:0 8px 26px rgba(0,0,0,.34)}
.sk9mini .sk9mini-v{background:#000;line-height:0}
.sk9mini iframe{display:block;width:100%;height:auto;aspect-ratio:16/9;border:0}
.sk9mini .sk9mini-play{display:block;position:relative;width:100%;padding:0;border:0;
  background:#000;line-height:0;cursor:pointer;min-height:0}
.sk9mini .sk9mini-play img{display:block;width:100%;height:auto;aspect-ratio:16/9;
  object-fit:cover;opacity:.82}
/* The disc and its triangle, the same play button the video cards carry, at the
   size this box can hold. */
.sk9mini .sk9mini-play i{position:absolute;left:50%;top:50%;width:40px;height:40px;
  margin:-20px 0 0 -20px;border-radius:50%;background:rgba(21,20,15,.72);
  border:2px solid rgba(255,255,255,.9)}
.sk9mini .sk9mini-play i::after{content:"";position:absolute;left:15px;top:10px;
  border-style:solid;border-width:8px 0 8px 13px;
  border-color:transparent transparent transparent #fff}
/* Where you left it, on the still, so the player says what it is holding
   before anybody taps it. */
.sk9mini .sk9mini-play span{position:absolute;right:6px;bottom:6px;line-height:1.4;
  background:rgba(21,20,15,.85);color:#fff;font-size:10px;font-weight:600;
  padding:2px 6px;border-radius:5px}
.sk9mini .sk9mini-bar{display:flex;align-items:center;gap:6px;padding:7px 7px 7px 10px;
  background:#15140f;color:#fff}
.sk9mini .sk9mini-bar b{flex:1;min-width:0;font-size:11px;font-weight:600;line-height:1.3;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:rgba(255,255,255,.88)}
.sk9mini .sk9mini-bar button{flex:0 0 auto;min-height:0;padding:5px 9px;border:0;
  border-radius:7px;background:rgba(255,255,255,.17);color:#fff;font:inherit;
  font-size:11px;font-weight:600;line-height:1;cursor:pointer}
.sk9mini .sk9mini-bar button:active{background:rgba(255,255,255,.3)}
